@emilia-protocol/gate 0.9.2 → 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 };
@@ -163,9 +164,26 @@ export function verifyAdmissibilityAgainstPinnedProfile(pinned, presented) {
163
164
  * verifyWebAuthnSignoff earns `class_a`. Used where the approver keys travel
164
165
  * with the receipt rather than being pinned by the relying party.
165
166
  *
167
+ * TRUST-LAUNDERING GUARD: an approver key carried INSIDE the receipt proves
168
+ * only that whoever minted the receipt also holds that key — it is NOT proof
169
+ * the relying party trusts that human. Crediting an elevated tier off such a
170
+ * key would collapse VERIFIED into ACCEPTED (any party can mint a fresh
171
+ * keypair, self-sign a signoff, and embed both). So path (b) elevates the
172
+ * tier ONLY when either: (i) the caller explicitly opts in with
173
+ * `allowEmbeddedApproverKeys:true` (the documented self-contained mode,
174
+ * DEFAULT OFF); or (ii) every embedded approver key that would earn the
175
+ * credit is present in the relying party's PINNED approver key set
176
+ * (opts.approverKeys). With no pin and no opt-in, path (b) may still VERIFY
177
+ * the signoff/quorum, but it does NOT elevate above `software`. Fail-closed.
178
+ *
166
179
  * @param {object} doc the EP-RECEIPT-v1 document
167
180
  * @param {object} [opts]
168
- * @param {object} [opts.approverKeys] pinned approver keys for path (a)
181
+ * @param {object} [opts.approverKeys] pinned approver keys for path (a) and the
182
+ * path-(b) fallback: a receipt-embedded approver key elevates the tier only if
183
+ * it is one of these pinned keys (unless allowEmbeddedApproverKeys is set)
184
+ * @param {boolean} [opts.allowEmbeddedApproverKeys=false] explicit opt-in to the
185
+ * self-contained mode where an UNPINNED approver key carried inside the receipt
186
+ * may still elevate the path-(b) tier. DEFAULT OFF (fail-closed).
169
187
  * @param {function} [opts.verifyAssurance] custom assurance verifier for path (a)
170
188
  * @param {string} [opts.rpId] bind embedded device assertions to this WebAuthn RP id (path b)
171
189
  * @param {boolean} [opts.detail] return a {tier, quorum, signoff} object instead of the string
@@ -185,30 +203,43 @@ export function receiptAssuranceTier(doc, opts = {}) {
185
203
  // --- Path (b): self-contained embedded per-signer evidence (DoD audit fix). ---
186
204
  const p = doc?.payload || {};
187
205
  const verifyOpts = opts.rpId ? { rpId: opts.rpId } : {};
206
+ // The relying party's PINNED approver public keys (base64url SPKI-DER strings).
207
+ // An embedded approver key elevates the tier only if it is in this set, unless
208
+ // the caller explicitly opts into the self-contained mode.
209
+ const allowEmbedded = opts.allowEmbeddedApproverKeys === true;
210
+ const pinnedKeys = pinnedApproverKeySet(opts.approverKeys);
211
+ const keyIsTrusted = (k) => allowEmbedded || (typeof k === 'string' && pinnedKeys.has(k));
188
212
 
189
213
  // quorum: a real, self-contained EP-QUORUM-v1 evidence document. Accept it
190
214
  // under payload.quorum or payload.claim.quorum. It only counts if it is a full
191
215
  // quorum document (policy + members with WebAuthn signoffs) AND verifyQuorum
192
216
  // returns valid. A bare {signers,threshold} block has no members to verify and
193
- // therefore CANNOT be credited quorum.
217
+ // therefore CANNOT be credited quorum. The cryptographic verification runs
218
+ // regardless (so `detail.quorum` reports validity), but the tier elevates only
219
+ // when every member's embedded approver key is pinned (or the caller opted in).
194
220
  const q = p.quorum || p.claim?.quorum;
195
221
  if (detail.tier !== 'quorum' && isQuorumEvidence(q)) {
196
222
  const qr = verifyQuorum(q, verifyOpts);
197
- detail.quorum = { valid: qr.valid, checks: qr.checks };
198
- if (qr.valid) detail.tier = 'quorum';
223
+ const membersTrusted = allowEmbedded
224
+ || (Array.isArray(q.members) && q.members.length > 0
225
+ && q.members.every((m) => keyIsTrusted(m?.approver_public_key)));
226
+ detail.quorum = { valid: qr.valid, checks: qr.checks, embedded_keys_trusted: membersTrusted };
227
+ if (qr.valid && membersTrusted) detail.tier = 'quorum';
199
228
  }
200
229
 
201
230
  // class_a: a verifiable WebAuthn device signoff. The signoff evidence is
202
231
  // {context, webauthn}; the approver key travels with it (signoff.approver_public_key)
203
- // or alongside it (payload.approver_public_key).
232
+ // or alongside it (payload.approver_public_key). That key elevates the tier only
233
+ // when it is pinned by the relying party (or the caller opted into embedded keys).
204
234
  if ((TIER_RANK[detail.tier] ?? 0) < TIER_RANK.class_a) {
205
235
  const so = p.signoff || p.claim?.signoff;
206
236
  if (isSignoffEvidence(so)) {
207
237
  const key = so.approver_public_key || p.approver_public_key || p.claim?.approver_public_key;
208
238
  if (key) {
209
239
  const sr = verifyWebAuthnSignoff(so, key, verifyOpts);
210
- detail.signoff = { valid: sr.valid, checks: sr.checks };
211
- if (sr.valid && (TIER_RANK[detail.tier] ?? 0) < TIER_RANK.class_a) detail.tier = 'class_a';
240
+ const trusted = keyIsTrusted(key);
241
+ detail.signoff = { valid: sr.valid, checks: sr.checks, embedded_key_trusted: trusted };
242
+ if (sr.valid && trusted && (TIER_RANK[detail.tier] ?? 0) < TIER_RANK.class_a) detail.tier = 'class_a';
212
243
  }
213
244
  }
214
245
  }
@@ -216,6 +247,34 @@ export function receiptAssuranceTier(doc, opts = {}) {
216
247
  return opts.detail ? detail : detail.tier;
217
248
  }
218
249
 
250
+ /**
251
+ * The set of PINNED approver public keys (base64url SPKI-DER strings) a relying
252
+ * party trusts, from the same `approverKeys` map path (a) uses. Accepts either a
253
+ * map of { keyId: { public_key } } (the EP-ASSURANCE-PROOF-v1 shape) or a plain
254
+ * array/set of key strings. Used to decide whether a receipt-embedded approver
255
+ * key may elevate the path-(b) tier. Never throws.
256
+ */
257
+ function pinnedApproverKeySet(approverKeys) {
258
+ const out = new Set();
259
+ if (!approverKeys) return out;
260
+ if (approverKeys instanceof Set) {
261
+ for (const k of approverKeys) if (typeof k === 'string' && k) out.add(k);
262
+ return out;
263
+ }
264
+ if (Array.isArray(approverKeys)) {
265
+ for (const k of approverKeys) if (typeof k === 'string' && k) out.add(k);
266
+ return out;
267
+ }
268
+ if (typeof approverKeys === 'object') {
269
+ for (const entry of Object.values(approverKeys)) {
270
+ if (typeof entry === 'string' && entry) { out.add(entry); continue; }
271
+ const pk = entry && typeof entry === 'object' ? entry.public_key : null;
272
+ if (typeof pk === 'string' && pk) out.add(pk);
273
+ }
274
+ }
275
+ return out;
276
+ }
277
+
219
278
  /** A quorum evidence doc must carry members with per-signer signoffs to be verifiable. */
220
279
  function isQuorumEvidence(q) {
221
280
  return !!q && typeof q === 'object' && q.policy && Array.isArray(q.members) && q.members.length > 0
@@ -238,8 +297,16 @@ function isSignoffEvidence(s) {
238
297
  * @param {object} [opts.keyRegistry] a key registry (createKeyRegistry) for rotation + revocation;
239
298
  * if given it supersedes trustedKeys — a receipt is verified only against keys valid (and not
240
299
  * revoked) at its issuance time.
300
+ * @param {object} [opts.approverKeys] PINNED approver keys ({ keyId: { public_key, key_class } }).
301
+ * Used both for the pinned assurance-proof path and to authorize receipt-embedded
302
+ * approver keys under the self-contained embedded-evidence path.
303
+ * @param {boolean} [opts.allowEmbeddedApproverKeys=false] opt into the self-contained
304
+ * embedded-evidence mode: when true, a class_a/quorum tier may be credited from an
305
+ * approver key carried INSIDE the receipt even if that key is not pinned. DEFAULT OFF
306
+ * — with no pinned approverKeys and no opt-in, embedded evidence verifies but does not
307
+ * elevate above 'software' (prevents VERIFIED collapsing into ACCEPTED / trust-laundering).
241
308
  */
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 } = {}) {
309
+ 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, allowEmbeddedApproverKeys = false } = {}) {
243
310
  // Production key custody: a registry (rotation + revocation) supersedes a flat
244
311
  // trustedKeys list. A flat list is coerced to an always-valid registry, so
245
312
  // existing callers are unchanged.
@@ -367,6 +434,10 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
367
434
  });
368
435
  const tierResult = receiptAssuranceTier(receipt, {
369
436
  rpId, detail: true, approverKeys: approver_keys || approverKeys, verifyAssurance,
437
+ // Trust-laundering guard: a receipt-embedded approver key does NOT elevate
438
+ // the tier unless it is in the pinned approverKeys set, or the operator
439
+ // explicitly opted into the self-contained embedded-evidence mode. DEFAULT OFF.
440
+ allowEmbeddedApproverKeys,
370
441
  });
371
442
  // Take the strongest tier either path proves.
372
443
  const have = (TIER_RANK[assurance.have] ?? 0) >= (TIER_RANK[tierResult.tier] ?? 0)
@@ -502,8 +573,11 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
502
573
  /**
503
574
  * Recommended end-to-end path. Reserves the receipt, runs the side effect,
504
575
  * commits one-time consumption only after success, and records execution.
505
- * If the side effect throws, the reservation is released so the approval can
506
- * 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.
507
581
  */
508
582
  async function run({ selector = {}, receipt = null, observedAction = null, admissibilityProfile = null, reliancePacket: presentedPacket = null, admissibility = null } = {}, fn, opts = {}) {
509
583
  if (typeof fn !== 'function') throw new Error('EMILIA Gate run(): fn is required');
@@ -512,23 +586,49 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
512
586
  return { ok: false, status: authorization.status, body: authorization.challenge, authorization };
513
587
  }
514
588
  const receiptId = authorization.evidence?.receipt_id;
515
- let actionRan = false;
589
+ let phase = 'reserved';
590
+ let consumptionCommitted = false;
516
591
  try {
592
+ phase = 'effect_attempted';
517
593
  const result = await fn(authorization);
518
- actionRan = true;
594
+ phase = 'effect_returned';
519
595
  if (typeof consumption.commit === 'function') await consumption.commit(receiptId);
596
+ consumptionCommitted = true;
597
+ phase = 'consumed';
520
598
  if (opts.recordExecution === false) return { ok: true, result, authorization, execution: null, packet: null };
599
+ phase = 'recording_execution';
521
600
  const execution = await recordExecution({ authorization, outcome: 'executed', observedAction });
522
601
  return { ok: true, result, authorization, execution, packet: reliancePacket({ authorization, execution }) };
523
602
  } catch (e) {
524
- if (!actionRan && typeof consumption.release === 'function') await consumption.release(receiptId);
525
- if (opts.recordExecution !== false) {
526
- await recordExecution({
527
- authorization,
528
- outcome: 'failed',
529
- detail: actionRan ? `post_execution_failure:${String(e?.message ?? e)}` : String(e?.message ?? e),
530
- observedAction,
531
- });
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);
532
632
  }
533
633
  throw e;
534
634
  }
@@ -684,6 +784,8 @@ export default {
684
784
  ADMISSIBILITY_VERDICTS,
685
785
  MemoryConsumptionStore,
686
786
  createEvidenceLog,
787
+ createAtomicEvidenceLog,
788
+ createMemoryAtomicEvidenceBackend,
687
789
  ASSURANCE_TIERS,
688
790
  DEFAULT_GATE_MANIFEST,
689
791
  HIGH_RISK_ACTION_PACKS,
package/package.json CHANGED
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "name": "@emilia-protocol/gate",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/emiliaprotocol/emilia-protocol.git",
7
+ "directory": "packages/gate"
8
+ },
4
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",
@@ -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",