@emilia-protocol/gate 0.18.2 → 0.20.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +64 -0
  3. package/action-refusal-statement.js +1 -0
  4. package/coverage-reconciliation-attestation.js +1 -0
  5. package/dist/action-refusal-statement.d.ts +109 -0
  6. package/dist/action-refusal-statement.d.ts.map +1 -0
  7. package/dist/action-refusal-statement.js +333 -0
  8. package/dist/action-refusal-statement.js.map +1 -0
  9. package/dist/coverage-reconciliation-attestation.d.ts +29 -0
  10. package/dist/coverage-reconciliation-attestation.d.ts.map +1 -0
  11. package/dist/coverage-reconciliation-attestation.js +111 -0
  12. package/dist/coverage-reconciliation-attestation.js.map +1 -0
  13. package/dist/gate-qualification-v2.d.ts +4 -0
  14. package/dist/gate-qualification-v2.d.ts.map +1 -1
  15. package/dist/gate-qualification-v2.js +39 -47
  16. package/dist/gate-qualification-v2.js.map +1 -1
  17. package/dist/index.d.ts +7 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +7 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/loss-allocation-schedule.d.ts +53 -0
  22. package/dist/loss-allocation-schedule.d.ts.map +1 -0
  23. package/dist/loss-allocation-schedule.js +351 -0
  24. package/dist/loss-allocation-schedule.js.map +1 -0
  25. package/dist/loss-experience-feed.d.ts +82 -0
  26. package/dist/loss-experience-feed.d.ts.map +1 -0
  27. package/dist/loss-experience-feed.js +314 -0
  28. package/dist/loss-experience-feed.js.map +1 -0
  29. package/dist/open-exposure-ledger-postgres.d.ts +40 -0
  30. package/dist/open-exposure-ledger-postgres.d.ts.map +1 -0
  31. package/dist/open-exposure-ledger-postgres.js +577 -0
  32. package/dist/open-exposure-ledger-postgres.js.map +1 -0
  33. package/dist/open-exposure-ledger.d.ts +243 -0
  34. package/dist/open-exposure-ledger.d.ts.map +1 -0
  35. package/dist/open-exposure-ledger.js +794 -0
  36. package/dist/open-exposure-ledger.js.map +1 -0
  37. package/dist/receipt-census.d.ts +21 -0
  38. package/dist/receipt-census.d.ts.map +1 -0
  39. package/dist/receipt-census.js +162 -0
  40. package/dist/receipt-census.js.map +1 -0
  41. package/dist/reliance-program.d.ts +88 -0
  42. package/dist/reliance-program.d.ts.map +1 -0
  43. package/dist/reliance-program.js +437 -0
  44. package/dist/reliance-program.js.map +1 -0
  45. package/dist/reliance-risk-crypto.d.ts +28 -0
  46. package/dist/reliance-risk-crypto.d.ts.map +1 -0
  47. package/dist/reliance-risk-crypto.js +110 -0
  48. package/dist/reliance-risk-crypto.js.map +1 -0
  49. package/dist/remedy-program.d.ts.map +1 -1
  50. package/dist/remedy-program.js +28 -18
  51. package/dist/remedy-program.js.map +1 -1
  52. package/loss-allocation-schedule.js +1 -0
  53. package/loss-experience-feed.js +1 -0
  54. package/open-exposure-ledger-postgres.js +1 -0
  55. package/open-exposure-ledger.js +1 -0
  56. package/package.json +42 -2
  57. package/receipt-census.js +1 -0
  58. package/reliance-program.js +3 -0
  59. package/src/action-refusal-statement.ts +396 -0
  60. package/src/coverage-reconciliation-attestation.ts +107 -0
  61. package/src/gate-qualification-v2.ts +56 -46
  62. package/src/index.ts +7 -0
  63. package/src/loss-allocation-schedule.ts +427 -0
  64. package/src/loss-experience-feed.ts +398 -0
  65. package/src/open-exposure-ledger-postgres.ts +701 -0
  66. package/src/open-exposure-ledger.ts +1213 -0
  67. package/src/receipt-census.ts +163 -0
  68. package/src/reliance-program.ts +499 -0
  69. package/src/reliance-risk-crypto.ts +114 -0
  70. package/src/remedy-program.ts +28 -17
@@ -0,0 +1,398 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Signed external loss-experience observations with governed action classes.
4
+ *
5
+ * The feed records what its issuer reports. It deliberately does not establish
6
+ * causation, insurance coverage, legal liability, adjudicated loss, solvency,
7
+ * payment, or authorization.
8
+ */
9
+ import {
10
+ RISK_DIGEST,
11
+ riskDigest,
12
+ riskExact,
13
+ riskIdentifier,
14
+ riskInstant,
15
+ riskRecord,
16
+ signRiskBody,
17
+ verifyRiskBody,
18
+ type RiskRecord,
19
+ type TrustedRiskKeys,
20
+ } from './reliance-risk-crypto.js';
21
+
22
+ export const LOSS_EXPERIENCE_FEED_VERSION = 'EP-LOSS-EXPERIENCE-FEED-v1';
23
+ export const LOSS_EXPERIENCE_FEED_CLAIM_BOUNDARY =
24
+ 'externally_reported_observation_not_verified_causation_not_insurance_coverage_not_legal_liability_not_adjudicated_loss_not_solvency_not_payment_not_authorization';
25
+
26
+ const PROGRAM_KEYS = ['program_id', 'version', 'source_digest', 'program_digest'] as const;
27
+ const PERIOD_KEYS = ['start', 'end'] as const;
28
+ const RECORD_KEYS = [
29
+ 'record_id',
30
+ 'lineage_digest',
31
+ 'receipt_digest',
32
+ 'action_class',
33
+ 'classification',
34
+ 'reported_amount_minor',
35
+ 'currency',
36
+ 'occurred_at',
37
+ 'reported_at',
38
+ 'source_record_digest',
39
+ 'event_type',
40
+ 'supersedes_record_digest',
41
+ ] as const;
42
+ const ANCHOR_KEYS = ['method', 'evidence_digest'] as const;
43
+ const BODY_KEYS = [
44
+ '@version',
45
+ 'feed_id',
46
+ 'reporting_party_id',
47
+ 'relying_party_id',
48
+ 'program',
49
+ 'period',
50
+ 'census_digest',
51
+ 'taxonomy_digest',
52
+ 'source_inventory_digest',
53
+ 'records',
54
+ 'issued_at',
55
+ 'expires_at',
56
+ 'timestamp_anchor',
57
+ 'claim_boundary',
58
+ ] as const;
59
+
60
+ const CLASSIFICATIONS = new Set([
61
+ 'NO_REPORTED_LOSS',
62
+ 'LOSS_REPORTED',
63
+ 'NEAR_MISS',
64
+ 'DISPUTED',
65
+ 'UNKNOWN',
66
+ ]);
67
+ const EVENT_TYPES = new Set(['OBSERVED', 'CORRECTED', 'WITHDRAWN']);
68
+
69
+ function money(value: unknown): value is string {
70
+ return typeof value === 'string' && /^(?:0|[1-9][0-9]{0,39})$/.test(value);
71
+ }
72
+
73
+ function validateRecord(value: unknown, periodStart: number, periodEnd: number, issuedAt: number): asserts value is RiskRecord {
74
+ if (!riskExact(value, RECORD_KEYS)
75
+ || !riskIdentifier(value.record_id)
76
+ || typeof value.lineage_digest !== 'string'
77
+ || !RISK_DIGEST.test(value.lineage_digest)
78
+ || typeof value.receipt_digest !== 'string'
79
+ || !RISK_DIGEST.test(value.receipt_digest)
80
+ || !riskIdentifier(value.action_class)
81
+ || typeof value.classification !== 'string'
82
+ || !CLASSIFICATIONS.has(value.classification)
83
+ || !money(value.reported_amount_minor)
84
+ || typeof value.currency !== 'string'
85
+ || !/^[A-Z]{3}$/.test(value.currency)
86
+ || typeof value.source_record_digest !== 'string'
87
+ || !RISK_DIGEST.test(value.source_record_digest)
88
+ || typeof value.event_type !== 'string'
89
+ || !EVENT_TYPES.has(value.event_type)) {
90
+ throw new TypeError('loss experience record shape or field is invalid');
91
+ }
92
+
93
+ const occurredAt = riskInstant(value.occurred_at);
94
+ const reportedAt = riskInstant(value.reported_at);
95
+ if (!Number.isFinite(occurredAt) || occurredAt < periodStart || occurredAt >= periodEnd
96
+ || !Number.isFinite(reportedAt) || reportedAt < occurredAt || reportedAt > issuedAt) {
97
+ throw new TypeError('loss experience occurred/reported time is invalid for the issued period');
98
+ }
99
+
100
+ if (value.event_type === 'OBSERVED') {
101
+ if (value.supersedes_record_digest !== null) {
102
+ throw new TypeError('an observed loss record cannot supersede another record');
103
+ }
104
+ } else if (typeof value.supersedes_record_digest !== 'string'
105
+ || !RISK_DIGEST.test(value.supersedes_record_digest)) {
106
+ throw new TypeError('a corrected or withdrawn record requires a supersedes record digest');
107
+ }
108
+ }
109
+
110
+ function validateBody(value: unknown): asserts value is RiskRecord {
111
+ if (!riskExact(value, BODY_KEYS)
112
+ || value['@version'] !== LOSS_EXPERIENCE_FEED_VERSION
113
+ || !riskIdentifier(value.feed_id)
114
+ || !riskIdentifier(value.reporting_party_id)
115
+ || !riskIdentifier(value.relying_party_id)
116
+ || !riskExact(value.program, PROGRAM_KEYS)
117
+ || !riskIdentifier(value.program.program_id)
118
+ || !Number.isSafeInteger(value.program.version)
119
+ || value.program.version < 1
120
+ || typeof value.program.source_digest !== 'string'
121
+ || !RISK_DIGEST.test(value.program.source_digest)
122
+ || typeof value.program.program_digest !== 'string'
123
+ || !RISK_DIGEST.test(value.program.program_digest)
124
+ || !riskExact(value.period, PERIOD_KEYS)
125
+ || typeof value.census_digest !== 'string'
126
+ || !RISK_DIGEST.test(value.census_digest)
127
+ || typeof value.taxonomy_digest !== 'string'
128
+ || !RISK_DIGEST.test(value.taxonomy_digest)
129
+ || typeof value.source_inventory_digest !== 'string'
130
+ || !RISK_DIGEST.test(value.source_inventory_digest)
131
+ || !Array.isArray(value.records)
132
+ || value.records.length > 10_000
133
+ || value.claim_boundary !== LOSS_EXPERIENCE_FEED_CLAIM_BOUNDARY) {
134
+ throw new TypeError('loss experience feed shape or field is invalid');
135
+ }
136
+
137
+ const periodStart = riskInstant(value.period.start);
138
+ const periodEnd = riskInstant(value.period.end);
139
+ const issuedAt = riskInstant(value.issued_at);
140
+ const expiresAt = riskInstant(value.expires_at);
141
+ if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd) || periodEnd <= periodStart
142
+ || !Number.isFinite(issuedAt) || issuedAt < periodEnd
143
+ || !Number.isFinite(expiresAt) || expiresAt <= issuedAt) {
144
+ throw new TypeError('loss experience feed period or validity is invalid');
145
+ }
146
+
147
+ for (const record of value.records) validateRecord(record, periodStart, periodEnd, issuedAt);
148
+ const recordIds = value.records.map((record: RiskRecord) => record.record_id);
149
+ if (new Set(recordIds).size !== recordIds.length) {
150
+ throw new TypeError('loss experience feed contains duplicate record identifiers');
151
+ }
152
+ const superseded = value.records
153
+ .filter((record: RiskRecord) => record.supersedes_record_digest !== null)
154
+ .map((record: RiskRecord) => record.supersedes_record_digest);
155
+ if (new Set(superseded).size !== superseded.length) {
156
+ throw new TypeError('loss experience feed forks one predecessor into multiple current records');
157
+ }
158
+
159
+ if (value.timestamp_anchor !== null
160
+ && (!riskExact(value.timestamp_anchor, ANCHOR_KEYS)
161
+ || !riskIdentifier(value.timestamp_anchor.method)
162
+ || typeof value.timestamp_anchor.evidence_digest !== 'string'
163
+ || !RISK_DIGEST.test(value.timestamp_anchor.evidence_digest))) {
164
+ throw new TypeError('loss experience timestamp anchor is invalid');
165
+ }
166
+ }
167
+
168
+ export interface VerifyLossExperienceFeedOptions {
169
+ trusted_keys?: TrustedRiskKeys;
170
+ now?: string | number;
171
+ expected_program?: RiskRecord;
172
+ expected_census_digest?: string;
173
+ expected_taxonomy_digest?: string;
174
+ expected_relying_party_id?: string;
175
+ expected_action_classes?: readonly string[];
176
+ commit_lineage_batch?: (
177
+ request: Readonly<LossExperienceLineageCommitRequest>,
178
+ ) => LossExperienceLineageCommitResult;
179
+ }
180
+
181
+ export interface LossExperiencePredecessorResolution {
182
+ current_head: boolean;
183
+ reporting_party_id: string;
184
+ relying_party_id: string;
185
+ program_digest: string;
186
+ record: RiskRecord;
187
+ }
188
+
189
+ export interface LossExperienceLineageTransition {
190
+ lineage_digest: string;
191
+ event_type: 'OBSERVED' | 'CORRECTED' | 'WITHDRAWN';
192
+ predecessor_digest: string | null;
193
+ successor_digest: string;
194
+ reporting_party_id: string;
195
+ relying_party_id: string;
196
+ program_digest: string;
197
+ receipt_digest: string;
198
+ action_class: string;
199
+ currency: string;
200
+ reported_at: string;
201
+ record: Readonly<RiskRecord>;
202
+ }
203
+
204
+ export interface LossExperienceLineageCommitRequest {
205
+ feed_digest: string;
206
+ transitions: readonly LossExperienceLineageTransition[];
207
+ /**
208
+ * The lineage store MUST invoke this callback inside the same transaction
209
+ * after locking/loading the current heads and before making successors
210
+ * visible. A refusal MUST leave every lineage unchanged.
211
+ */
212
+ validate_predecessors: (
213
+ predecessors: Readonly<Record<string, LossExperiencePredecessorResolution>>,
214
+ ) => LossExperienceLineageValidationResult;
215
+ }
216
+
217
+ export type LossExperienceLineageValidationResult =
218
+ | { accepted: true }
219
+ | { accepted: false; reason: string };
220
+
221
+ export type LossExperienceLineageCommitResult =
222
+ | { accepted: true }
223
+ | { accepted: false; reason: string };
224
+
225
+ function validExpectedProgram(value: unknown): value is RiskRecord {
226
+ return riskExact(value, PROGRAM_KEYS) && riskIdentifier(value.program_id)
227
+ && Number.isSafeInteger(value.version) && value.version >= 1
228
+ && RISK_DIGEST.test(value.source_digest) && RISK_DIGEST.test(value.program_digest);
229
+ }
230
+
231
+ function sameProgram(actual: RiskRecord, expected: RiskRecord): boolean {
232
+ return actual.program_id === expected.program_id && actual.version === expected.version
233
+ && actual.source_digest === expected.source_digest && actual.program_digest === expected.program_digest;
234
+ }
235
+
236
+ function validateLineage(
237
+ payload: RiskRecord,
238
+ artifactDigest: string,
239
+ options: VerifyLossExperienceFeedOptions,
240
+ ): string | null {
241
+ if (payload.records.length === 0) return null;
242
+ if (options.commit_lineage_batch === undefined) return 'lineage_store_required';
243
+ const transitions: LossExperienceLineageTransition[] = payload.records.map((record: RiskRecord) => ({
244
+ lineage_digest: record.lineage_digest,
245
+ event_type: record.event_type,
246
+ predecessor_digest: record.supersedes_record_digest,
247
+ successor_digest: riskDigest(record),
248
+ reporting_party_id: payload.reporting_party_id,
249
+ relying_party_id: payload.relying_party_id,
250
+ program_digest: payload.program.program_digest,
251
+ receipt_digest: record.receipt_digest,
252
+ action_class: record.action_class,
253
+ currency: record.currency,
254
+ reported_at: record.reported_at,
255
+ record: Object.freeze(structuredClone(record)),
256
+ }));
257
+ if (new Set(transitions.map((entry) => entry.lineage_digest)).size !== transitions.length) {
258
+ return 'lineage_batch_conflict';
259
+ }
260
+ const validatePredecessors = (
261
+ predecessors: Readonly<Record<string, LossExperiencePredecessorResolution>>,
262
+ ): LossExperienceLineageValidationResult => {
263
+ if (!riskRecord(predecessors)) return { accepted: false, reason: 'lineage_predecessors_invalid' };
264
+ for (const record of payload.records.filter((entry: RiskRecord) => entry.event_type !== 'OBSERVED')) {
265
+ const resolved = predecessors[record.supersedes_record_digest] as LossExperiencePredecessorResolution | undefined;
266
+ if (!resolved) return { accepted: false, reason: 'lineage_predecessor_missing' };
267
+ if (resolved.current_head !== true) return { accepted: false, reason: 'lineage_head_invalid' };
268
+ if (resolved.reporting_party_id !== payload.reporting_party_id
269
+ || resolved.relying_party_id !== payload.relying_party_id
270
+ || resolved.program_digest !== payload.program.program_digest) {
271
+ return { accepted: false, reason: 'lineage_context_mismatch' };
272
+ }
273
+ try {
274
+ validateRecord(resolved.record, Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY,
275
+ riskInstant(payload.issued_at));
276
+ } catch {
277
+ return { accepted: false, reason: 'lineage_predecessor_invalid' };
278
+ }
279
+ if (riskDigest(resolved.record) !== record.supersedes_record_digest) {
280
+ return { accepted: false, reason: 'lineage_predecessor_digest_mismatch' };
281
+ }
282
+ if (resolved.record.event_type === 'WITHDRAWN') {
283
+ return { accepted: false, reason: 'lineage_terminal_predecessor' };
284
+ }
285
+ if (resolved.record.lineage_digest !== record.lineage_digest
286
+ || resolved.record.receipt_digest !== record.receipt_digest
287
+ || resolved.record.action_class !== record.action_class
288
+ || resolved.record.currency !== record.currency) {
289
+ return { accepted: false, reason: 'lineage_record_mismatch' };
290
+ }
291
+ if (riskInstant(record.reported_at) <= riskInstant(resolved.record.reported_at)
292
+ || record.source_record_digest === resolved.record.source_record_digest
293
+ || riskDigest(record) === record.supersedes_record_digest) {
294
+ return { accepted: false, reason: 'lineage_order_invalid' };
295
+ }
296
+ }
297
+ return { accepted: true };
298
+ };
299
+
300
+ let committed: LossExperienceLineageCommitResult;
301
+ try {
302
+ committed = options.commit_lineage_batch(Object.freeze({
303
+ feed_digest: artifactDigest,
304
+ transitions: Object.freeze(transitions.map((entry) => Object.freeze(entry))),
305
+ validate_predecessors: validatePredecessors,
306
+ }));
307
+ } catch {
308
+ return 'lineage_commit_failed';
309
+ }
310
+ if (!riskRecord(committed) || committed.accepted !== true) {
311
+ return riskRecord(committed) && typeof committed.reason === 'string'
312
+ ? committed.reason
313
+ : 'lineage_commit_refused';
314
+ }
315
+ return null;
316
+ }
317
+
318
+ export function signLossExperienceFeed(
319
+ input: RiskRecord,
320
+ signer: { issuer_id: string; key_id: string; private_key: any },
321
+ ) {
322
+ const body: RiskRecord = { '@version': LOSS_EXPERIENCE_FEED_VERSION, ...input };
323
+ validateBody(body);
324
+ if (signer.issuer_id !== body.reporting_party_id) {
325
+ throw new TypeError('loss experience feed issuer must be the reporting party');
326
+ }
327
+ return signRiskBody(LOSS_EXPERIENCE_FEED_VERSION, body, signer);
328
+ }
329
+
330
+ export function verifyLossExperienceFeed(
331
+ feed: unknown,
332
+ options: VerifyLossExperienceFeedOptions = {},
333
+ ) {
334
+ const refuse = (reason: string, verified = false, feedDigest: string | null = null) => ({
335
+ accepted: false,
336
+ verified,
337
+ reason,
338
+ feed_digest: feedDigest,
339
+ claim_boundary: LOSS_EXPERIENCE_FEED_CLAIM_BOUNDARY,
340
+ });
341
+ const signed = verifyRiskBody(feed, LOSS_EXPERIENCE_FEED_VERSION, options.trusted_keys);
342
+ if (!signed.valid || !signed.body) return refuse(signed.reason ?? 'feed_invalid');
343
+ if (typeof signed.artifact_digest !== 'string') return refuse('feed_digest_missing', true);
344
+
345
+ const { issuer, ...payload } = signed.body;
346
+ if (issuer.id !== payload.reporting_party_id) {
347
+ return refuse('reporting_party_issuer_mismatch', true, signed.artifact_digest);
348
+ }
349
+ try {
350
+ validateBody(payload);
351
+ } catch {
352
+ return refuse('feed_schema_or_period_invalid', true, signed.artifact_digest);
353
+ }
354
+
355
+ const now = options.now === undefined
356
+ ? Date.now()
357
+ : (typeof options.now === 'string' ? Date.parse(options.now) : Number(options.now));
358
+ if (!Number.isFinite(now)) return refuse('verification_time_invalid', true, signed.artifact_digest);
359
+ if (now < riskInstant(payload.issued_at)) return refuse('feed_not_yet_issued', true, signed.artifact_digest);
360
+ if (now >= riskInstant(payload.expires_at)) return refuse('feed_expired', true, signed.artifact_digest);
361
+ if (options.expected_program === undefined || options.expected_census_digest === undefined
362
+ || options.expected_taxonomy_digest === undefined
363
+ || options.expected_relying_party_id === undefined
364
+ || options.expected_action_classes === undefined
365
+ || options.expected_action_classes.length < 1
366
+ || !options.expected_action_classes.every(riskIdentifier)) {
367
+ return refuse('context_binding_required', true, signed.artifact_digest);
368
+ }
369
+ if (!validExpectedProgram(options.expected_program)) {
370
+ return refuse('expected_program_invalid', true, signed.artifact_digest);
371
+ }
372
+ if (!sameProgram(payload.program, options.expected_program)) {
373
+ return refuse('program_binding_mismatch', true, signed.artifact_digest);
374
+ }
375
+ if (options.expected_census_digest !== payload.census_digest) {
376
+ return refuse('census_digest_mismatch', true, signed.artifact_digest);
377
+ }
378
+ if (options.expected_taxonomy_digest !== payload.taxonomy_digest) {
379
+ return refuse('taxonomy_digest_mismatch', true, signed.artifact_digest);
380
+ }
381
+ if (options.expected_relying_party_id !== payload.relying_party_id) {
382
+ return refuse('relying_party_mismatch', true, signed.artifact_digest);
383
+ }
384
+ const actionClasses = new Set(options.expected_action_classes);
385
+ if (!payload.records.every((record: RiskRecord) => actionClasses.has(record.action_class))) {
386
+ return refuse('action_class_taxonomy_mismatch', true, signed.artifact_digest);
387
+ }
388
+ const lineageFailure = validateLineage(payload, signed.artifact_digest, options);
389
+ if (lineageFailure !== null) return refuse(lineageFailure, true, signed.artifact_digest);
390
+
391
+ return {
392
+ accepted: true,
393
+ verified: true,
394
+ reason: null,
395
+ feed_digest: signed.artifact_digest,
396
+ claim_boundary: LOSS_EXPERIENCE_FEED_CLAIM_BOUNDARY,
397
+ };
398
+ }