@synoi/gateway-lite 0.1.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,801 @@
1
+ "use strict";
2
+ /**
3
+ * receipt-store.ts
4
+ *
5
+ * Persists Decision Receipts and a lightweight action log to SQLite so the
6
+ * dashboard can show them. Separate from the core EdgeStore / Evidence Journal
7
+ * (which live in synoi-memory) — this is the gateway's own observable surface.
8
+ *
9
+ * Schema:
10
+ * receipts — one row per Sentinel decision
11
+ * action_log — one row per gateway request (shadow + enforce)
12
+ * policy_suggestions — auto-classification suggestions per tenant+action_type
13
+ */
14
+ var __importDefault = (this && this.__importDefault) || function (mod) {
15
+ return (mod && mod.__esModule) ? mod : { "default": mod };
16
+ };
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.receiptStore = exports.ReceiptStore = void 0;
19
+ const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
20
+ const path_1 = __importDefault(require("path"));
21
+ const os_1 = __importDefault(require("os"));
22
+ const fs_1 = __importDefault(require("fs"));
23
+ // ─── ReceiptStore ─────────────────────────────────────────────────────────────
24
+ class ReceiptStore {
25
+ db;
26
+ constructor(dataDir) {
27
+ fs_1.default.mkdirSync(dataDir, { recursive: true });
28
+ this.db = new better_sqlite3_1.default(path_1.default.join(dataDir, 'receipts.db'));
29
+ this.db.pragma('journal_mode = WAL');
30
+ this.migrate();
31
+ }
32
+ migrate() {
33
+ this.db.exec(`
34
+ CREATE TABLE IF NOT EXISTS receipts (
35
+ receipt_id TEXT PRIMARY KEY,
36
+ intent_id TEXT NOT NULL,
37
+ tenant_id TEXT NOT NULL,
38
+ decision TEXT NOT NULL,
39
+ action_class TEXT NOT NULL,
40
+ risk_level TEXT NOT NULL,
41
+ action_type TEXT NOT NULL,
42
+ action_desc TEXT NOT NULL,
43
+ oid_hex TEXT NOT NULL,
44
+ provider TEXT NOT NULL DEFAULT '',
45
+ pts_tier INTEGER NOT NULL DEFAULT 0,
46
+ latency_ms INTEGER NOT NULL DEFAULT 0,
47
+ mode TEXT NOT NULL DEFAULT 'observe',
48
+ signature TEXT NOT NULL DEFAULT '',
49
+ signer_key_id TEXT NOT NULL DEFAULT '',
50
+ policy_refs TEXT NOT NULL DEFAULT '[]',
51
+ recorded_at INTEGER NOT NULL
52
+ );
53
+ CREATE INDEX IF NOT EXISTS idx_receipts_tenant
54
+ ON receipts(tenant_id, recorded_at DESC);
55
+ CREATE INDEX IF NOT EXISTS idx_receipts_decision
56
+ ON receipts(tenant_id, decision, recorded_at DESC);
57
+
58
+ CREATE TABLE IF NOT EXISTS action_log (
59
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
60
+ tenant_id TEXT NOT NULL,
61
+ action_type TEXT NOT NULL,
62
+ risk_level TEXT NOT NULL,
63
+ decision TEXT NOT NULL,
64
+ receipt_id TEXT NOT NULL,
65
+ oid_hex TEXT NOT NULL,
66
+ savings_usd REAL NOT NULL DEFAULT 0,
67
+ recorded_at INTEGER NOT NULL
68
+ );
69
+ CREATE INDEX IF NOT EXISTS idx_action_log_tenant
70
+ ON action_log(tenant_id, recorded_at DESC);
71
+
72
+ CREATE TABLE IF NOT EXISTS policy_suggestions (
73
+ tenant_id TEXT NOT NULL,
74
+ action_type TEXT NOT NULL,
75
+ suggested_class TEXT NOT NULL,
76
+ confidence REAL NOT NULL DEFAULT 0,
77
+ sample_count INTEGER NOT NULL DEFAULT 0,
78
+ example_desc TEXT NOT NULL DEFAULT '',
79
+ last_seen INTEGER NOT NULL,
80
+ PRIMARY KEY (tenant_id, action_type)
81
+ );
82
+
83
+ CREATE TABLE IF NOT EXISTS human_approvals (
84
+ approval_id TEXT PRIMARY KEY,
85
+ receipt_id TEXT NOT NULL,
86
+ tenant_id TEXT NOT NULL,
87
+ approved_by TEXT NOT NULL,
88
+ note TEXT NOT NULL DEFAULT '',
89
+ expires_at INTEGER,
90
+ approved_at INTEGER NOT NULL
91
+ );
92
+ CREATE INDEX IF NOT EXISTS idx_approvals_receipt
93
+ ON human_approvals(receipt_id);
94
+ CREATE INDEX IF NOT EXISTS idx_approvals_tenant
95
+ ON human_approvals(tenant_id, approved_at DESC);
96
+ `);
97
+ // Idempotent migrations for columns added after initial schema
98
+ try {
99
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN parent_receipt_id TEXT`);
100
+ }
101
+ catch { /* already exists */ }
102
+ // Sprint 1 (2026-05-19) — informational SRAID OID + manifest hash
103
+ try {
104
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN sraid_oid_hex TEXT`);
105
+ }
106
+ catch { }
107
+ try {
108
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN gateway_manifest_sha256 TEXT`);
109
+ }
110
+ catch { }
111
+ try {
112
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN degraded INTEGER`);
113
+ }
114
+ catch { }
115
+ try {
116
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN degraded_reason TEXT`);
117
+ }
118
+ catch { }
119
+ // Sprint 3 (2026-05-19) — anchor metadata. Receipts get a leaf_hash
120
+ // computed at signing time; the anchor worker batches unanchored
121
+ // receipts every 10 min, builds a Merkle root, and writes back
122
+ // batch_id + merkle_proof_json + anchor_* columns.
123
+ try {
124
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN leaf_hash TEXT`);
125
+ }
126
+ catch { }
127
+ try {
128
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN batch_id TEXT`);
129
+ }
130
+ catch { }
131
+ try {
132
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN merkle_proof_json TEXT`);
133
+ }
134
+ catch { }
135
+ try {
136
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN anchor_status TEXT`);
137
+ }
138
+ catch { } // 'pending' | 'submitted' | 'confirmed' | 'failed'
139
+ try {
140
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN anchored_at INTEGER`);
141
+ }
142
+ catch { }
143
+ try {
144
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_receipts_unanchored ON receipts(anchor_status, recorded_at) WHERE anchor_status IS NULL OR anchor_status = 'pending'`);
145
+ }
146
+ catch { }
147
+ // Compliance pack tagging (2026-05-19) — receipts carry a JSON array
148
+ // of compliance-subcategory tags (e.g. ['NIST.MEASURE-2.6',
149
+ // 'NIST.MANAGE-4.1', 'EU.Article12']). Auditors filter receipts by
150
+ // tag to assemble per-framework evidence bundles. Derivation rules
151
+ // live in src/compliance-tagger.ts.
152
+ try {
153
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN compliance_tags TEXT`);
154
+ }
155
+ catch { }
156
+ // Sprint 11 (2026-05-20) — post-quantum hybrid signature. Receipts
157
+ // produced from this version forward carry BOTH the Ed25519
158
+ // signature (signature column above) AND an ML-DSA-65 (FIPS 204)
159
+ // signature in this column, over the same canonical bytes. NULL on
160
+ // legacy receipts; the /verify endpoint falls back to Ed25519-only
161
+ // verification when this is NULL.
162
+ try {
163
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN ml_dsa_signature TEXT`);
164
+ }
165
+ catch { }
166
+ // T14 (#274) — Authority context columns. All optional; NULL when the
167
+ // gateway's direct-inference path ran without an GAP grant, GAP invocation,
168
+ // or matched risk-policy rule. When present, these values are included in
169
+ // the SIGNED canonical payload so tampering any authority field is
170
+ // cryptographically detectable.
171
+ try {
172
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN grant_oid TEXT`);
173
+ }
174
+ catch { }
175
+ try {
176
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN intent_oid TEXT`);
177
+ }
178
+ catch { }
179
+ try {
180
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN rule_id TEXT`);
181
+ }
182
+ catch { }
183
+ try {
184
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN subject_oid TEXT`);
185
+ }
186
+ catch { }
187
+ // #101 (Step 1) -- four governance-sensing fields. All signed.
188
+ // session_id: GAP actor_session_id; null on direct-inference path.
189
+ // policy_versions: JSON array of {id,version} tuples for active packs.
190
+ // action_payload_hash: SHA-256 hex of action payload; null when absent.
191
+ // sequence: per-tenant monotonic counter from tenant_sequences table.
192
+ try {
193
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN session_id TEXT`);
194
+ }
195
+ catch { }
196
+ try {
197
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN policy_versions TEXT`);
198
+ }
199
+ catch { }
200
+ try {
201
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN action_payload_hash TEXT`);
202
+ }
203
+ catch { }
204
+ try {
205
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN sequence INTEGER`);
206
+ }
207
+ catch { }
208
+ // #124 (Step 4) -- authorized-axis correlation key fields. All signed.
209
+ // target_id: v0 = pack_id for gateway policy packs; null on direct-inference.
210
+ // target_class: category of authorized target; null on direct-inference.
211
+ // state_sha256: SHA-256 of canonicalPolicyState at authorization time; null in v0.
212
+ try {
213
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN target_id TEXT`);
214
+ }
215
+ catch { }
216
+ try {
217
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN target_class TEXT`);
218
+ }
219
+ catch { }
220
+ try {
221
+ this.db.exec(`ALTER TABLE receipts ADD COLUMN state_sha256 TEXT`);
222
+ }
223
+ catch { }
224
+ // #124 -- index supporting getLatestAllowingReceiptForTarget.
225
+ // Idempotent; runs after the ADD COLUMN above.
226
+ try {
227
+ this.db.exec(`CREATE INDEX IF NOT EXISTS idx_receipts_target ON receipts(tenant_id, target_id, decision, recorded_at DESC)`);
228
+ }
229
+ catch { }
230
+ // Sequence counter table: one row per tenant. The counter is incremented
231
+ // atomically inside the same SQLite write transaction as the receipt row.
232
+ // Using SQLite's ROWID-backed INTEGER PRIMARY KEY guarantees monotonicity
233
+ // within the single-writer WAL journal without external locking.
234
+ this.db.exec(`
235
+ CREATE TABLE IF NOT EXISTS tenant_sequences (
236
+ tenant_id TEXT PRIMARY KEY,
237
+ seq INTEGER NOT NULL DEFAULT 0
238
+ )
239
+ `);
240
+ // Anchor batch table — one row per Merkle batch.
241
+ this.db.exec(`
242
+ CREATE TABLE IF NOT EXISTS anchor_batches (
243
+ batch_id TEXT PRIMARY KEY, -- random 16-byte hex
244
+ merkle_root TEXT NOT NULL, -- 32-byte hex
245
+ leaf_count INTEGER NOT NULL,
246
+ created_at INTEGER NOT NULL,
247
+ ots_proof TEXT, -- OpenTimestamps OTS bytes, base64 (NULL until submitted)
248
+ ots_submitted_at INTEGER,
249
+ ots_upgraded_at INTEGER, -- when Bitcoin attestation became available
250
+ rekor_log_id TEXT, -- sigstore Rekor log entry id (future)
251
+ rekor_log_index INTEGER,
252
+ rekor_at INTEGER,
253
+ notes TEXT
254
+ );
255
+ CREATE INDEX IF NOT EXISTS idx_anchor_batches_created ON anchor_batches(created_at DESC);
256
+ `);
257
+ // Sprint 5 — SynOI counter-signature on each batch.
258
+ // Gateway submits batch_root to control plane BEFORE OpenTimestamps;
259
+ // SynOI cross-validates the install's integrity posture and signs a
260
+ // canonical bundle. Both the SynOI signature AND the OTS proof end
261
+ // up on the same anchor_batches row.
262
+ try {
263
+ this.db.exec(`ALTER TABLE anchor_batches ADD COLUMN synoi_signature TEXT`);
264
+ }
265
+ catch { }
266
+ try {
267
+ this.db.exec(`ALTER TABLE anchor_batches ADD COLUMN synoi_signer_key_id TEXT`);
268
+ }
269
+ catch { }
270
+ try {
271
+ this.db.exec(`ALTER TABLE anchor_batches ADD COLUMN synoi_status TEXT`);
272
+ }
273
+ catch { } // 'trusted' | 'flagged'
274
+ try {
275
+ this.db.exec(`ALTER TABLE anchor_batches ADD COLUMN synoi_reason TEXT`);
276
+ }
277
+ catch { }
278
+ try {
279
+ this.db.exec(`ALTER TABLE anchor_batches ADD COLUMN synoi_signed_at INTEGER`);
280
+ }
281
+ catch { }
282
+ try {
283
+ this.db.exec(`ALTER TABLE anchor_batches ADD COLUMN synoi_canonical_bundle TEXT`);
284
+ }
285
+ catch { }
286
+ // N16 Phase 1: human_approvals is append-only.
287
+ //
288
+ // An approval record must never be modified or deleted after it is written —
289
+ // the Class C gate in isApproved() trusts its presence as immutable evidence.
290
+ // The triggers below enforce this at the SQLite layer so the invariant holds
291
+ // regardless of which code path touches the table.
292
+ this.db.exec(`
293
+ CREATE TRIGGER IF NOT EXISTS trg_approvals_no_update
294
+ BEFORE UPDATE ON human_approvals
295
+ BEGIN
296
+ SELECT RAISE(ABORT,'human_approvals are append-only');
297
+ END;
298
+
299
+ CREATE TRIGGER IF NOT EXISTS trg_approvals_no_delete
300
+ BEFORE DELETE ON human_approvals
301
+ BEGIN
302
+ SELECT RAISE(ABORT,'human_approvals are append-only');
303
+ END;
304
+ `);
305
+ }
306
+ /**
307
+ * Atomically increment and return the next sequence number for a tenant.
308
+ * Uses an INSERT OR REPLACE so the first call for a new tenant starts at 1.
309
+ * Called inside putReceipt; callers never need to call this directly.
310
+ */
311
+ nextSequence(tenant_id) {
312
+ this.db.prepare(`
313
+ INSERT INTO tenant_sequences (tenant_id, seq)
314
+ VALUES (@tenant_id, 1)
315
+ ON CONFLICT(tenant_id) DO UPDATE SET seq = seq + 1
316
+ `).run({ tenant_id });
317
+ const row = this.db.prepare(`SELECT seq FROM tenant_sequences WHERE tenant_id = ?`).get(tenant_id);
318
+ return row.seq;
319
+ }
320
+ putReceipt(r) {
321
+ // Sequence is NOT auto-assigned here. The caller is responsible for
322
+ // pre-fetching the sequence via nextSequence() and including it in both
323
+ // the signed canonical payload AND the StoredReceipt before calling
324
+ // putReceipt. This ensures the stored sequence always matches what was
325
+ // signed; silent auto-assignment here would mutate the canonical bytes
326
+ // after signing and break verification for legacy receipts or any path
327
+ // that was signed without a sequence. Only signAndStoreAnthropic (and
328
+ // equivalent signers for new paths) should call nextSequence.
329
+ const sequence = r.sequence ?? null;
330
+ this.db.prepare(`
331
+ INSERT OR REPLACE INTO receipts
332
+ (receipt_id, intent_id, tenant_id, decision, action_class, risk_level,
333
+ action_type, action_desc, oid_hex, provider, pts_tier, latency_ms,
334
+ mode, signature, signer_key_id, policy_refs, recorded_at, parent_receipt_id,
335
+ sraid_oid_hex, gateway_manifest_sha256, degraded, degraded_reason,
336
+ compliance_tags, ml_dsa_signature,
337
+ grant_oid, intent_oid, rule_id, subject_oid,
338
+ session_id, policy_versions, action_payload_hash, sequence,
339
+ target_id, target_class, state_sha256)
340
+ VALUES
341
+ (@receipt_id, @intent_id, @tenant_id, @decision, @action_class, @risk_level,
342
+ @action_type, @action_desc, @oid_hex, @provider, @pts_tier, @latency_ms,
343
+ @mode, @signature, @signer_key_id, @policy_refs, @recorded_at, @parent_receipt_id,
344
+ @sraid_oid_hex, @gateway_manifest_sha256, @degraded, @degraded_reason,
345
+ @compliance_tags, @ml_dsa_signature,
346
+ @grant_oid, @intent_oid, @rule_id, @subject_oid,
347
+ @session_id, @policy_versions, @action_payload_hash, @sequence,
348
+ @target_id, @target_class, @state_sha256)
349
+ `).run({
350
+ ...r,
351
+ parent_receipt_id: r.parent_receipt_id ?? null,
352
+ sraid_oid_hex: r.sraid_oid_hex ?? null,
353
+ gateway_manifest_sha256: r.gateway_manifest_sha256 ?? null,
354
+ degraded: r.degraded ? 1 : 0,
355
+ degraded_reason: r.degraded_reason ?? null,
356
+ compliance_tags: r.compliance_tags ?? null,
357
+ ml_dsa_signature: r.ml_dsa_signature ?? null,
358
+ grant_oid: r.grant_oid ?? null,
359
+ intent_oid: r.intent_oid ?? null,
360
+ rule_id: r.rule_id ?? null,
361
+ subject_oid: r.subject_oid ?? null,
362
+ session_id: r.session_id ?? null,
363
+ policy_versions: r.policy_versions ?? null,
364
+ action_payload_hash: r.action_payload_hash ?? null,
365
+ sequence,
366
+ // #124 -- correlation key fields
367
+ target_id: r.target_id ?? null,
368
+ target_class: r.target_class ?? null,
369
+ state_sha256: r.state_sha256 ?? null,
370
+ });
371
+ this.db.prepare(`
372
+ INSERT INTO action_log
373
+ (tenant_id, action_type, risk_level, decision, receipt_id, oid_hex, recorded_at)
374
+ VALUES
375
+ (@tenant_id, @action_type, @risk_level, @decision, @receipt_id, @oid_hex, @recorded_at)
376
+ `).run({
377
+ tenant_id: r.tenant_id,
378
+ action_type: r.action_type,
379
+ risk_level: r.risk_level,
380
+ decision: r.decision,
381
+ receipt_id: r.receipt_id,
382
+ oid_hex: r.oid_hex,
383
+ recorded_at: r.recorded_at,
384
+ });
385
+ this._updateSuggestion(r);
386
+ // SRAID Phase 4 — mirror the signed receipt as a CDRO into an
387
+ // EdgeStore-backed store. Best-effort; if the mirror fails the SQLite
388
+ // write is already done. Lazy require avoids a circular import at boot.
389
+ if (r.signature) {
390
+ try {
391
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
392
+ const { mirrorReceipt } = require('./cdro-mirror');
393
+ mirrorReceipt({
394
+ receipt_id: r.receipt_id,
395
+ tenant_id: r.tenant_id,
396
+ decision: r.decision,
397
+ action_class: r.action_class,
398
+ risk_level: r.risk_level,
399
+ oid_hex: r.oid_hex,
400
+ signature: r.signature,
401
+ signer_key_id: r.signer_key_id,
402
+ recorded_at: r.recorded_at,
403
+ gateway_manifest_sha256: r.gateway_manifest_sha256 ?? null,
404
+ });
405
+ }
406
+ catch { /* mirror is best-effort */ }
407
+ }
408
+ }
409
+ listReceipts(tenant_id, limit = 50, offset = 0) {
410
+ return this.db.prepare(`
411
+ SELECT * FROM receipts
412
+ WHERE tenant_id = ?
413
+ ORDER BY recorded_at DESC
414
+ LIMIT ? OFFSET ?
415
+ `).all(tenant_id, limit, offset);
416
+ }
417
+ getReceipt(receipt_id) {
418
+ return this.db.prepare(`SELECT * FROM receipts WHERE receipt_id = ? LIMIT 1`).get(receipt_id);
419
+ }
420
+ /**
421
+ * All receipts for a specific (tenant_id, session_id) pair within a time
422
+ * window. Used by session-observation.ts (#102) to compute windowed
423
+ * aggregates for the governance PIP layer.
424
+ *
425
+ * Tenant isolation is enforced at the SQL level: both tenant_id AND
426
+ * session_id must match. The query is bounded by the (tenant_id,
427
+ * recorded_at DESC) index so it never scans the full table; session_id
428
+ * filtering happens inside that already-indexed set.
429
+ *
430
+ * Limit of 10,000 prevents runaway memory on pathological sessions.
431
+ * Circuit-breaker (#103) should trip long before this limit is reached.
432
+ */
433
+ listReceiptsForSession(tenant_id, session_id, since_ms, limit = 10_000) {
434
+ return this.db.prepare(`
435
+ SELECT * FROM receipts
436
+ WHERE tenant_id = ?
437
+ AND session_id = ?
438
+ AND recorded_at >= ?
439
+ ORDER BY recorded_at ASC
440
+ LIMIT ?
441
+ `).all(tenant_id, session_id, since_ms, limit);
442
+ }
443
+ // ── Compliance export (2026-05-19) ─────────────────────────────────────
444
+ /**
445
+ * Receipts in a tenant + time window. Used by the compliance-export
446
+ * pipeline. `tag_filter`, when present, filters by a single
447
+ * NIST/EU/ISO subcategory tag.
448
+ */
449
+ receiptsForExport(args) {
450
+ if (args.tag_filter) {
451
+ // SQLite has no portable JSON contains operator. Tag strings are
452
+ // wrapped in quotes inside the JSON array; LIKE on the
453
+ // double-quoted-tag-substring matches without false positives.
454
+ const like = `%${JSON.stringify(args.tag_filter)}%`;
455
+ return this.db.prepare(`
456
+ SELECT * FROM receipts
457
+ WHERE tenant_id = ?
458
+ AND recorded_at BETWEEN ? AND ?
459
+ AND compliance_tags LIKE ?
460
+ ORDER BY recorded_at ASC
461
+ LIMIT ?
462
+ `).all(args.tenant_id, args.from_ms, args.to_ms, like, args.limit ?? 10_000);
463
+ }
464
+ return this.db.prepare(`
465
+ SELECT * FROM receipts
466
+ WHERE tenant_id = ?
467
+ AND recorded_at BETWEEN ? AND ?
468
+ ORDER BY recorded_at ASC
469
+ LIMIT ?
470
+ `).all(args.tenant_id, args.from_ms, args.to_ms, args.limit ?? 10_000);
471
+ }
472
+ /** Distinct compliance tags seen in receipts for a tenant + window. */
473
+ distinctTagsForTenant(tenant_id, from_ms, to_ms) {
474
+ const rows = this.db.prepare(`
475
+ SELECT DISTINCT compliance_tags
476
+ FROM receipts
477
+ WHERE tenant_id = ?
478
+ AND recorded_at BETWEEN ? AND ?
479
+ AND compliance_tags IS NOT NULL
480
+ `).all(tenant_id, from_ms, to_ms);
481
+ const set = new Set();
482
+ for (const r of rows) {
483
+ try {
484
+ const arr = JSON.parse(r.compliance_tags);
485
+ for (const t of arr)
486
+ set.add(t);
487
+ }
488
+ catch { /* skip malformed */ }
489
+ }
490
+ return [...set].sort();
491
+ }
492
+ // ── Sprint 3 — Anchor batch helpers ────────────────────────────────────
493
+ /** Receipts not yet assigned to an anchor batch. Ordered by recorded_at ASC
494
+ * so each batch covers a contiguous time window. */
495
+ getUnanchoredReceipts(limit) {
496
+ return this.db.prepare(`
497
+ SELECT * FROM receipts
498
+ WHERE (anchor_status IS NULL OR anchor_status = '')
499
+ ORDER BY recorded_at ASC
500
+ LIMIT ?
501
+ `).all(limit);
502
+ }
503
+ /** Insert an anchor batch row + mark each receipt as anchored with its
504
+ * Merkle proof. Atomic via SQLite transaction. */
505
+ recordAnchorBatch(args) {
506
+ const cs = args.synoi_countersignature ?? null;
507
+ const tx = this.db.transaction(() => {
508
+ this.db.prepare(`
509
+ INSERT INTO anchor_batches
510
+ (batch_id, merkle_root, leaf_count, created_at, ots_proof, ots_submitted_at,
511
+ rekor_log_id, rekor_log_index, rekor_at,
512
+ synoi_signature, synoi_signer_key_id, synoi_status, synoi_reason,
513
+ synoi_signed_at, synoi_canonical_bundle)
514
+ VALUES
515
+ (@batch_id, @merkle_root, @leaf_count, @created_at, @ots_proof, @ots_submitted_at,
516
+ @rekor_log_id, @rekor_log_index, @rekor_at,
517
+ @synoi_signature, @synoi_signer_key_id, @synoi_status, @synoi_reason,
518
+ @synoi_signed_at, @synoi_canonical_bundle)
519
+ `).run({
520
+ batch_id: args.batch_id,
521
+ merkle_root: args.merkle_root,
522
+ leaf_count: args.leaf_count,
523
+ created_at: args.created_at,
524
+ ots_proof: args.ots_proof ?? null,
525
+ ots_submitted_at: args.ots_submitted_at ?? null,
526
+ rekor_log_id: args.rekor_log_id ?? null,
527
+ rekor_log_index: args.rekor_log_index ?? null,
528
+ rekor_at: args.rekor_at ?? null,
529
+ synoi_signature: cs?.signature ?? null,
530
+ synoi_signer_key_id: cs?.signer_key_id ?? null,
531
+ synoi_status: cs?.status ?? null,
532
+ synoi_reason: cs?.reason ?? null,
533
+ synoi_signed_at: cs?.signed_at ?? null,
534
+ synoi_canonical_bundle: cs?.canonical_bundle ?? null,
535
+ });
536
+ const update = this.db.prepare(`
537
+ UPDATE receipts SET
538
+ leaf_hash = @leaf_hash,
539
+ batch_id = @batch_id,
540
+ merkle_proof_json = @proof_json,
541
+ anchor_status = @status,
542
+ anchored_at = @anchored_at
543
+ WHERE receipt_id = @receipt_id
544
+ `);
545
+ const status = args.ots_proof ? 'submitted' : 'pending';
546
+ for (const r of args.receipts) {
547
+ update.run({
548
+ batch_id: args.batch_id,
549
+ leaf_hash: r.leaf_hash,
550
+ proof_json: r.proof_json,
551
+ status,
552
+ anchored_at: args.created_at,
553
+ receipt_id: r.receipt_id,
554
+ });
555
+ }
556
+ });
557
+ tx();
558
+ }
559
+ getAnchorBatch(batch_id) {
560
+ return this.db.prepare(`SELECT * FROM anchor_batches WHERE batch_id = ? LIMIT 1`).get(batch_id);
561
+ }
562
+ /** For tests + diagnostics — list recent batches. */
563
+ listAnchorBatches(limit = 20) {
564
+ return this.db.prepare(`SELECT * FROM anchor_batches ORDER BY created_at DESC LIMIT ?`).all(limit);
565
+ }
566
+ /** Count of shadow_block decisions in the last 7 days */
567
+ /** Total receipts written since `since_ms`, across all tenants. Used by
568
+ * /status as a cheap liveness probe (indexed on recorded_at). */
569
+ countSince(since_ms) {
570
+ const row = this.db.prepare(`
571
+ SELECT COUNT(*) AS n FROM receipts WHERE recorded_at >= ?
572
+ `).get(since_ms);
573
+ return row.n;
574
+ }
575
+ /**
576
+ * Count receipts for a tenant matching any of a list of action_types
577
+ * within a sliding window. Powers mass-read rate-limit risk rules — the
578
+ * defense against the NYC H+H-class mass-exfiltration pattern. Cheap:
579
+ * (tenant_id, recorded_at) is the natural index, action_type filter is
580
+ * a small IN clause.
581
+ *
582
+ * Use case:
583
+ * countRecentActionsByType(
584
+ * 'nychhc-prod',
585
+ * ['patient.read', 'patient.search'],
586
+ * Date.now() - 5 * 60_000
587
+ * )
588
+ * → 142 reads in the last 5 minutes
589
+ *
590
+ * Trigger HITL if > N. See src/rate-limit.ts.
591
+ */
592
+ countRecentActionsByType(tenant_id, action_types, since_ms) {
593
+ if (action_types.length === 0)
594
+ return 0;
595
+ // SQLite parameter expansion for IN — build placeholder list.
596
+ const placeholders = action_types.map(() => '?').join(',');
597
+ const row = this.db.prepare(`
598
+ SELECT COUNT(*) AS n FROM receipts
599
+ WHERE tenant_id = ?
600
+ AND recorded_at >= ?
601
+ AND action_type IN (${placeholders})
602
+ AND decision IN ('allow', 'shadow_block')
603
+ `).get(tenant_id, since_ms, ...action_types);
604
+ return row.n;
605
+ }
606
+ shadowBlockCount(tenant_id) {
607
+ const since = Date.now() - 7 * 24 * 60 * 60 * 1000;
608
+ const row = this.db.prepare(`
609
+ SELECT COUNT(*) as n FROM receipts
610
+ WHERE tenant_id = ? AND decision = 'shadow_block' AND recorded_at >= ?
611
+ `).get(tenant_id, since);
612
+ return row.n;
613
+ }
614
+ getSuggestions(tenant_id) {
615
+ return this.db.prepare(`
616
+ SELECT * FROM policy_suggestions WHERE tenant_id = ?
617
+ ORDER BY sample_count DESC
618
+ `).all(tenant_id);
619
+ }
620
+ /** Summary stats for the dashboard */
621
+ summary(tenant_id, since_ms) {
622
+ const row = this.db.prepare(`
623
+ SELECT
624
+ COUNT(*) as total,
625
+ SUM(CASE WHEN decision = 'allow' THEN 1 ELSE 0 END) as allows,
626
+ SUM(CASE WHEN decision = 'shadow_block' THEN 1 ELSE 0 END) as shadow_blocks,
627
+ SUM(CASE WHEN decision = 'deny' THEN 1 ELSE 0 END) as hard_blocks
628
+ FROM receipts
629
+ WHERE tenant_id = ? AND recorded_at >= ?
630
+ `).get(tenant_id, since_ms);
631
+ return {
632
+ total: row.total ?? 0,
633
+ allows: row.allows ?? 0,
634
+ shadow_blocks: row.shadow_blocks ?? 0,
635
+ hard_blocks: row.hard_blocks ?? 0,
636
+ };
637
+ }
638
+ // ── Human approvals (Class C gate) ────────────────────────────────────────
639
+ /**
640
+ * Record that a human has approved a specific receipt_id for execution.
641
+ * In enforce mode the handler checks this before allowing Class C (high-risk) requests.
642
+ * expires_at: optional unix ms — approval expires (useful for time-boxed permits).
643
+ */
644
+ approveReceipt(receipt_id, approved_by, note = '', expires_at) {
645
+ const receipt = this.getReceipt(receipt_id);
646
+ if (!receipt)
647
+ throw new Error(`Receipt not found: ${receipt_id}`);
648
+ const approval_id = `apv-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
649
+ this.db.prepare(`
650
+ INSERT INTO human_approvals
651
+ (approval_id, receipt_id, tenant_id, approved_by, note, expires_at, approved_at)
652
+ VALUES (?, ?, ?, ?, ?, ?, ?)
653
+ `).run(approval_id, receipt_id, receipt.tenant_id, approved_by, note, expires_at ?? null, Date.now());
654
+ return approval_id;
655
+ }
656
+ /** Returns true if the given receipt_id has a valid (non-expired) human approval. */
657
+ isApproved(receipt_id) {
658
+ const row = this.db.prepare(`
659
+ SELECT approval_id, expires_at FROM human_approvals
660
+ WHERE receipt_id = ?
661
+ LIMIT 1
662
+ `).get(receipt_id);
663
+ if (!row)
664
+ return false;
665
+ if (row.expires_at !== null && Date.now() > row.expires_at)
666
+ return false;
667
+ return true;
668
+ }
669
+ listApprovals(tenant_id, limit = 50) {
670
+ return this.db.prepare(`
671
+ SELECT ha.*, r.action_type, r.risk_level, r.action_desc
672
+ FROM human_approvals ha
673
+ JOIN receipts r ON r.receipt_id = ha.receipt_id
674
+ WHERE ha.tenant_id = ?
675
+ ORDER BY ha.approved_at DESC
676
+ LIMIT ?
677
+ `).all(tenant_id, limit);
678
+ }
679
+ // ── KB stale detection helper ─────────────────────────────────────────────
680
+ /**
681
+ * Returns the recorded_at timestamp of the most recent 'allow' receipt for
682
+ * a given tenant + OID, or null if no such receipt exists.
683
+ * Used by the Notion stale detection path to determine when a cached response
684
+ * was first stored, so it can be compared against KB supersession timestamps.
685
+ */
686
+ getLatestAllowedAt(tenant_id, oid_hex) {
687
+ const row = this.db.prepare(`
688
+ SELECT MAX(recorded_at) AS latest FROM receipts
689
+ WHERE tenant_id = ? AND oid_hex = ? AND decision = 'allow'
690
+ `).get(tenant_id, oid_hex);
691
+ return row.latest;
692
+ }
693
+ /**
694
+ * Returns the receipt_id and recorded_at of the most recent 'allow' receipt
695
+ * for a given tenant + OID, or null if none exists.
696
+ *
697
+ * Used by the compare engine (#116) so AUTHORIZED items can carry the exact
698
+ * receipt id, enabling #121 revoke-and-reclassify without a full table scan.
699
+ * Kept separate from getLatestAllowedAt (timestamp-only) so existing callers
700
+ * are unaffected.
701
+ */
702
+ getLatestAllowingReceipt(tenant_id, oid_hex) {
703
+ const row = this.db.prepare(`
704
+ SELECT receipt_id, recorded_at FROM receipts
705
+ WHERE tenant_id = ? AND oid_hex = ? AND decision = 'allow'
706
+ ORDER BY recorded_at DESC LIMIT 1
707
+ `).get(tenant_id, oid_hex);
708
+ return row ?? null;
709
+ }
710
+ /**
711
+ * Returns the receipt_id and recorded_at of the most recent 'allow' receipt
712
+ * for a given (tenant_id, target_id) pair, or null if none exists.
713
+ *
714
+ * #124 — the authorized-path receipt producer (signAndStorePolicyChange)
715
+ * sets target_id on every policy-change receipt. The compare engine joins on
716
+ * (tenant_id, target_id) via this getter so a gateway-authorized policy change
717
+ * classifies AUTHORIZED without requiring oid_hex identity between the
718
+ * producer and the sensor.
719
+ *
720
+ * Tenant isolation is enforced at the SQL level: both tenant_id AND target_id
721
+ * must match. The (tenant_id, target_id, decision, recorded_at DESC) index
722
+ * keeps this O(log n).
723
+ *
724
+ * Window check stays in compare-engine (as with getLatestAllowingReceipt)
725
+ * so this getter returns the newest match and the engine applies the lookback
726
+ * / grace bounds.
727
+ */
728
+ getLatestAllowingReceiptForTarget(tenant_id, target_id) {
729
+ const row = this.db.prepare(`
730
+ SELECT receipt_id, recorded_at FROM receipts
731
+ WHERE tenant_id = ? AND target_id = ? AND decision = 'allow'
732
+ ORDER BY recorded_at DESC LIMIT 1
733
+ `).get(tenant_id, target_id);
734
+ return row ?? null;
735
+ }
736
+ /**
737
+ * #119 FIX 4 — window-bounded in-window allow-receipt existence check.
738
+ *
739
+ * Returns the latest allow-receipt for (tenant_id, target_id) WHOSE
740
+ * recorded_at falls within [window_start, window_end] inclusive, or null.
741
+ *
742
+ * The window bounds are pushed into the SQL so a newer out-of-window
743
+ * allow-receipt cannot shadow a legitimate in-window one. The plain
744
+ * getLatestAllowingReceiptForTarget returns the single newest row regardless
745
+ * of window; the compare engine then window-checked that one row, so a newer
746
+ * out-of-window receipt masked an in-window receipt and the change was
747
+ * misclassified ORPHANED -> wrongly blocked in enforce mode.
748
+ *
749
+ * Tenant isolation: both tenant_id AND target_id must match.
750
+ * Uses the (tenant_id, target_id, decision, recorded_at DESC) index (line 368).
751
+ */
752
+ getInWindowAllowingReceiptForTarget(tenant_id, target_id, window_start, window_end) {
753
+ const row = this.db.prepare(`
754
+ SELECT receipt_id, recorded_at FROM receipts
755
+ WHERE tenant_id = ? AND target_id = ? AND decision = 'allow'
756
+ AND recorded_at >= ? AND recorded_at <= ?
757
+ ORDER BY recorded_at DESC LIMIT 1
758
+ `).get(tenant_id, target_id, window_start, window_end);
759
+ return row ?? null;
760
+ }
761
+ // ── Auto-classification ────────────────────────────────────────────────────
762
+ _updateSuggestion(r) {
763
+ const suggested_class = riskToClass(r.risk_level);
764
+ const existing = this.db.prepare(`
765
+ SELECT * FROM policy_suggestions WHERE tenant_id = ? AND action_type = ?
766
+ `).get(r.tenant_id, r.action_type);
767
+ if (!existing) {
768
+ this.db.prepare(`
769
+ INSERT INTO policy_suggestions
770
+ (tenant_id, action_type, suggested_class, confidence, sample_count, example_desc, last_seen)
771
+ VALUES (?, ?, ?, ?, 1, ?, ?)
772
+ `).run(r.tenant_id, r.action_type, suggested_class, 0.6, r.action_desc, r.recorded_at);
773
+ return;
774
+ }
775
+ // Bayesian-lite: confidence grows with consistent sample_count
776
+ const newCount = existing.sample_count + 1;
777
+ const sameClass = existing.suggested_class === suggested_class;
778
+ const newConfidence = sameClass
779
+ ? Math.min(existing.confidence + 0.05, 0.98)
780
+ : Math.max(existing.confidence - 0.10, 0.40);
781
+ const newClass = newConfidence < 0.50 ? suggested_class : existing.suggested_class;
782
+ this.db.prepare(`
783
+ UPDATE policy_suggestions SET
784
+ suggested_class = ?, confidence = ?, sample_count = ?, last_seen = ?
785
+ WHERE tenant_id = ? AND action_type = ?
786
+ `).run(newClass, newConfidence, newCount, r.recorded_at, r.tenant_id, r.action_type);
787
+ }
788
+ }
789
+ exports.ReceiptStore = ReceiptStore;
790
+ function riskToClass(risk) {
791
+ if (risk === 'high')
792
+ return 'C';
793
+ if (risk === 'medium')
794
+ return 'B';
795
+ return 'A';
796
+ }
797
+ // ─── Singleton ────────────────────────────────────────────────────────────────
798
+ const _dataDir = process.env.SYNOI_DATA_DIR
799
+ ? path_1.default.join(process.env.SYNOI_DATA_DIR, '_gateway')
800
+ : path_1.default.join(os_1.default.homedir(), '.synoi', 'gateway', '_gateway');
801
+ exports.receiptStore = new ReceiptStore(_dataDir);