@emilia-protocol/gate 0.1.0 → 0.6.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.
@@ -0,0 +1,206 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EG-1 Conformance — the binary artifact that turns "we adopted EMILIA Gate"
4
+ * from a claim into a test result.
5
+ *
6
+ * The question EG-1 answers: *does your integration actually ENFORCE the gate,
7
+ * or are you only claiming it?* An integration earns EG-1 only if it
8
+ * demonstrably:
9
+ * 1. refuses a high-risk action with NO receipt (428);
10
+ * 2. refuses a software-tier receipt on a Class-A action;
11
+ * 3. refuses when the observed execution drifts from the authorized fields;
12
+ * 4. RUNS the action for a valid Class-A/quorum receipt;
13
+ * 5. refuses a replay of the same receipt;
14
+ * 6. refuses a tampered receipt;
15
+ * 7. emits an execution proof bound to the authorization decision;
16
+ * 8. produces a reliance packet whose verdict is "rely".
17
+ *
18
+ * This module is pure (no dependency on index.js, so no import cycle): it owns a
19
+ * throwaway issuer keypair, mints the scenario receipts, and drives any
20
+ * "subject" through the eight checks. A subject is an async `invoke` function
21
+ * representing one attempt at the guarded dangerous action:
22
+ *
23
+ * invoke({ receipt, observedAction }) -> {
24
+ * allowed: boolean, status: number, reason: string,
25
+ * // present only on an allowed run:
26
+ * decisionHash?: string, execution?: { authorizes_decision }, packet?: { verdict }
27
+ * }
28
+ *
29
+ * For integrations built on @emilia-protocol/gate, `makeGateInvoke(gate, ...)`
30
+ * produces a conformant `invoke` from `gate.run()`. Custom integrations (an HTTP
31
+ * service, a different language) implement `invoke` themselves and configure
32
+ * their gate to trust `harness.publicKey` for the run.
33
+ */
34
+ import crypto from 'node:crypto';
35
+
36
+ export const EG1_VERSION = 'EG-1';
37
+
38
+ // Same sorted-key canonical JSON the receipt signature is computed over.
39
+ const canon = (v) => (v == null ? JSON.stringify(v)
40
+ : Array.isArray(v) ? `[${v.map(canon).join(',')}]`
41
+ : typeof v === 'object' ? `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canon(v[k])).join(',')}}`
42
+ : JSON.stringify(v));
43
+
44
+ // The default high-risk action EG-1 exercises: a Class-A money movement, which
45
+ // the default gate manifest guards (selector { protocol:'mcp', tool:'release_payment' }).
46
+ export const EG1_DEFAULT_SELECTOR = Object.freeze({ protocol: 'mcp', tool: 'release_payment' });
47
+ export const EG1_DEFAULT_ACTION = Object.freeze({
48
+ action_type: 'payment.release',
49
+ amount_usd: 40000,
50
+ currency: 'USD',
51
+ payment_instruction_id: 'pi_eg1_40000',
52
+ beneficiary_account_hash: 'sha256:eg1-beneficiary',
53
+ });
54
+
55
+ export const EG1_CHECKS = Object.freeze([
56
+ { id: 'missing_receipt_refused', title: 'missing receipt → 428' },
57
+ { id: 'software_on_classA_refused', title: 'software receipt on Class-A action → refused' },
58
+ { id: 'execution_drift_refused', title: 'observed execution drift → refused' },
59
+ { id: 'valid_classA_runs', title: 'valid Class-A/quorum receipt → runs' },
60
+ { id: 'replay_refused', title: 'same receipt replay → refused' },
61
+ { id: 'tampered_refused', title: 'tampered receipt → refused' },
62
+ { id: 'execution_proof_binds', title: 'execution proof binds to authorization decision' },
63
+ { id: 'reliance_packet_rely', title: 'reliance packet returns verdict "rely"' },
64
+ ]);
65
+
66
+ /**
67
+ * Create an EG-1 harness: a throwaway issuer key + a receipt minter for the
68
+ * scenarios. Configure the subject's gate to trust `publicKey` for the run.
69
+ */
70
+ export function createEg1Harness({ now = Date.now, action = EG1_DEFAULT_ACTION, idPrefix = 'eg1' } = {}) {
71
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
72
+ const pub = publicKey.export({ type: 'spki', format: 'der' }).toString('base64url');
73
+ let counter = 0;
74
+ const nowMs = () => (typeof now === 'function' ? now() : now);
75
+
76
+ /**
77
+ * Mint a scenario receipt.
78
+ * @param {object} o
79
+ * @param {'allow'|'allow_with_signoff'} [o.outcome] 'allow'=software, 'allow_with_signoff'=Class-A
80
+ * @param {object} [o.quorum] a quorum block (signers/threshold) -> quorum tier
81
+ * @param {object} [o.tamper] fields assigned to the claim AFTER signing (breaks the signature)
82
+ */
83
+ function mint({ outcome = 'allow_with_signoff', quorum = null, tamper = null, extra = {} } = {}) {
84
+ const payload = {
85
+ receipt_id: `${idPrefix}_${++counter}`,
86
+ subject: 'agent:eg1-conformance',
87
+ issuer: 'ep:org:eg1',
88
+ created_at: new Date(nowMs()).toISOString(),
89
+ claim: {
90
+ ...action,
91
+ outcome,
92
+ approver: 'ep:approver:eg1',
93
+ ...(quorum ? { quorum } : {}),
94
+ ...extra,
95
+ },
96
+ };
97
+ const value = crypto.sign(null, Buffer.from(canon(payload), 'utf8'), privateKey).toString('base64url');
98
+ const receipt = { '@version': 'EP-RECEIPT-v1', payload, signature: { algorithm: 'Ed25519', value } };
99
+ if (tamper) Object.assign(receipt.payload.claim, tamper); // tamper AFTER signing -> signature no longer binds
100
+ return receipt;
101
+ }
102
+
103
+ return { publicKey: pub, mint, action, now: nowMs };
104
+ }
105
+
106
+ /**
107
+ * Adapt an @emilia-protocol/gate instance into an EG-1 `invoke`. The gate must
108
+ * have been built trusting the harness public key. Uses gate.run() so the
109
+ * execution proof + reliance packet are produced on the allowed path.
110
+ */
111
+ export function makeGateInvoke(gate, { selector = EG1_DEFAULT_SELECTOR, action = EG1_DEFAULT_ACTION } = {}) {
112
+ if (!gate || typeof gate.run !== 'function') {
113
+ throw new Error('makeGateInvoke requires an EMILIA Gate instance (with .run)');
114
+ }
115
+ return async ({ receipt, observedAction }) => {
116
+ const out = await gate.run(
117
+ { selector, receipt, observedAction: observedAction ?? action },
118
+ async () => ({ eg1: 'side-effect-ran' }),
119
+ );
120
+ if (!out.ok) {
121
+ return { allowed: false, status: out.status, reason: out.authorization?.reason ?? 'refused' };
122
+ }
123
+ return {
124
+ allowed: true,
125
+ status: 200,
126
+ reason: out.authorization?.reason ?? 'allow',
127
+ decisionHash: out.authorization?.evidence?.hash ?? null,
128
+ execution: out.execution ?? null,
129
+ packet: out.packet ?? null,
130
+ };
131
+ };
132
+ }
133
+
134
+ const pick = (r) => ({ allowed: !!r.allowed, status: r.status ?? null, reason: r.reason ?? null });
135
+
136
+ /**
137
+ * Drive a subject through the eight EG-1 checks and return a JSON report.
138
+ * @param {object} o
139
+ * @param {(scenario:object)=>Promise<object>} o.invoke the integration under test
140
+ * @param {object} o.harness from createEg1Harness()
141
+ * @param {object} [o.action] the high-risk action (defaults to the harness action)
142
+ */
143
+ export async function runEg1({ invoke, harness, action } = {}) {
144
+ if (typeof invoke !== 'function') throw new Error('runEg1 requires an invoke(scenario) function');
145
+ if (!harness || typeof harness.mint !== 'function') throw new Error('runEg1 requires a harness from createEg1Harness()');
146
+ const act = action || harness.action || EG1_DEFAULT_ACTION;
147
+ const observed = act;
148
+ const drift = { ...act, amount_usd: Number(act.amount_usd ?? 0) + 1 };
149
+
150
+ const results = {};
151
+ const set = (id, pass, observed_) => { results[id] = { pass: !!pass, observed: observed_ }; };
152
+
153
+ // 1. missing receipt → 428
154
+ let r = await invoke({ receipt: null, observedAction: observed });
155
+ set('missing_receipt_refused', !r.allowed && r.status === 428, pick(r));
156
+
157
+ // 2. software receipt on a Class-A action → refused
158
+ r = await invoke({ receipt: harness.mint({ outcome: 'allow' }), observedAction: observed });
159
+ set('software_on_classA_refused', !r.allowed && /assurance/i.test(r.reason || ''), pick(r));
160
+
161
+ // 3. observed execution drift → refused
162
+ r = await invoke({ receipt: harness.mint({ outcome: 'allow_with_signoff' }), observedAction: drift });
163
+ set('execution_drift_refused', !r.allowed && /binding/i.test(r.reason || ''), pick(r));
164
+
165
+ // 4. valid Class-A receipt → runs (capture for 5/7/8)
166
+ const valid = harness.mint({ outcome: 'allow_with_signoff' });
167
+ r = await invoke({ receipt: valid, observedAction: observed });
168
+ const validAllowed = r.allowed === true;
169
+ set('valid_classA_runs', validAllowed, pick(r));
170
+
171
+ // 7. execution proof binds to the authorization decision
172
+ const boundOk = validAllowed
173
+ && !!r.execution && !!r.execution.authorizes_decision
174
+ && !!r.decisionHash && r.execution.authorizes_decision === r.decisionHash;
175
+ set('execution_proof_binds', boundOk, {
176
+ authorizes_decision: r.execution?.authorizes_decision ?? null,
177
+ decision_hash: r.decisionHash ?? null,
178
+ });
179
+
180
+ // 8. reliance packet verdict "rely"
181
+ set('reliance_packet_rely', validAllowed && String(r.packet?.verdict || '').toLowerCase() === 'rely', {
182
+ verdict: r.packet?.verdict ?? null,
183
+ });
184
+
185
+ // 5. same receipt replay → refused
186
+ r = await invoke({ receipt: valid, observedAction: observed });
187
+ set('replay_refused', !r.allowed && /replay/i.test(r.reason || ''), pick(r));
188
+
189
+ // 6. tampered receipt → refused
190
+ r = await invoke({ receipt: harness.mint({ outcome: 'allow_with_signoff', tamper: { amount_usd: 9_999_999 } }), observedAction: observed });
191
+ set('tampered_refused', !r.allowed && r.status === 428, pick(r));
192
+
193
+ const checks = EG1_CHECKS.map((c) => ({ id: c.id, title: c.title, ...results[c.id] }));
194
+ const passedCount = checks.filter((c) => c.pass).length;
195
+ const passed = passedCount === checks.length;
196
+ return {
197
+ standard: EG1_VERSION,
198
+ passed,
199
+ badge: passed ? 'EG-1 Enforced' : 'EG-1 not earned',
200
+ summary: { passed: passedCount, total: checks.length },
201
+ checks,
202
+ generated_at: new Date(harness.now ? harness.now() : Date.now()).toISOString(),
203
+ };
204
+ }
205
+
206
+ export default { EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR, createEg1Harness, makeGateInvoke, runEg1 };
package/eg1.mjs ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * EG-1 Conformance — runnable proof. Run: node eg1.mjs [--json]
3
+ *
4
+ * Self-certifies the reference EMILIA Gate against the eight EG-1 checks. An
5
+ * adopter earns EG-1 by pointing this harness at THEIR integration: build your
6
+ * gate trusting `harness.publicKey`, pass it to gateConformance(), and ship the
7
+ * JSON report + the "EG-1 Enforced" badge.
8
+ *
9
+ * import { createEg1Harness, gateConformance } from '@emilia-protocol/gate';
10
+ * const harness = createEg1Harness();
11
+ * const gate = createTrustedActionFirewall({ trustedKeys: [harness.publicKey] });
12
+ * const report = await gateConformance({ gate, harness });
13
+ *
14
+ * Exit code is 0 only if all eight checks pass — CI-friendly.
15
+ * @license Apache-2.0
16
+ */
17
+ import { gateConformanceSelfTest } from './index.js';
18
+
19
+ const report = await gateConformanceSelfTest();
20
+
21
+ if (process.argv.includes('--json')) {
22
+ // eslint-disable-next-line no-console
23
+ console.log(JSON.stringify(report, null, 2));
24
+ process.exit(report.passed ? 0 : 1);
25
+ }
26
+
27
+ const G = (s) => `\x1b[32m${s}\x1b[0m`;
28
+ const R = (s) => `\x1b[31m${s}\x1b[0m`;
29
+ const line = (s) => console.log(s);
30
+
31
+ line('='.repeat(64));
32
+ line(' EG-1 Conformance — does this integration ENFORCE EMILIA Gate?');
33
+ line('='.repeat(64));
34
+ for (const c of report.checks) {
35
+ line(` ${c.pass ? G('PASS') : R('FAIL')} ${c.title}`);
36
+ }
37
+ line(' ' + '-'.repeat(60));
38
+ line(` ${report.passed ? G(`✓ ${report.badge}`) : R(`✗ ${report.badge}`)} (${report.summary.passed}/${report.summary.total})`);
39
+ line('='.repeat(64));
40
+ process.exit(report.passed ? 0 : 1);
@@ -0,0 +1,98 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Execution-field binding for EMILIA Gate. This is the executor-side control
3
+ // that prevents "the signed claim said X, the system mutated Y".
4
+
5
+ import crypto from 'node:crypto';
6
+
7
+ export const EXECUTION_BINDING_VERSION = 'EP-GATE-EXECUTION-BINDING-v1';
8
+
9
+ export function canonicalize(v) {
10
+ if (v === null || v === undefined) return JSON.stringify(v);
11
+ if (Array.isArray(v)) return `[${v.map(canonicalize).join(',')}]`;
12
+ if (typeof v === 'object') {
13
+ return `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonicalize(v[k])).join(',')}}`;
14
+ }
15
+ return JSON.stringify(v);
16
+ }
17
+
18
+ export function hashCanonical(v) {
19
+ return crypto.createHash('sha256').update(canonicalize(v)).digest('hex');
20
+ }
21
+
22
+ function hasValue(v) {
23
+ return v !== undefined && v !== null;
24
+ }
25
+
26
+ function normalize(v) {
27
+ if (Array.isArray(v)) return [...new Set(v.map((x) => String(x)))].sort();
28
+ if (v && typeof v === 'object') {
29
+ return Object.fromEntries(Object.keys(v).sort().map((k) => [k, normalize(v[k])]));
30
+ }
31
+ return v;
32
+ }
33
+
34
+ function equalValue(a, b) {
35
+ return hashCanonical(normalize(a)) === hashCanonical(normalize(b));
36
+ }
37
+
38
+ export function materialFieldsFor(requirement) {
39
+ const fields = requirement?.execution_binding?.required_fields;
40
+ return Array.isArray(fields) ? [...new Set(fields.filter(Boolean))] : [];
41
+ }
42
+
43
+ /**
44
+ * Verifies that the signed claim and the executor-observed mutation fields
45
+ * match for the action pack. The executor must pass `observedAction` from the
46
+ * system of record, not from the agent request body.
47
+ */
48
+ export function verifyExecutionBinding({ requirement, receipt, observedAction } = {}) {
49
+ const requiredFields = materialFieldsFor(requirement);
50
+ if (requiredFields.length === 0) {
51
+ return { ok: true, required: false, required_fields: [], signed_hash: null, observed_hash: null };
52
+ }
53
+
54
+ const signed = receipt?.payload?.claim || {};
55
+ const observed = observedAction || {};
56
+ const missingSigned = [];
57
+ const missingObserved = [];
58
+ const mismatched = [];
59
+ const signedValues = {};
60
+ const observedValues = {};
61
+
62
+ for (const field of requiredFields) {
63
+ const expected = signed[field];
64
+ const actual = observed[field];
65
+ if (!hasValue(expected)) {
66
+ missingSigned.push(field);
67
+ continue;
68
+ }
69
+ signedValues[field] = normalize(expected);
70
+ if (!hasValue(actual)) {
71
+ missingObserved.push(field);
72
+ continue;
73
+ }
74
+ observedValues[field] = normalize(actual);
75
+ if (!equalValue(expected, actual)) mismatched.push(field);
76
+ }
77
+
78
+ return {
79
+ '@version': EXECUTION_BINDING_VERSION,
80
+ ok: missingSigned.length === 0 && missingObserved.length === 0 && mismatched.length === 0,
81
+ required: true,
82
+ required_fields: requiredFields,
83
+ missing_signed_fields: missingSigned,
84
+ missing_observed_fields: missingObserved,
85
+ mismatched_fields: mismatched,
86
+ signed_hash: hashCanonical(signedValues),
87
+ observed_hash: hashCanonical(observedValues),
88
+ note: 'Executor MUST provide observedAction from the system of record; request-body fields are not a trust source.',
89
+ };
90
+ }
91
+
92
+ export default {
93
+ EXECUTION_BINDING_VERSION,
94
+ canonicalize,
95
+ hashCanonical,
96
+ materialFieldsFor,
97
+ verifyExecutionBinding,
98
+ };
package/index.js CHANGED
@@ -21,7 +21,7 @@
21
21
  * adds the three things a firewall needs over a bare verifier: assurance-tier
22
22
  * enforcement, replay defense, and the evidence log. Fails closed.
23
23
  */
24
- import {
24
+ const {
25
25
  verifyEmiliaReceipt,
26
26
  receiptChallenge,
27
27
  receiptRequiredHeader,
@@ -29,11 +29,27 @@ import {
29
29
  findActionRequirement,
30
30
  RECEIPT_REQUIRED_STATUS,
31
31
  RECEIPT_REQUIRED_HEADER,
32
- } from '../require-receipt/index.js';
32
+ } = await import('@emilia-protocol/require-receipt').catch(() => import('../require-receipt/index.js'));
33
33
  import { MemoryConsumptionStore } from './store.js';
34
34
  import { createEvidenceLog } from './evidence.js';
35
+ import { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskManifest } from './action-packs.js';
36
+ import { hashCanonical, verifyExecutionBinding } from './execution-binding.js';
37
+ import { buildReliancePacket } from './reliance-packet.js';
38
+ import { createEg1Harness, makeGateInvoke, runEg1, EG1_DEFAULT_SELECTOR } from './eg1-conformance.js';
39
+ import { createKeyRegistry, asKeyRegistry } from './key-registry.js';
40
+ import { classifyRetention, buildRetentionExport } from './retention.js';
35
41
 
36
42
  export { MemoryConsumptionStore, createEvidenceLog };
43
+ export { createDurableConsumptionStore, createMemoryBackend } from './store.js';
44
+ export { createKeyRegistry, asKeyRegistry } from './key-registry.js';
45
+ export { classifyRetention, buildRetentionExport, RETENTION_EXPORT_VERSION } from './retention.js';
46
+ export { DEFAULT_GATE_MANIFEST, HIGH_RISK_ACTION_PACKS, createDefaultActionRiskManifest };
47
+ export { EXECUTION_BINDING_VERSION, canonicalize, hashCanonical, materialFieldsFor, verifyExecutionBinding } from './execution-binding.js';
48
+ export { RELIANCE_PACKET_VERSION, buildReliancePacket } from './reliance-packet.js';
49
+ export {
50
+ EG1_VERSION, EG1_CHECKS, EG1_DEFAULT_ACTION, EG1_DEFAULT_SELECTOR,
51
+ createEg1Harness, makeGateInvoke, runEg1,
52
+ } from './eg1-conformance.js';
37
53
  export const ASSURANCE_TIERS = ['software', 'class_a', 'quorum'];
38
54
  const TIER_RANK = { software: 0, class_a: 1, quorum: 2 };
39
55
 
@@ -64,8 +80,15 @@ export function receiptAssuranceTier(doc) {
64
80
  * @param {object} [opts.store] consumption store (default in-memory)
65
81
  * @param {object} [opts.log] evidence log (default in-memory, hash-chained)
66
82
  * @param {boolean} [opts.allowInlineKey=false] accept the receipt's own key (integrity, NOT trust)
83
+ * @param {object} [opts.keyRegistry] a key registry (createKeyRegistry) for rotation + revocation;
84
+ * if given it supersedes trustedKeys — a receipt is verified only against keys valid (and not
85
+ * revoked) at its issuance time.
67
86
  */
68
- export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now } = {}) {
87
+ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900, store, log, allowInlineKey = false, allowEphemeralStore = false, strictEvidence = true, now = Date.now, keyRegistry = null } = {}) {
88
+ // Production key custody: a registry (rotation + revocation) supersedes a flat
89
+ // trustedKeys list. A flat list is coerced to an always-valid registry, so
90
+ // existing callers are unchanged.
91
+ const registry = keyRegistry ? asKeyRegistry(keyRegistry) : (trustedKeys.length ? asKeyRegistry(trustedKeys) : null);
69
92
  if (manifest) {
70
93
  const m = validateActionRiskManifest(manifest);
71
94
  if (!m.ok) throw new Error('EMILIA Gate: invalid action-risk manifest: ' + m.errors.join('; '));
@@ -88,10 +111,11 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
88
111
  }
89
112
  const evidence = log || createEvidenceLog({ strict: strictEvidence });
90
113
 
91
- async function check({ selector = {}, receipt = null } = {}) {
114
+ async function check({ selector = {}, receipt = null, observedAction = null, consumptionMode = 'consume' } = {}) {
92
115
  const requirement = manifest ? findActionRequirement(manifest, selector) : null;
93
116
  const action = requirement?.action_type || selector.action_type || selector.action || null;
94
117
  const requiredTier = requirement?.assurance_class || selector.assurance_class || 'software';
118
+ const observed = observedAction || selector.observedAction || selector.actionDetails || null;
95
119
 
96
120
  async function decide(allow, status, reason, extra = {}) {
97
121
  const entry = {
@@ -105,6 +129,7 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
105
129
  required_tier: requiredTier,
106
130
  receipt_id: receipt?.payload?.receipt_id ?? null,
107
131
  subject: receipt?.payload?.subject ?? null,
132
+ observed_action_hash: observed ? hashCanonical(observed) : null,
108
133
  ...extra,
109
134
  };
110
135
  let record;
@@ -144,8 +169,14 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
144
169
  if (!receipt) {
145
170
  return decide(false, RECEIPT_REQUIRED_STATUS, 'receipt_required');
146
171
  }
147
- // Signature / freshness / action-binding / outcome.
148
- const v = verifyEmiliaReceipt(receipt, { trustedKeys, allowInlineKey, action, maxAgeSec });
172
+ // Signature / freshness / action-binding / outcome. Production key custody:
173
+ // resolve the issuer keys valid (and not revoked) at THIS receipt's issuance
174
+ // time. A revoked or out-of-window key is excluded, so its signature does not
175
+ // verify and the action is refused (fail closed).
176
+ const effectiveKeys = registry
177
+ ? registry.keysValidAt(receipt?.payload?.created_at)
178
+ : trustedKeys;
179
+ const v = verifyEmiliaReceipt(receipt, { trustedKeys: effectiveKeys, allowInlineKey, action, maxAgeSec });
149
180
  if (!v.ok) {
150
181
  return decide(false, RECEIPT_REQUIRED_STATUS, `receipt_rejected:${v.reason}`, { rejected: v });
151
182
  }
@@ -154,6 +185,13 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
154
185
  if ((TIER_RANK[have] ?? 0) < (TIER_RANK[requiredTier] ?? 0)) {
155
186
  return decide(false, RECEIPT_REQUIRED_STATUS, 'assurance_too_low', { have_tier: have, need_tier: requiredTier });
156
187
  }
188
+ // The high-risk action packs define material fields that must be observed
189
+ // by the executor from the system of record. A signed, harmless-looking
190
+ // claim cannot authorize a different real mutation.
191
+ const executionBinding = verifyExecutionBinding({ requirement, receipt, observedAction: observed });
192
+ if (!executionBinding.ok) {
193
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'execution_binding_failed', { execution_binding: executionBinding, have_tier: have });
194
+ }
157
195
  // One-time consumption (replay defense). Require a stable, issuer-generated
158
196
  // receipt_id — never fall back to a content hash, whose canonicalization can
159
197
  // differ across language implementations and silently break replay detection
@@ -162,11 +200,21 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
162
200
  if (!receiptId) {
163
201
  return decide(false, RECEIPT_REQUIRED_STATUS, 'receipt_rejected:missing_receipt_id');
164
202
  }
165
- const fresh = await consumption.consume(receiptId);
203
+ let fresh;
204
+ if (consumptionMode === 'reserve') {
205
+ if (typeof consumption.reserve !== 'function') {
206
+ return decide(false, RECEIPT_REQUIRED_STATUS, 'consumption_store_lacks_reserve', { consumption_key: receiptId });
207
+ }
208
+ fresh = await consumption.reserve(receiptId);
209
+ } else if (consumptionMode === 'none') {
210
+ fresh = true;
211
+ } else {
212
+ fresh = await consumption.consume(receiptId);
213
+ }
166
214
  if (!fresh) {
167
215
  return decide(false, RECEIPT_REQUIRED_STATUS, 'replay_refused', { consumption_key: receiptId });
168
216
  }
169
- return decide(true, 200, 'allow', { signer: v.signer, outcome: v.outcome, have_tier: have });
217
+ return decide(true, 200, 'allow', { signer: v.signer, outcome: v.outcome, have_tier: have, execution_binding: executionBinding, consumption_mode: consumptionMode });
170
218
  }
171
219
 
172
220
  /** Express/Connect middleware: refuse the route unless a sufficient receipt is present. */
@@ -180,7 +228,10 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
180
228
  const hdr = req.headers?.['x-emilia-receipt'];
181
229
  if (hdr) { try { doc = JSON.parse(Buffer.from(hdr, 'base64').toString('utf8')); } catch { /* fallthrough */ } }
182
230
  if (!doc && req.body?.emilia_receipt) doc = req.body.emilia_receipt;
183
- const out = await check({ selector, receipt: doc });
231
+ const observedAction = typeof opts.observedAction === 'function'
232
+ ? opts.observedAction(req)
233
+ : (opts.observedAction || req.emiliaObservedAction || null);
234
+ const out = await check({ selector, receipt: doc, observedAction });
184
235
  if (!out.allow) {
185
236
  res.setHeader(RECEIPT_REQUIRED_HEADER, out.header);
186
237
  return res.status(out.status).json(out.challenge);
@@ -196,7 +247,7 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
196
247
  * commits to the exact authorization decision (`authorizes_decision` = that
197
248
  * decision's evidence hash), so authorization and execution are one chain.
198
249
  */
199
- async function recordExecution({ authorization, outcome = 'executed', detail } = {}) {
250
+ async function recordExecution({ authorization, outcome = 'executed', detail, observedAction = null, executionBinding = null } = {}) {
200
251
  const auth = authorization?.evidence || authorization || {};
201
252
  return evidence.record({
202
253
  kind: 'execution',
@@ -205,10 +256,47 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
205
256
  action: authorization?.action ?? auth.action ?? null,
206
257
  receipt_id: auth.receipt_id ?? null,
207
258
  outcome, // 'executed' | 'failed'
259
+ observed_action_hash: observedAction ? hashCanonical(observedAction) : null,
260
+ execution_binding: executionBinding || authorization?.evidence?.execution_binding || authorization?.execution_binding || null,
208
261
  ...(detail !== undefined ? { detail } : {}),
209
262
  });
210
263
  }
211
264
 
265
+ /**
266
+ * Recommended end-to-end path. Reserves the receipt, runs the side effect,
267
+ * commits one-time consumption only after success, and records execution.
268
+ * If the side effect throws, the reservation is released so the approval can
269
+ * be retried safely.
270
+ */
271
+ async function run({ selector = {}, receipt = null, observedAction = null } = {}, fn, opts = {}) {
272
+ if (typeof fn !== 'function') throw new Error('EMILIA Gate run(): fn is required');
273
+ const authorization = await check({ selector, receipt, observedAction, consumptionMode: 'reserve' });
274
+ if (!authorization.allow) {
275
+ return { ok: false, status: authorization.status, body: authorization.challenge, authorization };
276
+ }
277
+ const receiptId = authorization.evidence?.receipt_id;
278
+ let actionRan = false;
279
+ try {
280
+ const result = await fn(authorization);
281
+ actionRan = true;
282
+ if (typeof consumption.commit === 'function') await consumption.commit(receiptId);
283
+ if (opts.recordExecution === false) return { ok: true, result, authorization, execution: null, packet: null };
284
+ const execution = await recordExecution({ authorization, outcome: 'executed', observedAction });
285
+ return { ok: true, result, authorization, execution, packet: reliancePacket({ authorization, execution }) };
286
+ } catch (e) {
287
+ if (!actionRan && typeof consumption.release === 'function') await consumption.release(receiptId);
288
+ if (opts.recordExecution !== false) {
289
+ await recordExecution({
290
+ authorization,
291
+ outcome: 'failed',
292
+ detail: actionRan ? `post_execution_failure:${String(e?.message ?? e)}` : String(e?.message ?? e),
293
+ observedAction,
294
+ });
295
+ }
296
+ throw e;
297
+ }
298
+ }
299
+
212
300
  /**
213
301
  * Wrap any function so it runs only behind a passing gate check, and (unless
214
302
  * disabled) emits an execution receipt after it runs — the full firewall loop:
@@ -218,26 +306,97 @@ export function createGate({ manifest = null, trustedKeys = [], maxAgeSec = 900,
218
306
  return async function guarded(...args) {
219
307
  const selector = typeof opts.selector === 'function' ? opts.selector(...args) : (opts.selector || {});
220
308
  const receipt = typeof opts.receipt === 'function' ? opts.receipt(...args) : (opts.receipt ?? null);
221
- const out = await check({ selector, receipt });
222
- if (!out.allow) {
223
- const e = new Error(`EMILIA Gate refused (${out.reason})`);
309
+ const observedAction = typeof opts.observedAction === 'function'
310
+ ? opts.observedAction(...args)
311
+ : (opts.observedAction || selector.observedAction || null);
312
+ const out = await run({ selector, receipt, observedAction }, () => fn(...args), { recordExecution: opts.recordExecution });
313
+ if (!out.ok) {
314
+ const e = new Error(`EMILIA Gate refused (${out.authorization.reason})`);
224
315
  e.code = 'EMILIA_RECEIPT_REQUIRED';
225
- e.gate = out;
226
- throw e;
227
- }
228
- if (opts.recordExecution === false) return fn(...args);
229
- try {
230
- const result = await fn(...args);
231
- await recordExecution({ authorization: out, outcome: 'executed' });
232
- return result;
233
- } catch (e) {
234
- await recordExecution({ authorization: out, outcome: 'failed', detail: String(e?.message ?? e) });
316
+ e.gate = out.authorization;
235
317
  throw e;
236
318
  }
319
+ return out.result;
237
320
  };
238
321
  }
239
322
 
240
- return { check, recordExecution, middleware, guard, evidence, store: consumption };
323
+ function reliancePacket({ authorization, execution = null, binding = null } = {}) {
324
+ return buildReliancePacket({
325
+ decision: authorization,
326
+ execution,
327
+ evidence,
328
+ manifest,
329
+ binding,
330
+ });
331
+ }
332
+
333
+ /** Retention classification over this gate's evidence log (hot/cold/expired/legal-hold). */
334
+ function retention(opts = {}) {
335
+ return classifyRetention(evidence.all(), opts);
336
+ }
337
+ /** The auditor/SIEM export manifest for this gate's evidence log. */
338
+ function retentionExport(opts = {}) {
339
+ return buildRetentionExport(evidence.all(), opts);
340
+ }
341
+
342
+ return {
343
+ check, run, recordExecution, middleware, guard, reliancePacket, evidence,
344
+ store: consumption, keyRegistry: registry, retention, retentionExport,
345
+ };
346
+ }
347
+
348
+ export function createTrustedActionFirewall(opts = {}) {
349
+ const { manifest = createDefaultActionRiskManifest(), ...rest } = opts;
350
+ return createGate({ ...rest, manifest });
351
+ }
352
+
353
+ /**
354
+ * EG-1 conformance for an existing gate. The gate MUST have been built trusting
355
+ * `harness.publicKey` (otherwise every valid receipt is rejected and the gate
356
+ * cannot earn EG-1). Returns the EG-1 JSON report.
357
+ * @param {object} o
358
+ * @param {object} o.gate an EMILIA Gate (createGate/createTrustedActionFirewall)
359
+ * @param {object} o.harness the harness whose key the gate trusts (createEg1Harness)
360
+ * @param {object} [o.action] the high-risk action to exercise
361
+ * @param {object} [o.selector] the manifest selector for that action
362
+ */
363
+ export async function gateConformance({ gate, harness, action, selector = EG1_DEFAULT_SELECTOR } = {}) {
364
+ if (!gate || typeof gate.run !== 'function') {
365
+ throw new Error('gateConformance requires a gate built trusting harness.publicKey');
366
+ }
367
+ if (!harness) throw new Error('gateConformance requires the harness whose key the gate trusts');
368
+ const act = action || harness.action;
369
+ const invoke = makeGateInvoke(gate, { selector, action: act });
370
+ return runEg1({ invoke, harness, action: act });
371
+ }
372
+
373
+ /**
374
+ * Self-certify the reference gate: build a default Trusted Action Firewall that
375
+ * trusts a fresh EG-1 harness key, then run all eight checks. This is the
376
+ * canonical "EMILIA Gate earns EG-1" proof — runnable as a CLI (`eg1.mjs`),
377
+ * shown on /gate, and the template an adopter copies for their integration.
378
+ */
379
+ export async function gateConformanceSelfTest({ now } = {}) {
380
+ const harness = createEg1Harness({ now });
381
+ const gate = createTrustedActionFirewall({ trustedKeys: [harness.publicKey], now });
382
+ return gateConformance({ gate, harness });
241
383
  }
242
384
 
243
- export default { createGate, receiptAssuranceTier, MemoryConsumptionStore, createEvidenceLog, ASSURANCE_TIERS };
385
+ export default {
386
+ createGate,
387
+ createTrustedActionFirewall,
388
+ receiptAssuranceTier,
389
+ MemoryConsumptionStore,
390
+ createEvidenceLog,
391
+ ASSURANCE_TIERS,
392
+ DEFAULT_GATE_MANIFEST,
393
+ HIGH_RISK_ACTION_PACKS,
394
+ gateConformance,
395
+ gateConformanceSelfTest,
396
+ createEg1Harness,
397
+ runEg1,
398
+ createKeyRegistry,
399
+ asKeyRegistry,
400
+ classifyRetention,
401
+ buildRetentionExport,
402
+ };