@emilia-protocol/gate 0.9.3 → 0.9.4

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/evidence.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @emilia-protocol/gate — evidence log (tamper-evident audit of decisions).
2
+ * @emilia-protocol/gate — evidence log (tamper-detecting audit of decisions).
3
3
  * @license Apache-2.0
4
4
  *
5
5
  * Every gate decision — allow or deny — appends a hash-chained record. Each
@@ -7,7 +7,10 @@
7
7
  * breaks the chain and `verify()` catches it. This is the compliance and
8
8
  * insurance artifact: a provable account of exactly which consequential actions
9
9
  * were authorized, refused, and why. Default sink is in-memory; pass `sink` to
10
- * stream records to durable/append-only storage.
10
+ * stream records to storage. This local chain detects alteration only when the
11
+ * verifier receives its complete history from genesis; a sink alone cannot
12
+ * prevent truncation, restart-from-genesis, or cross-replica forks. Use
13
+ * createAtomicEvidenceLog for a shared production head.
11
14
  *
12
15
  * Hashing is over CANONICAL JSON (sorted keys) so the same logical record hashes
13
16
  * identically across systems and languages — a plain JSON.stringify is
@@ -39,8 +42,20 @@ export function createEvidenceLog({ sink, strict = false } = {}) {
39
42
  let prev = 'genesis';
40
43
 
41
44
  return {
45
+ // A sink can persist this process-local chain, but it cannot make the head
46
+ // atomic across replicas or continue it after restart. Never advertise the
47
+ // local logger as a fleet-safe production ledger.
48
+ durable: false,
49
+ persisted: typeof sink === 'function' && strict === true,
50
+ strict: strict === true,
51
+ forkAware: false,
52
+ atomicAppend: false,
42
53
  async record(entry) {
43
- const body = { seq: records.length, prev_hash: prev, ...entry };
54
+ // Deep-copy the caller's entry: a shallow spread embeds live references to
55
+ // nested objects, so a caller mutating them after record() would silently
56
+ // corrupt the hash-chained evidence record (and anything a sink persisted).
57
+ const snapshot = entry && typeof entry === 'object' ? structuredClone(entry) : entry;
58
+ const body = { seq: records.length, prev_hash: prev, ...snapshot };
44
59
  const hash = sha256hex(canonical(body));
45
60
  const rec = { ...body, hash };
46
61
  if (sink) {
@@ -81,4 +96,258 @@ export function createEvidenceLog({ sink, strict = false } = {}) {
81
96
  };
82
97
  }
83
98
 
84
- export default { createEvidenceLog };
99
+ const LOG_HEX_256 = /^[0-9a-f]{64}$/;
100
+ const LOG_RESERVED_FIELDS = new Set(['seq', 'prev_hash', 'record_id', 'hash']);
101
+
102
+ /** Verify one logger acknowledgement independently of the logger that emitted it. */
103
+ export function verifyEvidenceRecord(record, { atomicRequired = false, expectedEntry } = {}) {
104
+ try {
105
+ if (!record || typeof record !== 'object' || Array.isArray(record)
106
+ || !Number.isSafeInteger(record.seq) || record.seq < 0
107
+ || (record.prev_hash !== 'genesis' && !LOG_HEX_256.test(record.prev_hash))
108
+ || !LOG_HEX_256.test(record.hash)) return false;
109
+ const hasRecordId = Object.prototype.hasOwnProperty.call(record, 'record_id');
110
+ if ((atomicRequired && !hasRecordId)
111
+ || (hasRecordId && (typeof record.record_id !== 'string'
112
+ || record.record_id.length < 16 || record.record_id.length > 256))) return false;
113
+ const { hash, ...body } = record;
114
+ if (sha256hex(canonical(body)) !== hash) return false;
115
+ if (expectedEntry !== undefined) {
116
+ if (!expectedEntry || typeof expectedEntry !== 'object' || Array.isArray(expectedEntry)) return false;
117
+ const entry = Object.fromEntries(
118
+ Object.entries(record).filter(([key]) => !LOG_RESERVED_FIELDS.has(key)),
119
+ );
120
+ if (canonical(entry) !== canonical(expectedEntry)) return false;
121
+ }
122
+ return true;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+
128
+ function assertLogEntry(entry) {
129
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
130
+ throw new Error('atomic evidence entry must be an object');
131
+ }
132
+ for (const field of LOG_RESERVED_FIELDS) {
133
+ if (Object.prototype.hasOwnProperty.call(entry, field)) {
134
+ throw new Error(`atomic evidence entry must not supply reserved field ${field}`);
135
+ }
136
+ }
137
+ const stack = [{ value: entry, depth: 0 }];
138
+ const seen = new WeakSet();
139
+ let nodes = 0;
140
+ let stringBytes = 0;
141
+ while (stack.length) {
142
+ const { value, depth } = stack.pop();
143
+ if (++nodes > 50000 || depth > 64) throw new Error('atomic evidence entry exceeds resource limits');
144
+ if (value === null || typeof value === 'boolean') continue;
145
+ if (typeof value === 'number') {
146
+ if (!Number.isSafeInteger(value)) throw new Error('atomic evidence entry contains a non-safe integer');
147
+ continue;
148
+ }
149
+ if (typeof value === 'string') {
150
+ stringBytes += Buffer.byteLength(value, 'utf8');
151
+ if (stringBytes > 1024 * 1024) throw new Error('atomic evidence entry exceeds string limit');
152
+ continue;
153
+ }
154
+ if (typeof value !== 'object') throw new Error('atomic evidence entry is not canonical JSON');
155
+ if (seen.has(value)) throw new Error('atomic evidence entry contains a cycle or alias');
156
+ seen.add(value);
157
+ if (Array.isArray(value)) {
158
+ for (const child of value) stack.push({ value: child, depth: depth + 1 });
159
+ } else {
160
+ for (const [key, child] of Object.entries(value)) {
161
+ stringBytes += Buffer.byteLength(key, 'utf8');
162
+ if (stringBytes > 1024 * 1024) throw new Error('atomic evidence entry exceeds string limit');
163
+ stack.push({ value: child, depth: depth + 1 });
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ function validateAtomicRecord(record, expectedId, expectedEntry, expectedRecord = null) {
170
+ return record?.record_id === expectedId
171
+ && verifyEvidenceRecord(record, { atomicRequired: true, expectedEntry })
172
+ && (expectedRecord === null || canonical(record) === canonical(expectedRecord));
173
+ }
174
+
175
+ function validHead(head) {
176
+ return head === null || Boolean(head && Number.isSafeInteger(head.seq) && head.seq >= 0 && LOG_HEX_256.test(head.hash));
177
+ }
178
+
179
+ /**
180
+ * Fleet-safe, fail-closed evidence log over an atomic shared-head backend.
181
+ *
182
+ * Backend contract (all operations are scoped by streamId):
183
+ * readHead(streamId) -> null | { seq, hash }
184
+ * getById(streamId, recordId) -> null | record
185
+ * appendIfHead(streamId, expectedHeadHash|null, record) -> boolean
186
+ * readAll(streamId) -> record[] // optional, for verify/all
187
+ *
188
+ * appendIfHead MUST atomically compare the current head, append the immutable
189
+ * record, reject duplicate record_id, and advance the head in one durable
190
+ * transaction. A true return MUST provide immediate read-after-write visibility
191
+ * through getById. `backend.durable === true` is a deployment capability
192
+ * assertion; this module tests the protocol but cannot prove storage hardware
193
+ * semantics.
194
+ */
195
+ export function createAtomicEvidenceLog(backend, {
196
+ streamId = 'emilia-gate',
197
+ maxRetries = 32,
198
+ recordIdFactory = () => crypto.randomUUID(),
199
+ } = {}) {
200
+ for (const method of ['readHead', 'getById', 'appendIfHead']) {
201
+ if (typeof backend?.[method] !== 'function') {
202
+ throw new Error(`createAtomicEvidenceLog: backend must implement async ${method}()`);
203
+ }
204
+ }
205
+ if (typeof streamId !== 'string' || !streamId || streamId.length > 256) {
206
+ throw new Error('createAtomicEvidenceLog: streamId must be a non-empty string of at most 256 characters');
207
+ }
208
+ if (!Number.isSafeInteger(maxRetries) || maxRetries < 1 || maxRetries > 1024) {
209
+ throw new Error('createAtomicEvidenceLog: maxRetries must be an integer from 1 to 1024');
210
+ }
211
+ if (typeof recordIdFactory !== 'function') {
212
+ throw new Error('createAtomicEvidenceLog: recordIdFactory must be a function');
213
+ }
214
+
215
+ async function recover(recordId, snapshot, expectedRecord = null) {
216
+ const existing = await backend.getById(streamId, recordId);
217
+ if (existing === null || existing === undefined) return null;
218
+ if (!validateAtomicRecord(existing, recordId, snapshot, expectedRecord)) {
219
+ throw new Error('atomic evidence backend returned a conflicting record_id');
220
+ }
221
+ return structuredClone(existing);
222
+ }
223
+
224
+ return {
225
+ durable: backend.durable === true,
226
+ persisted: backend.durable === true,
227
+ strict: true,
228
+ forkAware: true,
229
+ atomicAppend: true,
230
+ streamId,
231
+
232
+ async record(entry) {
233
+ const snapshot = structuredClone(entry);
234
+ assertLogEntry(snapshot);
235
+ const recordId = recordIdFactory();
236
+ if (typeof recordId !== 'string' || recordId.length < 16 || recordId.length > 256) {
237
+ throw new Error('atomic evidence record id must be a string of 16 to 256 characters');
238
+ }
239
+
240
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
241
+ const recovered = await recover(recordId, snapshot);
242
+ if (recovered) return recovered;
243
+
244
+ const head = await backend.readHead(streamId);
245
+ if (!validHead(head)) throw new Error('atomic evidence backend returned a malformed head');
246
+ const seq = head === null ? 0 : head.seq + 1;
247
+ const prevHash = head === null ? 'genesis' : head.hash;
248
+ const body = { seq, prev_hash: prevHash, record_id: recordId, ...snapshot };
249
+ const record = { ...body, hash: sha256hex(canonical(body)) };
250
+ try {
251
+ if ((await backend.appendIfHead(streamId, head?.hash ?? null, record)) === true) {
252
+ const persisted = await recover(recordId, snapshot, record);
253
+ if (persisted) return persisted;
254
+ throw new Error('atomic_evidence_append_not_observable');
255
+ }
256
+ } catch (error) {
257
+ // The append may have linearized before its response was lost. Recover
258
+ // by stable id; if no record is visible, fail closed and let the caller
259
+ // retain/freeze its execution reservation.
260
+ const afterError = await recover(recordId, snapshot, record);
261
+ if (afterError) return afterError;
262
+ const wrapped = new Error('atomic_evidence_append_indeterminate');
263
+ wrapped.cause = error;
264
+ throw wrapped;
265
+ }
266
+ }
267
+ throw new Error('atomic_evidence_contention_limit');
268
+ },
269
+
270
+ async all() {
271
+ if (typeof backend.readAll !== 'function') throw new Error('atomic evidence backend does not expose readAll()');
272
+ const records = await backend.readAll(streamId);
273
+ if (!Array.isArray(records)) throw new Error('atomic evidence backend returned malformed history');
274
+ return structuredClone(records);
275
+ },
276
+
277
+ async verify() {
278
+ try {
279
+ if (typeof backend.readAll !== 'function') return { ok: false, reason: 'read_all_unavailable' };
280
+ const records = await backend.readAll(streamId);
281
+ if (!Array.isArray(records)) return { ok: false, reason: 'malformed_history' };
282
+ let prev = 'genesis';
283
+ const ids = new Set();
284
+ for (let index = 0; index < records.length; index++) {
285
+ const record = records[index];
286
+ if (!record || typeof record !== 'object' || Array.isArray(record)
287
+ || record.seq !== index || record.prev_hash !== prev
288
+ || typeof record.record_id !== 'string' || ids.has(record.record_id)) {
289
+ return { ok: false, at: index, reason: 'sequence_or_predecessor_mismatch' };
290
+ }
291
+ const { hash, ...body } = record;
292
+ if (!LOG_HEX_256.test(hash) || sha256hex(canonical(body)) !== hash) {
293
+ return { ok: false, at: index, reason: 'hash_mismatch' };
294
+ }
295
+ ids.add(record.record_id);
296
+ prev = hash;
297
+ }
298
+ const head = await backend.readHead(streamId);
299
+ const expectedHead = records.length === 0 ? null : { seq: records.length - 1, hash: prev };
300
+ if (canonical(head) !== canonical(expectedHead)) return { ok: false, reason: 'head_mismatch' };
301
+ return { ok: true, length: records.length, head: expectedHead?.hash ?? null };
302
+ } catch {
303
+ return { ok: false, reason: 'backend_read_failed_or_malformed' };
304
+ }
305
+ },
306
+ };
307
+ }
308
+
309
+ /** In-memory contract model for tests. It is intentionally not durable. */
310
+ export function createMemoryAtomicEvidenceBackend() {
311
+ const streams = new Map();
312
+ const state = (streamId) => {
313
+ if (!streams.has(streamId)) streams.set(streamId, { records: [], byId: new Map() });
314
+ return streams.get(streamId);
315
+ };
316
+ return {
317
+ durable: false,
318
+ async readHead(streamId) {
319
+ const records = state(streamId).records;
320
+ const record = records[records.length - 1];
321
+ return record ? { seq: record.seq, hash: record.hash } : null;
322
+ },
323
+ async getById(streamId, recordId) {
324
+ return structuredClone(state(streamId).byId.get(recordId) ?? null);
325
+ },
326
+ async appendIfHead(streamId, expectedHeadHash, record) {
327
+ const s = state(streamId);
328
+ const head = s.records[s.records.length - 1] ?? null;
329
+ const actual = head?.hash ?? null;
330
+ if (actual !== expectedHeadHash || s.byId.has(record.record_id)
331
+ || record.seq !== s.records.length || record.prev_hash !== (head?.hash ?? 'genesis')) return false;
332
+ const snapshot = structuredClone(record);
333
+ s.records.push(snapshot);
334
+ s.byId.set(snapshot.record_id, snapshot);
335
+ return true;
336
+ },
337
+ async readAll(streamId) { return structuredClone(state(streamId).records); },
338
+ };
339
+ }
340
+
341
+ export const __atomicEvidenceSecurityInternals = Object.freeze({
342
+ canonical,
343
+ assertLogEntry,
344
+ validateAtomicRecord,
345
+ validHead,
346
+ });
347
+
348
+ export default {
349
+ createEvidenceLog,
350
+ verifyEvidenceRecord,
351
+ createAtomicEvidenceLog,
352
+ createMemoryAtomicEvidenceBackend,
353
+ };
package/index.js CHANGED
@@ -44,7 +44,7 @@ const {
44
44
  const { verifyWebAuthnSignoff, verifyQuorum } = await import('@emilia-protocol/verify')
45
45
  .catch(() => import('../verify/index.js'));
46
46
  import { MemoryConsumptionStore } from './store.js';
47
- import { createEvidenceLog } from './evidence.js';
47
+ import { createAtomicEvidenceLog, createEvidenceLog, createMemoryAtomicEvidenceBackend } 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
50
  import { buildReliancePacket, ADMISSIBILITY_VERDICTS } from './reliance-packet.js';
@@ -54,8 +54,9 @@ import { createKeyRegistry, asKeyRegistry } from './key-registry.js';
54
54
  import { classifyRetention, buildRetentionExport } from './retention.js';
55
55
  import { createDefaultActionControlManifest, findActionControl, validateActionControlManifest } from './action-control-manifest.js';
56
56
 
57
- export { MemoryConsumptionStore, createEvidenceLog };
58
- export { createDurableConsumptionStore, createMemoryBackend } from './store.js';
57
+ export { MemoryConsumptionStore, createEvidenceLog, createAtomicEvidenceLog, createMemoryAtomicEvidenceBackend };
58
+ export { createDurableConsumptionStore, createMemoryBackend, DURABLE_CONSUMPTION_VERSION } from './store.js';
59
+ export { createDurableChallengeStore, challengeStorageKey, challengeBodyDigest, DURABLE_CHALLENGE_STORE_VERSION } from './challenge-store.js';
59
60
  export { createKeyRegistry, asKeyRegistry } from './key-registry.js';
60
61
  export { classifyRetention, buildRetentionExport, RETENTION_EXPORT_VERSION } from './retention.js';
61
62
  export { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskManifest };
@@ -572,8 +573,11 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
572
573
  /**
573
574
  * Recommended end-to-end path. Reserves the receipt, runs the side effect,
574
575
  * commits one-time consumption only after success, and records execution.
575
- * If the side effect throws, the reservation is released so the approval can
576
- * be retried safely.
576
+ * Once the executor is invoked, an exception is an INDETERMINATE outcome: the
577
+ * external effect may have happened before its response was lost. The receipt
578
+ * is therefore committed (or left reserved if the store is unavailable),
579
+ * never released automatically. Callers that need retries must make the
580
+ * downstream effect idempotent under the receipt id and reconcile its result.
577
581
  */
578
582
  async function run({ selector = {}, receipt = null, observedAction = null, admissibilityProfile = null, reliancePacket: presentedPacket = null, admissibility = null } = {}, fn, opts = {}) {
579
583
  if (typeof fn !== 'function') throw new Error('EMILIA Gate run(): fn is required');
@@ -582,23 +586,49 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
582
586
  return { ok: false, status: authorization.status, body: authorization.challenge, authorization };
583
587
  }
584
588
  const receiptId = authorization.evidence?.receipt_id;
585
- let actionRan = false;
589
+ let phase = 'reserved';
590
+ let consumptionCommitted = false;
586
591
  try {
592
+ phase = 'effect_attempted';
587
593
  const result = await fn(authorization);
588
- actionRan = true;
594
+ phase = 'effect_returned';
589
595
  if (typeof consumption.commit === 'function') await consumption.commit(receiptId);
596
+ consumptionCommitted = true;
597
+ phase = 'consumed';
590
598
  if (opts.recordExecution === false) return { ok: true, result, authorization, execution: null, packet: null };
599
+ phase = 'recording_execution';
591
600
  const execution = await recordExecution({ authorization, outcome: 'executed', observedAction });
592
601
  return { ok: true, result, authorization, execution, packet: reliancePacket({ authorization, execution }) };
593
602
  } catch (e) {
594
- if (!actionRan && typeof consumption.release === 'function') await consumption.release(receiptId);
595
- if (opts.recordExecution !== false) {
596
- await recordExecution({
597
- authorization,
598
- outcome: 'failed',
599
- detail: actionRan ? `post_execution_failure:${String(e?.message ?? e)}` : String(e?.message ?? e),
600
- observedAction,
601
- });
603
+ // An exception after invoking fn() cannot establish that no external
604
+ // effect occurred. Burn the approval if possible; if storage is down, the
605
+ // ownership-fenced reservation remains and still blocks replay.
606
+ let consumptionError = null;
607
+ if (!consumptionCommitted && phase !== 'reserved' && typeof consumption.commit === 'function') {
608
+ try {
609
+ await consumption.commit(receiptId);
610
+ consumptionCommitted = true;
611
+ } catch (commitError) {
612
+ consumptionError = commitError;
613
+ }
614
+ }
615
+ if (opts.recordExecution !== false && phase !== 'recording_execution') {
616
+ try {
617
+ await recordExecution({
618
+ authorization,
619
+ outcome: 'indeterminate',
620
+ // Exception text frequently contains provider payloads, record IDs,
621
+ // or secrets. The caller still receives the original exception;
622
+ // the portable evidence record carries only the closed outcome.
623
+ detail: { code: 'effect_attempted_outcome_unknown' },
624
+ observedAction,
625
+ });
626
+ } catch (recordError) {
627
+ if (!consumptionError) consumptionError = recordError;
628
+ }
629
+ }
630
+ if (consumptionError && e && typeof e === 'object') {
631
+ e.consumption_error = String(consumptionError?.message ?? consumptionError);
602
632
  }
603
633
  throw e;
604
634
  }
@@ -754,6 +784,8 @@ export default {
754
784
  ADMISSIBILITY_VERDICTS,
755
785
  MemoryConsumptionStore,
756
786
  createEvidenceLog,
787
+ createAtomicEvidenceLog,
788
+ createMemoryAtomicEvidenceBackend,
757
789
  ASSURANCE_TIERS,
758
790
  DEFAULT_GATE_MANIFEST,
759
791
  HIGH_RISK_ACTION_PACKS,
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "@emilia-protocol/gate",
3
- "version": "0.9.3",
4
- "description": "EMILIA Gate — the Trusted Action Firewall. Deny-by-default enforcement for consequential machine actions: an action runs only with a valid, in-scope, sufficiently-assured, non-replayed EMILIA authorization receipt (proof a named human authorized this exact action). Composes require-receipt + verify; adds assurance-tier enforcement, one-time consumption (replay defense), a tamper-evident evidence log, default high-risk action packs, execution-field binding, reliance packets, an MCP drop-in, a GitHub system-of-record adapter, and EG-1 conformance. Framework-agnostic, fails closed. Reference implementation, experimental.",
3
+ "version": "0.9.4",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/emiliaprotocol/emilia-protocol.git",
7
+ "directory": "packages/gate"
8
+ },
9
+ "description": "EMILIA Gate \u2014 the Trusted Action Firewall. Deny-by-default enforcement for consequential machine actions: an action runs only with a valid, in-scope, sufficiently-assured, non-replayed EMILIA authorization receipt (proof a named human authorized this exact action). Composes require-receipt + verify; adds assurance-tier enforcement, one-time consumption (replay defense), a tamper-evident evidence log, default high-risk action packs, execution-field binding, reliance packets, an MCP drop-in, a GitHub system-of-record adapter, and EG-1 conformance. Framework-agnostic, fails closed. Reference implementation, experimental.",
5
10
  "license": "Apache-2.0",
6
11
  "type": "module",
7
12
  "main": "index.js",
@@ -32,19 +37,32 @@
32
37
  "./reports/underwriter": "./reports/underwriter.js",
33
38
  "./metrics": "./metrics.js",
34
39
  "./breakglass": "./breakglass.js",
40
+ "./challenge-store": "./challenge-store.js",
35
41
  "./store-postgres": "./store-postgres.js",
42
+ "./evidence": "./evidence.js",
36
43
  "./reports/auditor-workpaper": "./reports/auditor-workpaper.js",
37
44
  "./reports/reperform": "./reports/reperform.js",
38
- "./reports/external-verification": "./reports/external-verification.js"
45
+ "./reports/external-verification": "./reports/external-verification.js",
46
+ "./reports/assurance-package": "./reports/assurance-package.js",
47
+ "./reliance-kernel": "./reliance-kernel.js",
48
+ "./aec-execution": "./aec-execution.js"
49
+ },
50
+ "bin": {
51
+ "ep-assure": "./ep-assure.mjs"
39
52
  },
40
53
  "files": [
41
54
  "index.js",
42
55
  "store.js",
56
+ "challenge-store.js",
43
57
  "evidence.js",
44
58
  "action-packs.js",
45
59
  "action-control-manifest.js",
46
60
  "execution-binding.js",
47
61
  "reliance-packet.js",
62
+ "reliance-kernel.js",
63
+ "aec-execution.js",
64
+ "reports/assurance-package.js",
65
+ "ep-assure.mjs",
48
66
  "key-registry.js",
49
67
  "retention.js",
50
68
  "eg1-conformance.js",
@@ -0,0 +1,114 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EP-RELIANCE-KERNEL-v1 — runtime enforcement wrapper.
4
+ *
5
+ * The pure verdict lives in @emilia-protocol/verify/reliance (evaluateReliance).
6
+ * This is the deny-by-default RUNTIME point a relying party puts in front of a
7
+ * consequential action: it evaluates the evidence packet against the relying
8
+ * party's pinned EP-RELIANCE-PROFILE-v1, appends the decision to a tamper-evident
9
+ * evidence log, and — on anything other than `rely` — returns a machine-readable
10
+ * refusal (HTTP 428, the same Receipt-Required status the Gate uses) naming the
11
+ * closed verdict and what evidence was required.
12
+ *
13
+ * ALLOW iff verdict === 'rely'. Every other closed verdict, a thrown verifier,
14
+ * or a strict evidence-log failure denies. The kernel never re-derives a verdict
15
+ * of its own — it enforces the one the pure offline verifier computed.
16
+ */
17
+ import { createEvidenceLog } from './evidence.js';
18
+
19
+ const RECEIPT_REQUIRED_STATUS = 428;
20
+
21
+ // Same cross-package resolution the Gate uses: prefer the published verifier,
22
+ // fall back to the in-repo source so the monorepo builds without a node_modules
23
+ // link. evaluateReliance is pure/offline; no DB, no network.
24
+ const { evaluateReliance, RELIANCE_VERDICTS, RELIANCE_PROFILE_VERSION } = await import('@emilia-protocol/verify/reliance')
25
+ .catch(() => import('../verify/reliance.js'));
26
+
27
+ export { RELIANCE_VERDICTS, RELIANCE_PROFILE_VERSION };
28
+
29
+ /** Build the 428 refusal for a non-`rely` verdict. */
30
+ function relianceChallenge(verdict, reasons, profile) {
31
+ return {
32
+ status: RECEIPT_REQUIRED_STATUS,
33
+ error: 'do_not_rely',
34
+ verdict,
35
+ reasons: Array.isArray(reasons) ? reasons : [],
36
+ required_assurance: profile?.required_assurance ?? null,
37
+ required_authority: profile?.required_authority === true,
38
+ required_evidence: Array.isArray(profile?.required_evidence) ? profile.required_evidence : [],
39
+ header: { name: 'Reliance-Refused', value: verdict },
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Create a reliance kernel bound to one relying-party profile.
45
+ *
46
+ * @param {object} cfg
47
+ * @param {object} cfg.profile the pinned EP-RELIANCE-PROFILE-v1
48
+ * @param {object} [cfg.log] an evidence log (createEvidenceLog); one is created if absent
49
+ * @param {boolean} [cfg.strictEvidence=true] fail closed if the evidence log sink fails
50
+ * @returns {{ check: Function, evidence: object }}
51
+ */
52
+ export function createRelianceKernel({ profile, log, strictEvidence = true } = {}) {
53
+ const evidence = log || createEvidenceLog({ strict: strictEvidence });
54
+
55
+ /**
56
+ * Evaluate + enforce one evidence packet.
57
+ * @param {object} input the evaluateReliance input MINUS relying_party_profile (bound here)
58
+ * @param {object} [opts] verifier options { approverKeys, logPublicKey, rpId, revokerKeys }
59
+ * @returns {Promise<{ allow:boolean, status:number, verdict:string, reasons:string[], checks:object, challenge:(object|null), decision:object }>}
60
+ */
61
+ async function check(input = {}, opts = {}) {
62
+ let result;
63
+ try {
64
+ result = evaluateReliance({ ...input, relying_party_profile: profile }, opts);
65
+ } catch (err) {
66
+ // A thrown verifier is not a maybe — it is a refusal.
67
+ result = { verdict: 'do_not_rely_unsigned', rely: false, reasons: [`verifier_error:${err?.message || 'threw'}`], checks: {} };
68
+ }
69
+
70
+ const allow = result.verdict === 'rely';
71
+ const actionHash = input?.action?.action_hash ?? input?.receipt?.action_hash ?? null;
72
+
73
+ // Deny-by-default: record the decision to the tamper-evident log first. In
74
+ // strict mode a log-sink failure THROWS, which we convert to a refusal — an
75
+ // action whose decision cannot be durably recorded must not proceed.
76
+ let decision;
77
+ try {
78
+ decision = await evidence.record({
79
+ type: 'reliance.decision',
80
+ verdict: result.verdict,
81
+ allow,
82
+ action_hash: actionHash,
83
+ reasons: result.reasons,
84
+ checks: result.checks,
85
+ profile: result.profile ?? null,
86
+ });
87
+ } catch (err) {
88
+ return {
89
+ allow: false,
90
+ status: RECEIPT_REQUIRED_STATUS,
91
+ verdict: 'do_not_rely_unsigned',
92
+ reasons: [`evidence_log_failed:${err?.message || 'sink'}`],
93
+ checks: result.checks || {},
94
+ challenge: relianceChallenge('do_not_rely_unsigned', ['evidence_log_failed'], profile),
95
+ decision: null,
96
+ };
97
+ }
98
+
99
+ return {
100
+ allow,
101
+ status: allow ? 200 : RECEIPT_REQUIRED_STATUS,
102
+ verdict: result.verdict,
103
+ reasons: result.reasons,
104
+ checks: result.checks,
105
+ challenge: allow ? null : relianceChallenge(result.verdict, result.reasons, profile),
106
+ decision,
107
+ };
108
+ }
109
+
110
+ return { check, evidence };
111
+ }
112
+
113
+ const relianceKernelApi = { createRelianceKernel };
114
+ export default relianceKernelApi;