@markstanden/opencode-test-container 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +263 -2
- package/container/Containerfile +24 -0
- package/container/README.md +29 -0
- package/dist/adapters/opencode/events.d.ts +90 -0
- package/dist/adapters/opencode/events.js +213 -0
- package/dist/adapters/opencode/runtime.d.ts +87 -0
- package/dist/adapters/opencode/runtime.js +136 -0
- package/dist/adapters/podman/container.d.ts +62 -0
- package/dist/adapters/podman/container.js +119 -0
- package/dist/adapters/podman/image.d.ts +50 -0
- package/dist/adapters/podman/image.js +124 -0
- package/dist/adapters/process.d.ts +38 -0
- package/dist/adapters/process.js +71 -0
- package/dist/campaign/assess.d.ts +45 -0
- package/dist/campaign/assess.js +208 -0
- package/dist/campaign/close.d.ts +17 -0
- package/dist/campaign/close.js +112 -0
- package/dist/campaign/evidence-guard.d.ts +24 -0
- package/dist/campaign/evidence-guard.js +49 -0
- package/dist/campaign/freeze.d.ts +94 -0
- package/dist/campaign/freeze.js +297 -0
- package/dist/campaign/inventory.d.ts +62 -0
- package/dist/campaign/inventory.js +192 -0
- package/dist/campaign/judge.d.ts +209 -0
- package/dist/campaign/judge.js +1285 -0
- package/dist/campaign/ledger.d.ts +64 -0
- package/dist/campaign/ledger.js +149 -0
- package/dist/campaign/prepare.d.ts +129 -0
- package/dist/campaign/prepare.js +483 -0
- package/dist/campaign/primary.d.ts +96 -0
- package/dist/campaign/primary.js +189 -0
- package/dist/campaign/run.d.ts +151 -0
- package/dist/campaign/run.js +562 -0
- package/dist/campaign/status.d.ts +76 -0
- package/dist/campaign/status.js +237 -0
- package/dist/cli/otc.d.ts +51 -0
- package/dist/cli/otc.js +435 -0
- package/dist/contracts/canonical.d.ts +21 -0
- package/dist/contracts/canonical.js +44 -0
- package/dist/contracts/types.d.ts +261 -0
- package/dist/contracts/types.js +9 -0
- package/dist/contracts/validate.d.ts +72 -0
- package/dist/contracts/validate.js +1021 -0
- package/dist/contracts/version.d.ts +44 -0
- package/dist/contracts/version.js +147 -0
- package/dist/evaluators/docs/analysis/accounting.d.ts +140 -0
- package/dist/evaluators/docs/analysis/accounting.js +237 -0
- package/dist/evaluators/docs/analysis/blinding.d.ts +40 -0
- package/dist/evaluators/docs/analysis/blinding.js +80 -0
- package/dist/evaluators/docs/analysis/bounds.d.ts +40 -0
- package/dist/evaluators/docs/analysis/bounds.js +31 -0
- package/dist/evaluators/docs/analysis/budget.d.ts +137 -0
- package/dist/evaluators/docs/analysis/budget.js +305 -0
- package/dist/evaluators/docs/analysis/closure.d.ts +175 -0
- package/dist/evaluators/docs/analysis/closure.js +251 -0
- package/dist/evaluators/docs/analysis/decision.d.ts +59 -0
- package/dist/evaluators/docs/analysis/decision.js +161 -0
- package/dist/evaluators/docs/analysis/freeze.d.ts +201 -0
- package/dist/evaluators/docs/analysis/freeze.js +395 -0
- package/dist/evaluators/docs/analysis/guardrails.d.ts +51 -0
- package/dist/evaluators/docs/analysis/guardrails.js +194 -0
- package/dist/evaluators/docs/analysis/journal.d.ts +150 -0
- package/dist/evaluators/docs/analysis/journal.js +475 -0
- package/dist/evaluators/docs/analysis/ledger.d.ts +78 -0
- package/dist/evaluators/docs/analysis/ledger.js +145 -0
- package/dist/evaluators/docs/analysis/measures.d.ts +302 -0
- package/dist/evaluators/docs/analysis/measures.js +1069 -0
- package/dist/evaluators/docs/analysis/model.d.ts +236 -0
- package/dist/evaluators/docs/analysis/model.js +740 -0
- package/dist/evaluators/docs/analysis/pairing.d.ts +68 -0
- package/dist/evaluators/docs/analysis/pairing.js +95 -0
- package/dist/evaluators/docs/analysis/records.d.ts +229 -0
- package/dist/evaluators/docs/analysis/records.js +935 -0
- package/dist/evaluators/docs/analysis/recovery.d.ts +225 -0
- package/dist/evaluators/docs/analysis/recovery.js +507 -0
- package/dist/evaluators/docs/analysis/render.d.ts +235 -0
- package/dist/evaluators/docs/analysis/render.js +566 -0
- package/dist/evaluators/docs/analysis/replay.d.ts +15 -0
- package/dist/evaluators/docs/analysis/replay.js +1272 -0
- package/dist/evaluators/docs/analysis/screening/inventory.d.ts +136 -0
- package/dist/evaluators/docs/analysis/screening/inventory.js +307 -0
- package/dist/evaluators/docs/analysis/screening/noteVersion.d.ts +15 -0
- package/dist/evaluators/docs/analysis/screening/noteVersion.js +46 -0
- package/dist/evaluators/docs/analysis/screening/reconcile.d.ts +38 -0
- package/dist/evaluators/docs/analysis/screening/reconcile.js +40 -0
- package/dist/evaluators/docs/analysis/screening/rules.d.ts +163 -0
- package/dist/evaluators/docs/analysis/screening/rules.js +330 -0
- package/dist/evaluators/docs/analysis/screening/scan.d.ts +72 -0
- package/dist/evaluators/docs/analysis/screening/scan.js +362 -0
- package/dist/evaluators/docs/analysis/selector.d.ts +68 -0
- package/dist/evaluators/docs/analysis/selector.js +123 -0
- package/dist/evaluators/docs/assess.d.ts +129 -0
- package/dist/evaluators/docs/assess.js +433 -0
- package/dist/evaluators/docs/campaign.d.ts +133 -0
- package/dist/evaluators/docs/campaign.js +222 -0
- package/dist/evaluators/docs/index.d.ts +25 -0
- package/dist/evaluators/docs/index.js +25 -0
- package/dist/evaluators/docs/journal.d.ts +150 -0
- package/dist/evaluators/docs/journal.js +475 -0
- package/dist/evaluators/docs/scoring.d.ts +367 -0
- package/dist/evaluators/docs/scoring.js +662 -0
- package/dist/evaluators/skills/assess.d.ts +94 -0
- package/dist/evaluators/skills/assess.js +207 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +29 -0
- package/dist/legacy/prod014.d.ts +86 -0
- package/dist/legacy/prod014.js +209 -0
- package/dist/reporting/report.d.ts +79 -0
- package/dist/reporting/report.js +242 -0
- package/docs/architecture.md +106 -0
- package/docs/capability-matrix.md +84 -0
- package/docs/offline-rehearsal.md +75 -0
- package/docs/parity-record.md +81 -0
- package/docs/pr-description.md +103 -0
- package/docs/release-0.1.0.md +52 -0
- package/docs/releasing.md +82 -0
- package/docs/schema.md +97 -0
- package/examples/skills/README.md +13 -0
- package/examples/skills/campaign.json +73 -0
- package/examples/skills/skills/otel-helper/SKILL.md +10 -0
- package/examples/skills/suite.json +29 -0
- package/package.json +45 -5
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in judge recovery: the registered policy, the eligibility decision, the
|
|
3
|
+
* dual spend claim and the checked per-slot selection record.
|
|
4
|
+
*
|
|
5
|
+
* With no registered policy the campaign keeps its historical behaviour: the
|
|
6
|
+
* first unusable assessment stops it. A registered policy permits at most one
|
|
7
|
+
* fresh blind assessment of the same evidence, and only when a complete
|
|
8
|
+
* captured response failed with a JSON syntax error. Every other outcome —
|
|
9
|
+
* transport uncertainty, truncation, schema failure, semantic inconsistency,
|
|
10
|
+
* integrity or freeze problems, unresolved reservations — remains an explicit
|
|
11
|
+
* stop. Nothing here calls a model.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { claimBudget, releaseClaim } from "./budget.js";
|
|
15
|
+
/** The policy schema version this module understands. */
|
|
16
|
+
export const RECOVERY_POLICY_VERSION = 1;
|
|
17
|
+
/** The single failure class the initial policy may make eligible. */
|
|
18
|
+
export const INITIAL_ELIGIBLE_CLASS = "json-syntax";
|
|
19
|
+
/** Whether a value is a plain JSON object. */
|
|
20
|
+
function isRecord(value) {
|
|
21
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
/** A non-negative integer, or null. */
|
|
24
|
+
function countOf(value) {
|
|
25
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0
|
|
26
|
+
? value
|
|
27
|
+
: null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Read and validate one registered recovery policy.
|
|
31
|
+
*
|
|
32
|
+
* The initial policy is deliberately narrow: completion evidence is mandatory,
|
|
33
|
+
* at most one recovery per slot is permitted, and the only eligible failure
|
|
34
|
+
* class is a JSON syntax failure. A declaration that widens any of those is
|
|
35
|
+
* refused rather than silently honoured, so a registration cannot become a
|
|
36
|
+
* general retry authorisation.
|
|
37
|
+
* @param value The manifest's `assessment.recovery` declaration.
|
|
38
|
+
* @returns The normalised policy, or every problem found.
|
|
39
|
+
*/
|
|
40
|
+
export function parseRecoveryPolicy(value) {
|
|
41
|
+
if (!isRecord(value)) {
|
|
42
|
+
return { ok: false, problems: ["recovery policy must be an object"] };
|
|
43
|
+
}
|
|
44
|
+
const problems = [
|
|
45
|
+
...versionProblems(value),
|
|
46
|
+
...classProblems(value),
|
|
47
|
+
...evidenceProblems(value),
|
|
48
|
+
...budgetProblems(value),
|
|
49
|
+
...limitProblems(value),
|
|
50
|
+
];
|
|
51
|
+
if (problems.length > 0)
|
|
52
|
+
return { ok: false, problems };
|
|
53
|
+
return { ok: true, policy: normalisedPolicy(value) };
|
|
54
|
+
}
|
|
55
|
+
/** Problems with the declared schema version. */
|
|
56
|
+
function versionProblems(value) {
|
|
57
|
+
return value.version === RECOVERY_POLICY_VERSION
|
|
58
|
+
? []
|
|
59
|
+
: [`recovery policy version must be ${RECOVERY_POLICY_VERSION}`];
|
|
60
|
+
}
|
|
61
|
+
/** Problems with the eligible failure classes. */
|
|
62
|
+
function classProblems(value) {
|
|
63
|
+
const classes = value.eligibleFailureClasses;
|
|
64
|
+
if (!Array.isArray(classes) || classes.length === 0) {
|
|
65
|
+
return [
|
|
66
|
+
`eligibleFailureClasses must be a nonempty array containing only ${JSON.stringify(INITIAL_ELIGIBLE_CLASS)}`,
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
return classes.some((entry) => entry !== INITIAL_ELIGIBLE_CLASS)
|
|
70
|
+
? [
|
|
71
|
+
`eligibleFailureClasses may contain only ${JSON.stringify(INITIAL_ELIGIBLE_CLASS)} under policy version ${RECOVERY_POLICY_VERSION}`,
|
|
72
|
+
]
|
|
73
|
+
: [];
|
|
74
|
+
}
|
|
75
|
+
/** Problems with the mandatory completion evidence and per-slot bound. */
|
|
76
|
+
function evidenceProblems(value) {
|
|
77
|
+
const problems = [];
|
|
78
|
+
if (value.requiresCompletionEvidence !== true) {
|
|
79
|
+
problems.push("requiresCompletionEvidence must be true; the initial policy cannot waive it");
|
|
80
|
+
}
|
|
81
|
+
if (value.maxRecoveriesPerSlot !== 1) {
|
|
82
|
+
problems.push("maxRecoveriesPerSlot must be 1 under this policy");
|
|
83
|
+
}
|
|
84
|
+
return problems;
|
|
85
|
+
}
|
|
86
|
+
/** Problems with the two campaign-wide ceilings. */
|
|
87
|
+
function budgetProblems(value) {
|
|
88
|
+
const problems = [];
|
|
89
|
+
const recovery = countOf(value.recoveryCeiling);
|
|
90
|
+
if (recovery === null || recovery < 1) {
|
|
91
|
+
problems.push("recoveryCeiling must be a positive integer");
|
|
92
|
+
}
|
|
93
|
+
const total = countOf(value.totalJudgeAttempts);
|
|
94
|
+
if (total === null || total < 1) {
|
|
95
|
+
problems.push("totalJudgeAttempts must be a positive integer");
|
|
96
|
+
}
|
|
97
|
+
else if (recovery !== null && total < recovery) {
|
|
98
|
+
problems.push("totalJudgeAttempts must be at least the recovery ceiling");
|
|
99
|
+
}
|
|
100
|
+
return problems;
|
|
101
|
+
}
|
|
102
|
+
/** Problems with the declared provider-request capability. */
|
|
103
|
+
function limitProblems(value) {
|
|
104
|
+
const limit = value.providerRequestLimit;
|
|
105
|
+
if (limit === undefined) {
|
|
106
|
+
return [
|
|
107
|
+
"providerRequestLimit must be declared: null when the adapter exposes no request count",
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
if (limit === null)
|
|
111
|
+
return [];
|
|
112
|
+
const count = countOf(limit);
|
|
113
|
+
return count === null || count < 1
|
|
114
|
+
? [
|
|
115
|
+
"providerRequestLimit must be null (not exposed) or a positive integer",
|
|
116
|
+
]
|
|
117
|
+
: [];
|
|
118
|
+
}
|
|
119
|
+
/** The validated policy, normalised to its fixed shape. */
|
|
120
|
+
function normalisedPolicy(value) {
|
|
121
|
+
const limit = value.providerRequestLimit;
|
|
122
|
+
return {
|
|
123
|
+
version: RECOVERY_POLICY_VERSION,
|
|
124
|
+
eligibleFailureClasses: [INITIAL_ELIGIBLE_CLASS],
|
|
125
|
+
requiresCompletionEvidence: true,
|
|
126
|
+
maxRecoveriesPerSlot: 1,
|
|
127
|
+
recoveryCeiling: countOf(value.recoveryCeiling),
|
|
128
|
+
totalJudgeAttempts: countOf(value.totalJudgeAttempts),
|
|
129
|
+
providerRequestLimit: limit === null || limit === undefined ? null : limit,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The canonical hash of a registered policy.
|
|
134
|
+
* @param policy The normalised policy.
|
|
135
|
+
* @returns The SHA-256 of its canonical JSON.
|
|
136
|
+
*/
|
|
137
|
+
export function recoveryPolicySha256(policy) {
|
|
138
|
+
return createHash("sha256")
|
|
139
|
+
.update(JSON.stringify(policy), "utf8")
|
|
140
|
+
.digest("hex");
|
|
141
|
+
}
|
|
142
|
+
/** The numeric ordinal of an `attempt-NNN` id. */
|
|
143
|
+
function ordinal(attemptId) {
|
|
144
|
+
return Number(attemptId.slice("attempt-".length));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Decide whether one failed slot may receive its single recovery attempt.
|
|
148
|
+
*
|
|
149
|
+
* Every refusal names the specific fact that stopped it, so a status report can
|
|
150
|
+
* distinguish an ineligible failure from an exhausted budget or a broken
|
|
151
|
+
* premise. A usable first assessment is never replaced.
|
|
152
|
+
* @param policy The registered policy.
|
|
153
|
+
* @param facts The recorded facts for the slot and the campaign.
|
|
154
|
+
* @returns The decision.
|
|
155
|
+
*/
|
|
156
|
+
export function recoveryDecision(policy, facts) {
|
|
157
|
+
const reason = [
|
|
158
|
+
contextRefusal(facts),
|
|
159
|
+
firstAssessmentRefusal(facts),
|
|
160
|
+
layoutRefusal(policy, facts),
|
|
161
|
+
eligibilityRefusal(policy, facts),
|
|
162
|
+
budgetRefusal(policy, facts),
|
|
163
|
+
].find((entry) => entry !== null);
|
|
164
|
+
if (reason !== undefined)
|
|
165
|
+
return { eligible: false, reason };
|
|
166
|
+
return {
|
|
167
|
+
eligible: true,
|
|
168
|
+
attemptId: `attempt-${String(facts.attemptIds.length + 1).padStart(3, "0")}`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/** Refuse when the ledger, the freeze or a reservation forbids dispatch. */
|
|
172
|
+
function contextRefusal(facts) {
|
|
173
|
+
if (facts.ledgerState !== "open") {
|
|
174
|
+
return `campaign ledger is ${facts.ledgerState}; no dispatch`;
|
|
175
|
+
}
|
|
176
|
+
if (facts.freezeProblems.length > 0) {
|
|
177
|
+
return `frozen premise changed: ${facts.freezeProblems.join("; ")}`;
|
|
178
|
+
}
|
|
179
|
+
return facts.unresolvedReservations.length > 0
|
|
180
|
+
? `unresolved reservation(s): ${facts.unresolvedReservations.join(", ")}`
|
|
181
|
+
: null;
|
|
182
|
+
}
|
|
183
|
+
/** Refuse when a usable assessment already exists for the slot. */
|
|
184
|
+
function firstAssessmentRefusal(facts) {
|
|
185
|
+
if (facts.attemptStatus === "judged") {
|
|
186
|
+
return "the first assessment is usable; a valid judgement is never replaced";
|
|
187
|
+
}
|
|
188
|
+
return facts.judgedAttemptIds.length > 0
|
|
189
|
+
? "a usable assessment already exists for this slot"
|
|
190
|
+
: null;
|
|
191
|
+
}
|
|
192
|
+
/** Refuse an attempt layout that cannot accept one recovery. */
|
|
193
|
+
function layoutRefusal(policy, facts) {
|
|
194
|
+
const attempts = [...facts.attemptIds].sort();
|
|
195
|
+
if (attempts.length === 0)
|
|
196
|
+
return "no judge attempt recorded for the slot";
|
|
197
|
+
for (let index = 0; index < attempts.length; index += 1) {
|
|
198
|
+
if (ordinal(attempts[index]) !== index + 1) {
|
|
199
|
+
return `attempt sequence is not contiguous: ${attempts.join(", ")}`;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return attempts.length > policy.maxRecoveriesPerSlot
|
|
203
|
+
? `a recovery attempt already exists (${attempts.join(", ")}); a second unusable assessment stops the campaign`
|
|
204
|
+
: null;
|
|
205
|
+
}
|
|
206
|
+
/** Refuse a failure class or capture the policy does not make eligible. */
|
|
207
|
+
function eligibilityRefusal(policy, facts) {
|
|
208
|
+
if (!policy.eligibleFailureClasses.includes(INITIAL_ELIGIBLE_CLASS)) {
|
|
209
|
+
return "policy makes no failure class eligible";
|
|
210
|
+
}
|
|
211
|
+
if (facts.failureClass !== INITIAL_ELIGIBLE_CLASS) {
|
|
212
|
+
return `failure class ${JSON.stringify(facts.failureClass)} is not eligible; only ${INITIAL_ELIGIBLE_CLASS} is`;
|
|
213
|
+
}
|
|
214
|
+
return policy.requiresCompletionEvidence &&
|
|
215
|
+
facts.completionComplete !== true
|
|
216
|
+
? "completion of the final assessed response cannot be established"
|
|
217
|
+
: null;
|
|
218
|
+
}
|
|
219
|
+
/** Refuse when either campaign-wide ceiling is exhausted. */
|
|
220
|
+
function budgetRefusal(policy, facts) {
|
|
221
|
+
if (facts.recoveryClaimsUsed >= policy.recoveryCeiling) {
|
|
222
|
+
return `recovery ceiling reached: ${policy.recoveryCeiling} authorised recovery attempt(s)`;
|
|
223
|
+
}
|
|
224
|
+
return facts.judgeClaimsUsed >= policy.totalJudgeAttempts
|
|
225
|
+
? `total judge-attempt ceiling reached: ${policy.totalJudgeAttempts} authorised attempt(s)`
|
|
226
|
+
: null;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Build one selection record.
|
|
230
|
+
* @param input The slot identity, the selected attempt and every attempt fact.
|
|
231
|
+
* @returns The record.
|
|
232
|
+
*/
|
|
233
|
+
export function selectionRecordOf(input) {
|
|
234
|
+
return {
|
|
235
|
+
schemeVersion: 1,
|
|
236
|
+
slot: input.slot,
|
|
237
|
+
pairId: input.pairId,
|
|
238
|
+
stamp: input.stamp,
|
|
239
|
+
arm: input.arm,
|
|
240
|
+
scenario: input.scenario,
|
|
241
|
+
selectedAttemptId: input.selectedAttemptId,
|
|
242
|
+
policySha256: input.policySha256,
|
|
243
|
+
attempts: [...input.attempts].sort((left, right) => left.attemptId.localeCompare(right.attemptId)),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Check one recorded selection against the evidence it claims to describe.
|
|
248
|
+
*
|
|
249
|
+
* The record is evidence, not a trusted pointer: its identity, attempt set,
|
|
250
|
+
* ordering, policy eligibility, packet identity and usable judgement status are
|
|
251
|
+
* all re-derived. A tampered record, an ineligible selection, or a replacement
|
|
252
|
+
* of an already-valid first assessment is refused.
|
|
253
|
+
* @param value The recorded selection, as read from disk.
|
|
254
|
+
* @param facts The actual slot and evidence facts.
|
|
255
|
+
* @returns Every problem; empty means the selection is trustworthy.
|
|
256
|
+
*/
|
|
257
|
+
export function selectionProblems(value, facts) {
|
|
258
|
+
if (!isRecord(value))
|
|
259
|
+
return ["selection record must be an object"];
|
|
260
|
+
const identity = identityProblems(value, facts);
|
|
261
|
+
const selected = value.selectedAttemptId;
|
|
262
|
+
if (typeof selected !== "string" || !/^attempt-\d{3}$/.test(selected)) {
|
|
263
|
+
return [
|
|
264
|
+
...identity,
|
|
265
|
+
"selection selectedAttemptId must match attempt-NNN",
|
|
266
|
+
];
|
|
267
|
+
}
|
|
268
|
+
if (!Array.isArray(value.attempts)) {
|
|
269
|
+
return [...identity, "selection attempts must be an array"];
|
|
270
|
+
}
|
|
271
|
+
const parsed = attemptRecordsOf(value.attempts);
|
|
272
|
+
return [
|
|
273
|
+
...identity,
|
|
274
|
+
...parsed.problems,
|
|
275
|
+
...attemptSetProblems(parsed.recorded, facts),
|
|
276
|
+
...factProblems(parsed.recorded, facts),
|
|
277
|
+
...choiceProblems(value, parsed.recorded, selected, facts),
|
|
278
|
+
];
|
|
279
|
+
}
|
|
280
|
+
/** The record's own identity against the slot it claims. */
|
|
281
|
+
function identityProblems(value, facts) {
|
|
282
|
+
const problems = [];
|
|
283
|
+
if (value.schemeVersion !== 1) {
|
|
284
|
+
problems.push("selection schemeVersion must be 1");
|
|
285
|
+
}
|
|
286
|
+
for (const field of ["pairId", "stamp", "arm", "scenario"]) {
|
|
287
|
+
const expected = facts[field];
|
|
288
|
+
if (expected !== undefined && value[field] !== expected) {
|
|
289
|
+
problems.push(`selection ${field} ${JSON.stringify(value[field])} does not match the slot ${JSON.stringify(expected)}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (facts.slot !== undefined && value.slot !== facts.slot) {
|
|
293
|
+
problems.push("selection slot does not match the schedule");
|
|
294
|
+
}
|
|
295
|
+
return problems;
|
|
296
|
+
}
|
|
297
|
+
/** One parsed attempt entry, or the reason it is malformed. */
|
|
298
|
+
function attemptEntryOf(entry) {
|
|
299
|
+
const identity = entryIdentityOf(entry);
|
|
300
|
+
if (identity.problem !== undefined)
|
|
301
|
+
return { problem: identity.problem };
|
|
302
|
+
const attemptId = identity.attemptId;
|
|
303
|
+
const problem = entryFieldProblem(entry, `selection attempt ${attemptId}`);
|
|
304
|
+
if (problem !== undefined)
|
|
305
|
+
return { problem };
|
|
306
|
+
const value = entry;
|
|
307
|
+
return {
|
|
308
|
+
attempt: {
|
|
309
|
+
attemptId,
|
|
310
|
+
judgementStatus: value.judgementStatus,
|
|
311
|
+
packetSha256: value.packetSha256,
|
|
312
|
+
failureClass: (value.failureClass ?? null),
|
|
313
|
+
complete: (value.complete ?? null),
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/** One entry's attempt id, or the shape problem. */
|
|
318
|
+
function entryIdentityOf(entry) {
|
|
319
|
+
if (!isRecord(entry) ||
|
|
320
|
+
typeof entry.attemptId !== "string" ||
|
|
321
|
+
!/^attempt-\d{3}$/.test(entry.attemptId)) {
|
|
322
|
+
return { problem: "selection attempt entry is malformed" };
|
|
323
|
+
}
|
|
324
|
+
return { attemptId: entry.attemptId };
|
|
325
|
+
}
|
|
326
|
+
/** The first malformed field of one attempt entry, or undefined. */
|
|
327
|
+
function entryFieldProblem(entry, label) {
|
|
328
|
+
const value = entry;
|
|
329
|
+
if (typeof value.judgementStatus !== "string") {
|
|
330
|
+
return `${label} has no judgement status`;
|
|
331
|
+
}
|
|
332
|
+
if (value.packetSha256 !== null && typeof value.packetSha256 !== "string") {
|
|
333
|
+
return `${label} packetSha256 is malformed`;
|
|
334
|
+
}
|
|
335
|
+
const failureClass = value.failureClass ?? null;
|
|
336
|
+
if (failureClass !== null && typeof failureClass !== "string") {
|
|
337
|
+
return `${label} failureClass is malformed`;
|
|
338
|
+
}
|
|
339
|
+
const complete = value.complete ?? null;
|
|
340
|
+
return complete !== null && typeof complete !== "boolean"
|
|
341
|
+
? `${label} complete is malformed`
|
|
342
|
+
: undefined;
|
|
343
|
+
}
|
|
344
|
+
/** Parse every recorded attempt entry, reporting malformed and repeated ones. */
|
|
345
|
+
function attemptRecordsOf(entries) {
|
|
346
|
+
const recorded = new Map();
|
|
347
|
+
const problems = [];
|
|
348
|
+
for (const entry of entries) {
|
|
349
|
+
const parsed = attemptEntryOf(entry);
|
|
350
|
+
if (parsed.problem !== undefined) {
|
|
351
|
+
problems.push(parsed.problem);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const attempt = parsed.attempt;
|
|
355
|
+
if (recorded.has(attempt.attemptId)) {
|
|
356
|
+
problems.push(`selection repeats attempt ${attempt.attemptId}`);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
recorded.set(attempt.attemptId, attempt);
|
|
360
|
+
}
|
|
361
|
+
return { recorded, problems };
|
|
362
|
+
}
|
|
363
|
+
/** The claimed attempt set against the attempts actually present. */
|
|
364
|
+
function attemptSetProblems(recorded, facts) {
|
|
365
|
+
const actual = [...facts.attemptIds].sort();
|
|
366
|
+
const claimed = [...recorded.keys()].sort();
|
|
367
|
+
return claimed.join(",") === actual.join(",")
|
|
368
|
+
? []
|
|
369
|
+
: [
|
|
370
|
+
`selection attempts ${JSON.stringify(claimed)} do not match the evidence ${JSON.stringify(actual)}`,
|
|
371
|
+
];
|
|
372
|
+
}
|
|
373
|
+
/** The claimed attempt facts against the re-derived facts. */
|
|
374
|
+
function factProblems(recorded, facts) {
|
|
375
|
+
if (facts.attemptFacts === undefined)
|
|
376
|
+
return [];
|
|
377
|
+
const problems = [];
|
|
378
|
+
for (const [attemptId, entry] of recorded) {
|
|
379
|
+
const actualEntry = facts.attemptFacts[attemptId];
|
|
380
|
+
if (actualEntry === undefined)
|
|
381
|
+
continue;
|
|
382
|
+
for (const field of ATTEMPT_FACT_FIELDS) {
|
|
383
|
+
if (entry[field] !== actualEntry[field]) {
|
|
384
|
+
problems.push(`selection ${attemptId} ${field} ${JSON.stringify(entry[field])} does not match the recorded evidence ${JSON.stringify(actualEntry[field])}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return problems;
|
|
389
|
+
}
|
|
390
|
+
/** The fields the selection record must re-derive exactly. */
|
|
391
|
+
const ATTEMPT_FACT_FIELDS = [
|
|
392
|
+
"judgementStatus",
|
|
393
|
+
"packetSha256",
|
|
394
|
+
"failureClass",
|
|
395
|
+
"complete",
|
|
396
|
+
];
|
|
397
|
+
/** Problems with the chosen attempt and its relation to the manifest. */
|
|
398
|
+
function choiceProblems(value, recorded, selected, facts) {
|
|
399
|
+
const problems = [];
|
|
400
|
+
const selectedEntry = recorded.get(selected);
|
|
401
|
+
if (selectedEntry === undefined) {
|
|
402
|
+
problems.push(`selection names an absent attempt ${selected}`);
|
|
403
|
+
}
|
|
404
|
+
else if (selectedEntry.judgementStatus !== "judged") {
|
|
405
|
+
problems.push(`selection names an unusable attempt ${selected} (${selectedEntry.judgementStatus})`);
|
|
406
|
+
}
|
|
407
|
+
const manifest = facts.manifestAttemptId;
|
|
408
|
+
if (manifest !== null && selected !== manifest) {
|
|
409
|
+
problems.push(...recoveryChoiceProblems(value, recorded, selected, manifest, facts));
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
412
|
+
problems.push(...firstChoiceProblems(recorded, selected, facts));
|
|
413
|
+
}
|
|
414
|
+
return problems;
|
|
415
|
+
}
|
|
416
|
+
/** Problems with a selection that differs from the frozen manifest. */
|
|
417
|
+
function recoveryChoiceProblems(value, recorded, selected, manifest, facts) {
|
|
418
|
+
const problems = [];
|
|
419
|
+
if (facts.policy === null) {
|
|
420
|
+
problems.push("selection differs from the frozen manifest selector with no registered policy");
|
|
421
|
+
}
|
|
422
|
+
else if (value.policySha256 !== recoveryPolicySha256(facts.policy)) {
|
|
423
|
+
problems.push("selection policySha256 does not match the registered policy");
|
|
424
|
+
}
|
|
425
|
+
const base = recorded.get(manifest);
|
|
426
|
+
if (base === undefined) {
|
|
427
|
+
problems.push(`selection has no entry for the manifest attempt ${manifest}`);
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
problems.push(...baseEligibilityProblems(base, recorded.get(selected), manifest, facts));
|
|
431
|
+
}
|
|
432
|
+
if (ordinal(selected) !== ordinal(manifest) + 1) {
|
|
433
|
+
problems.push(`selection ${selected} is not the next attempt after ${manifest}`);
|
|
434
|
+
}
|
|
435
|
+
const recoveries = facts.attemptIds.filter((attempt) => ordinal(attempt) > ordinal(manifest));
|
|
436
|
+
const permitted = facts.policy?.maxRecoveriesPerSlot ?? 0;
|
|
437
|
+
if (recoveries.length > permitted) {
|
|
438
|
+
problems.push(`selection holds ${recoveries.length} recovery attempt(s); at most ${permitted} permitted`);
|
|
439
|
+
}
|
|
440
|
+
return problems;
|
|
441
|
+
}
|
|
442
|
+
/** Problems with the first attempt a recovery supersedes. */
|
|
443
|
+
function baseEligibilityProblems(base, selected, manifest, facts) {
|
|
444
|
+
const problems = baseClassProblems(base, facts);
|
|
445
|
+
if (facts.policy?.requiresCompletionEvidence === true &&
|
|
446
|
+
base.complete !== true) {
|
|
447
|
+
problems.push(`selection recovers from incomplete capture for ${manifest}`);
|
|
448
|
+
}
|
|
449
|
+
if (base.packetSha256 === null ||
|
|
450
|
+
base.packetSha256 !== selected?.packetSha256) {
|
|
451
|
+
problems.push("selection attempts were judged from different packets");
|
|
452
|
+
}
|
|
453
|
+
return problems;
|
|
454
|
+
}
|
|
455
|
+
/** Problems with the superseded attempt's status and failure class. */
|
|
456
|
+
function baseClassProblems(base, facts) {
|
|
457
|
+
const problems = [];
|
|
458
|
+
if (base.judgementStatus === "judged") {
|
|
459
|
+
problems.push(`selection replaces the usable first assessment`);
|
|
460
|
+
}
|
|
461
|
+
if (base.failureClass === null ||
|
|
462
|
+
!facts.policy?.eligibleFailureClasses.includes(base.failureClass)) {
|
|
463
|
+
problems.push(`selection recovers from an ineligible failure class ${JSON.stringify(base.failureClass)}`);
|
|
464
|
+
}
|
|
465
|
+
return problems;
|
|
466
|
+
}
|
|
467
|
+
/** Problems with a first-assessment selection beside a later judgement. */
|
|
468
|
+
function firstChoiceProblems(recorded, selected, facts) {
|
|
469
|
+
const judgedLater = facts.attemptIds.filter((attempt) => ordinal(attempt) > ordinal(selected) &&
|
|
470
|
+
recorded.get(attempt)?.judgementStatus === "judged");
|
|
471
|
+
return judgedLater.length > 0
|
|
472
|
+
? [
|
|
473
|
+
`selection ignores a later usable judgement ${judgedLater.join(", ")}`,
|
|
474
|
+
]
|
|
475
|
+
: [];
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Claim the recovery and judge budgets atomically.
|
|
479
|
+
*
|
|
480
|
+
* The recovery claim is taken first, then the judge claim, each with the
|
|
481
|
+
* filesystem deciding the winner. If the judge claim refuses, the recovery
|
|
482
|
+
* reservation is released only because it provably dispatched nothing; a
|
|
483
|
+
* spawned call keeps consuming both ceilings.
|
|
484
|
+
* @param root The campaign evidence root.
|
|
485
|
+
* @param policy The registered policy.
|
|
486
|
+
* @param identity The slot identity and the logical attempt key.
|
|
487
|
+
* @param identity.stamp The trial stamp the claim names.
|
|
488
|
+
* @param identity.attemptId The recovery attempt id.
|
|
489
|
+
* @param identity.key The logical attempt key; a repeat is refused.
|
|
490
|
+
* @param identity.at An injected claim timestamp, for determinism.
|
|
491
|
+
* @returns Both claims, or the refusal.
|
|
492
|
+
*/
|
|
493
|
+
export function claimRecoveryBudget(root, policy, identity) {
|
|
494
|
+
const recovery = claimBudget(root, "recovery", policy.recoveryCeiling, identity);
|
|
495
|
+
if (!recovery.ok) {
|
|
496
|
+
return {
|
|
497
|
+
ok: false,
|
|
498
|
+
reason: `recovery budget refused: ${recovery.reason}`,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
const judge = claimBudget(root, "judge", policy.totalJudgeAttempts, identity);
|
|
502
|
+
if (!judge.ok) {
|
|
503
|
+
releaseClaim(recovery.claim);
|
|
504
|
+
return { ok: false, reason: `judge budget refused: ${judge.reason}` };
|
|
505
|
+
}
|
|
506
|
+
return { ok: true, recovery: recovery.claim, judge: judge.claim };
|
|
507
|
+
}
|