@emilia-protocol/gate 0.9.0 → 0.9.2

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/index.js CHANGED
@@ -47,7 +47,7 @@ import { MemoryConsumptionStore } from './store.js';
47
47
  import { createEvidenceLog } from './evidence.js';
48
48
  import { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskManifest } from './action-packs.js';
49
49
  import { hashCanonical, verifyExecutionBinding } from './execution-binding.js';
50
- import { buildReliancePacket } from './reliance-packet.js';
50
+ import { buildReliancePacket, ADMISSIBILITY_VERDICTS } from './reliance-packet.js';
51
51
  import { createEg1Harness, makeGateInvoke, runEg1, EG1_DEFAULT_SELECTOR, mintDeviceSignoff, mintQuorumEvidence } from './eg1-conformance.js';
52
52
  import { CF1_VERSION, CF1_CHECKS, runCf1 } from './cf1-conformance.js';
53
53
  import { createKeyRegistry, asKeyRegistry } from './key-registry.js';
@@ -72,7 +72,14 @@ export {
72
72
  validateActionControlManifest,
73
73
  } from './action-control-manifest.js';
74
74
  export { EXECUTION_BINDING_VERSION, canonicalize, hashCanonical, materialFieldsFor, verifyExecutionBinding } from './execution-binding.js';
75
- export { RELIANCE_PACKET_VERSION, buildReliancePacket } from './reliance-packet.js';
75
+ export { RELIANCE_PACKET_VERSION, ADMISSIBILITY_VERDICTS, buildReliancePacket } from './reliance-packet.js';
76
+ export {
77
+ EXTERNAL_VERIFICATION_STATEMENT_VERSION,
78
+ EXTERNAL_VERIFICATION_DOMAIN,
79
+ externalVerificationDigest,
80
+ signExternalVerificationStatement,
81
+ verifyExternalVerificationStatement,
82
+ } from './reports/external-verification.js';
76
83
  export {
77
84
  EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR,
78
85
  createEg1Harness, makeGateInvoke, runEg1, mintDeviceSignoff, mintQuorumEvidence,
@@ -81,6 +88,55 @@ export { CF1_VERSION, CF1_CHECKS, runCf1 } from './cf1-conformance.js';
81
88
  export const ASSURANCE_TIERS = ['software', 'class_a', 'quorum'];
82
89
  const TIER_RANK = { software: 0, class_a: 1, quorum: 2 };
83
90
 
91
+ /**
92
+ * Verify a PRE-COMPUTED reliance packet's admissibility block against a profile
93
+ * the relying party PINNED. This is the gate's whole job re: admissibility — it
94
+ * does NOT re-evaluate raw evidence and does NOT define the bar. The relying
95
+ * party's own evaluator (lib/evidence/admissibility-profiles.js) computes the
96
+ * verdict OFFLINE against its pinned profile and produces the reliance packet;
97
+ * the gate only confirms the packet answers the SAME profile_hash and carries an
98
+ * 'admissible' verdict. Pure, dependency-light, fail-closed.
99
+ *
100
+ * @param {{id?:string, profile_hash:string}} pinned the profile the relying party requires
101
+ * @param {object|null} presented a reliance packet, or its `.admissibility` block,
102
+ * as produced by buildReliancePacket / the relying party's evaluator
103
+ * @returns {{ok:boolean, reason:string|null, pinned_hash:string, presented_hash:string|null, verdict:string|null}}
104
+ * ok:true ONLY when the presented profile_hash equals the pinned hash AND the
105
+ * verdict is exactly 'admissible'. Every other case fails closed with a distinct reason.
106
+ */
107
+ export function verifyAdmissibilityAgainstPinnedProfile(pinned, presented) {
108
+ const pinnedHash = pinned && typeof pinned.profile_hash === 'string' ? pinned.profile_hash : null;
109
+ // A pin with no hash is a misconfiguration: refuse, do not silently pass.
110
+ if (!pinnedHash) {
111
+ return { ok: false, reason: 'pinned_profile_missing_hash', pinned_hash: null, presented_hash: null, verdict: null };
112
+ }
113
+ // Accept either a full reliance packet (has .admissibility) or the block itself.
114
+ const adm = presented && typeof presented === 'object'
115
+ ? (presented.admissibility !== undefined ? presented.admissibility : presented)
116
+ : null;
117
+ if (!adm || typeof adm !== 'object') {
118
+ return { ok: false, reason: 'admissibility_profile_pinned_but_absent', pinned_hash: pinnedHash, presented_hash: null, verdict: null };
119
+ }
120
+ const presentedHash = typeof adm.profile_hash === 'string' ? adm.profile_hash : null;
121
+ const verdict = typeof adm.verdict === 'string' ? adm.verdict : null;
122
+ // Constant-work equality is unnecessary (hashes are public), but the mismatch
123
+ // MUST be a distinct, named refusal: a presented verdict for a DIFFERENT bar is
124
+ // not evidence about the pinned bar.
125
+ if (presentedHash === null || presentedHash !== pinnedHash) {
126
+ return { ok: false, reason: 'profile_hash_mismatch', pinned_hash: pinnedHash, presented_hash: presentedHash, verdict };
127
+ }
128
+ // Verdict must be recognized AND exactly 'admissible'. Any other closed-set
129
+ // member (missing_evidence/stale/conflicted/unverifiable), an unrecognized
130
+ // string, or a missing verdict fails closed and names the verdict it saw.
131
+ if (verdict === null || !ADMISSIBILITY_VERDICTS.includes(verdict)) {
132
+ return { ok: false, reason: 'admissibility_verdict_unrecognized', pinned_hash: pinnedHash, presented_hash: presentedHash, verdict };
133
+ }
134
+ if (verdict !== 'admissible') {
135
+ return { ok: false, reason: `admissibility_not_admissible:${verdict}`, pinned_hash: pinnedHash, presented_hash: presentedHash, verdict };
136
+ }
137
+ return { ok: true, reason: null, pinned_hash: pinnedHash, presented_hash: presentedHash, verdict };
138
+ }
139
+
84
140
  /**
85
141
  * The assurance tier a receipt has CRYPTOGRAPHICALLY EARNED.
86
142
  *
@@ -183,7 +239,7 @@ function isSignoffEvidence(s) {
183
239
  * if given it supersedes trustedKeys — a receipt is verified only against keys valid (and not
184
240
  * revoked) at its issuance time.
185
241
  */
186
- export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now, keyRegistry = null, approverKeys = {}, approver_keys = null, verifyAssurance = null, rpId = null } = {}) {
242
+ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now, keyRegistry = null, approverKeys = {}, approver_keys = null, verifyAssurance = null, rpId = null, requiredAdmissibilityProfile = null } = {}) {
187
243
  // Production key custody: a registry (rotation + revocation) supersedes a flat
188
244
  // trustedKeys list. A flat list is coerced to an always-valid registry, so
189
245
  // existing callers are unchanged.
@@ -210,10 +266,19 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
210
266
  }
211
267
  const evidence = log || createEvidenceLog({ strict: strictEvidence });
212
268
 
213
- async function check({ selector = {}, receipt = null, observedAction = null, consumptionMode = 'consume' } = {}) {
269
+ async function check({ selector = {}, receipt = null, observedAction = null, consumptionMode = 'consume', admissibilityProfile = null, reliancePacket: presentedPacket = null, admissibility = null } = {}) {
214
270
  const requirement = manifest ? findActionRequirement(manifest, selector) : null;
215
271
  const action = requirement?.action_type || selector.action_type || selector.action || null;
216
- const requiredTier = requirement?.assurance_class || selector.assurance_class || 'software';
272
+ // Assurance tier the action requires (cryptographically checked below). For a
273
+ // manifest-guarded action the tier MUST be declared explicitly: never fall
274
+ // back to the weakest 'software' tier because assurance_class was omitted —
275
+ // that would let a guarded, possibly critical, action accept a bare
276
+ // machine-signed receipt (a fail-open). A guarded requirement with no tier
277
+ // is a misconfiguration and fails closed just below. Only selector-only
278
+ // checks (no manifest requirement) use the documented 'software' default.
279
+ const requiredTier = requirement
280
+ ? requirement.assurance_class
281
+ : (selector.assurance_class || 'software');
217
282
  const observed = observedAction || selector.observedAction || selector.actionDetails || null;
218
283
 
219
284
  async function decide(allow, status, reason, extra = {}) {
@@ -268,6 +333,14 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
268
333
  if (!receipt) {
269
334
  return decide(false, RECEIPT_REQUIRED_STATUS, 'receipt_required');
270
335
  }
336
+ // A manifest-guarded action that declares no assurance_class is a
337
+ // misconfiguration. Fail CLOSED rather than defaulting to the weakest tier
338
+ // (which would accept a bare machine-signed receipt for a guarded action).
339
+ // validateActionRiskManifest also rejects such a manifest at author time;
340
+ // this is defense in depth for a manifest loaded without re-validation.
341
+ if (requirement && requirement.receipt_required !== false && !requiredTier) {
342
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'manifest_missing_assurance_class');
343
+ }
271
344
  // Signature / freshness / action-binding / outcome. Production key custody:
272
345
  // resolve the issuer keys valid (and not revoked) at THIS receipt's issuance
273
346
  // time. A revoked or out-of-window key is excluded, so its signature does not
@@ -324,6 +397,29 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
324
397
  if (!executionBinding.ok) {
325
398
  return decide(false, RECEIPT_REQUIRED_STATUS, 'execution_binding_failed', { execution_binding: executionBinding, have_tier: have, assurance_tier_source: 'cryptographic_verification' });
326
399
  }
400
+ // OPT-IN admissibility pinning. When the caller pins a required admissibility
401
+ // profile {id, profile_hash} (gate-level requiredAdmissibilityProfile, a
402
+ // per-call admissibilityProfile, or selector.admissibilityProfile), the gate
403
+ // REFUSES unless a presented reliance packet's admissibility block was computed
404
+ // against the SAME pinned profile_hash AND carries an 'admissible' verdict. The
405
+ // gate does NOT re-evaluate raw evidence and does NOT define the bar — the
406
+ // relying party's own evaluator produced the verdict OFFLINE against its pinned
407
+ // profile. Checked BEFORE consumption so a mismatch never burns the receipt.
408
+ // When no profile is pinned, this whole block is inert — behavior is
409
+ // byte-for-byte unchanged from the pre-admissibility gate.
410
+ const pinnedProfile = admissibilityProfile || selector.admissibilityProfile || requiredAdmissibilityProfile;
411
+ if (pinnedProfile) {
412
+ const presentedAdmissibility = admissibility ?? presentedPacket ?? selector.reliancePacket ?? selector.admissibility ?? null;
413
+ const adm = verifyAdmissibilityAgainstPinnedProfile(pinnedProfile, presentedAdmissibility);
414
+ if (!adm.ok) {
415
+ return decide(false, RECEIPT_REQUIRED_STATUS, adm.reason, {
416
+ admissibility_check: adm,
417
+ pinned_profile: { id: pinnedProfile.id ?? null, profile_hash: pinnedProfile.profile_hash ?? null },
418
+ have_tier: have,
419
+ assurance_tier_source: 'cryptographic_verification',
420
+ });
421
+ }
422
+ }
327
423
  // One-time consumption (replay defense). Require a stable, issuer-generated
328
424
  // receipt_id — never fall back to a content hash, whose canonicalization can
329
425
  // differ across language implementations and silently break replay detection
@@ -346,7 +442,16 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
346
442
  if (!fresh) {
347
443
  return decide(false, RECEIPT_REQUIRED_STATUS, 'replay_refused', { consumption_key: receiptId });
348
444
  }
349
- return decide(true, 200, 'allow', { signer: v.signer, outcome: v.outcome, have_tier: have, assurance_tier_source: 'cryptographic_verification', execution_binding: executionBinding, consumption_mode: consumptionMode });
445
+ const allowExtra = { signer: v.signer, outcome: v.outcome, have_tier: have, assurance_tier_source: 'cryptographic_verification', execution_binding: executionBinding, consumption_mode: consumptionMode };
446
+ // Carry the admissibility block (from the presented packet) onto the decision
447
+ // so a reliance packet built from this decision embeds the verdict the relying
448
+ // party's evaluator computed. Only when something was actually presented.
449
+ const presentedAdmForAllow = admissibility ?? presentedPacket ?? selector.reliancePacket ?? selector.admissibility ?? null;
450
+ if (presentedAdmForAllow) {
451
+ const admBlock = presentedAdmForAllow.admissibility !== undefined ? presentedAdmForAllow.admissibility : presentedAdmForAllow;
452
+ if (admBlock) allowExtra.admissibility = admBlock;
453
+ }
454
+ return decide(true, 200, 'allow', allowExtra);
350
455
  }
351
456
 
352
457
  /** Express/Connect middleware: refuse the route unless a sufficient receipt is present. */
@@ -400,9 +505,9 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
400
505
  * If the side effect throws, the reservation is released so the approval can
401
506
  * be retried safely.
402
507
  */
403
- async function run({ selector = {}, receipt = null, observedAction = null } = {}, fn, opts = {}) {
508
+ async function run({ selector = {}, receipt = null, observedAction = null, admissibilityProfile = null, reliancePacket: presentedPacket = null, admissibility = null } = {}, fn, opts = {}) {
404
509
  if (typeof fn !== 'function') throw new Error('EMILIA Gate run(): fn is required');
405
- const authorization = await check({ selector, receipt, observedAction, consumptionMode: 'reserve' });
510
+ const authorization = await check({ selector, receipt, observedAction, consumptionMode: 'reserve', admissibilityProfile, reliancePacket: presentedPacket, admissibility });
406
511
  if (!authorization.allow) {
407
512
  return { ok: false, status: authorization.status, body: authorization.challenge, authorization };
408
513
  }
@@ -441,7 +546,13 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
441
546
  const observedAction = typeof opts.observedAction === 'function'
442
547
  ? opts.observedAction(...args)
443
548
  : (opts.observedAction || selector.observedAction || null);
444
- const out = await run({ selector, receipt, observedAction }, () => fn(...args), { recordExecution: opts.recordExecution });
549
+ const admissibilityProfile = typeof opts.admissibilityProfile === 'function'
550
+ ? opts.admissibilityProfile(...args)
551
+ : (opts.admissibilityProfile ?? null);
552
+ const presentedPacket = typeof opts.reliancePacket === 'function'
553
+ ? opts.reliancePacket(...args)
554
+ : (opts.reliancePacket ?? opts.admissibility ?? null);
555
+ const out = await run({ selector, receipt, observedAction, admissibilityProfile, reliancePacket: presentedPacket }, () => fn(...args), { recordExecution: opts.recordExecution });
445
556
  if (!out.ok) {
446
557
  const e = new Error(`EMILIA Gate refused (${out.authorization.reason})`);
447
558
  e.code = 'EMILIA_RECEIPT_REQUIRED';
@@ -452,13 +563,22 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
452
563
  };
453
564
  }
454
565
 
455
- function reliancePacket({ authorization, execution = null, binding = null } = {}) {
566
+ function reliancePacket({ authorization, execution = null, binding = null, admissibility = null } = {}) {
567
+ // The admissibility block rides on the authorization decision's evidence when
568
+ // a reliance packet was presented at check() time; an explicit `admissibility`
569
+ // arg overrides it. buildReliancePacket fails closed on a non-'admissible'
570
+ // block, so a do_not_rely verdict can never be laundered into rely here.
571
+ const adm = admissibility
572
+ ?? authorization?.evidence?.admissibility
573
+ ?? authorization?.admissibility
574
+ ?? null;
456
575
  return buildReliancePacket({
457
576
  decision: authorization,
458
577
  execution,
459
578
  evidence,
460
579
  manifest,
461
580
  binding,
581
+ admissibility: adm,
462
582
  });
463
583
  }
464
584
 
@@ -560,6 +680,8 @@ export default {
560
680
  createGate,
561
681
  createTrustedActionFirewall,
562
682
  receiptAssuranceTier,
683
+ verifyAdmissibilityAgainstPinnedProfile,
684
+ ADMISSIBILITY_VERDICTS,
563
685
  MemoryConsumptionStore,
564
686
  createEvidenceLog,
565
687
  ASSURANCE_TIERS,
package/metering.js ADDED
@@ -0,0 +1,227 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EMILIA Gate — usage metering (the value metric, EP-GATE-USAGE-v1).
4
+ *
5
+ * Pricing scales with what the gate actually protects: PROTECTED IRREVERSIBLE
6
+ * ACTIONS (guarded decisions — allows AND denies both consume enforcement) plus
7
+ * RECEIPT-YEARS under retention (one receipt kept one year = one receipt-year;
8
+ * horizons follow retention.js, default 6y = 2190 days). NEVER seats.
9
+ *
10
+ * Pure functions over evidence-log entries (`evidence.all()`); no clock, no
11
+ * network. The billing period is an explicit [periodStart, periodEnd) window —
12
+ * INCLUSIVE start, EXCLUSIVE end — so adjacent periods never double-count an
13
+ * entry sitting exactly on a boundary.
14
+ *
15
+ * Fail-closed posture for billing: an unbounded or reversed period is refused
16
+ * (thrown); malformed entries are surfaced in `integrity_warnings` rather than
17
+ * silently dropped; an entry whose allow flag is not literally `true` is
18
+ * counted as a deny; a statement over foreign or malformed usage is refused.
19
+ * `buildUsageStatement` output is deterministic (sorted keys + content hash)
20
+ * and UNSIGNED — the deployer signs it; the content hash binds that signature
21
+ * to exactly these numbers for billing reconciliation.
22
+ */
23
+ import crypto from 'node:crypto';
24
+
25
+ export const USAGE_VERSION = 'EP-GATE-USAGE-v1';
26
+
27
+ const DAYS_PER_YEAR = 365;
28
+ // Mirrors retention.js's cold-horizon default (coldDays = 2190 = 6y): a receipt
29
+ // is presumed retained for the full audit horizon unless the entry states its own.
30
+ const DEFAULT_RETENTION_YEARS = 2190 / DAYS_PER_YEAR;
31
+
32
+ function sha256hex(s) {
33
+ return crypto.createHash('sha256').update(s).digest('hex');
34
+ }
35
+
36
+ /** Canonical JSON (recursive sorted keys) — matches evidence.js / @emilia-protocol/verify. */
37
+ function canonical(v) {
38
+ if (v === null || v === undefined) return JSON.stringify(v);
39
+ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
40
+ if (typeof v === 'object') {
41
+ return `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonical(v[k])).join(',')}}`;
42
+ }
43
+ return JSON.stringify(v);
44
+ }
45
+
46
+ function toMs(t) {
47
+ if (t == null) return null;
48
+ const ms = typeof t === 'number' ? t : Date.parse(t);
49
+ return Number.isFinite(ms) ? ms : null;
50
+ }
51
+
52
+ /** Key-sorted copy so output is byte-stable regardless of entry order. */
53
+ function sortedCounts(map) {
54
+ const out = {};
55
+ for (const k of Object.keys(map).sort()) out[k] = map[k];
56
+ return out;
57
+ }
58
+
59
+ /** Entry-level day resolution (1/365y ≈ 0.00274) without float noise. */
60
+ function round6(x) {
61
+ return Math.round(x * 1e6) / 1e6;
62
+ }
63
+
64
+ /**
65
+ * The retention this entry STATES for itself, in years.
66
+ * @returns {number|null|NaN} years, `null` when unstated (default applies),
67
+ * `NaN` when stated but invalid (default applies + integrity warning — a
68
+ * malformed stated value must never silently shrink the metered total).
69
+ */
70
+ function statedRetentionYears(e) {
71
+ if (e.retention_years != null) {
72
+ const y = Number(e.retention_years);
73
+ return Number.isFinite(y) && y >= 0 ? y : NaN;
74
+ }
75
+ if (e.retention_days != null) {
76
+ const d = Number(e.retention_days);
77
+ return Number.isFinite(d) && d >= 0 ? d / DAYS_PER_YEAR : NaN;
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * Meter a billing period over evidence entries.
84
+ *
85
+ * Billable = `kind: 'decision'` entries on GUARDED actions (`not_guarded`
86
+ * pass-throughs are free; execution records are provenance, not enforcement).
87
+ * Window is [periodStart, periodEnd): inclusive start, exclusive end.
88
+ *
89
+ * @param {Array<object>} entries evidence.all()
90
+ * @param {object} o
91
+ * @param {string|number} o.periodStart ISO or ms — required
92
+ * @param {string|number} o.periodEnd ISO or ms — required, >= periodStart
93
+ * @param {number} [o.retentionYearsDefault=6] applied when an entry states no retention
94
+ * @returns {{protected_actions:number, allows:number, denies:number,
95
+ * replays_blocked:number, by_action_type:object, by_tier:object,
96
+ * receipt_years:number, period:object, integrity_warnings:object[]}}
97
+ */
98
+ export function meterUsage(entries = [], { periodStart, periodEnd, retentionYearsDefault = DEFAULT_RETENTION_YEARS } = {}) {
99
+ const startMs = toMs(periodStart);
100
+ const endMs = toMs(periodEnd);
101
+ // An unbounded or reversed period is not meterable — refuse rather than emit
102
+ // a statement whose window cannot be reconciled.
103
+ if (startMs == null || endMs == null) {
104
+ throw new Error('meterUsage: periodStart and periodEnd are required (ISO string or ms)');
105
+ }
106
+ if (endMs < startMs) throw new Error('meterUsage: periodEnd must be >= periodStart');
107
+ if (!Number.isFinite(retentionYearsDefault) || retentionYearsDefault < 0) {
108
+ throw new Error('meterUsage: retentionYearsDefault must be a finite number >= 0');
109
+ }
110
+
111
+ const warnings = [];
112
+ const byAction = Object.create(null);
113
+ const byTier = Object.create(null);
114
+ let protectedActions = 0;
115
+ let allows = 0;
116
+ let denies = 0;
117
+ let replaysBlocked = 0;
118
+ let receiptYears = 0;
119
+
120
+ entries.forEach((e, index) => {
121
+ if (!e || typeof e !== 'object' || Array.isArray(e)) {
122
+ warnings.push({ index, reason: 'not_an_object' });
123
+ return;
124
+ }
125
+ const t = toMs(e.at);
126
+ if (t == null) {
127
+ // Unplaceable in ANY period — surfaced, never silently dropped.
128
+ warnings.push({ index, reason: 'unparseable_at' });
129
+ return;
130
+ }
131
+ if (t < startMs || t >= endMs) return; // outside [start, end)
132
+ if (typeof e.kind !== 'string' || e.kind.length === 0) {
133
+ warnings.push({ index, reason: 'missing_kind' });
134
+ return;
135
+ }
136
+ if (e.kind !== 'decision') return; // execution/other records are not billable
137
+ if (e.reason === 'not_guarded') return; // pass-throughs are free by design
138
+
139
+ protectedActions += 1;
140
+ // Fail closed: only a literal `true` counts as an allow.
141
+ if (e.allow === true) allows += 1; else denies += 1;
142
+ if (e.reason === 'replay_refused') replaysBlocked += 1;
143
+
144
+ const action = typeof e.action === 'string' && e.action ? e.action : 'unknown';
145
+ byAction[action] = (byAction[action] || 0) + 1;
146
+ const tier = typeof e.required_tier === 'string' && e.required_tier ? e.required_tier : 'unknown';
147
+ byTier[tier] = (byTier[tier] || 0) + 1;
148
+
149
+ const stated = statedRetentionYears(e);
150
+ if (Number.isNaN(stated)) {
151
+ warnings.push({ index, reason: 'invalid_stated_retention' });
152
+ receiptYears += retentionYearsDefault;
153
+ } else {
154
+ receiptYears += stated ?? retentionYearsDefault;
155
+ }
156
+ });
157
+
158
+ return {
159
+ '@version': USAGE_VERSION,
160
+ period: {
161
+ start: new Date(startMs).toISOString(),
162
+ end: new Date(endMs).toISOString(),
163
+ bounds: 'inclusive_start_exclusive_end',
164
+ },
165
+ protected_actions: protectedActions,
166
+ allows,
167
+ denies,
168
+ replays_blocked: replaysBlocked,
169
+ by_action_type: sortedCounts(byAction),
170
+ by_tier: sortedCounts(byTier),
171
+ receipt_years: round6(receiptYears),
172
+ retention_years_default: retentionYearsDefault,
173
+ integrity_warnings: warnings,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Build the signed-ready usage statement handed to billing reconciliation.
179
+ * UNSIGNED — the deployer signs it; `content_hash` (sha256 over the canonical
180
+ * JSON of everything else) binds that signature to exactly these numbers.
181
+ * Deterministic: same usage + org → byte-identical statement, regardless of
182
+ * the entry order the usage was metered from.
183
+ */
184
+ export function buildUsageStatement(usage, { org } = {}) {
185
+ // Never emit a statement over an artifact of a different or unknown format,
186
+ // and never one that fails to name who it bills.
187
+ if (!usage || usage['@version'] !== USAGE_VERSION) {
188
+ throw new Error(`buildUsageStatement: usage must be a ${USAGE_VERSION} object from meterUsage`);
189
+ }
190
+ if (!org || typeof org !== 'string') {
191
+ throw new Error('buildUsageStatement: org is required — a statement must name the billed party');
192
+ }
193
+ if (!usage.period || !usage.period.start || !usage.period.end) {
194
+ throw new Error('buildUsageStatement: usage.period is missing');
195
+ }
196
+ for (const k of ['protected_actions', 'allows', 'denies', 'replays_blocked', 'receipt_years']) {
197
+ if (!Number.isFinite(usage[k]) || usage[k] < 0) {
198
+ throw new Error(`buildUsageStatement: usage.${k} must be a finite number >= 0`);
199
+ }
200
+ }
201
+ const warningCount = Array.isArray(usage.integrity_warnings) ? usage.integrity_warnings.length : 0;
202
+ const body = {
203
+ '@version': USAGE_VERSION,
204
+ kind: 'usage_statement',
205
+ org,
206
+ period: {
207
+ start: usage.period.start,
208
+ end: usage.period.end,
209
+ bounds: 'inclusive_start_exclusive_end',
210
+ },
211
+ protected_actions: usage.protected_actions,
212
+ allows: usage.allows,
213
+ denies: usage.denies,
214
+ replays_blocked: usage.replays_blocked,
215
+ by_action_type: sortedCounts(usage.by_action_type || {}),
216
+ by_tier: sortedCounts(usage.by_tier || {}),
217
+ receipt_years: usage.receipt_years,
218
+ retention_years_default: usage.retention_years_default ?? DEFAULT_RETENTION_YEARS,
219
+ // Data-quality signal for the reconciler: a statement metered over a log
220
+ // with integrity warnings is flagged incomplete, never silently clean.
221
+ integrity_warning_count: warningCount,
222
+ complete: warningCount === 0,
223
+ };
224
+ return { ...body, content_hash: sha256hex(canonical(body)) };
225
+ }
226
+
227
+ export default { meterUsage, buildUsageStatement, USAGE_VERSION };