@actuarial-ts/agents 0.2.0 → 0.3.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.
- package/README.md +4 -2
- package/dist/advisor.d.ts +2 -1
- package/dist/advisor.d.ts.map +1 -1
- package/dist/advisor.js +3 -1
- package/dist/advisor.js.map +1 -1
- package/dist/divergence.d.ts.map +1 -1
- package/dist/divergence.js +6 -1
- package/dist/divergence.js.map +1 -1
- package/dist/judgment.d.ts +26 -0
- package/dist/judgment.d.ts.map +1 -1
- package/dist/judgment.js +21 -0
- package/dist/judgment.js.map +1 -1
- package/dist/mcp.d.ts +44 -9
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +57 -40
- package/dist/mcp.js.map +1 -1
- package/dist/promotion.d.ts +26 -0
- package/dist/promotion.d.ts.map +1 -1
- package/dist/promotion.js +49 -1
- package/dist/promotion.js.map +1 -1
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +11 -1
- package/dist/remote.js.map +1 -1
- package/dist/tools.d.ts +87 -6
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +175 -15
- package/dist/tools.js.map +1 -1
- package/package.json +7 -5
- package/src/advisor.ts +141 -0
- package/src/divergence.ts +642 -0
- package/src/errors.ts +62 -0
- package/src/evals.ts +162 -0
- package/src/index.ts +9 -0
- package/src/judgment.ts +458 -0
- package/src/mcp.ts +274 -0
- package/src/promotion.ts +1240 -0
- package/src/remote.ts +371 -0
- package/src/tools.ts +561 -0
package/src/promotion.ts
ADDED
|
@@ -0,0 +1,1240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* promoteStudy: notebook study -> governed judgment (interchange spec rev 2.1,
|
|
3
|
+
* Section 6). A four-gate judgment chain built on createJudgmentChain:
|
|
4
|
+
*
|
|
5
|
+
* Gate 1 "study-intake" - schema/integrity validation via interchange
|
|
6
|
+
* parseDocument, ASOP 23 data review on the study's
|
|
7
|
+
* triangles, selection coherence (spec 3.2 rule),
|
|
8
|
+
* segment resolution (v1: one selection per
|
|
9
|
+
* segment), and the tolerance ceiling.
|
|
10
|
+
* Gate 2 "replay-verify" - table-exact intents replayed through
|
|
11
|
+
* interchange/core; approx/value-only targets
|
|
12
|
+
* labeled verified-by-value; supportingResults
|
|
13
|
+
* refereed via crosscheck at the effective
|
|
14
|
+
* tolerance; a "disagree" verdict HARD-BLOCKS.
|
|
15
|
+
* Gate 3 "rationale" - a DRAFT rationale (template-assembled from the
|
|
16
|
+
* study narrative; options.draftRationale is the
|
|
17
|
+
* injection hook for future agent drafting); resume
|
|
18
|
+
* REQUIRES a non-blank rationale AND attestation.
|
|
19
|
+
* Gate 4 "apply" - deps.applySelections per selection,
|
|
20
|
+
* deps.runAnalysis, ledger entries carrying the
|
|
21
|
+
* Gate 3 rationale + attestation verbatim, and
|
|
22
|
+
* deps.persistNote with the trail + ledger JSON.
|
|
23
|
+
*
|
|
24
|
+
* Architecture decisions (each is load-bearing):
|
|
25
|
+
*
|
|
26
|
+
* - EAGER, DETERMINISTIC INTAKE. All Gate 1/2 computation (parse, review,
|
|
27
|
+
* coherence, segment resolution, replay, crosschecks) runs synchronously at
|
|
28
|
+
* chain-CONSTRUCTION time. Everything is a pure function of the study
|
|
29
|
+
* document plus deps.resolveSegment, so a host that reconstructs the chain
|
|
30
|
+
* after a restart (promoteStudy again with the same study) gets IDENTICAL
|
|
31
|
+
* gates and resume schemas - which is what makes the Gate 2 hard-block
|
|
32
|
+
* structural: when any referee verdict is "disagree", the replay-verify
|
|
33
|
+
* resume schema literally only admits { decision: "abort" }. Mastra's own
|
|
34
|
+
* resume validation rejects an accept; no runtime flag to lose across a
|
|
35
|
+
* snapshot rehydration, no advisory check an agent could talk its way past.
|
|
36
|
+
*
|
|
37
|
+
* - STUDY-CONTENT FAILURES SURFACE AS GATE FAILURES. A malformed study
|
|
38
|
+
* (BAD_INTERCHANGE), a tolerance above the host ceiling
|
|
39
|
+
* (TOLERANCE_CEILING_EXCEEDED), an unresolvable or ambiguous segment
|
|
40
|
+
* (SEGMENT_UNRESOLVED / SEGMENT_AMBIGUOUS), an incoherent selection under
|
|
41
|
+
* refuse strictness (INCOHERENT_SELECTION), or an empty study (EMPTY_STUDY)
|
|
42
|
+
* never throws out of promoteStudy itself: the captured error is thrown
|
|
43
|
+
* from Gate 1's gatherEvidence, so the run fails AT the study-intake gate
|
|
44
|
+
* with the named error, where the host's gate UI can render it.
|
|
45
|
+
* promoteStudy throws synchronously only for programmer errors
|
|
46
|
+
* (BAD_PROMOTION_OPTIONS: missing deps, non-positive ceiling, ...).
|
|
47
|
+
*
|
|
48
|
+
* - DATA REVIEW CHOICE (documented per plan task B2): where a segment group
|
|
49
|
+
* contains exactly one paid and one incurred triangle, the full
|
|
50
|
+
* @actuarial-ts/data reviewTriangles pair review runs (it includes the
|
|
51
|
+
* per-triangle monotonicity and interior-missing checks). Every other core
|
|
52
|
+
* loss triangle gets the per-triangle STRUCTURAL subset of those checks
|
|
53
|
+
* (non-decreasing cumulative rows, interior missing cells), reported in the
|
|
54
|
+
* same DataReviewReport shape. Non-core measures (earnedPremium, custom:*)
|
|
55
|
+
* are exposure/reference data, not loss triangles: their review is reported
|
|
56
|
+
* "not-evaluated" rather than silently passed - unless a selection
|
|
57
|
+
* references one, which fails intake (BAD_INTERCHANGE from the converter).
|
|
58
|
+
* Review findings, including fail-status checks, are EVIDENCE for the human
|
|
59
|
+
* intake decision, not automatic blocks; only the named error classes above
|
|
60
|
+
* block mechanically. The recommendation says so when checks fail.
|
|
61
|
+
*
|
|
62
|
+
* - PURITY. This module never reads a clock; options.now supplies every
|
|
63
|
+
* timestamp (envelope createdAt at construction, ledger timestamps at
|
|
64
|
+
* resume). The tenant seam is untouched: deps close over the host's own
|
|
65
|
+
* authenticated context, and nothing model-facing exists here at all.
|
|
66
|
+
*
|
|
67
|
+
* FOOTGUN (inherited from createJudgmentChain): the returned workflow exposes
|
|
68
|
+
* .then(step), making it an ACCIDENTAL THENABLE. Never await promoteStudy's
|
|
69
|
+
* return value and never return it from an async function - assign it
|
|
70
|
+
* synchronously and register it on a Mastra instance.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
import {
|
|
74
|
+
ReservingError,
|
|
75
|
+
runChainLadder,
|
|
76
|
+
type LdfSelections,
|
|
77
|
+
type Triangle,
|
|
78
|
+
} from "@actuarial-ts/core";
|
|
79
|
+
import {
|
|
80
|
+
CONVENTION_PROFILES,
|
|
81
|
+
CORE_MEASURES,
|
|
82
|
+
DETERMINISTIC_CL_PROFILE,
|
|
83
|
+
crosscheck,
|
|
84
|
+
docToSelections,
|
|
85
|
+
docToTriangle,
|
|
86
|
+
isValueOnlySelection,
|
|
87
|
+
parseDocument,
|
|
88
|
+
resultToDoc,
|
|
89
|
+
verifyIntegrity,
|
|
90
|
+
type CoherenceCheck,
|
|
91
|
+
type CrosscheckReportDoc,
|
|
92
|
+
type DocToSelectionsResult,
|
|
93
|
+
type MethodResultDoc,
|
|
94
|
+
type SelectionDoc,
|
|
95
|
+
type StudyBody,
|
|
96
|
+
type StudyDoc,
|
|
97
|
+
type TriangleDoc,
|
|
98
|
+
} from "@actuarial-ts/interchange";
|
|
99
|
+
import {
|
|
100
|
+
reviewTriangles,
|
|
101
|
+
type DataCheck,
|
|
102
|
+
type DataReviewReport,
|
|
103
|
+
} from "@actuarial-ts/data";
|
|
104
|
+
import type { AssumptionActor } from "@actuarial-ts/compliance";
|
|
105
|
+
import { z } from "zod";
|
|
106
|
+
import { AgentsError } from "./errors.js";
|
|
107
|
+
import {
|
|
108
|
+
createJudgmentChain,
|
|
109
|
+
type JudgmentChainWorkflow,
|
|
110
|
+
type JudgmentGateSpec,
|
|
111
|
+
type JudgmentLedgerEntry,
|
|
112
|
+
} from "./judgment.js";
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Public API surface
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The host-adapter seam (spec 6 Gate 4; the workbench adapter arrives with
|
|
119
|
+
* plan task B4). resolveSegment is SYNCHRONOUS so promoteStudy can stay
|
|
120
|
+
* synchronous (the accidental-thenable footgun forbids async construction);
|
|
121
|
+
* the mutation hooks may be async - they run inside gate execution.
|
|
122
|
+
*/
|
|
123
|
+
export interface PromoteStudyDeps {
|
|
124
|
+
/**
|
|
125
|
+
* Maps a selection's triangle segment labels to exactly one host workspace
|
|
126
|
+
* target, or null when nothing matches (v1: no fuzzy matching). A
|
|
127
|
+
* single-segment host returns its only target regardless of labels.
|
|
128
|
+
*/
|
|
129
|
+
resolveSegment(labels: Record<string, string>): string | null;
|
|
130
|
+
/**
|
|
131
|
+
* Applies one promoted selection through the host's service layer.
|
|
132
|
+
* `replayedSelections` is the core LdfSelections the replay verified;
|
|
133
|
+
* `segmentTarget` is the resolved workspace target (a two-parameter
|
|
134
|
+
* implementation that closes over its single segment may ignore it).
|
|
135
|
+
*/
|
|
136
|
+
applySelections(
|
|
137
|
+
selectionDoc: SelectionDoc,
|
|
138
|
+
replayedSelections: LdfSelections,
|
|
139
|
+
segmentTarget: string,
|
|
140
|
+
): Promise<void> | void;
|
|
141
|
+
/** Reruns the host analysis after the selections are applied. */
|
|
142
|
+
runAnalysis(label: string): Promise<void> | void;
|
|
143
|
+
/** Persists a completion note (called twice: trail, then ledger JSON). */
|
|
144
|
+
persistNote(text: string, author: string): Promise<void> | void;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Context handed to the rationale-drafting hook. */
|
|
148
|
+
export interface DraftRationaleContext {
|
|
149
|
+
study: StudyBody;
|
|
150
|
+
studyIntegrity: string;
|
|
151
|
+
intake: StudyIntakeEvidence;
|
|
152
|
+
replay: ReplayVerifyEvidence;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface PromoteStudyOptions {
|
|
156
|
+
/**
|
|
157
|
+
* The host's replay-tolerance ceiling. The EFFECTIVE tolerance is
|
|
158
|
+
* min(study replayTolerance, ceiling); a study STATING a tolerance above
|
|
159
|
+
* the ceiling fails intake (tolerance editing is not an escape hatch).
|
|
160
|
+
*/
|
|
161
|
+
toleranceCeiling: number;
|
|
162
|
+
/** Actor recorded when a resume payload names none. Default "actuary". */
|
|
163
|
+
actorDefault?: string;
|
|
164
|
+
/** Host-injected clock (purity: this package never reads Date). */
|
|
165
|
+
now: () => string;
|
|
166
|
+
/**
|
|
167
|
+
* Coherence/integrity strictness for intake. Default "refuse" (promotion
|
|
168
|
+
* is a refuse-mode consumer per spec 3.2); "warn" downgrades divergence
|
|
169
|
+
* to evidence warnings.
|
|
170
|
+
*/
|
|
171
|
+
strictness?: "warn" | "refuse";
|
|
172
|
+
/**
|
|
173
|
+
* Injection hook for agent-assisted rationale drafting (spec 9.2 arrives
|
|
174
|
+
* later); the default assembles a template from the study narrative. The
|
|
175
|
+
* human always owns the final text - this drafts, never decides.
|
|
176
|
+
*/
|
|
177
|
+
draftRationale?: (ctx: DraftRationaleContext) => Promise<string> | string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Evidence shapes (exported so hosts/tests can type gate payloads)
|
|
182
|
+
|
|
183
|
+
/** Prominent tolerance block (spec 6 Gate 1). */
|
|
184
|
+
export interface ReplayToleranceEvidence {
|
|
185
|
+
/** expectations.replayTolerance as stated, or null when the study omits it. */
|
|
186
|
+
stated: number | null;
|
|
187
|
+
/** The convention-profile default the 10x flag is measured against. */
|
|
188
|
+
profileId: string;
|
|
189
|
+
profileDefault: number;
|
|
190
|
+
/** true when the stated tolerance exceeds 10x the profile default. */
|
|
191
|
+
exceedsTenTimesProfileDefault: boolean;
|
|
192
|
+
/** The host ceiling from options. */
|
|
193
|
+
ceiling: number;
|
|
194
|
+
/** min(stated ?? profileDefault, ceiling): what Gate 2 referees at. */
|
|
195
|
+
effective: number;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface TriangleReviewEvidence {
|
|
199
|
+
/** paired-asop23 = reviewTriangles on a paid/incurred pair; structural =
|
|
200
|
+
* per-triangle checks; not-evaluated = non-core measure. */
|
|
201
|
+
mode: "paired-asop23" | "structural" | "not-evaluated";
|
|
202
|
+
triangles: { integrity: string; measure: string }[];
|
|
203
|
+
report: DataReviewReport;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface SegmentResolutionEvidence {
|
|
207
|
+
selectionIntegrity: string;
|
|
208
|
+
labels: Record<string, string>;
|
|
209
|
+
target: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface SelectionCoherenceEvidence {
|
|
213
|
+
selectionIntegrity: string;
|
|
214
|
+
triangleIntegrity: string;
|
|
215
|
+
coherence: CoherenceCheck;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface StudyIntakeEvidence {
|
|
219
|
+
study: {
|
|
220
|
+
title: string;
|
|
221
|
+
analyst: string | null;
|
|
222
|
+
sourceRef: string | null;
|
|
223
|
+
summary: string;
|
|
224
|
+
integrity: string;
|
|
225
|
+
};
|
|
226
|
+
/** FIRST, on purpose: the spec requires prominent display. */
|
|
227
|
+
replayTolerance: ReplayToleranceEvidence;
|
|
228
|
+
dataReview: TriangleReviewEvidence[];
|
|
229
|
+
coherence: SelectionCoherenceEvidence[];
|
|
230
|
+
segments: SegmentResolutionEvidence[];
|
|
231
|
+
warnings: string[];
|
|
232
|
+
/**
|
|
233
|
+
* ALWAYS populated: the verification-scope disclosure. Coherence, replay,
|
|
234
|
+
* and referee all verify the study against its OWN embedded triangle;
|
|
235
|
+
* whether that triangle IS the host workspace's book of business is not
|
|
236
|
+
* machine-verified anywhere in the chain. Stating this in the evidence
|
|
237
|
+
* keeps the gates honest about what they checked - the data-binding
|
|
238
|
+
* judgment belongs to the reviewing actuary, and the UI must say so
|
|
239
|
+
* rather than let "verified" read wider than it is.
|
|
240
|
+
*/
|
|
241
|
+
workspaceBindingNote: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Per replay target: how this shore verified it (spec 3.2 capabilities). */
|
|
245
|
+
export interface ReplayTargetLabel {
|
|
246
|
+
/** "12-24" for a development interval, or "tail". */
|
|
247
|
+
target: string;
|
|
248
|
+
/** exact = independently recomputed; value-only = applied as stated. */
|
|
249
|
+
capability: "exact" | "value-only";
|
|
250
|
+
/** The disclosure-honest label: replayed-exact or verified-by-value. */
|
|
251
|
+
label: "replayed-exact" | "verified-by-value";
|
|
252
|
+
note?: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface SelectionReplayEvidence {
|
|
256
|
+
selectionIntegrity: string;
|
|
257
|
+
triangleIntegrity: string;
|
|
258
|
+
segmentTarget: string;
|
|
259
|
+
targets: ReplayTargetLabel[];
|
|
260
|
+
/** Every intent judgmental/external: the whole replay is value transport. */
|
|
261
|
+
verifiedByValueOnly: boolean;
|
|
262
|
+
replayTotals: { ultimate: number; unpaid: number };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export interface SupportingCrosscheckEvidence {
|
|
266
|
+
resultIntegrity: string;
|
|
267
|
+
engine: { name: string; version: string };
|
|
268
|
+
/** null when the result could not be refereed (see reason). */
|
|
269
|
+
verdict: "agree" | "disagree" | "not-comparable" | "verified-by-value" | null;
|
|
270
|
+
report: CrosscheckReportDoc | null;
|
|
271
|
+
reason?: string;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface ReplayVerifyEvidence {
|
|
275
|
+
effectiveTolerance: number;
|
|
276
|
+
replays: SelectionReplayEvidence[];
|
|
277
|
+
crosschecks: SupportingCrosscheckEvidence[];
|
|
278
|
+
/** Any crosscheck verdict "disagree": the gate cannot accept (spec 6). */
|
|
279
|
+
hardBlocked: boolean;
|
|
280
|
+
/** States exactly what the verification consisted of - including, when the
|
|
281
|
+
* study carried no supportingResults, that it was coherence + replay only. */
|
|
282
|
+
verification: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* A supporting result the study carries that is NOT reproducible (spec 16).
|
|
287
|
+
*
|
|
288
|
+
* A `witnessed` result attests what an engine produced on one run; re-running
|
|
289
|
+
* it does not reproduce the number. That is legitimate evidence, but it is a
|
|
290
|
+
* materially different thing from a replayable derivation, and the actuary
|
|
291
|
+
* whose attestation goes on the ledger has to know which one they are relying
|
|
292
|
+
* on. Surfacing it here is what makes the attestation informed rather than
|
|
293
|
+
* nominal — the promotion is not blocked, it is disclosed.
|
|
294
|
+
*/
|
|
295
|
+
export interface WitnessedResultNotice {
|
|
296
|
+
method: string;
|
|
297
|
+
engine: string;
|
|
298
|
+
seed: number | null;
|
|
299
|
+
/** The engine's own repeat-run self-check, when it performed one. */
|
|
300
|
+
stability: {
|
|
301
|
+
repeats: number;
|
|
302
|
+
byteIdentical: boolean;
|
|
303
|
+
maxRelativeDeviation: number | null;
|
|
304
|
+
} | null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export interface RationaleEvidence {
|
|
308
|
+
draftRationale: string;
|
|
309
|
+
attestationRequired: true;
|
|
310
|
+
study: { title: string; sourceRef: string | null; analyst: string | null };
|
|
311
|
+
/**
|
|
312
|
+
* Non-reproducible supporting results, empty when every result is
|
|
313
|
+
* deterministic or seeded-reproducible.
|
|
314
|
+
*/
|
|
315
|
+
witnessedResults: WitnessedResultNotice[];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Collects the study's `witnessed` supporting results (spec 16). */
|
|
319
|
+
function witnessedResultsOf(study: StudyBody): WitnessedResultNotice[] {
|
|
320
|
+
const notices: WitnessedResultNotice[] = [];
|
|
321
|
+
for (const doc of study.supportingResults ?? []) {
|
|
322
|
+
const result = doc.result as Record<string, unknown>;
|
|
323
|
+
if (result["reproducibility"] !== "witnessed") continue;
|
|
324
|
+
const rawStability = result["stability"];
|
|
325
|
+
const stability =
|
|
326
|
+
rawStability !== null && typeof rawStability === "object"
|
|
327
|
+
? (rawStability as Record<string, unknown>)
|
|
328
|
+
: null;
|
|
329
|
+
notices.push({
|
|
330
|
+
method: String(result["method"] ?? "(unnamed method)"),
|
|
331
|
+
engine: `${doc.result.engine.name}@${doc.result.engine.version}`,
|
|
332
|
+
seed: typeof result["seed"] === "number" ? result["seed"] : null,
|
|
333
|
+
stability:
|
|
334
|
+
stability === null
|
|
335
|
+
? null
|
|
336
|
+
: {
|
|
337
|
+
repeats: Number(stability["repeats"] ?? 0),
|
|
338
|
+
byteIdentical: stability["byteIdentical"] === true,
|
|
339
|
+
maxRelativeDeviation:
|
|
340
|
+
typeof stability["maxRelativeDeviation"] === "number"
|
|
341
|
+
? stability["maxRelativeDeviation"]
|
|
342
|
+
: null,
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return notices;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export interface ApplyEvidence {
|
|
350
|
+
applications: {
|
|
351
|
+
segmentTarget: string;
|
|
352
|
+
selectionIntegrity: string;
|
|
353
|
+
developmentCount: number;
|
|
354
|
+
tailFactor: number;
|
|
355
|
+
}[];
|
|
356
|
+
analysisLabel: string;
|
|
357
|
+
ledgerSource: string;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The workflow id promoteStudy builds its chain with. */
|
|
361
|
+
export const PROMOTION_CHAIN_ID = "promote-study";
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Intake computation (pure; runs once, at construction)
|
|
365
|
+
|
|
366
|
+
interface SelectionUnit {
|
|
367
|
+
selectionDoc: SelectionDoc;
|
|
368
|
+
triangleDoc: TriangleDoc;
|
|
369
|
+
labels: Record<string, string>;
|
|
370
|
+
target: string;
|
|
371
|
+
replayed: DocToSelectionsResult;
|
|
372
|
+
replayDoc: MethodResultDoc;
|
|
373
|
+
replayTotals: { ultimate: number; unpaid: number };
|
|
374
|
+
targets: ReplayTargetLabel[];
|
|
375
|
+
verifiedByValueOnly: boolean;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
interface PromotionContext {
|
|
379
|
+
study: StudyBody;
|
|
380
|
+
studyIntegrity: string;
|
|
381
|
+
tolerance: ReplayToleranceEvidence;
|
|
382
|
+
dataReview: TriangleReviewEvidence[];
|
|
383
|
+
units: SelectionUnit[];
|
|
384
|
+
crosschecks: SupportingCrosscheckEvidence[];
|
|
385
|
+
hardBlocked: boolean;
|
|
386
|
+
warnings: string[];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
type IntakeOutcome =
|
|
390
|
+
| { ok: true; ctx: PromotionContext }
|
|
391
|
+
| { ok: false; error: Error };
|
|
392
|
+
|
|
393
|
+
const MAX_STRUCTURAL_DETAILS = 20;
|
|
394
|
+
|
|
395
|
+
function capDetails(items: string[]): string[] {
|
|
396
|
+
return items.length <= MAX_STRUCTURAL_DETAILS
|
|
397
|
+
? items
|
|
398
|
+
: [...items.slice(0, MAX_STRUCTURAL_DETAILS), `+${items.length - MAX_STRUCTURAL_DETAILS} more`];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function structuralCheck(
|
|
402
|
+
id: string,
|
|
403
|
+
description: string,
|
|
404
|
+
findings: string[],
|
|
405
|
+
): DataCheck {
|
|
406
|
+
return {
|
|
407
|
+
id,
|
|
408
|
+
description,
|
|
409
|
+
status: findings.length > 0 ? "warning" : "pass",
|
|
410
|
+
details: capDetails(findings),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* The per-triangle STRUCTURAL subset of the data package's triangle review
|
|
416
|
+
* (the pair-wise checks need a paid/incurred pair; these do not).
|
|
417
|
+
*/
|
|
418
|
+
function structuralReview(tri: Triangle): DataReviewReport {
|
|
419
|
+
const nonDecreasing: string[] = [];
|
|
420
|
+
const interiorMissing: string[] = [];
|
|
421
|
+
for (let i = 0; i < tri.values.length; i++) {
|
|
422
|
+
const row = tri.values[i]!;
|
|
423
|
+
let prev: number | null = null;
|
|
424
|
+
let prevAge: number | null = null;
|
|
425
|
+
for (let j = 0; j < row.length; j++) {
|
|
426
|
+
const v = row[j];
|
|
427
|
+
if (v === null || v === undefined) continue;
|
|
428
|
+
const age = tri.ages[j]!;
|
|
429
|
+
if (prev !== null && v < prev) {
|
|
430
|
+
nonDecreasing.push(
|
|
431
|
+
`${tri.kind} ${tri.origins[i]} age ${prevAge} -> ${age}: ${prev} -> ${v}`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
prev = v;
|
|
435
|
+
prevAge = age;
|
|
436
|
+
}
|
|
437
|
+
const observed = row.map((v) => v !== null && v !== undefined);
|
|
438
|
+
const first = observed.indexOf(true);
|
|
439
|
+
const last = observed.lastIndexOf(true);
|
|
440
|
+
if (first === -1) continue;
|
|
441
|
+
for (let j = first + 1; j < last; j++) {
|
|
442
|
+
if (!observed[j]) {
|
|
443
|
+
interiorMissing.push(`${tri.kind} ${tri.origins[i]} age ${tri.ages[j]}: interior cell missing`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const checks: DataCheck[] = [
|
|
448
|
+
structuralCheck(
|
|
449
|
+
"negative-incremental",
|
|
450
|
+
"Cumulative values are non-decreasing along each origin row (salvage/case takedowns can legitimately violate this)",
|
|
451
|
+
nonDecreasing,
|
|
452
|
+
),
|
|
453
|
+
structuralCheck(
|
|
454
|
+
"interior-missing",
|
|
455
|
+
"No row has a missing cell between observed cells",
|
|
456
|
+
interiorMissing,
|
|
457
|
+
),
|
|
458
|
+
];
|
|
459
|
+
const summary = { pass: 0, warning: 0, fail: 0, notEvaluated: 0 };
|
|
460
|
+
for (const c of checks) {
|
|
461
|
+
if (c.status === "pass") summary.pass++;
|
|
462
|
+
else if (c.status === "warning") summary.warning++;
|
|
463
|
+
}
|
|
464
|
+
return { checks, summary };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function notEvaluatedReview(reason: string): DataReviewReport {
|
|
468
|
+
return {
|
|
469
|
+
checks: [
|
|
470
|
+
{
|
|
471
|
+
id: "loss-triangle-review",
|
|
472
|
+
description: "ASOP 23-oriented triangle review",
|
|
473
|
+
status: "not-evaluated",
|
|
474
|
+
details: [`not evaluated: ${reason}`],
|
|
475
|
+
},
|
|
476
|
+
],
|
|
477
|
+
summary: { pass: 0, warning: 0, fail: 0, notEvaluated: 1 },
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function segmentKeyOf(labels: Record<string, string>): string {
|
|
482
|
+
return JSON.stringify(Object.entries(labels).sort(([a], [b]) => a.localeCompare(b)));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function labelsOf(triangleDoc: TriangleDoc): Record<string, string> {
|
|
486
|
+
return triangleDoc.triangle.segment?.labels ?? {};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* ASOP 23 review over the study's triangles: paired reviewTriangles wherever
|
|
491
|
+
* a segment group holds exactly one paid and one incurred core triangle;
|
|
492
|
+
* structural checks for every other core loss triangle; not-evaluated for
|
|
493
|
+
* non-core measures (see the module doc for the rationale).
|
|
494
|
+
*/
|
|
495
|
+
function reviewStudyTriangles(
|
|
496
|
+
triangles: readonly TriangleDoc[],
|
|
497
|
+
warnings: string[],
|
|
498
|
+
): TriangleReviewEvidence[] {
|
|
499
|
+
const isCore = (doc: TriangleDoc): boolean =>
|
|
500
|
+
(CORE_MEASURES as readonly string[]).includes(doc.triangle.measure);
|
|
501
|
+
|
|
502
|
+
const bySegment = new Map<string, TriangleDoc[]>();
|
|
503
|
+
for (const doc of triangles) {
|
|
504
|
+
if (!isCore(doc)) continue;
|
|
505
|
+
const key = segmentKeyOf(labelsOf(doc));
|
|
506
|
+
const list = bySegment.get(key);
|
|
507
|
+
if (list) list.push(doc);
|
|
508
|
+
else bySegment.set(key, [doc]);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const paired = new Set<TriangleDoc>();
|
|
512
|
+
const evidence: TriangleReviewEvidence[] = [];
|
|
513
|
+
for (const group of bySegment.values()) {
|
|
514
|
+
const paidDocs = group.filter((d) => d.triangle.measure === "paid");
|
|
515
|
+
const incurredDocs = group.filter((d) => d.triangle.measure === "incurred");
|
|
516
|
+
if (paidDocs.length === 1 && incurredDocs.length === 1) {
|
|
517
|
+
const paidDoc = paidDocs[0]!;
|
|
518
|
+
const incurredDoc = incurredDocs[0]!;
|
|
519
|
+
const paidConv = docToTriangle(paidDoc);
|
|
520
|
+
const incurredConv = docToTriangle(incurredDoc);
|
|
521
|
+
warnings.push(...paidConv.warnings, ...incurredConv.warnings);
|
|
522
|
+
evidence.push({
|
|
523
|
+
mode: "paired-asop23",
|
|
524
|
+
triangles: [
|
|
525
|
+
{ integrity: paidDoc.integrity, measure: "paid" },
|
|
526
|
+
{ integrity: incurredDoc.integrity, measure: "incurred" },
|
|
527
|
+
],
|
|
528
|
+
report: reviewTriangles(paidConv.triangle, incurredConv.triangle),
|
|
529
|
+
});
|
|
530
|
+
paired.add(paidDoc);
|
|
531
|
+
paired.add(incurredDoc);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
for (const doc of triangles) {
|
|
536
|
+
if (paired.has(doc)) continue;
|
|
537
|
+
if (!isCore(doc)) {
|
|
538
|
+
evidence.push({
|
|
539
|
+
mode: "not-evaluated",
|
|
540
|
+
triangles: [{ integrity: doc.integrity, measure: doc.triangle.measure }],
|
|
541
|
+
report: notEvaluatedReview(
|
|
542
|
+
`measure "${doc.triangle.measure}" is exposure/reference data, not a core loss triangle`,
|
|
543
|
+
),
|
|
544
|
+
});
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const conv = docToTriangle(doc);
|
|
548
|
+
warnings.push(...conv.warnings);
|
|
549
|
+
evidence.push({
|
|
550
|
+
mode: "structural",
|
|
551
|
+
triangles: [{ integrity: doc.integrity, measure: doc.triangle.measure }],
|
|
552
|
+
report: structuralReview(conv.triangle),
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
return evidence;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** The shared conventionProfile the 10x flag is measured against. */
|
|
559
|
+
function referenceProfileId(supportingResults: StudyBody["supportingResults"]): string {
|
|
560
|
+
const profiles = new Set<string>();
|
|
561
|
+
for (const doc of supportingResults ?? []) {
|
|
562
|
+
const profile = doc.result.engine.conventionProfile;
|
|
563
|
+
if (typeof profile === "string" && profile in CONVENTION_PROFILES) profiles.add(profile);
|
|
564
|
+
}
|
|
565
|
+
return profiles.size === 1 ? [...profiles][0]! : DETERMINISTIC_CL_PROFILE.id;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function targetLabelsOf(coherence: CoherenceCheck): ReplayTargetLabel[] {
|
|
569
|
+
return coherence.findings.map((finding) => {
|
|
570
|
+
const target =
|
|
571
|
+
finding.target === "tail"
|
|
572
|
+
? "tail"
|
|
573
|
+
: `${finding.target.fromAgeMonths}-${finding.target.toAgeMonths}`;
|
|
574
|
+
const exact = finding.capability === "exact";
|
|
575
|
+
const label: ReplayTargetLabel = {
|
|
576
|
+
target,
|
|
577
|
+
capability: finding.capability,
|
|
578
|
+
label: exact ? "replayed-exact" : "verified-by-value",
|
|
579
|
+
};
|
|
580
|
+
if (finding.note !== undefined) label.note = finding.note;
|
|
581
|
+
return label;
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function computeIntake(
|
|
586
|
+
deps: PromoteStudyDeps,
|
|
587
|
+
studyInput: unknown,
|
|
588
|
+
options: PromoteStudyOptions,
|
|
589
|
+
): IntakeOutcome {
|
|
590
|
+
const strictness = options.strictness ?? "refuse";
|
|
591
|
+
const warnings: string[] = [];
|
|
592
|
+
try {
|
|
593
|
+
// 1. Schema + envelope-integrity validation via the interchange parser.
|
|
594
|
+
const parsed = parseDocument(studyInput, { strictness });
|
|
595
|
+
warnings.push(...parsed.warnings);
|
|
596
|
+
if (parsed.doc.kind !== "study") {
|
|
597
|
+
throw new ReservingError(
|
|
598
|
+
"BAD_INTERCHANGE",
|
|
599
|
+
`promoteStudy needs a document of kind "study"; got kind "${parsed.doc.kind}"`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
const doc = parsed.doc as StudyDoc;
|
|
603
|
+
const study = doc.study;
|
|
604
|
+
if (study.selections.length === 0) {
|
|
605
|
+
throw new AgentsError(
|
|
606
|
+
"EMPTY_STUDY",
|
|
607
|
+
`Study "${study.title}" contains no selections; the promotion unit must carry at least one`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// 2. Nested-document integrity (the study envelope's tag covers the study
|
|
612
|
+
// body, not the embedded documents' own claims).
|
|
613
|
+
const nested: { kind: string; doc: { integrity: string; kind: string } }[] = [
|
|
614
|
+
...study.triangles.map((t) => ({ kind: "triangle", doc: t })),
|
|
615
|
+
...study.selections.map((s) => ({ kind: "selection", doc: s })),
|
|
616
|
+
...(study.supportingResults ?? []).map((r) => ({ kind: r.kind, doc: r })),
|
|
617
|
+
];
|
|
618
|
+
for (const { kind, doc: embedded } of nested) {
|
|
619
|
+
const check = verifyIntegrity(embedded as never);
|
|
620
|
+
if (!check.ok) {
|
|
621
|
+
const message =
|
|
622
|
+
`Embedded ${kind} document states integrity ${check.actual ?? "(none)"} but its semantic ` +
|
|
623
|
+
`body hashes to ${check.expected}`;
|
|
624
|
+
if (strictness === "refuse") throw new ReservingError("BAD_INTERCHANGE", message);
|
|
625
|
+
warnings.push(message);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// 3. Tolerance ceiling + the 10x-profile-default flag (spec 6 Gate 1).
|
|
630
|
+
const stated = study.expectations?.replayTolerance ?? null;
|
|
631
|
+
const ceiling = options.toleranceCeiling;
|
|
632
|
+
if (stated !== null && stated > ceiling) {
|
|
633
|
+
throw new AgentsError(
|
|
634
|
+
"TOLERANCE_CEILING_EXCEEDED",
|
|
635
|
+
`Study replayTolerance ${stated} exceeds the host ceiling ${ceiling}; the host cannot ` +
|
|
636
|
+
"verify at the study's stated tolerance, so the study fails intake (loosen nothing here - " +
|
|
637
|
+
"fix the study upstream or raise the host ceiling deliberately)",
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
const profileId = referenceProfileId(study.supportingResults);
|
|
641
|
+
const profileDefault = CONVENTION_PROFILES[profileId]!.tolerance.central;
|
|
642
|
+
const tolerance: ReplayToleranceEvidence = {
|
|
643
|
+
stated,
|
|
644
|
+
profileId,
|
|
645
|
+
profileDefault,
|
|
646
|
+
exceedsTenTimesProfileDefault: stated !== null && stated > 10 * profileDefault,
|
|
647
|
+
ceiling,
|
|
648
|
+
effective: Math.min(stated ?? profileDefault, ceiling),
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
// 4. ASOP 23 data review.
|
|
652
|
+
const dataReview = reviewStudyTriangles(study.triangles, warnings);
|
|
653
|
+
|
|
654
|
+
// 5. Per selection: triangle linkage, coherence, segment, replay.
|
|
655
|
+
const trianglesByTag = new Map(study.triangles.map((t) => [t.integrity, t]));
|
|
656
|
+
const targetsSeen = new Map<string, string>(); // target -> selection integrity
|
|
657
|
+
const units: SelectionUnit[] = [];
|
|
658
|
+
for (const selectionDoc of study.selections) {
|
|
659
|
+
const tag = selectionDoc.selection.appliesTo.triangleIntegrity;
|
|
660
|
+
const triangleDoc = trianglesByTag.get(tag);
|
|
661
|
+
if (triangleDoc === undefined) {
|
|
662
|
+
throw new ReservingError(
|
|
663
|
+
"BAD_INTERCHANGE",
|
|
664
|
+
`Selection ${selectionDoc.integrity} applies to triangle ${tag}, which the study does not carry`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
const replayed = docToSelections(selectionDoc, { triangleDoc, strictness });
|
|
668
|
+
warnings.push(...replayed.warnings);
|
|
669
|
+
|
|
670
|
+
const labels = labelsOf(triangleDoc);
|
|
671
|
+
const target = deps.resolveSegment(labels);
|
|
672
|
+
if (target === null) {
|
|
673
|
+
throw new AgentsError(
|
|
674
|
+
"SEGMENT_UNRESOLVED",
|
|
675
|
+
`No workspace target matches segment labels ${JSON.stringify(labels)} for selection ` +
|
|
676
|
+
`${selectionDoc.integrity} (v1 resolves exact matches only; no fuzzy matching)`,
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
const priorSelection = targetsSeen.get(target);
|
|
680
|
+
if (priorSelection !== undefined) {
|
|
681
|
+
throw new AgentsError(
|
|
682
|
+
"SEGMENT_AMBIGUOUS",
|
|
683
|
+
`Selections ${priorSelection} and ${selectionDoc.integrity} both resolve to workspace ` +
|
|
684
|
+
`target "${target}"; v1 promotes exactly one selection per segment`,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
targetsSeen.set(target, selectionDoc.integrity);
|
|
688
|
+
|
|
689
|
+
const replayResult = runChainLadder(
|
|
690
|
+
docToTriangle(triangleDoc).triangle,
|
|
691
|
+
replayed.selections,
|
|
692
|
+
);
|
|
693
|
+
warnings.push(...replayResult.warnings);
|
|
694
|
+
const replayDoc = resultToDoc(replayResult, {
|
|
695
|
+
triangleDoc,
|
|
696
|
+
selectionDoc,
|
|
697
|
+
createdAt: options.now(),
|
|
698
|
+
parameters: { replayOf: selectionDoc.integrity, source: "promotion replay-verify" },
|
|
699
|
+
});
|
|
700
|
+
units.push({
|
|
701
|
+
selectionDoc,
|
|
702
|
+
triangleDoc,
|
|
703
|
+
labels,
|
|
704
|
+
target,
|
|
705
|
+
replayed,
|
|
706
|
+
replayDoc,
|
|
707
|
+
replayTotals: {
|
|
708
|
+
ultimate: replayResult.totals.ultimate,
|
|
709
|
+
unpaid: replayResult.totals.unpaid,
|
|
710
|
+
},
|
|
711
|
+
targets: targetLabelsOf(replayed.coherence),
|
|
712
|
+
verifiedByValueOnly: isValueOnlySelection(selectionDoc.selection),
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// 6. Referee each supporting result against its replay (spec 6 Gate 2).
|
|
717
|
+
const crosschecks: SupportingCrosscheckEvidence[] = [];
|
|
718
|
+
for (const supporting of study.supportingResults ?? []) {
|
|
719
|
+
const engine = {
|
|
720
|
+
name: supporting.result.engine.name,
|
|
721
|
+
version: supporting.result.engine.version,
|
|
722
|
+
};
|
|
723
|
+
if (supporting.kind !== "method-result") {
|
|
724
|
+
crosschecks.push({
|
|
725
|
+
resultIntegrity: supporting.integrity,
|
|
726
|
+
engine,
|
|
727
|
+
verdict: null,
|
|
728
|
+
report: null,
|
|
729
|
+
reason:
|
|
730
|
+
"stochastic results compare at distribution level only (spec 3.2); not refereed in this phase",
|
|
731
|
+
});
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const unit = units.find(
|
|
735
|
+
(u) =>
|
|
736
|
+
u.selectionDoc.integrity === supporting.result.appliesTo.selectionIntegrity &&
|
|
737
|
+
u.triangleDoc.integrity === supporting.result.appliesTo.triangleIntegrity,
|
|
738
|
+
);
|
|
739
|
+
if (unit === undefined) {
|
|
740
|
+
crosschecks.push({
|
|
741
|
+
resultIntegrity: supporting.integrity,
|
|
742
|
+
engine,
|
|
743
|
+
verdict: null,
|
|
744
|
+
report: null,
|
|
745
|
+
reason:
|
|
746
|
+
"appliesTo tags match no (triangle, selection) pair in the study; nothing to referee against",
|
|
747
|
+
});
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
const report = crosscheck({
|
|
751
|
+
a: supporting,
|
|
752
|
+
b: unit.replayDoc,
|
|
753
|
+
tolerance: tolerance.effective,
|
|
754
|
+
selection: unit.selectionDoc,
|
|
755
|
+
createdAt: options.now(),
|
|
756
|
+
});
|
|
757
|
+
crosschecks.push({
|
|
758
|
+
resultIntegrity: supporting.integrity,
|
|
759
|
+
engine,
|
|
760
|
+
verdict: report.report.verdict,
|
|
761
|
+
report,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
ok: true,
|
|
767
|
+
ctx: {
|
|
768
|
+
study,
|
|
769
|
+
studyIntegrity: doc.integrity,
|
|
770
|
+
tolerance,
|
|
771
|
+
dataReview,
|
|
772
|
+
units,
|
|
773
|
+
crosschecks,
|
|
774
|
+
hardBlocked: crosschecks.some((c) => c.verdict === "disagree"),
|
|
775
|
+
warnings,
|
|
776
|
+
},
|
|
777
|
+
};
|
|
778
|
+
} catch (err) {
|
|
779
|
+
return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// ---------------------------------------------------------------------------
|
|
784
|
+
// Evidence assembly
|
|
785
|
+
|
|
786
|
+
function reviewSummaryText(dataReview: TriangleReviewEvidence[]): string {
|
|
787
|
+
const totals = { pass: 0, warning: 0, fail: 0, notEvaluated: 0 };
|
|
788
|
+
for (const entry of dataReview) {
|
|
789
|
+
totals.pass += entry.report.summary.pass;
|
|
790
|
+
totals.warning += entry.report.summary.warning;
|
|
791
|
+
totals.fail += entry.report.summary.fail;
|
|
792
|
+
totals.notEvaluated += entry.report.summary.notEvaluated;
|
|
793
|
+
}
|
|
794
|
+
return `${totals.pass} pass, ${totals.warning} warning, ${totals.fail} fail, ${totals.notEvaluated} not evaluated`;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** See StudyIntakeEvidence.workspaceBindingNote; one constant so every
|
|
798
|
+
* intake states the same scope, verbatim. */
|
|
799
|
+
export const WORKSPACE_BINDING_NOTE =
|
|
800
|
+
"Scope of verification: coherence, replay, and referee checks verify the study against " +
|
|
801
|
+
"its OWN embedded triangle. Whether that triangle is this workspace's book of business " +
|
|
802
|
+
"is not machine-verified; that binding remains the reviewing actuary's judgment.";
|
|
803
|
+
|
|
804
|
+
function intakeEvidenceOf(ctx: PromotionContext): StudyIntakeEvidence {
|
|
805
|
+
return {
|
|
806
|
+
study: {
|
|
807
|
+
title: ctx.study.title,
|
|
808
|
+
analyst: ctx.study.narrative.analyst ?? null,
|
|
809
|
+
sourceRef: ctx.study.narrative.sourceRef ?? null,
|
|
810
|
+
summary: ctx.study.narrative.summary,
|
|
811
|
+
integrity: ctx.studyIntegrity,
|
|
812
|
+
},
|
|
813
|
+
replayTolerance: ctx.tolerance,
|
|
814
|
+
dataReview: ctx.dataReview,
|
|
815
|
+
coherence: ctx.units.map((u) => ({
|
|
816
|
+
selectionIntegrity: u.selectionDoc.integrity,
|
|
817
|
+
triangleIntegrity: u.triangleDoc.integrity,
|
|
818
|
+
coherence: u.replayed.coherence,
|
|
819
|
+
})),
|
|
820
|
+
segments: ctx.units.map((u) => ({
|
|
821
|
+
selectionIntegrity: u.selectionDoc.integrity,
|
|
822
|
+
labels: u.labels,
|
|
823
|
+
target: u.target,
|
|
824
|
+
})),
|
|
825
|
+
warnings: ctx.warnings,
|
|
826
|
+
workspaceBindingNote: WORKSPACE_BINDING_NOTE,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function replayEvidenceOf(ctx: PromotionContext): ReplayVerifyEvidence {
|
|
831
|
+
const refereed = ctx.crosschecks.filter((c) => c.verdict !== null);
|
|
832
|
+
const verification =
|
|
833
|
+
(ctx.study.supportingResults ?? []).length === 0
|
|
834
|
+
? "Verification was selection coherence plus replay only: the study carried no " +
|
|
835
|
+
"supportingResults, so no cross-engine referee step ran (spec 6 Gate 2)."
|
|
836
|
+
: `Verification was coherence, replay, and referee: ${refereed.length} supporting ` +
|
|
837
|
+
`result(s) crosschecked against the replay at effective tolerance ${ctx.tolerance.effective}` +
|
|
838
|
+
(refereed.length < ctx.crosschecks.length
|
|
839
|
+
? `; ${ctx.crosschecks.length - refereed.length} could not be refereed (see reasons)`
|
|
840
|
+
: "") +
|
|
841
|
+
".";
|
|
842
|
+
return {
|
|
843
|
+
effectiveTolerance: ctx.tolerance.effective,
|
|
844
|
+
replays: ctx.units.map((u) => ({
|
|
845
|
+
selectionIntegrity: u.selectionDoc.integrity,
|
|
846
|
+
triangleIntegrity: u.triangleDoc.integrity,
|
|
847
|
+
segmentTarget: u.target,
|
|
848
|
+
targets: u.targets,
|
|
849
|
+
verifiedByValueOnly: u.verifiedByValueOnly,
|
|
850
|
+
replayTotals: u.replayTotals,
|
|
851
|
+
})),
|
|
852
|
+
crosschecks: ctx.crosschecks,
|
|
853
|
+
hardBlocked: ctx.hardBlocked,
|
|
854
|
+
verification,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function defaultDraftRationale(ctx: DraftRationaleContext): string {
|
|
859
|
+
const { study, replay } = ctx;
|
|
860
|
+
const source = study.narrative.sourceRef ?? study.title;
|
|
861
|
+
const analyst = study.narrative.analyst !== undefined ? ` by ${study.narrative.analyst}` : "";
|
|
862
|
+
const verdicts = replay.crosschecks
|
|
863
|
+
.filter((c) => c.verdict !== null)
|
|
864
|
+
.map((c) => `${c.engine.name} ${c.verdict}`)
|
|
865
|
+
.join(", ");
|
|
866
|
+
return (
|
|
867
|
+
`Adopting the selections of study "${study.title}" (${source})${analyst}. ` +
|
|
868
|
+
`Study summary: ${study.narrative.summary} ` +
|
|
869
|
+
`Replay verification: ${replay.replays.length} selection(s) verified at effective tolerance ` +
|
|
870
|
+
`${replay.effectiveTolerance}${verdicts.length > 0 ? `; referee verdicts: ${verdicts}` : ""}. ` +
|
|
871
|
+
replay.verification
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// ---------------------------------------------------------------------------
|
|
876
|
+
// Resume-schema building blocks
|
|
877
|
+
|
|
878
|
+
const nonBlankString = z
|
|
879
|
+
.string()
|
|
880
|
+
.refine((s) => s.trim() !== "", { message: "must not be blank" });
|
|
881
|
+
|
|
882
|
+
const actorField = z.string().min(1).optional();
|
|
883
|
+
|
|
884
|
+
/** Maps a free-form actor string onto the compliance ledger's closed enum:
|
|
885
|
+
* exact enum values pass through; anything else (an unattended MCP client,
|
|
886
|
+
* a named service) records as "agent" - disclosure-true for a non-human
|
|
887
|
+
* decider. What is recorded where: every ledger entry's `actor` carries
|
|
888
|
+
* this COARSE enum; the RAW actor string is preserved verbatim inside the
|
|
889
|
+
* attestation entry's value (`value.actor`), so the precise identity
|
|
890
|
+
* survives the enum mapping. */
|
|
891
|
+
function toAssumptionActor(actor: string): AssumptionActor {
|
|
892
|
+
return actor === "default" || actor === "actuary" || actor === "agent" ? actor : "agent";
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
interface AcceptAbortDecision {
|
|
896
|
+
decision: "accept" | "abort";
|
|
897
|
+
rationale: string;
|
|
898
|
+
actor?: string;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
interface RationaleDecision {
|
|
902
|
+
decision: "approve" | "abort";
|
|
903
|
+
rationale: string;
|
|
904
|
+
attestation: string;
|
|
905
|
+
actor?: string;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
interface ApplyDecision {
|
|
909
|
+
decision: "apply" | "abort";
|
|
910
|
+
rationale: string;
|
|
911
|
+
actor?: string;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** The gate a promotion was aborted at, or null when it is still live. */
|
|
915
|
+
function abortedStage(decisions: Record<string, unknown>): string | null {
|
|
916
|
+
for (const gateId of ["study-intake", "replay-verify", "rationale"]) {
|
|
917
|
+
const decision = decisions[gateId] as { decision?: unknown } | undefined;
|
|
918
|
+
if (decision?.decision === "abort") return gateId;
|
|
919
|
+
}
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const skipWhenAborted = (ctx: { decisions: Record<string, unknown> }): string | null => {
|
|
924
|
+
const stage = abortedStage(ctx.decisions);
|
|
925
|
+
return stage === null ? null : `promotion aborted at the ${stage} gate`;
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
// ---------------------------------------------------------------------------
|
|
929
|
+
// The chain factory
|
|
930
|
+
|
|
931
|
+
function validateOptions(deps: PromoteStudyDeps, options: PromoteStudyOptions): void {
|
|
932
|
+
const badOption = (detail: string): never => {
|
|
933
|
+
throw new AgentsError("BAD_PROMOTION_OPTIONS", `promoteStudy: ${detail}`);
|
|
934
|
+
};
|
|
935
|
+
if (typeof deps !== "object" || deps === null) badOption("deps must be an object");
|
|
936
|
+
for (const key of ["resolveSegment", "applySelections", "runAnalysis", "persistNote"] as const) {
|
|
937
|
+
if (typeof deps[key] !== "function") badOption(`deps.${key} must be a function`);
|
|
938
|
+
}
|
|
939
|
+
if (!Number.isFinite(options.toleranceCeiling) || options.toleranceCeiling <= 0) {
|
|
940
|
+
badOption(`toleranceCeiling must be a positive finite number; got ${options.toleranceCeiling}`);
|
|
941
|
+
}
|
|
942
|
+
if (typeof options.now !== "function") badOption("now must be a function returning an ISO timestamp");
|
|
943
|
+
if (options.strictness !== undefined && options.strictness !== "warn" && options.strictness !== "refuse") {
|
|
944
|
+
badOption(`strictness must be "warn" or "refuse"; got ${JSON.stringify(options.strictness)}`);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Builds the promotion judgment chain for one study. SYNCHRONOUS by design
|
|
950
|
+
* (see the accidental-thenable footgun in the module doc): assign the return
|
|
951
|
+
* value and register it on a Mastra instance, exactly like any
|
|
952
|
+
* createJudgmentChain workflow; then createRun/start/resume drive the gates.
|
|
953
|
+
*
|
|
954
|
+
* Reconstruction contract: promoteStudy(deps, studyDoc, options) is
|
|
955
|
+
* deterministic given the study document and deps.resolveSegment, so a host
|
|
956
|
+
* resuming a snapshot after a restart rebuilds the identical chain.
|
|
957
|
+
*/
|
|
958
|
+
export function promoteStudy(
|
|
959
|
+
deps: PromoteStudyDeps,
|
|
960
|
+
studyDoc: unknown,
|
|
961
|
+
options: PromoteStudyOptions,
|
|
962
|
+
): JudgmentChainWorkflow {
|
|
963
|
+
validateOptions(deps, options);
|
|
964
|
+
const intake = computeIntake(deps, studyDoc, options);
|
|
965
|
+
const ctx = intake.ok ? intake.ctx : null;
|
|
966
|
+
|
|
967
|
+
/** Gates 2-4 run only after Gate 1 succeeded, which requires intake.ok. */
|
|
968
|
+
const requireCtx = (): PromotionContext => {
|
|
969
|
+
if (ctx === null) {
|
|
970
|
+
throw intake.ok ? new Error("unreachable") : intake.error;
|
|
971
|
+
}
|
|
972
|
+
return ctx;
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
const intakeGate: JudgmentGateSpec<AcceptAbortDecision> = {
|
|
976
|
+
id: "study-intake",
|
|
977
|
+
stage: "study-intake",
|
|
978
|
+
resumeSchema: z.object({
|
|
979
|
+
decision: z.enum(["accept", "abort"]),
|
|
980
|
+
rationale: nonBlankString,
|
|
981
|
+
actor: actorField,
|
|
982
|
+
}),
|
|
983
|
+
gatherEvidence: () => {
|
|
984
|
+
const context = requireCtx(); // intake failures surface HERE, as a gate failure
|
|
985
|
+
const evidence = intakeEvidenceOf(context);
|
|
986
|
+
const tolerance = context.tolerance;
|
|
987
|
+
const flag = tolerance.exceedsTenTimesProfileDefault
|
|
988
|
+
? ` WARNING: the stated tolerance exceeds 10x the ${tolerance.profileId} profile default (${tolerance.profileDefault}).`
|
|
989
|
+
: "";
|
|
990
|
+
const review = reviewSummaryText(context.dataReview);
|
|
991
|
+
const hasFailures = context.dataReview.some((entry) => entry.report.summary.fail > 0);
|
|
992
|
+
return {
|
|
993
|
+
recommendation:
|
|
994
|
+
`Replay tolerance: stated ${tolerance.stated ?? "(none)"}, host ceiling ${tolerance.ceiling}, ` +
|
|
995
|
+
`effective ${tolerance.effective}.${flag} Data review: ${review}. ` +
|
|
996
|
+
`${context.units.length} selection(s) resolved to segment target(s) ` +
|
|
997
|
+
`[${context.units.map((u) => u.target).join(", ")}]. ` +
|
|
998
|
+
(hasFailures
|
|
999
|
+
? "The data review found failing checks; recommend abort unless the findings are explainable."
|
|
1000
|
+
: "Intake checks passed; accept to proceed to replay verification."),
|
|
1001
|
+
evidence,
|
|
1002
|
+
};
|
|
1003
|
+
},
|
|
1004
|
+
applyDecision: async (_gateCtx, decision) => ({
|
|
1005
|
+
summary:
|
|
1006
|
+
decision.decision === "abort"
|
|
1007
|
+
? "promotion aborted at study intake"
|
|
1008
|
+
: "study accepted for promotion",
|
|
1009
|
+
}),
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
// The Gate 2 hard-block is STRUCTURAL: on any "disagree" referee verdict
|
|
1013
|
+
// the resume schema admits only { decision: "abort" } - Mastra's own
|
|
1014
|
+
// resume validation rejects an accept, and because intake is recomputed
|
|
1015
|
+
// deterministically on reconstruction, the block survives restarts.
|
|
1016
|
+
const replayDecisionSchema = ctx?.hardBlocked
|
|
1017
|
+
? z.literal("abort")
|
|
1018
|
+
: z.enum(["accept", "abort"]);
|
|
1019
|
+
const replayGate: JudgmentGateSpec<AcceptAbortDecision> = {
|
|
1020
|
+
id: "replay-verify",
|
|
1021
|
+
stage: "replay-verify",
|
|
1022
|
+
resumeSchema: z.object({
|
|
1023
|
+
decision: replayDecisionSchema,
|
|
1024
|
+
rationale: nonBlankString,
|
|
1025
|
+
actor: actorField,
|
|
1026
|
+
}) as z.ZodType<AcceptAbortDecision>,
|
|
1027
|
+
skipWhen: skipWhenAborted,
|
|
1028
|
+
gatherEvidence: () => {
|
|
1029
|
+
const context = requireCtx();
|
|
1030
|
+
const evidence = replayEvidenceOf(context);
|
|
1031
|
+
const valueOnlyCount = evidence.replays.filter((r) => r.verifiedByValueOnly).length;
|
|
1032
|
+
return {
|
|
1033
|
+
recommendation: evidence.hardBlocked
|
|
1034
|
+
? "UNACCEPTABLE: a supporting result DISAGREES with the replay at the effective " +
|
|
1035
|
+
`tolerance ${evidence.effectiveTolerance}. The gate cannot accept this study; abort ` +
|
|
1036
|
+
"and fix it upstream (spec 6: tolerance editing is not an escape hatch)."
|
|
1037
|
+
: `${evidence.replays.length} selection(s) verified at tolerance ${evidence.effectiveTolerance}` +
|
|
1038
|
+
(valueOnlyCount > 0
|
|
1039
|
+
? `; ${valueOnlyCount} verified by value only (no independent recomputation)`
|
|
1040
|
+
: "") +
|
|
1041
|
+
`. ${evidence.verification} Accept to proceed to the rationale gate.`,
|
|
1042
|
+
evidence,
|
|
1043
|
+
};
|
|
1044
|
+
},
|
|
1045
|
+
applyDecision: async (_gateCtx, decision) => ({
|
|
1046
|
+
summary:
|
|
1047
|
+
decision.decision === "abort"
|
|
1048
|
+
? "promotion aborted at replay verification"
|
|
1049
|
+
: `replay verification accepted (${requireCtx().units.length} selection(s), tolerance ${
|
|
1050
|
+
requireCtx().tolerance.effective
|
|
1051
|
+
})`,
|
|
1052
|
+
}),
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
const rationaleGate: JudgmentGateSpec<RationaleDecision> = {
|
|
1056
|
+
id: "rationale",
|
|
1057
|
+
stage: "rationale",
|
|
1058
|
+
resumeSchema: z.object({
|
|
1059
|
+
decision: z.enum(["approve", "abort"]),
|
|
1060
|
+
rationale: nonBlankString,
|
|
1061
|
+
attestation: nonBlankString,
|
|
1062
|
+
actor: actorField,
|
|
1063
|
+
}),
|
|
1064
|
+
skipWhen: skipWhenAborted,
|
|
1065
|
+
gatherEvidence: async () => {
|
|
1066
|
+
const context = requireCtx();
|
|
1067
|
+
const draftCtx: DraftRationaleContext = {
|
|
1068
|
+
study: context.study,
|
|
1069
|
+
studyIntegrity: context.studyIntegrity,
|
|
1070
|
+
intake: intakeEvidenceOf(context),
|
|
1071
|
+
replay: replayEvidenceOf(context),
|
|
1072
|
+
};
|
|
1073
|
+
const draft = await (options.draftRationale ?? defaultDraftRationale)(draftCtx);
|
|
1074
|
+
const witnessedResults = witnessedResultsOf(context.study);
|
|
1075
|
+
const evidence: RationaleEvidence = {
|
|
1076
|
+
draftRationale: draft,
|
|
1077
|
+
attestationRequired: true,
|
|
1078
|
+
study: {
|
|
1079
|
+
title: context.study.title,
|
|
1080
|
+
sourceRef: context.study.narrative.sourceRef ?? null,
|
|
1081
|
+
analyst: context.study.narrative.analyst ?? null,
|
|
1082
|
+
},
|
|
1083
|
+
witnessedResults,
|
|
1084
|
+
};
|
|
1085
|
+
// Disclosed, never silently blocked: the actuary decides whether a
|
|
1086
|
+
// non-reproducible result is acceptable support, but they must be told
|
|
1087
|
+
// it is one before their attestation is written to the ledger.
|
|
1088
|
+
const witnessedNotice =
|
|
1089
|
+
witnessedResults.length === 0
|
|
1090
|
+
? ""
|
|
1091
|
+
: ` ATTENTION: ${witnessedResults.length} supporting result(s) are WITNESSED, not ` +
|
|
1092
|
+
`reproducible — ${witnessedResults
|
|
1093
|
+
.map((w) => {
|
|
1094
|
+
const drift =
|
|
1095
|
+
w.stability === null
|
|
1096
|
+
? "no stability check was run"
|
|
1097
|
+
: w.stability.byteIdentical
|
|
1098
|
+
? `${w.stability.repeats} repeat runs agreed exactly`
|
|
1099
|
+
: `repeat runs DIFFERED (max relative deviation ${w.stability.maxRelativeDeviation ?? "unreported"})`;
|
|
1100
|
+
return `${w.method} on ${w.engine} (${drift})`;
|
|
1101
|
+
})
|
|
1102
|
+
.join("; ")}. Re-running these will not reproduce the numbers this study relies on. ` +
|
|
1103
|
+
"Your attestation covers relying on them (spec 16).";
|
|
1104
|
+
return {
|
|
1105
|
+
recommendation:
|
|
1106
|
+
"Review and edit the draft rationale; the final text you resume with is recorded " +
|
|
1107
|
+
"verbatim in the assumption ledger. An attestation (who authored/reviewed the " +
|
|
1108
|
+
"rationale) is required." +
|
|
1109
|
+
witnessedNotice,
|
|
1110
|
+
evidence,
|
|
1111
|
+
};
|
|
1112
|
+
},
|
|
1113
|
+
applyDecision: async (_gateCtx, decision) => ({
|
|
1114
|
+
summary:
|
|
1115
|
+
decision.decision === "abort"
|
|
1116
|
+
? "promotion aborted at the rationale gate"
|
|
1117
|
+
: "rationale approved (attestation on file)",
|
|
1118
|
+
}),
|
|
1119
|
+
};
|
|
1120
|
+
|
|
1121
|
+
const analysisLabelOf = (context: PromotionContext): string =>
|
|
1122
|
+
`Study promotion - ${context.study.title}`;
|
|
1123
|
+
const ledgerSourceOf = (context: PromotionContext): string =>
|
|
1124
|
+
`${context.study.narrative.sourceRef ?? context.study.title} ` +
|
|
1125
|
+
`(study ${context.studyIntegrity}, tolerance ${context.tolerance.effective})`;
|
|
1126
|
+
|
|
1127
|
+
const applyGate: JudgmentGateSpec<ApplyDecision> = {
|
|
1128
|
+
id: "apply",
|
|
1129
|
+
stage: "apply",
|
|
1130
|
+
resumeSchema: z.object({
|
|
1131
|
+
decision: z.enum(["apply", "abort"]),
|
|
1132
|
+
rationale: nonBlankString,
|
|
1133
|
+
actor: actorField,
|
|
1134
|
+
}),
|
|
1135
|
+
skipWhen: skipWhenAborted,
|
|
1136
|
+
gatherEvidence: () => {
|
|
1137
|
+
const context = requireCtx();
|
|
1138
|
+
const evidence: ApplyEvidence = {
|
|
1139
|
+
applications: context.units.map((u) => ({
|
|
1140
|
+
segmentTarget: u.target,
|
|
1141
|
+
selectionIntegrity: u.selectionDoc.integrity,
|
|
1142
|
+
developmentCount: u.selectionDoc.selection.development.length,
|
|
1143
|
+
tailFactor: u.selectionDoc.selection.tail?.value ?? 1,
|
|
1144
|
+
})),
|
|
1145
|
+
analysisLabel: analysisLabelOf(context),
|
|
1146
|
+
ledgerSource: ledgerSourceOf(context),
|
|
1147
|
+
};
|
|
1148
|
+
return {
|
|
1149
|
+
recommendation:
|
|
1150
|
+
`Apply ${evidence.applications.length} selection(s) to ` +
|
|
1151
|
+
`[${evidence.applications.map((a) => a.segmentTarget).join(", ")}], rerun the analysis, ` +
|
|
1152
|
+
"and record the ledger entries. This mutates host state.",
|
|
1153
|
+
evidence,
|
|
1154
|
+
};
|
|
1155
|
+
},
|
|
1156
|
+
applyDecision: async (gateCtx, decision) => {
|
|
1157
|
+
if (decision.decision === "abort") {
|
|
1158
|
+
return { summary: "promotion aborted at the apply gate" };
|
|
1159
|
+
}
|
|
1160
|
+
const context = requireCtx();
|
|
1161
|
+
const approval = gateCtx.decisions["rationale"] as RationaleDecision | undefined;
|
|
1162
|
+
if (approval === undefined || approval.decision !== "approve") {
|
|
1163
|
+
throw new AgentsError(
|
|
1164
|
+
"MISSING_RATIONALE",
|
|
1165
|
+
"The apply gate ran without an approved rationale gate decision; the ledger records " +
|
|
1166
|
+
"the rationale-gate text verbatim and cannot proceed without it",
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
const actor = approval.actor ?? options.actorDefault ?? "actuary";
|
|
1170
|
+
const ledgerActor = toAssumptionActor(actor);
|
|
1171
|
+
const source = ledgerSourceOf(context);
|
|
1172
|
+
|
|
1173
|
+
for (const unit of context.units) {
|
|
1174
|
+
await deps.applySelections(unit.selectionDoc, unit.replayed.selections, unit.target);
|
|
1175
|
+
}
|
|
1176
|
+
await deps.runAnalysis(analysisLabelOf(context));
|
|
1177
|
+
|
|
1178
|
+
const ledgerEntries: JudgmentLedgerEntry[] = context.units.map((unit) => ({
|
|
1179
|
+
field: `selections.${unit.target}`,
|
|
1180
|
+
value: {
|
|
1181
|
+
development: unit.selectionDoc.selection.development.map((d) => ({
|
|
1182
|
+
fromAgeMonths: d.fromAgeMonths,
|
|
1183
|
+
toAgeMonths: d.toAgeMonths,
|
|
1184
|
+
value: d.value,
|
|
1185
|
+
})),
|
|
1186
|
+
tailFactor: unit.selectionDoc.selection.tail?.value ?? 1,
|
|
1187
|
+
},
|
|
1188
|
+
source,
|
|
1189
|
+
rationale: approval.rationale, // Gate 3's final text, verbatim
|
|
1190
|
+
actor: ledgerActor,
|
|
1191
|
+
}));
|
|
1192
|
+
// The attestation entry's value carries BOTH strings verbatim: the
|
|
1193
|
+
// attestation itself (spec 8: lands in the ledger) and the RAW actor
|
|
1194
|
+
// identity, which the entry-level `actor` collapses to the coarse
|
|
1195
|
+
// compliance enum (see toAssumptionActor).
|
|
1196
|
+
ledgerEntries.push({
|
|
1197
|
+
field: "promotion.attestation",
|
|
1198
|
+
value: { attestation: approval.attestation, actor },
|
|
1199
|
+
source,
|
|
1200
|
+
rationale: approval.rationale,
|
|
1201
|
+
actor: ledgerActor,
|
|
1202
|
+
});
|
|
1203
|
+
return {
|
|
1204
|
+
summary: `applied ${context.units.length} selection(s) to [${context.units
|
|
1205
|
+
.map((u) => u.target)
|
|
1206
|
+
.join(", ")}]`,
|
|
1207
|
+
ledgerEntries,
|
|
1208
|
+
};
|
|
1209
|
+
},
|
|
1210
|
+
};
|
|
1211
|
+
|
|
1212
|
+
return createJudgmentChain({
|
|
1213
|
+
id: PROMOTION_CHAIN_ID,
|
|
1214
|
+
gates: [intakeGate, replayGate, rationaleGate, applyGate],
|
|
1215
|
+
now: options.now,
|
|
1216
|
+
onComplete: async (outcome, chainCtx) => {
|
|
1217
|
+
// Persist the trail + ledger JSON exactly like the server's ELR chain,
|
|
1218
|
+
// but only when the promotion actually applied (aborted chains complete
|
|
1219
|
+
// with their trail and an empty ledger; nothing to persist).
|
|
1220
|
+
const applied = chainCtx.decisions["apply"] as ApplyDecision | undefined;
|
|
1221
|
+
if (applied?.decision !== "apply") return;
|
|
1222
|
+
const approval = chainCtx.decisions["rationale"] as RationaleDecision | undefined;
|
|
1223
|
+
const author = approval?.actor ?? options.actorDefault ?? "actuary";
|
|
1224
|
+
await deps.persistNote(
|
|
1225
|
+
`Study promotion trail:\n${outcome.trail
|
|
1226
|
+
.map((t) => `- ${t.stage}: ${t.decision}${t.rationale ? ` - ${t.rationale}` : ""}`)
|
|
1227
|
+
.join("\n")}`,
|
|
1228
|
+
author,
|
|
1229
|
+
);
|
|
1230
|
+
await deps.persistNote(
|
|
1231
|
+
`Study promotion assumption ledger:\n${JSON.stringify(
|
|
1232
|
+
{ entries: outcome.ledger.entries },
|
|
1233
|
+
null,
|
|
1234
|
+
2,
|
|
1235
|
+
)}`,
|
|
1236
|
+
author,
|
|
1237
|
+
);
|
|
1238
|
+
},
|
|
1239
|
+
});
|
|
1240
|
+
}
|