@emilia-protocol/gate 0.1.0 → 0.7.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.
package/retention.js ADDED
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EMILIA Gate — evidence retention policy (production audit custody).
4
+ *
5
+ * The evidence log is the compliance/insurance artifact. Production custody adds
6
+ * a retention POLICY over it: classify each decision/execution record as HOT
7
+ * (recent, fast access), COLD (older, archival), or EXPIRED (past the retention
8
+ * horizon, eligible for deletion) — and honor a LEGAL HOLD that pins records so
9
+ * they are never expired. `EP_AUDIT_HOT_DAYS` / `EP_AUDIT_COLD_DAYS` set the
10
+ * horizons; legal hold is a set of evidence hashes.
11
+ *
12
+ * Pure functions over the evidence entries (each has `.at` ISO and `.hash`); the
13
+ * gate never deletes anything itself — it tells the operator what is eligible.
14
+ */
15
+
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+
18
+ function ageDays(atISO, nowMs) {
19
+ const t = Date.parse(atISO);
20
+ if (!Number.isFinite(t)) return null;
21
+ return (nowMs - t) / DAY_MS;
22
+ }
23
+
24
+ /**
25
+ * Classify evidence entries into retention buckets.
26
+ * @param {Array<{at:string, hash?:string}>} entries evidence.all()
27
+ * @param {object} o
28
+ * @param {number} [o.hotDays=365]
29
+ * @param {number} [o.coldDays=2190] (6y)
30
+ * @param {number} [o.now=Date.now()]
31
+ * @param {Set<string>|string[]} [o.legalHold] hashes pinned indefinitely
32
+ * @returns {{hot:object[], cold:object[], expired:object[], legal_hold:object[], unknown:object[], summary:object}}
33
+ */
34
+ export function classifyRetention(entries = [], {
35
+ hotDays = 365, coldDays = 2190, now = Date.now(), legalHold,
36
+ } = {}) {
37
+ const held = legalHold instanceof Set ? legalHold : new Set(legalHold || []);
38
+ const buckets = { hot: [], cold: [], expired: [], legal_hold: [], unknown: [] };
39
+ for (const e of entries) {
40
+ const tagged = { hash: e.hash ?? null, at: e.at ?? null, kind: e.kind ?? null };
41
+ if (e.hash && held.has(e.hash)) { buckets.legal_hold.push(tagged); continue; }
42
+ const age = ageDays(e.at, now);
43
+ if (age == null) { buckets.unknown.push(tagged); continue; }
44
+ if (age <= hotDays) buckets.hot.push(tagged);
45
+ else if (age <= coldDays) buckets.cold.push(tagged);
46
+ else buckets.expired.push(tagged);
47
+ }
48
+ return {
49
+ ...buckets,
50
+ summary: {
51
+ total: entries.length,
52
+ hot: buckets.hot.length,
53
+ cold: buckets.cold.length,
54
+ expired: buckets.expired.length,
55
+ legal_hold: buckets.legal_hold.length,
56
+ unknown: buckets.unknown.length,
57
+ hot_days: hotDays,
58
+ cold_days: coldDays,
59
+ },
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Build an export manifest (the artifact handed to an auditor / SIEM). Includes
65
+ * the evidence head so the export is verifiably tied to a chain state.
66
+ */
67
+ export function buildRetentionExport(entries = [], opts = {}) {
68
+ const cls = classifyRetention(entries, opts);
69
+ const last = entries[entries.length - 1] || null;
70
+ return {
71
+ '@version': 'EP-GATE-RETENTION-EXPORT-v1',
72
+ generated_at: new Date(opts.now || Date.now()).toISOString(),
73
+ hot_days: cls.summary.hot_days,
74
+ cold_days: cls.summary.cold_days,
75
+ evidence_head: last?.hash ?? null,
76
+ counts: {
77
+ total: cls.summary.total, hot: cls.summary.hot, cold: cls.summary.cold,
78
+ expired: cls.summary.expired, legal_hold: cls.summary.legal_hold, unknown: cls.summary.unknown,
79
+ },
80
+ entries: entries.map((e) => ({ hash: e.hash ?? null, at: e.at ?? null, kind: e.kind ?? null })),
81
+ };
82
+ }
83
+
84
+ export const RETENTION_EXPORT_VERSION = 'EP-GATE-RETENTION-EXPORT-v1';
85
+ export default { classifyRetention, buildRetentionExport, RETENTION_EXPORT_VERSION };
package/store.js CHANGED
@@ -10,17 +10,38 @@
10
10
  export class MemoryConsumptionStore {
11
11
  constructor() {
12
12
  this.seen = new Set();
13
+ this.reserved = new Set();
13
14
  }
14
15
 
15
16
  /** Returns true the FIRST time a key is seen, false on every replay. */
16
17
  async consume(key) {
17
- if (this.seen.has(key)) return false;
18
+ if (this.seen.has(key) || this.reserved.has(key)) return false;
18
19
  this.seen.add(key);
19
20
  return true;
20
21
  }
21
22
 
23
+ /** Reserve an id while an action is in flight; blocks concurrent replay. */
24
+ async reserve(key) {
25
+ if (this.seen.has(key) || this.reserved.has(key)) return false;
26
+ this.reserved.add(key);
27
+ return true;
28
+ }
29
+
30
+ /** Commit a reserved id after the action succeeds. */
31
+ async commit(key) {
32
+ this.reserved.delete(key);
33
+ this.seen.add(key);
34
+ return true;
35
+ }
36
+
37
+ /** Release a reserved id after the action fails; approval stays retryable. */
38
+ async release(key) {
39
+ this.reserved.delete(key);
40
+ return true;
41
+ }
42
+
22
43
  async has(key) {
23
- return this.seen.has(key);
44
+ return this.seen.has(key) || this.reserved.has(key);
24
45
  }
25
46
 
26
47
  get size() {
@@ -28,4 +49,55 @@ export class MemoryConsumptionStore {
28
49
  }
29
50
  }
30
51
 
31
- export default { MemoryConsumptionStore };
52
+ /**
53
+ * Production custody for replay defense: a durable consumption store backed by
54
+ * any shared key-value backend (Redis, Postgres, DynamoDB, ...), so a receipt
55
+ * consumed on one pod/lambda cannot be replayed on another.
56
+ *
57
+ * The backend MUST provide an ATOMIC insert-if-absent — this is the single
58
+ * correctness primitive that makes replay defense sound under concurrency:
59
+ *
60
+ * backend = {
61
+ * async addIfAbsent(key, value): boolean // true iff it inserted (Redis SET NX,
62
+ * // Postgres INSERT .. ON CONFLICT DO NOTHING)
63
+ * async set(key, value): void // overwrite
64
+ * async delete(key): void
65
+ * async has(key): boolean
66
+ * }
67
+ *
68
+ * State per receipt id is a single key: 'reserved' while in flight, 'committed'
69
+ * once the action succeeded. reserve() is the atomic gate; a second reserve()
70
+ * (concurrent replay) loses the race and is refused. Optional `ttlSeconds` is
71
+ * passed through to the backend so consumed ids can expire after the receipt's
72
+ * own max age (the gate already rejects stale receipts on freshness).
73
+ */
74
+ export function createDurableConsumptionStore(backend, { ttlSeconds } = {}) {
75
+ for (const m of ['addIfAbsent', 'set', 'delete', 'has']) {
76
+ if (typeof backend?.[m] !== 'function') {
77
+ throw new Error(`createDurableConsumptionStore: backend must implement async ${m}(). `
78
+ + 'addIfAbsent MUST be atomic (e.g. Redis SET NX) or replay defense is not fleet-safe.');
79
+ }
80
+ }
81
+ const opt = ttlSeconds ? { ttlSeconds } : undefined;
82
+ return {
83
+ async reserve(key) { return (await backend.addIfAbsent(key, 'reserved', opt)) === true; },
84
+ async commit(key) { await backend.set(key, 'committed', opt); return true; },
85
+ async release(key) { await backend.delete(key); return true; },
86
+ async consume(key) { return (await backend.addIfAbsent(key, 'committed', opt)) === true; },
87
+ async has(key) { return (await backend.has(key)) === true; },
88
+ };
89
+ }
90
+
91
+ /** In-memory reference backend (for tests/single-process). addIfAbsent is atomic in a single thread. */
92
+ export function createMemoryBackend() {
93
+ const map = new Map();
94
+ return {
95
+ async addIfAbsent(key, value) { if (map.has(key)) return false; map.set(key, value); return true; },
96
+ async set(key, value) { map.set(key, value); },
97
+ async delete(key) { map.delete(key); },
98
+ async has(key) { return map.has(key); },
99
+ get size() { return map.size; },
100
+ };
101
+ }
102
+
103
+ export default { MemoryConsumptionStore, createDurableConsumptionStore, createMemoryBackend };