@uwmd/core 1.1.2 → 1.5.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 +3 -3
- package/dist/agents/bancroft.d.ts +22 -1
- package/dist/agents/bancroft.d.ts.map +1 -1
- package/dist/agents/bancroft.js +86 -60
- package/dist/agents/bancroft.js.map +1 -1
- package/dist/agents/provider.d.ts +62 -0
- package/dist/agents/provider.d.ts.map +1 -0
- package/dist/agents/provider.js +28 -0
- package/dist/agents/provider.js.map +1 -0
- package/dist/agents/providers/anthropic.d.ts +20 -0
- package/dist/agents/providers/anthropic.d.ts.map +1 -0
- package/dist/agents/providers/anthropic.js +104 -0
- package/dist/agents/providers/anthropic.js.map +1 -0
- package/dist/agents/providers/replay.d.ts +46 -0
- package/dist/agents/providers/replay.d.ts.map +1 -0
- package/dist/agents/providers/replay.js +162 -0
- package/dist/agents/providers/replay.js.map +1 -0
- package/dist/agents/schemas.d.ts +3 -3
- package/dist/agents/schemas.d.ts.map +1 -1
- package/dist/agents/schemas.js.map +1 -1
- package/dist/browser.d.ts +33 -4
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +26 -3
- package/dist/browser.js.map +1 -1
- package/dist/calc/builtins.d.ts +10 -0
- package/dist/calc/builtins.d.ts.map +1 -1
- package/dist/calc/builtins.js +64 -42
- package/dist/calc/builtins.js.map +1 -1
- package/dist/calc/errors.js +1 -1
- package/dist/calc/evaluator.js +6 -2
- package/dist/calc/evaluator.js.map +1 -1
- package/dist/calc/index.d.ts +7 -0
- package/dist/calc/index.d.ts.map +1 -1
- package/dist/calc/index.js +13 -1
- package/dist/calc/index.js.map +1 -1
- package/dist/calc/quantize.d.ts +56 -0
- package/dist/calc/quantize.d.ts.map +1 -0
- package/dist/calc/quantize.js +116 -0
- package/dist/calc/quantize.js.map +1 -0
- package/dist/cascade.d.ts +60 -6
- package/dist/cascade.d.ts.map +1 -1
- package/dist/cascade.js +93 -14
- package/dist/cascade.js.map +1 -1
- package/dist/cli-packages.d.ts +11 -0
- package/dist/cli-packages.d.ts.map +1 -0
- package/dist/cli-packages.js +177 -0
- package/dist/cli-packages.js.map +1 -0
- package/dist/cli.js +691 -37
- package/dist/cli.js.map +1 -1
- package/dist/composition.d.ts +182 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/composition.js +536 -0
- package/dist/composition.js.map +1 -0
- package/dist/deal-package-context.d.ts +52 -0
- package/dist/deal-package-context.d.ts.map +1 -0
- package/dist/deal-package-context.js +132 -0
- package/dist/deal-package-context.js.map +1 -0
- package/dist/deal-package-zip.d.ts +36 -0
- package/dist/deal-package-zip.d.ts.map +1 -0
- package/dist/deal-package-zip.js +132 -0
- package/dist/deal-package-zip.js.map +1 -0
- package/dist/deal-package.d.ts +82 -0
- package/dist/deal-package.d.ts.map +1 -0
- package/dist/deal-package.js +189 -0
- package/dist/deal-package.js.map +1 -0
- package/dist/defaults.d.ts +48 -0
- package/dist/defaults.d.ts.map +1 -1
- package/dist/defaults.js +512 -0
- package/dist/defaults.js.map +1 -1
- package/dist/index.d.ts +39 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -3
- package/dist/index.js.map +1 -1
- package/dist/init.js +1 -1
- package/dist/init.js.map +1 -1
- package/dist/integrity.d.ts +9 -0
- package/dist/integrity.d.ts.map +1 -1
- package/dist/integrity.js +12 -2
- package/dist/integrity.js.map +1 -1
- package/dist/lease-abstract.d.ts +82 -0
- package/dist/lease-abstract.d.ts.map +1 -0
- package/dist/lease-abstract.js +183 -0
- package/dist/lease-abstract.js.map +1 -0
- package/dist/lite-bridge.d.ts +67 -0
- package/dist/lite-bridge.d.ts.map +1 -0
- package/dist/lite-bridge.js +564 -0
- package/dist/lite-bridge.js.map +1 -0
- package/dist/lite.d.ts +63 -0
- package/dist/lite.d.ts.map +1 -0
- package/dist/lite.js +398 -0
- package/dist/lite.js.map +1 -0
- package/dist/market-data.d.ts +134 -0
- package/dist/market-data.d.ts.map +1 -0
- package/dist/market-data.js +292 -0
- package/dist/market-data.js.map +1 -0
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +278 -13
- package/dist/modules.js.map +1 -1
- package/dist/packs/excel-emit.d.ts +18 -1
- package/dist/packs/excel-emit.d.ts.map +1 -1
- package/dist/packs/excel-emit.js +18 -1
- package/dist/packs/excel-emit.js.map +1 -1
- package/dist/packs/hospitality.d.ts +3 -0
- package/dist/packs/hospitality.d.ts.map +1 -0
- package/dist/packs/hospitality.js +122 -0
- package/dist/packs/hospitality.js.map +1 -0
- package/dist/packs/index.d.ts +5 -1
- package/dist/packs/index.d.ts.map +1 -1
- package/dist/packs/index.js +13 -1
- package/dist/packs/index.js.map +1 -1
- package/dist/packs/land.d.ts +3 -0
- package/dist/packs/land.d.ts.map +1 -0
- package/dist/packs/land.js +124 -0
- package/dist/packs/land.js.map +1 -0
- package/dist/packs/senior-housing.d.ts +3 -0
- package/dist/packs/senior-housing.d.ts.map +1 -0
- package/dist/packs/senior-housing.js +123 -0
- package/dist/packs/senior-housing.js.map +1 -0
- package/dist/packs/student-housing.d.ts +3 -0
- package/dist/packs/student-housing.d.ts.map +1 -0
- package/dist/packs/student-housing.js +124 -0
- package/dist/packs/student-housing.js.map +1 -0
- package/dist/parser.d.ts +9 -0
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +40 -6
- package/dist/parser.js.map +1 -1
- package/dist/protocol.d.ts +125 -9
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +182 -4
- package/dist/protocol.js.map +1 -1
- package/dist/receipts.d.ts +396 -0
- package/dist/receipts.d.ts.map +1 -0
- package/dist/receipts.js +913 -0
- package/dist/receipts.js.map +1 -0
- package/dist/runner.d.ts +13 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +5 -5
- package/dist/runner.js.map +1 -1
- package/dist/source-representation.d.ts +40 -0
- package/dist/source-representation.d.ts.map +1 -0
- package/dist/source-representation.js +114 -0
- package/dist/source-representation.js.map +1 -0
- package/dist/types.d.ts +63 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +34 -0
- package/dist/types.js.map +1 -1
- package/dist/uwcsv.d.ts.map +1 -1
- package/dist/uwcsv.js +10 -78
- package/dist/uwcsv.js.map +1 -1
- package/dist/uwxml.d.ts.map +1 -1
- package/dist/uwxml.js +10 -2
- package/dist/uwxml.js.map +1 -1
- package/dist/validator.d.ts.map +1 -1
- package/dist/validator.js +27 -10
- package/dist/validator.js.map +1 -1
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/dist/zip-safety.d.ts +15 -0
- package/dist/zip-safety.d.ts.map +1 -0
- package/dist/zip-safety.js +109 -0
- package/dist/zip-safety.js.map +1 -0
- package/package.json +12 -3
package/dist/receipts.js
ADDED
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
// Verification receipts — RFC 0016 / `spec/UW_RECEIPT_v1.md`.
|
|
2
|
+
//
|
|
3
|
+
// A receipt is a small, detached JSON document that binds a canonical digest of
|
|
4
|
+
// an underwriting record to the deterministic outputs a named calc pack produced
|
|
5
|
+
// from it. It lets a party who did not run the calculation confirm, offline,
|
|
6
|
+
// that a stated set of numbers follows from a stated set of inputs.
|
|
7
|
+
//
|
|
8
|
+
// It deliberately attests NOTHING about whether those inputs are true. A record
|
|
9
|
+
// asserting a fabricated NOI can carry a perfectly valid receipt. See the
|
|
10
|
+
// assurance boundary in `spec/UW_RECEIPT_v1.md` §6 — consumers MUST NOT render a
|
|
11
|
+
// `verified` verdict as an unqualified checkmark.
|
|
12
|
+
//
|
|
13
|
+
// Layering: unsigned issuance and verification live here because core already
|
|
14
|
+
// has SHA-256 via `integrity.sha256TextHex` with no external dependency.
|
|
15
|
+
// Signature creation and validation belong to the separate signing package
|
|
16
|
+
// RFC 0010 proposes; a verifier without one returns `unverifiable` for a signed
|
|
17
|
+
// receipt rather than silently ignoring the signature.
|
|
18
|
+
import { evaluateCalc } from './calc/index.js';
|
|
19
|
+
import { canonicalizeUWEnvelope, fromUWEnvelope, toUWEnvelope, } from './envelope.js';
|
|
20
|
+
import { canonicalizeExact } from './integrity-canonical.js';
|
|
21
|
+
import { sha256TextHex } from './integrity.js';
|
|
22
|
+
import { canonicalizeUWLiteFinancial, parseUWLite } from './lite.js';
|
|
23
|
+
import { compileUWLite } from './lite-bridge.js';
|
|
24
|
+
import { getPackForAssetClass } from './packs/index.js';
|
|
25
|
+
import { parseUWFile } from './parser.js';
|
|
26
|
+
import { PROTOCOL_VERSION } from './protocol.js';
|
|
27
|
+
import { detectUWSourceRepresentation, UW_LITE_REPRESENTATION_ID, UW_LITE_REPRESENTATION_VERSION, UWX_REPRESENTATION_ID, UWX_REPRESENTATION_VERSION, } from './source-representation.js';
|
|
28
|
+
import { validateUWFile } from './validator.js';
|
|
29
|
+
import { CORE_PACKAGE_NAME, CORE_VERSION } from './version.js';
|
|
30
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
31
|
+
/**
|
|
32
|
+
* Receipt format version.
|
|
33
|
+
*
|
|
34
|
+
* **1.1 covers two RFCs, and bumps only once.** RFC 0021 (rollup verification)
|
|
35
|
+
* and RFC 0022 (`inputs_provenance`) were accepted the same day and both amend
|
|
36
|
+
* the format RFC 0016 owns. Each RFC records the same rule — whichever is
|
|
37
|
+
* implemented first establishes the extension section, the second amends it —
|
|
38
|
+
* because two independent bumps for two simultaneously-accepted RFCs would make
|
|
39
|
+
* the version number meaningless. RFC 0022 landed first; RFC 0021's rollup
|
|
40
|
+
* entries are additive within the section established here and MUST NOT bump
|
|
41
|
+
* this again. See `UW_RECEIPT_v1.md` §10.
|
|
42
|
+
*/
|
|
43
|
+
export const UW_RECEIPT_VERSION = '1.1';
|
|
44
|
+
/**
|
|
45
|
+
* Receipt versions this verifier can read. A 1.0 receipt is still valid: every
|
|
46
|
+
* 1.1 addition is optional, so absence means "the issuer stated nothing", not
|
|
47
|
+
* "non-conforming".
|
|
48
|
+
*/
|
|
49
|
+
export const SUPPORTED_RECEIPT_VERSIONS = Object.freeze(['1.0', '1.1']);
|
|
50
|
+
/** Canonicalization used for the Lite representation (UW_LITE_SPEC §6). */
|
|
51
|
+
export const UW_LITE_CANONICALIZATION = 'uw-lite-financial';
|
|
52
|
+
/**
|
|
53
|
+
* Version of the *canonicalization rules*, which is not the version of the Lite
|
|
54
|
+
* *grammar* — the two move independently, and this field previously carried
|
|
55
|
+
* `UW_LITE_REPRESENTATION_VERSION`, which conflated them. RFC 0025 changed how a
|
|
56
|
+
* percent display normalizes without changing a single production of the
|
|
57
|
+
* grammar: `5.51%` parsed before and parses now, it just canonicalizes to a
|
|
58
|
+
* different double. Stamping the grammar version would have claimed a source
|
|
59
|
+
* change that did not happen, and left the change that *did* happen unstamped.
|
|
60
|
+
* `UWX_CANONICALIZATION_VERSION` is separate for the same reason.
|
|
61
|
+
*/
|
|
62
|
+
export const UW_LITE_CANONICALIZATION_VERSION = '1.1';
|
|
63
|
+
export const UWX_CANONICALIZATION_VERSION = '1.0';
|
|
64
|
+
/** Canonicalization used for structured records (Document Envelope 1.0). */
|
|
65
|
+
export const UWX_CANONICALIZATION = 'uw-envelope-semantic';
|
|
66
|
+
/**
|
|
67
|
+
* Tolerance for comparing a stated numeric result against recomputation.
|
|
68
|
+
* Relative for large magnitudes, absolute near zero.
|
|
69
|
+
*
|
|
70
|
+
* Since §VIII.5, a *conforming* issuer can never need this: both sides quantize
|
|
71
|
+
* before comparison, so they agree exactly or disagree materially. It is kept as
|
|
72
|
+
* defence-in-depth against a third-party issuer running an alternate precision
|
|
73
|
+
* routine — that lands as a clean `RCP-03` naming the calc rather than an
|
|
74
|
+
* `RCP-04` digest mismatch that reads as corruption.
|
|
75
|
+
*/
|
|
76
|
+
export const RECEIPT_RESULT_TOLERANCE = 1e-6;
|
|
77
|
+
/** The policy set a receipt names when the host applies core's built-in rules. */
|
|
78
|
+
export const BUILTIN_POLICY_SET = 'builtin';
|
|
79
|
+
export const BUILTIN_POLICY_SET_VERSION = '1.0';
|
|
80
|
+
/** `sha256:<64 lowercase hex>` — the only digest spelling a receipt may carry. */
|
|
81
|
+
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
82
|
+
/** Typed error, consistent with `ProtocolError` / `CalcError` / `ExcelEmitError`. */
|
|
83
|
+
export class ReceiptError extends Error {
|
|
84
|
+
code;
|
|
85
|
+
constructor(code, message) {
|
|
86
|
+
super(`[${code}] ${message}`);
|
|
87
|
+
this.name = 'ReceiptError';
|
|
88
|
+
this.code = code;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Parse, canonicalize, and digest a record. Throws a typed `ReceiptError` when
|
|
93
|
+
* the document has parse-level errors — an issuer MUST NOT emit a receipt for
|
|
94
|
+
* such a document (RFC 0016, extending UW_LITE_SPEC §5 to every representation).
|
|
95
|
+
*/
|
|
96
|
+
export async function resolveReceiptSubject(content, options = {}) {
|
|
97
|
+
const detection = detectUWSourceRepresentation(content, options.filename, options.representation);
|
|
98
|
+
if (detection.representation === UW_LITE_REPRESENTATION_ID) {
|
|
99
|
+
const lite = parseUWLite(content);
|
|
100
|
+
const blocking = lite.issues.filter((issue) => issue.severity === 'error');
|
|
101
|
+
if (blocking.length > 0) {
|
|
102
|
+
throw new ReceiptError('RCP_PARSE_ERRORS', `Refusing to issue: the Lite document has ${blocking.length} parse error(s) [${blocking
|
|
103
|
+
.map((issue) => issue.code)
|
|
104
|
+
.join(', ')}].`);
|
|
105
|
+
}
|
|
106
|
+
const canonical = canonicalizeUWLiteFinancial(lite);
|
|
107
|
+
const compiled = compileUWLite(lite);
|
|
108
|
+
if (!compiled.ok) {
|
|
109
|
+
throw new ReceiptError('RCP_COMPILE_FAILED', `Refusing to issue: the Lite document does not compile [${compiled.report.issues
|
|
110
|
+
.filter((issue) => issue.severity === 'error')
|
|
111
|
+
.map((issue) => issue.code)
|
|
112
|
+
.join(', ')}].`);
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
subject: {
|
|
116
|
+
representation: UW_LITE_REPRESENTATION_ID,
|
|
117
|
+
representation_version: UW_LITE_REPRESENTATION_VERSION,
|
|
118
|
+
canonicalization: UW_LITE_CANONICALIZATION,
|
|
119
|
+
canonicalization_version: UW_LITE_CANONICALIZATION_VERSION,
|
|
120
|
+
digest: await digestOf(canonical),
|
|
121
|
+
},
|
|
122
|
+
canonical,
|
|
123
|
+
parsed: fromUWEnvelope(compiled.envelope),
|
|
124
|
+
envelope: compiled.envelope,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
let parsed;
|
|
128
|
+
try {
|
|
129
|
+
parsed = parseUWFile(content);
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
throw new ReceiptError('RCP_PARSE_ERRORS', `Refusing to issue: the structured document does not parse (${e instanceof Error ? e.message : String(e)}).`);
|
|
133
|
+
}
|
|
134
|
+
const envelope = toUWEnvelope(parsed);
|
|
135
|
+
const canonical = canonicalizeUWEnvelope(envelope);
|
|
136
|
+
return {
|
|
137
|
+
subject: {
|
|
138
|
+
representation: UWX_REPRESENTATION_ID,
|
|
139
|
+
representation_version: UWX_REPRESENTATION_VERSION,
|
|
140
|
+
canonicalization: UWX_CANONICALIZATION,
|
|
141
|
+
canonicalization_version: UWX_CANONICALIZATION_VERSION,
|
|
142
|
+
digest: await digestOf(canonical),
|
|
143
|
+
},
|
|
144
|
+
canonical,
|
|
145
|
+
parsed,
|
|
146
|
+
envelope,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
async function digestOf(canonical) {
|
|
150
|
+
return `sha256:${await sha256TextHex(canonical)}`;
|
|
151
|
+
}
|
|
152
|
+
// ─── Computation ─────────────────────────────────────────────────────────────
|
|
153
|
+
/**
|
|
154
|
+
* Run every calc a pack declares. Throws when any one fails — a receipt states
|
|
155
|
+
* the pack's complete output set or it states nothing (RFC 0016).
|
|
156
|
+
*/
|
|
157
|
+
export function computeReceiptResults(pack, parsed) {
|
|
158
|
+
const decls = pack.calculations ?? [];
|
|
159
|
+
if (decls.length === 0) {
|
|
160
|
+
throw new ReceiptError('RCP_NO_CALCULATIONS', `Pack '${pack.id}' declares no calculations, so it has no outputs to attest.`);
|
|
161
|
+
}
|
|
162
|
+
const ctx = { parsed, prior_results: {}, locale: 'en-US' };
|
|
163
|
+
const results = [];
|
|
164
|
+
const failures = [];
|
|
165
|
+
for (const decl of decls) {
|
|
166
|
+
const result = evaluateCalc(decl, ctx);
|
|
167
|
+
if (!result.ok) {
|
|
168
|
+
failures.push(`${decl.id} (${result.error?.code ?? 'unknown'})`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
// The engine reports "inputs absent" as a successful evaluation to null.
|
|
172
|
+
// A receipt records that as an uncomputed output, not as a computed null.
|
|
173
|
+
const computed = result.value !== null;
|
|
174
|
+
results.push({
|
|
175
|
+
calc_id: result.calc_id,
|
|
176
|
+
value: computed ? result.value : null,
|
|
177
|
+
...(decl.unit ? { unit: decl.unit } : {}),
|
|
178
|
+
computed,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (failures.length > 0) {
|
|
182
|
+
throw new ReceiptError('RCP_COMPUTATION_FAILED', `Refusing to issue: ${failures.length} of ${decls.length} pack calculations did not evaluate [${failures.join(', ')}].`);
|
|
183
|
+
}
|
|
184
|
+
// Deterministic order regardless of declaration order, so re-issuance over an
|
|
185
|
+
// unmodified record reproduces byte-identical results.
|
|
186
|
+
return results.sort((left, right) => left.calc_id.localeCompare(right.calc_id));
|
|
187
|
+
}
|
|
188
|
+
/** SHA-256 over the RFC 8785 canonicalization of the result set. */
|
|
189
|
+
export async function computeResultsDigest(results) {
|
|
190
|
+
return digestOf(canonicalizeExact(results));
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Issue an unsigned receipt for a record. Either returns a receipt or throws a
|
|
194
|
+
* typed `ReceiptError` — never a caveated or partial receipt.
|
|
195
|
+
*/
|
|
196
|
+
export async function issueReceipt(content, options = {}) {
|
|
197
|
+
const resolved = await resolveReceiptSubject(content, options);
|
|
198
|
+
const pack = resolvePack(resolved.parsed, options.pack);
|
|
199
|
+
const results = computeReceiptResults(pack, resolved.parsed);
|
|
200
|
+
const validation = validateUWFile(resolved.parsed);
|
|
201
|
+
return {
|
|
202
|
+
receipt_version: UW_RECEIPT_VERSION,
|
|
203
|
+
subject: resolved.subject,
|
|
204
|
+
computation: {
|
|
205
|
+
pack: pack.id,
|
|
206
|
+
pack_version: pack.version,
|
|
207
|
+
engine: options.engine ?? CORE_PACKAGE_NAME,
|
|
208
|
+
engine_version: options.engine_version ?? CORE_VERSION,
|
|
209
|
+
protocol_version: options.protocol_version ?? PROTOCOL_VERSION,
|
|
210
|
+
results,
|
|
211
|
+
results_digest: await computeResultsDigest(results),
|
|
212
|
+
},
|
|
213
|
+
policy: {
|
|
214
|
+
policy_set: options.policy_set ?? BUILTIN_POLICY_SET,
|
|
215
|
+
policy_set_version: options.policy_set_version ?? BUILTIN_POLICY_SET_VERSION,
|
|
216
|
+
validation: {
|
|
217
|
+
errors: validation.errors.length,
|
|
218
|
+
warnings: validation.warnings.length,
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
// Sorted by document_id so re-issuance over an unchanged record reproduces
|
|
222
|
+
// byte-identical bytes regardless of the order the host listed them in —
|
|
223
|
+
// the re-issuance-stability invariant the conformance suite asserts.
|
|
224
|
+
...(options.inputs_provenance && options.inputs_provenance.length > 0
|
|
225
|
+
? {
|
|
226
|
+
inputs_provenance: [...options.inputs_provenance].sort((a, b) => a.document_id < b.document_id ? -1 : a.document_id > b.document_id ? 1 : 0),
|
|
227
|
+
}
|
|
228
|
+
: {}),
|
|
229
|
+
issued_at: options.issued_at ?? new Date().toISOString(),
|
|
230
|
+
issuer: options.issuer ?? `${CORE_PACKAGE_NAME}@${CORE_VERSION}`,
|
|
231
|
+
signature: null,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function resolvePack(parsed, override) {
|
|
235
|
+
const assetClass = parsed.frontmatter.asset_class;
|
|
236
|
+
const pack = override ?? (assetClass ? getPackForAssetClass(assetClass) : null);
|
|
237
|
+
if (!pack) {
|
|
238
|
+
throw new ReceiptError('RCP_PACK_UNRESOLVED', `No calc pack is registered for asset class '${assetClass ?? '(unset)'}'; pass one explicitly to issue a receipt.`);
|
|
239
|
+
}
|
|
240
|
+
if (!pack.version) {
|
|
241
|
+
throw new ReceiptError('RCP_PACK_UNVERSIONED', `Pack '${pack.id}' declares no version; an unversioned pack cannot be named in a receipt.`);
|
|
242
|
+
}
|
|
243
|
+
return pack;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The RFC 8785 canonicalization a signature covers: the receipt with
|
|
247
|
+
* `signature` set to null.
|
|
248
|
+
*/
|
|
249
|
+
export function receiptSigningPayload(receipt) {
|
|
250
|
+
return canonicalizeExact({ ...receipt, signature: null });
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Verify a receipt against a record.
|
|
254
|
+
*
|
|
255
|
+
* Reports exactly one of three verdicts and never collapses `unverifiable` into
|
|
256
|
+
* either of the others. Precedence:
|
|
257
|
+
*
|
|
258
|
+
* 1. A digest mismatch is decisive — the record changed. `failed` — unless the
|
|
259
|
+
* receipt was canonicalized under different rules than this verifier
|
|
260
|
+
* applies, in which case the two digests were never comparable and the
|
|
261
|
+
* disagreement is attributable to the rules rather than to the record:
|
|
262
|
+
* `unverifiable` (RCP-10). This mirrors the engine-identity carve-out at
|
|
263
|
+
* step 3, and exists for the same reason RFC 0023 gave — a verifier must
|
|
264
|
+
* not report corruption when the only thing that changed is its own
|
|
265
|
+
* arithmetic. See RFC 0025.
|
|
266
|
+
* 2. A referenced input that resolves but whose digest disagrees is also
|
|
267
|
+
* decisive — the observation set or child record changed after issuance.
|
|
268
|
+
* `failed` (RCP-12). Placed here, above the indeterminate group, because a
|
|
269
|
+
* reference the verifier *does* hold and *can* compare is real evidence,
|
|
270
|
+
* and burying it under a later step would let an unknown pack mask it.
|
|
271
|
+
* 3. Otherwise, anything this verifier cannot decide (unknown pack, pack
|
|
272
|
+
* version it does not hold, a signature with no backend, or a referenced
|
|
273
|
+
* input it does not hold — RCP-11) → `unverifiable`.
|
|
274
|
+
* 4. Otherwise, recomputation disagreement → `failed`, unless the issuing
|
|
275
|
+
* engine identity (name *and* version) differs from this verifier's, in
|
|
276
|
+
* which case the disagreement is attributable to the engine and the
|
|
277
|
+
* verdict is `unverifiable` (RFC 0016 open question, resolved in
|
|
278
|
+
* `spec/UW_RECEIPT_v1.md` §5).
|
|
279
|
+
* 5. Otherwise `verified`.
|
|
280
|
+
*
|
|
281
|
+
* The verifier always recomputes; `results_digest` is checked only as a cheap
|
|
282
|
+
* corruption signal and never authorizes skipping the recomputation.
|
|
283
|
+
*/
|
|
284
|
+
export async function verifyReceipt(receipt, content, options = {}) {
|
|
285
|
+
assertUWReceipt(receipt);
|
|
286
|
+
const issues = [];
|
|
287
|
+
// The document must canonicalize before anything can be compared to it.
|
|
288
|
+
let resolved;
|
|
289
|
+
try {
|
|
290
|
+
resolved = await resolveReceiptSubject(content, {
|
|
291
|
+
filename: options.filename,
|
|
292
|
+
representation: options.representation ?? receipt.subject.representation,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
return {
|
|
297
|
+
verdict: 'unverifiable',
|
|
298
|
+
issues: [
|
|
299
|
+
{
|
|
300
|
+
code: 'RCP-09',
|
|
301
|
+
severity: 'indeterminate',
|
|
302
|
+
message: `The record could not be canonicalized for comparison: ${e instanceof Error ? e.message : String(e)}`,
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
// 1 — digest. Decisive on mismatch, but only between comparable digests: two
|
|
308
|
+
// canonicalization versions can disagree about a document neither side
|
|
309
|
+
// touched, so the version is checked before the mismatch is attributed.
|
|
310
|
+
if (resolved.subject.digest !== receipt.subject.digest) {
|
|
311
|
+
if (resolved.subject.canonicalization_version !== receipt.subject.canonicalization_version) {
|
|
312
|
+
return {
|
|
313
|
+
verdict: 'unverifiable',
|
|
314
|
+
issues: [
|
|
315
|
+
{
|
|
316
|
+
code: 'RCP-10',
|
|
317
|
+
severity: 'indeterminate',
|
|
318
|
+
message: `The receipt was canonicalized under '${receipt.subject.canonicalization}' version ${receipt.subject.canonicalization_version}; this verifier applies version ${resolved.subject.canonicalization_version}. The digests are not comparable, so this is not evidence the record changed.`,
|
|
319
|
+
expected: receipt.subject.digest,
|
|
320
|
+
actual: resolved.subject.digest,
|
|
321
|
+
},
|
|
322
|
+
],
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
verdict: 'failed',
|
|
327
|
+
issues: [
|
|
328
|
+
{
|
|
329
|
+
code: 'RCP-01',
|
|
330
|
+
severity: 'failure',
|
|
331
|
+
message: 'The record\'s canonical financial content has changed since the receipt was issued.',
|
|
332
|
+
expected: receipt.subject.digest,
|
|
333
|
+
actual: resolved.subject.digest,
|
|
334
|
+
},
|
|
335
|
+
],
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
// 2 — referenced inputs (receipt format 1.1). A reference we hold and can
|
|
339
|
+
// compare is decisive; one we do not hold is indeterminate, and the two must
|
|
340
|
+
// not be collapsed. Held references are checked first so a mutated
|
|
341
|
+
// observation set is not masked by an unrelated missing one.
|
|
342
|
+
const references = receipt.inputs_provenance ?? [];
|
|
343
|
+
const held = options.inputs ?? {};
|
|
344
|
+
const unresolved = [];
|
|
345
|
+
for (const reference of references) {
|
|
346
|
+
const actual = held[reference.document_id];
|
|
347
|
+
if (actual === undefined) {
|
|
348
|
+
unresolved.push(reference);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (actual !== reference.digest) {
|
|
352
|
+
return {
|
|
353
|
+
verdict: 'failed',
|
|
354
|
+
issues: [
|
|
355
|
+
{
|
|
356
|
+
code: 'RCP-12',
|
|
357
|
+
severity: 'failure',
|
|
358
|
+
message: `The ${reference.source} input '${reference.document_id}'${reference.as_of ? ` (as of ${reference.as_of})` : ''} has changed since the receipt was issued, so the stated results no longer follow from the inputs named.`,
|
|
359
|
+
expected: reference.digest,
|
|
360
|
+
actual,
|
|
361
|
+
},
|
|
362
|
+
],
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
for (const reference of unresolved) {
|
|
367
|
+
issues.push({
|
|
368
|
+
code: 'RCP-11',
|
|
369
|
+
severity: 'indeterminate',
|
|
370
|
+
message: `This verifier does not hold the ${reference.source} input '${reference.document_id}'${reference.as_of ? ` (as of ${reference.as_of})` : ''}, so it cannot confirm the inputs the computation used. This is not evidence the record changed.`,
|
|
371
|
+
expected: reference.digest,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
// 3 — can this verifier decide at all?
|
|
375
|
+
const pack = lookupPack(receipt.computation.pack, options.packs);
|
|
376
|
+
if (!pack) {
|
|
377
|
+
issues.push({
|
|
378
|
+
code: 'RCP-05',
|
|
379
|
+
severity: 'indeterminate',
|
|
380
|
+
message: `This verifier does not hold pack '${receipt.computation.pack}', so it cannot recompute the stated results.`,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
else if (pack.version !== receipt.computation.pack_version) {
|
|
384
|
+
issues.push({
|
|
385
|
+
code: 'RCP-06',
|
|
386
|
+
severity: 'indeterminate',
|
|
387
|
+
message: `This verifier holds pack '${pack.id}' at version ${pack.version}; the receipt names ${receipt.computation.pack_version}.`,
|
|
388
|
+
expected: receipt.computation.pack_version,
|
|
389
|
+
actual: pack.version,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
if (receipt.signature && !options.signatureVerifier) {
|
|
393
|
+
issues.push({
|
|
394
|
+
code: 'RCP-08',
|
|
395
|
+
severity: 'indeterminate',
|
|
396
|
+
message: 'The receipt is signed and this verifier has no signature backend; the signature was not ignored, it was not checked.',
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
if (issues.some((issue) => issue.severity === 'indeterminate')) {
|
|
400
|
+
return { verdict: 'unverifiable', issues };
|
|
401
|
+
}
|
|
402
|
+
// 4 — recompute. `pack` is non-null here: a null pack produced RCP-05 above.
|
|
403
|
+
const resolvedPack = pack;
|
|
404
|
+
let recomputed;
|
|
405
|
+
try {
|
|
406
|
+
recomputed = computeReceiptResults(resolvedPack, resolved.parsed);
|
|
407
|
+
}
|
|
408
|
+
catch (e) {
|
|
409
|
+
return {
|
|
410
|
+
verdict: 'failed',
|
|
411
|
+
issues: [
|
|
412
|
+
{
|
|
413
|
+
code: 'RCP-03',
|
|
414
|
+
severity: 'failure',
|
|
415
|
+
message: `Recomputation did not produce the pack's declared outputs: ${e instanceof Error ? e.message : String(e)}`,
|
|
416
|
+
},
|
|
417
|
+
],
|
|
418
|
+
recomputed: [],
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
// Engine identity is the pair, not the version alone. A version string only
|
|
422
|
+
// means something within one engine's release history: `2.1.0` of some other
|
|
423
|
+
// vendor's engine is not a later or earlier build of this one, it is an
|
|
424
|
+
// unrelated implementation that happens to have reached the same number. So a
|
|
425
|
+
// disagreement is attributable to the record only when both halves match.
|
|
426
|
+
const verifierEngine = options.engine ?? CORE_PACKAGE_NAME;
|
|
427
|
+
const verifierEngineVersion = options.engine_version ?? CORE_VERSION;
|
|
428
|
+
const engineMatches = receipt.computation.engine === verifierEngine &&
|
|
429
|
+
receipt.computation.engine_version === verifierEngineVersion;
|
|
430
|
+
if (receipt.signature && options.signatureVerifier) {
|
|
431
|
+
const valid = await options.signatureVerifier.verify(receipt, receiptSigningPayload(receipt));
|
|
432
|
+
if (!valid) {
|
|
433
|
+
return {
|
|
434
|
+
verdict: 'failed',
|
|
435
|
+
issues: [
|
|
436
|
+
{
|
|
437
|
+
code: 'RCP-01',
|
|
438
|
+
severity: 'failure',
|
|
439
|
+
message: 'The receipt signature did not validate.',
|
|
440
|
+
},
|
|
441
|
+
],
|
|
442
|
+
recomputed,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const disagreements = compareResults(receipt.computation.results, recomputed);
|
|
447
|
+
if (disagreements.length > 0) {
|
|
448
|
+
if (!engineMatches) {
|
|
449
|
+
return {
|
|
450
|
+
verdict: 'unverifiable',
|
|
451
|
+
issues: [
|
|
452
|
+
{
|
|
453
|
+
code: 'RCP-07',
|
|
454
|
+
severity: 'indeterminate',
|
|
455
|
+
message: `Results disagree, but the receipt was issued by engine ${receipt.computation.engine}@${receipt.computation.engine_version} and this verifier runs ${verifierEngine}@${verifierEngineVersion}. The disagreement cannot be attributed to the record.`,
|
|
456
|
+
expected: `${receipt.computation.engine}@${receipt.computation.engine_version}`,
|
|
457
|
+
actual: `${verifierEngine}@${verifierEngineVersion}`,
|
|
458
|
+
},
|
|
459
|
+
...disagreements,
|
|
460
|
+
],
|
|
461
|
+
recomputed,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
return { verdict: 'failed', issues: disagreements, recomputed };
|
|
465
|
+
}
|
|
466
|
+
// Corruption check, reported but not decisive on its own — recomputation
|
|
467
|
+
// already agreed, so a stale digest here means the field, not the numbers.
|
|
468
|
+
const expectedResultsDigest = await computeResultsDigest(receipt.computation.results);
|
|
469
|
+
if (expectedResultsDigest !== receipt.computation.results_digest) {
|
|
470
|
+
return {
|
|
471
|
+
verdict: 'failed',
|
|
472
|
+
issues: [
|
|
473
|
+
{
|
|
474
|
+
code: 'RCP-04',
|
|
475
|
+
severity: 'failure',
|
|
476
|
+
message: 'results_digest does not recompute over the stated results.',
|
|
477
|
+
expected: expectedResultsDigest,
|
|
478
|
+
actual: receipt.computation.results_digest,
|
|
479
|
+
},
|
|
480
|
+
],
|
|
481
|
+
recomputed,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
return { verdict: 'verified', issues: [], recomputed };
|
|
485
|
+
}
|
|
486
|
+
function lookupPack(id, packs) {
|
|
487
|
+
if (packs)
|
|
488
|
+
return packs.find((pack) => pack.id === id) ?? null;
|
|
489
|
+
for (const assetClass of [
|
|
490
|
+
'multifamily',
|
|
491
|
+
'office',
|
|
492
|
+
'retail',
|
|
493
|
+
'industrial',
|
|
494
|
+
'self_storage',
|
|
495
|
+
]) {
|
|
496
|
+
const pack = getPackForAssetClass(assetClass);
|
|
497
|
+
if (pack?.id === id)
|
|
498
|
+
return pack;
|
|
499
|
+
}
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
function compareResults(stated, recomputed) {
|
|
503
|
+
const issues = [];
|
|
504
|
+
const statedById = new Map(stated.map((result) => [result.calc_id, result]));
|
|
505
|
+
const recomputedById = new Map(recomputed.map((result) => [result.calc_id, result]));
|
|
506
|
+
for (const result of recomputed) {
|
|
507
|
+
if (!statedById.has(result.calc_id)) {
|
|
508
|
+
issues.push({
|
|
509
|
+
code: 'RCP-02',
|
|
510
|
+
severity: 'failure',
|
|
511
|
+
calc_id: result.calc_id,
|
|
512
|
+
message: `The receipt omits '${result.calc_id}', which the pack declares as an output.`,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
for (const result of stated) {
|
|
517
|
+
if (!recomputedById.has(result.calc_id)) {
|
|
518
|
+
issues.push({
|
|
519
|
+
code: 'RCP-02',
|
|
520
|
+
severity: 'failure',
|
|
521
|
+
calc_id: result.calc_id,
|
|
522
|
+
message: `The receipt states '${result.calc_id}', which the pack does not compute.`,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
for (const statedResult of stated) {
|
|
527
|
+
const actual = recomputedById.get(statedResult.calc_id);
|
|
528
|
+
if (!actual)
|
|
529
|
+
continue;
|
|
530
|
+
if (statedResult.computed !== actual.computed) {
|
|
531
|
+
issues.push({
|
|
532
|
+
code: 'RCP-03',
|
|
533
|
+
severity: 'failure',
|
|
534
|
+
calc_id: statedResult.calc_id,
|
|
535
|
+
message: statedResult.computed
|
|
536
|
+
? `The receipt states a computed '${statedResult.calc_id}', but the record lacks the inputs to produce it.`
|
|
537
|
+
: `The receipt marks '${statedResult.calc_id}' uncomputed, but the record does produce it.`,
|
|
538
|
+
expected: statedResult.computed ? 'computed' : 'uncomputed',
|
|
539
|
+
actual: actual.computed ? 'computed' : 'uncomputed',
|
|
540
|
+
});
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (!statedResult.computed)
|
|
544
|
+
continue;
|
|
545
|
+
if (!valuesAgree(statedResult.value, actual.value)) {
|
|
546
|
+
issues.push({
|
|
547
|
+
code: 'RCP-03',
|
|
548
|
+
severity: 'failure',
|
|
549
|
+
calc_id: statedResult.calc_id,
|
|
550
|
+
message: `Stated '${statedResult.calc_id}' does not follow from the record.`,
|
|
551
|
+
expected: String(statedResult.value),
|
|
552
|
+
actual: String(actual.value),
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return issues;
|
|
557
|
+
}
|
|
558
|
+
function valuesAgree(stated, actual) {
|
|
559
|
+
if (typeof stated === 'number' && typeof actual === 'number') {
|
|
560
|
+
if (Number.isNaN(stated) || Number.isNaN(actual))
|
|
561
|
+
return false;
|
|
562
|
+
if (stated === actual)
|
|
563
|
+
return true;
|
|
564
|
+
const scale = Math.max(1, Math.abs(stated), Math.abs(actual));
|
|
565
|
+
return Math.abs(stated - actual) <= RECEIPT_RESULT_TOLERANCE * scale;
|
|
566
|
+
}
|
|
567
|
+
return stated === actual;
|
|
568
|
+
}
|
|
569
|
+
// ─── Shape validation ────────────────────────────────────────────────────────
|
|
570
|
+
// ─── Rollup receipts (RFC 0021 §6) ───────────────────────────────────────────
|
|
571
|
+
//
|
|
572
|
+
// A composite states aggregate figures. It does not compute them in the calc
|
|
573
|
+
// engine, because the Tier-3 sandbox has no iteration and no array indexing —
|
|
574
|
+
// RFC 0019 hit exactly this wall for `mixed_use` components, and a portfolio
|
|
575
|
+
// total over N assets is the same problem at a different scale.
|
|
576
|
+
//
|
|
577
|
+
// Rather than change the sandbox, the verifier evaluates a fixed, tiny,
|
|
578
|
+
// non-extensible aggregation vocabulary over the named children. Nothing here
|
|
579
|
+
// lets a module or a document author introduce a new aggregation, and nothing
|
|
580
|
+
// here makes the Tier-3 evaluator iterate. If a general aggregation primitive
|
|
581
|
+
// ever lands, it supersedes this rather than sitting beside it.
|
|
582
|
+
/** The complete permitted set. Deliberately closed. */
|
|
583
|
+
export const ROLLUP_FUNCTIONS = Object.freeze([
|
|
584
|
+
'sum',
|
|
585
|
+
'count',
|
|
586
|
+
'min',
|
|
587
|
+
'max',
|
|
588
|
+
'weighted_average',
|
|
589
|
+
]);
|
|
590
|
+
/** The section a composite carries its stated aggregates in. */
|
|
591
|
+
export const PORTFOLIO_ROLLUP_SECTION = 'portfolio_rollup';
|
|
592
|
+
export function validateRollupAggregate(candidate) {
|
|
593
|
+
const errors = [];
|
|
594
|
+
if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) {
|
|
595
|
+
return [{ category: 'validate', code: 'COMP-ROLLUP-DISAGREES', message: 'Aggregate must be an object.' }];
|
|
596
|
+
}
|
|
597
|
+
const a = candidate;
|
|
598
|
+
const fail = (message, pointer) => {
|
|
599
|
+
errors.push({ category: 'validate', code: 'COMP-ROLLUP-DISAGREES', message, ...(pointer ? { pointer } : {}) });
|
|
600
|
+
};
|
|
601
|
+
if (typeof a.id !== 'string' || a.id.length === 0)
|
|
602
|
+
fail('Aggregate requires an id.', 'id');
|
|
603
|
+
if (!ROLLUP_FUNCTIONS.includes(a.fn)) {
|
|
604
|
+
fail(`fn must be one of ${ROLLUP_FUNCTIONS.join(', ')}; got '${String(a.fn)}'.`, 'fn');
|
|
605
|
+
}
|
|
606
|
+
if (typeof a.over !== 'string' || a.over.length === 0)
|
|
607
|
+
fail('Aggregate requires an over path.', 'over');
|
|
608
|
+
if (!Array.isArray(a.members) || a.members.length === 0) {
|
|
609
|
+
fail('Aggregate requires a non-empty members array.', 'members');
|
|
610
|
+
}
|
|
611
|
+
else if (new Set(a.members).size !== a.members.length) {
|
|
612
|
+
// A member counted twice would inflate a sum, and a portfolio total is
|
|
613
|
+
// exactly the number nobody re-checks by hand.
|
|
614
|
+
fail('Aggregate names a member more than once.', 'members');
|
|
615
|
+
}
|
|
616
|
+
if (typeof a.value !== 'number' || !Number.isFinite(a.value)) {
|
|
617
|
+
fail('Aggregate requires a finite stated value.', 'value');
|
|
618
|
+
}
|
|
619
|
+
if (a.fn === 'weighted_average') {
|
|
620
|
+
if (typeof a.weight_by !== 'string' || a.weight_by.length === 0) {
|
|
621
|
+
fail('weighted_average requires weight_by.', 'weight_by');
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
else if (a.weight_by !== undefined) {
|
|
625
|
+
fail(`weight_by is only meaningful for weighted_average, not '${String(a.fn)}'.`, 'weight_by');
|
|
626
|
+
}
|
|
627
|
+
return errors;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Evaluate one aggregate over member values.
|
|
631
|
+
*
|
|
632
|
+
* Returns `null` when it cannot be evaluated — a missing member or a
|
|
633
|
+
* non-numeric value. Null is *not* zero: a portfolio NOI that silently treats
|
|
634
|
+
* an unreadable child as contributing nothing is the failure this whole design
|
|
635
|
+
* exists to prevent.
|
|
636
|
+
*/
|
|
637
|
+
export function evaluateRollup(aggregate, values, weights) {
|
|
638
|
+
const xs = [];
|
|
639
|
+
for (const member of aggregate.members) {
|
|
640
|
+
const v = values.get(member);
|
|
641
|
+
if (v === undefined || !Number.isFinite(v)) {
|
|
642
|
+
// `count` is not exempt: counting members whose value could not be read
|
|
643
|
+
// would report a count over a set the verifier never actually saw.
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
xs.push(v);
|
|
647
|
+
}
|
|
648
|
+
if (xs.length === 0)
|
|
649
|
+
return null;
|
|
650
|
+
switch (aggregate.fn) {
|
|
651
|
+
case 'sum':
|
|
652
|
+
return xs.reduce((acc, x) => acc + x, 0);
|
|
653
|
+
case 'count':
|
|
654
|
+
return xs.length;
|
|
655
|
+
case 'min':
|
|
656
|
+
return Math.min(...xs);
|
|
657
|
+
case 'max':
|
|
658
|
+
return Math.max(...xs);
|
|
659
|
+
case 'weighted_average': {
|
|
660
|
+
if (!weights)
|
|
661
|
+
return null;
|
|
662
|
+
let numerator = 0;
|
|
663
|
+
let denominator = 0;
|
|
664
|
+
for (const [i, member] of aggregate.members.entries()) {
|
|
665
|
+
const w = weights.get(member);
|
|
666
|
+
if (w === undefined || !Number.isFinite(w))
|
|
667
|
+
return null;
|
|
668
|
+
numerator += xs[i] * w;
|
|
669
|
+
denominator += w;
|
|
670
|
+
}
|
|
671
|
+
// Zero total weight has no meaningful average; refuse rather than divide.
|
|
672
|
+
if (denominator === 0)
|
|
673
|
+
return null;
|
|
674
|
+
return numerator / denominator;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Stage 2 of rollup verification: recompute each stated aggregate over exactly
|
|
680
|
+
* the members it names.
|
|
681
|
+
*
|
|
682
|
+
* Stage 1 — verifying each child's own receipt — is existing behaviour applied
|
|
683
|
+
* per child, so the caller performs it and passes the verdicts in. Splitting it
|
|
684
|
+
* this way keeps this function synchronous and free of I/O, and lets a host
|
|
685
|
+
* that already verified its children reuse that work.
|
|
686
|
+
*
|
|
687
|
+
* Verdict precedence mirrors `verifyReceipt`, and for the same reasons:
|
|
688
|
+
*
|
|
689
|
+
* - any child `failed` → `failed` (the parent's total rests on it)
|
|
690
|
+
* - any child `unverifiable`,
|
|
691
|
+
* or a named member absent → `unverifiable`
|
|
692
|
+
* - a stated aggregate disagrees → `failed` (`COMP-ROLLUP-DISAGREES`)
|
|
693
|
+
* - otherwise → `verified`
|
|
694
|
+
*
|
|
695
|
+
* A verified rollup means the stated total follows deterministically from those
|
|
696
|
+
* child records as they stand. It does **not** mean the children are complete,
|
|
697
|
+
* that the portfolio contains every asset it should, or that any input is true.
|
|
698
|
+
*/
|
|
699
|
+
export function verifyRollup(aggregates, members) {
|
|
700
|
+
const issues = [];
|
|
701
|
+
const results = [];
|
|
702
|
+
const byId = new Map(members.map((m) => [m.document_id, m]));
|
|
703
|
+
for (const aggregate of aggregates) {
|
|
704
|
+
const structural = validateRollupAggregate(aggregate);
|
|
705
|
+
if (structural.length > 0) {
|
|
706
|
+
issues.push({
|
|
707
|
+
code: 'RCP-02',
|
|
708
|
+
severity: 'failure',
|
|
709
|
+
message: `Aggregate '${String(aggregate.id)}' is malformed: ${structural.map((e) => e.message).join('; ')}`,
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
if (issues.length > 0)
|
|
714
|
+
return { verdict: 'failed', issues, aggregates: results };
|
|
715
|
+
// Stage 1 verdicts, decided before any arithmetic: a total computed over a
|
|
716
|
+
// child whose own receipt failed is not worth reporting as agreeing.
|
|
717
|
+
let sawUnverifiable = false;
|
|
718
|
+
for (const member of members) {
|
|
719
|
+
if (member.verdict === 'failed') {
|
|
720
|
+
return {
|
|
721
|
+
verdict: 'failed',
|
|
722
|
+
issues: [{
|
|
723
|
+
code: 'RCP-03',
|
|
724
|
+
severity: 'failure',
|
|
725
|
+
message: `Child record '${member.document_id}' failed its own receipt verification, so no aggregate over it can be trusted.`,
|
|
726
|
+
}],
|
|
727
|
+
aggregates: results,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
if (member.verdict === 'unverifiable')
|
|
731
|
+
sawUnverifiable = true;
|
|
732
|
+
}
|
|
733
|
+
for (const aggregate of aggregates) {
|
|
734
|
+
const values = new Map();
|
|
735
|
+
const weights = new Map();
|
|
736
|
+
let missing = null;
|
|
737
|
+
for (const id of aggregate.members) {
|
|
738
|
+
const member = byId.get(id);
|
|
739
|
+
if (!member || member.value === null || member.value === undefined) {
|
|
740
|
+
missing = id;
|
|
741
|
+
break;
|
|
742
|
+
}
|
|
743
|
+
values.set(id, member.value);
|
|
744
|
+
if (aggregate.fn === 'weighted_average') {
|
|
745
|
+
if (member.weight === null || member.weight === undefined) {
|
|
746
|
+
missing = id;
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
weights.set(id, member.weight);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (missing !== null) {
|
|
753
|
+
issues.push({
|
|
754
|
+
code: 'RCP-11',
|
|
755
|
+
severity: 'indeterminate',
|
|
756
|
+
message: `Aggregate '${aggregate.id}' names member '${missing}', whose value at '${aggregate.over}' is not available to this verifier.`,
|
|
757
|
+
});
|
|
758
|
+
results.push({ id: aggregate.id, stated: aggregate.value, recomputed: null, agrees: false });
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
const recomputed = evaluateRollup(aggregate, values, weights);
|
|
762
|
+
const agrees = recomputed !== null && valuesAgree(aggregate.value, recomputed);
|
|
763
|
+
results.push({ id: aggregate.id, stated: aggregate.value, recomputed, agrees });
|
|
764
|
+
if (!agrees) {
|
|
765
|
+
issues.push({
|
|
766
|
+
code: 'COMP-ROLLUP-DISAGREES',
|
|
767
|
+
severity: 'failure',
|
|
768
|
+
message: `Aggregate '${aggregate.id}' states ${aggregate.value} but ${aggregate.fn} over ${aggregate.members.length} member(s) at '${aggregate.over}' recomputes to ${recomputed === null ? 'nothing evaluable' : recomputed}.`,
|
|
769
|
+
expected: String(aggregate.value),
|
|
770
|
+
actual: recomputed === null ? 'unevaluable' : String(recomputed),
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (issues.some((i) => i.severity === 'failure')) {
|
|
775
|
+
return { verdict: 'failed', issues, aggregates: results };
|
|
776
|
+
}
|
|
777
|
+
if (sawUnverifiable || issues.some((i) => i.severity === 'indeterminate')) {
|
|
778
|
+
if (!issues.some((i) => i.severity === 'indeterminate')) {
|
|
779
|
+
issues.push({
|
|
780
|
+
code: 'RCP-11',
|
|
781
|
+
severity: 'indeterminate',
|
|
782
|
+
message: 'At least one child record could not be verified, so the rollup over it is undecided.',
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
return { verdict: 'unverifiable', issues, aggregates: results };
|
|
786
|
+
}
|
|
787
|
+
return { verdict: 'verified', issues, aggregates: results };
|
|
788
|
+
}
|
|
789
|
+
/** Structural check mirroring `spec/schemas/uw-receipt.schema.json`. */
|
|
790
|
+
export function assertUWReceipt(value) {
|
|
791
|
+
const fail = (message) => {
|
|
792
|
+
throw new ReceiptError('RCP_MALFORMED', message);
|
|
793
|
+
};
|
|
794
|
+
if (typeof value !== 'object' || value === null)
|
|
795
|
+
fail('A receipt must be an object.');
|
|
796
|
+
const receipt = value;
|
|
797
|
+
// Accepts every supported version, not just the current one: each 1.1
|
|
798
|
+
// addition is optional, so a 1.0 receipt is still a valid receipt and
|
|
799
|
+
// refusing to read it would strand every receipt issued before this release.
|
|
800
|
+
if (typeof receipt['receipt_version'] !== 'string' ||
|
|
801
|
+
!SUPPORTED_RECEIPT_VERSIONS.includes(receipt['receipt_version'])) {
|
|
802
|
+
fail(`Unsupported receipt_version '${String(receipt['receipt_version'])}' (supported: ${SUPPORTED_RECEIPT_VERSIONS.join(', ')}).`);
|
|
803
|
+
}
|
|
804
|
+
const subject = receipt['subject'];
|
|
805
|
+
if (typeof subject !== 'object' || subject === null)
|
|
806
|
+
fail('receipt.subject must be an object.');
|
|
807
|
+
const s = subject;
|
|
808
|
+
for (const key of [
|
|
809
|
+
'representation',
|
|
810
|
+
'representation_version',
|
|
811
|
+
'canonicalization',
|
|
812
|
+
'canonicalization_version',
|
|
813
|
+
'digest',
|
|
814
|
+
]) {
|
|
815
|
+
if (typeof s[key] !== 'string')
|
|
816
|
+
fail(`receipt.subject.${key} must be a string.`);
|
|
817
|
+
}
|
|
818
|
+
if (!DIGEST_PATTERN.test(s['digest'])) {
|
|
819
|
+
fail('receipt.subject.digest must be sha256 followed by 64 lowercase hex characters.');
|
|
820
|
+
}
|
|
821
|
+
const computation = receipt['computation'];
|
|
822
|
+
if (typeof computation !== 'object' || computation === null) {
|
|
823
|
+
fail('receipt.computation must be an object.');
|
|
824
|
+
}
|
|
825
|
+
const c = computation;
|
|
826
|
+
for (const key of ['pack', 'pack_version', 'engine', 'engine_version', 'results_digest']) {
|
|
827
|
+
if (typeof c[key] !== 'string')
|
|
828
|
+
fail(`receipt.computation.${key} must be a string.`);
|
|
829
|
+
}
|
|
830
|
+
if (!Array.isArray(c['results']))
|
|
831
|
+
fail('receipt.computation.results must be an array.');
|
|
832
|
+
for (const entry of c['results']) {
|
|
833
|
+
if (typeof entry !== 'object' || entry === null)
|
|
834
|
+
fail('Each result must be an object.');
|
|
835
|
+
const r = entry;
|
|
836
|
+
if (typeof r['calc_id'] !== 'string')
|
|
837
|
+
fail('Each result needs a string calc_id.');
|
|
838
|
+
if (!['number', 'string', 'boolean'].includes(typeof r['value']) && r['value'] !== null) {
|
|
839
|
+
fail(`Result '${String(r['calc_id'])}' has a non-scalar value.`);
|
|
840
|
+
}
|
|
841
|
+
if (typeof r['computed'] !== 'boolean') {
|
|
842
|
+
fail(`Result '${String(r['calc_id'])}' must declare a boolean 'computed'.`);
|
|
843
|
+
}
|
|
844
|
+
if (r['computed'] === false && r['value'] !== null) {
|
|
845
|
+
fail(`Result '${String(r['calc_id'])}' is marked uncomputed but carries a value.`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
const policy = receipt['policy'];
|
|
849
|
+
if (typeof policy !== 'object' || policy === null)
|
|
850
|
+
fail('receipt.policy must be an object.');
|
|
851
|
+
const p = policy;
|
|
852
|
+
if (typeof p['policy_set'] !== 'string')
|
|
853
|
+
fail('receipt.policy.policy_set must be a string.');
|
|
854
|
+
if (typeof p['policy_set_version'] !== 'string') {
|
|
855
|
+
fail('receipt.policy.policy_set_version must be a string.');
|
|
856
|
+
}
|
|
857
|
+
const validation = p['validation'];
|
|
858
|
+
if (typeof validation !== 'object' || validation === null) {
|
|
859
|
+
fail('receipt.policy.validation must be an object.');
|
|
860
|
+
}
|
|
861
|
+
const v = validation;
|
|
862
|
+
for (const key of ['errors', 'warnings']) {
|
|
863
|
+
if (typeof v[key] !== 'number' || !Number.isInteger(v[key])) {
|
|
864
|
+
fail(`receipt.policy.validation.${key} must be an integer.`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
// inputs_provenance (1.1). Optional, but malformed-if-present: a reference
|
|
868
|
+
// with no digest cannot be checked, and silently dropping it would turn an
|
|
869
|
+
// unverifiable receipt into an apparently clean one.
|
|
870
|
+
const provenance = receipt['inputs_provenance'];
|
|
871
|
+
if (provenance !== undefined) {
|
|
872
|
+
if (!Array.isArray(provenance))
|
|
873
|
+
fail('receipt.inputs_provenance must be an array.');
|
|
874
|
+
const seen = new Set();
|
|
875
|
+
for (const [i, raw] of provenance.entries()) {
|
|
876
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
877
|
+
fail(`receipt.inputs_provenance[${i}] must be an object.`);
|
|
878
|
+
}
|
|
879
|
+
const ref = raw;
|
|
880
|
+
for (const key of ['source', 'document_id', 'digest']) {
|
|
881
|
+
if (typeof ref[key] !== 'string' || ref[key].length === 0) {
|
|
882
|
+
fail(`receipt.inputs_provenance[${i}].${key} must be a non-empty string.`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
if (!DIGEST_PATTERN.test(ref['digest'])) {
|
|
886
|
+
fail(`receipt.inputs_provenance[${i}].digest must be 'sha256:<64 lowercase hex>'.`);
|
|
887
|
+
}
|
|
888
|
+
if (ref['as_of'] !== undefined && typeof ref['as_of'] !== 'string') {
|
|
889
|
+
fail(`receipt.inputs_provenance[${i}].as_of must be a string when present.`);
|
|
890
|
+
}
|
|
891
|
+
// Two entries for one id would make the digest check order-dependent.
|
|
892
|
+
const id = ref['document_id'];
|
|
893
|
+
if (seen.has(id))
|
|
894
|
+
fail(`receipt.inputs_provenance names '${id}' more than once.`);
|
|
895
|
+
seen.add(id);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
if (typeof receipt['issued_at'] !== 'string')
|
|
899
|
+
fail('receipt.issued_at must be a string.');
|
|
900
|
+
if (typeof receipt['issuer'] !== 'string')
|
|
901
|
+
fail('receipt.issuer must be a string.');
|
|
902
|
+
const signature = receipt['signature'];
|
|
903
|
+
if (signature !== null && signature !== undefined) {
|
|
904
|
+
if (typeof signature !== 'object')
|
|
905
|
+
fail('receipt.signature must be an object or null.');
|
|
906
|
+
const sig = signature;
|
|
907
|
+
for (const key of ['algorithm', 'key_id', 'value']) {
|
|
908
|
+
if (typeof sig[key] !== 'string')
|
|
909
|
+
fail(`receipt.signature.${key} must be a string.`);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
//# sourceMappingURL=receipts.js.map
|