drupal-mcp-connector 2.11.0 → 2.13.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,611 @@
1
+ /**
2
+ * Attributable usage, quotas, and abuse signals on the relay edge (#256 /
3
+ * DEV-126).
4
+ *
5
+ * Metering at the seam, lab bounds. Every edge decision (allow or deny) and
6
+ * every fan-down receipt is a record keyed by the grant-resolved tenant and
7
+ * the validated principal, carrying the request / decision / receipt ids the
8
+ * frame already stamps. Quotas fail closed at this boundary: a tenant or
9
+ * principal without a row is refused, an exhausted window is refused, and a
10
+ * principal that keeps earning denials is locked. Cost signals are measured
11
+ * (units, bytes, duration), never priced — pricing and invoicing are not this
12
+ * module.
13
+ *
14
+ * Nothing here is a hosted metering sink. The ledger is in-process and
15
+ * bounded; a restart clears it, and `reconcileUsage` says so when it dropped
16
+ * rows. Caller-supplied tenant / principal fields are never authority: the
17
+ * edge attributes from its own identity object and grant tables.
18
+ */
19
+
20
+ import { randomUUID } from "node:crypto";
21
+ import { createRateLimiter } from "./rate-limit.js";
22
+
23
+ /** Record phases. A denied decision never has a receipt. */
24
+ export const USAGE_PHASES = Object.freeze(["decision", "receipt"]);
25
+
26
+ /** Decision vocabulary at the edge. */
27
+ export const USAGE_DECISIONS = Object.freeze(["allow", "deny"]);
28
+
29
+ /**
30
+ * Receipt outcomes. `unknown` means the frame crossed but no settled
31
+ * response came back — the tenant may have executed the request.
32
+ */
33
+ export const RECEIPT_OUTCOMES = Object.freeze(["ok", "failed", "unknown"]);
34
+
35
+ /** Reconciliation states over one request / decision / receipt chain. */
36
+ export const RECONCILE_STATES = Object.freeze([
37
+ "settled",
38
+ "denied",
39
+ "missing",
40
+ "duplicate",
41
+ "uncertain",
42
+ ]);
43
+
44
+ const PHASE_SET = new Set(USAGE_PHASES);
45
+ const DECISION_SET = new Set(USAGE_DECISIONS);
46
+ const OUTCOME_SET = new Set(RECEIPT_OUTCOMES);
47
+
48
+ const DEFAULT_WINDOW_SEC = 60;
49
+ const DEFAULT_LOCK_SEC = 300;
50
+ const DEFAULT_MAX_RECORDS = 10_000;
51
+ const DEFAULT_MAX_KEYS = 10_000;
52
+
53
+ function cleanKey(value) {
54
+ const key = typeof value === "string" ? value.trim() : "";
55
+ return key && !key.startsWith("_") ? key : "";
56
+ }
57
+
58
+ function positiveInt(value) {
59
+ return Number.isInteger(value) && value > 0 ? value : null;
60
+ }
61
+
62
+ function isRecord(value) {
63
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
64
+ }
65
+
66
+ function grantIds(values) {
67
+ if (!Array.isArray(values)) return [];
68
+ return [...new Set(values.map((value) => cleanKey(value)).filter(Boolean))];
69
+ }
70
+
71
+ function identityValue(value) {
72
+ return typeof value === "string" && value ? value : null;
73
+ }
74
+
75
+ /**
76
+ * Principal partition key: inbound `sub`, then `azp` / client id, exactly
77
+ * as the issuer minted them. Matches the precedence and the raw-value
78
+ * convention of `auth.actors` / `auth.policies` / `resolveTenantRoute`:
79
+ * config keys are trimmed, identity values are not, so a padded claim never
80
+ * matches a row and fails closed. Never a caller field.
81
+ *
82
+ * @param {object|null} identity
83
+ * @returns {string|null}
84
+ */
85
+ export function usagePrincipalKey(identity) {
86
+ if (!isRecord(identity)) return null;
87
+ return identityValue(identity.sub) ?? identityValue(identity.clientId);
88
+ }
89
+
90
+ /**
91
+ * Tenant a principal's usage is attributed to when the edge has not yet
92
+ * routed (early denials): the unique tenant its grant names, else null.
93
+ * Attribution only — routing authority stays with `resolveTenantRoute`.
94
+ *
95
+ * @param {object|null} identity
96
+ * @param {object|null} tenantGrants
97
+ * @returns {string|null}
98
+ */
99
+ export function attributedTenant(identity, tenantGrants) {
100
+ if (!isRecord(identity) || !isRecord(tenantGrants)) return null;
101
+ const clientId = typeof identity.clientId === "string" ? identity.clientId : "";
102
+ if (!clientId) return null;
103
+ const granted = grantIds(new Map(Object.entries(tenantGrants)).get(clientId));
104
+ return granted.length === 1 ? granted[0] : null;
105
+ }
106
+
107
+ const QUOTA_KEYS = new Set(["tenants", "principals", "abuse"]);
108
+
109
+ function invalid(reason) {
110
+ return Object.freeze({ invalid: true, reason });
111
+ }
112
+
113
+ /**
114
+ * @returns {{rows: object, required: boolean}|{invalid: true, reason: string}}
115
+ */
116
+ function quotaRows(raw, name) {
117
+ if (raw === undefined || raw === null) return { rows: Object.freeze({}), required: false };
118
+ if (!isRecord(raw)) return invalid(name);
119
+ const entries = [];
120
+ for (const [rawKey, value] of Object.entries(raw)) {
121
+ const key = cleanKey(rawKey);
122
+ if (!key) continue;
123
+ if (!isRecord(value)) return invalid(`${name}.${key}`);
124
+ const requests = positiveInt(value.requests);
125
+ if (requests === null) return invalid(`${name}.${key}.requests`);
126
+ const windowSec = value.windowSec === undefined
127
+ ? DEFAULT_WINDOW_SEC
128
+ : positiveInt(value.windowSec);
129
+ if (windowSec === null) return invalid(`${name}.${key}.windowSec`);
130
+ entries.push([key, Object.freeze({ requests, windowSec })]);
131
+ }
132
+ return { rows: Object.freeze(Object.fromEntries(entries)), required: entries.length > 0 };
133
+ }
134
+
135
+ function abuseBlock(raw) {
136
+ if (raw === undefined || raw === null) return null;
137
+ if (!isRecord(raw)) return invalid("abuse");
138
+ const denials = positiveInt(raw.denials);
139
+ if (denials === null) return invalid("abuse.denials");
140
+ const windowSec = raw.windowSec === undefined ? DEFAULT_WINDOW_SEC : positiveInt(raw.windowSec);
141
+ if (windowSec === null) return invalid("abuse.windowSec");
142
+ const lockSec = raw.lockSec === undefined ? DEFAULT_LOCK_SEC : positiveInt(raw.lockSec);
143
+ if (lockSec === null) return invalid("abuse.lockSec");
144
+ return Object.freeze({ denials, windowSec, lockSec });
145
+ }
146
+
147
+ /**
148
+ * Normalize `auth.quotas`.
149
+ *
150
+ * Shape: `{ tenants: { "<agentId>": { requests, windowSec? } },
151
+ * principals: { "<sub|azp>": { requests, windowSec? } },
152
+ * abuse: { denials, windowSec?, lockSec? } }`. Comment keys are ignored.
153
+ * Anything else that is not exactly that shape — a table that is not an
154
+ * object, an unknown key, a sub-table that is not an object, a row with a
155
+ * non-positive-integer `requests` / `windowSec`, a malformed `abuse` block
156
+ * — makes the whole table **invalid**, never "omitted": the gate then
157
+ * refuses everything and the edge refuses to start. Only `null` /
158
+ * `undefined` (absent) and a comment-only object mean "omitted". A sub-table that names any id is *required*,
159
+ * so an id without a row fails closed.
160
+ *
161
+ * @param {object|null} raw
162
+ * @returns {object|null} Null when the table is omitted or comment-only;
163
+ * `{ invalid: true, reason }` naming the offending path; else the table.
164
+ */
165
+ export function normalizeQuotas(raw) {
166
+ if (raw === null || raw === undefined) return null;
167
+ if (!isRecord(raw)) return invalid("quotas");
168
+ for (const rawKey of Object.keys(raw)) {
169
+ const key = cleanKey(rawKey);
170
+ if (key && !QUOTA_KEYS.has(key)) return invalid(key);
171
+ }
172
+ const bag = new Map(Object.entries(raw));
173
+ const tenants = quotaRows(bag.get("tenants"), "tenants");
174
+ if (tenants.invalid) return tenants;
175
+ const principals = quotaRows(bag.get("principals"), "principals");
176
+ if (principals.invalid) return principals;
177
+ const abuse = abuseBlock(bag.get("abuse"));
178
+ if (abuse?.invalid) return abuse;
179
+ if (!tenants.required && !principals.required && abuse === null) return null;
180
+ return Object.freeze({
181
+ tenants: tenants.rows,
182
+ tenantsRequired: tenants.required,
183
+ principals: principals.rows,
184
+ principalsRequired: principals.required,
185
+ abuse,
186
+ });
187
+ }
188
+
189
+ /**
190
+ * Whether a quota table is in force (fail-closed even when every row is
191
+ * malformed).
192
+ *
193
+ * @param {object|null} raw
194
+ * @returns {boolean}
195
+ */
196
+ export function quotasRequired(raw) {
197
+ return normalizeQuotas(raw) !== null;
198
+ }
199
+
200
+ /**
201
+ * Quota and abuse gate. Fixed windows per tenant and per principal, plus a
202
+ * denial counter that locks a principal. Every request reaching the window
203
+ * checks counts whether or not it is then allowed, so a flood of refused
204
+ * requests cannot probe for free; a request refused by the abuse lock is
205
+ * refused before the windows and does not drain the tenant's shared one.
206
+ *
207
+ * @param {object} [options]
208
+ * @param {object|null} [options.quotas] Raw `auth.quotas`.
209
+ * @param {() => number} [options.now]
210
+ * @param {number} [options.maxKeys] Soft cap on tracked principals in the
211
+ * abuse table; expired entries are pruned first, then the oldest.
212
+ * @returns {{enabled: boolean, check: Function, noteDenial: Function, state: Function, stats: Function}}
213
+ */
214
+ export function createQuotaGate({
215
+ quotas = null,
216
+ now = () => Date.now(),
217
+ maxKeys = DEFAULT_MAX_KEYS,
218
+ } = {}) {
219
+ const table = normalizeQuotas(quotas);
220
+ if (!table) {
221
+ return Object.freeze({
222
+ enabled: false,
223
+ invalid: false,
224
+ reason: null,
225
+ check: () => ({ allowed: true }),
226
+ noteDenial: () => ({ locked: false }),
227
+ state: () => ({ locked: false, retryAfterSec: 0, denials: 0 }),
228
+ stats: () => ({ trackedPrincipals: 0, maxKeys: 0 }),
229
+ });
230
+ }
231
+ if (table.invalid) {
232
+ // A quota table that cannot be read authorizes nobody. The edge refuses
233
+ // to start on this; the gate refuses every request as defense in depth.
234
+ return Object.freeze({
235
+ enabled: true,
236
+ invalid: true,
237
+ reason: table.reason,
238
+ check: () => ({ allowed: false, reason: "not_entitled", scope: "config", retryAfterSec: 0 }),
239
+ noteDenial: () => ({ locked: false }),
240
+ state: () => ({ locked: false, retryAfterSec: 0, denials: 0 }),
241
+ stats: () => ({ trackedPrincipals: 0, maxKeys: 0 }),
242
+ });
243
+ }
244
+
245
+ function limiters(rows) {
246
+ return new Map(Object.entries(rows).map(([id, row]) => [
247
+ id,
248
+ createRateLimiter({ limit: row.requests, windowMs: row.windowSec * 1000, now }),
249
+ ]));
250
+ }
251
+ const tenantLimiters = limiters(table.tenants);
252
+ const principalLimiters = limiters(table.principals);
253
+ const abuse = table.abuse;
254
+ const keyBound = positiveInt(maxKeys) ?? DEFAULT_MAX_KEYS;
255
+ /** @type {Map<string, {times: number[], lockedUntil: number}>} */
256
+ const denials = new Map();
257
+
258
+ function live(entry, t) {
259
+ if (entry.lockedUntil > t) return true;
260
+ if (entry.lockedUntil) return false;
261
+ return entry.times.some((ts) => t - ts < abuse.windowSec * 1000);
262
+ }
263
+
264
+ /** Drop expired principals; if still at the bound, drop the oldest. */
265
+ function prune(t) {
266
+ for (const [key, entry] of denials) {
267
+ if (!live(entry, t)) denials.delete(key);
268
+ }
269
+ while (denials.size >= keyBound) {
270
+ const oldest = denials.keys().next().value;
271
+ if (oldest === undefined) break;
272
+ denials.delete(oldest);
273
+ }
274
+ }
275
+
276
+ function deny(reason, scope, retryAfterSec = 0) {
277
+ return { allowed: false, reason, scope, retryAfterSec };
278
+ }
279
+
280
+ function lockState(key) {
281
+ if (!abuse || !key) return { locked: false, retryAfterSec: 0, denials: 0 };
282
+ const entry = denials.get(key);
283
+ if (!entry) return { locked: false, retryAfterSec: 0, denials: 0 };
284
+ const t = now();
285
+ if (entry.lockedUntil > t) {
286
+ return {
287
+ locked: true,
288
+ retryAfterSec: Math.ceil((entry.lockedUntil - t) / 1000),
289
+ denials: entry.times.length,
290
+ };
291
+ }
292
+ if (entry.lockedUntil) {
293
+ denials.delete(key);
294
+ return { locked: false, retryAfterSec: 0, denials: 0 };
295
+ }
296
+ entry.times = entry.times.filter((ts) => t - ts < abuse.windowSec * 1000);
297
+ if (!entry.times.length) denials.delete(key);
298
+ return { locked: false, retryAfterSec: 0, denials: entry.times.length };
299
+ }
300
+
301
+ return Object.freeze({
302
+ enabled: true,
303
+ invalid: false,
304
+ reason: null,
305
+
306
+ /**
307
+ * The abuse lock is checked first: a locked principal is refused before
308
+ * the windows and does not consume the tenant's shared window. Every
309
+ * request that reaches the window checks counts, allowed or not.
310
+ *
311
+ * @param {{tenant?: string|null, principalKey?: string|null}} params
312
+ * @returns {{allowed: true}|{allowed: false, reason: string, scope: string, retryAfterSec: number}}
313
+ */
314
+ check({ tenant = null, principalKey = null } = {}) {
315
+ const key = identityValue(principalKey);
316
+ const lock = lockState(key);
317
+ if (lock.locked) return deny("abuse_locked", "abuse", lock.retryAfterSec);
318
+ if (table.tenantsRequired) {
319
+ const tenantId = identityValue(tenant);
320
+ const limiter = tenantId ? tenantLimiters.get(tenantId) : undefined;
321
+ if (!limiter) return deny("not_entitled", "tenant");
322
+ const verdict = limiter.check(tenantId);
323
+ if (!verdict.allowed) return deny("quota_exceeded", "tenant", verdict.retryAfterSec);
324
+ }
325
+ if (table.principalsRequired) {
326
+ const limiter = key ? principalLimiters.get(key) : undefined;
327
+ if (!limiter) return deny("not_entitled", "principal");
328
+ const verdict = limiter.check(key);
329
+ if (!verdict.allowed) return deny("quota_exceeded", "principal", verdict.retryAfterSec);
330
+ }
331
+ return { allowed: true };
332
+ },
333
+
334
+ /**
335
+ * Count one post-authentication denial against a principal.
336
+ * @param {string|null} principalKey
337
+ * @returns {{locked: boolean, retryAfterSec?: number}}
338
+ */
339
+ noteDenial(principalKey) {
340
+ if (!abuse) return { locked: false };
341
+ const key = identityValue(principalKey);
342
+ if (!key) return { locked: false };
343
+ const current = lockState(key);
344
+ if (current.locked) return { locked: true, retryAfterSec: current.retryAfterSec };
345
+ const entry = denials.get(key) ?? { times: [], lockedUntil: 0 };
346
+ if (!denials.has(key) && denials.size >= keyBound) prune(now());
347
+ entry.times.push(now());
348
+ denials.set(key, entry);
349
+ if (entry.times.length >= abuse.denials) {
350
+ entry.lockedUntil = now() + abuse.lockSec * 1000;
351
+ return { locked: true, retryAfterSec: abuse.lockSec };
352
+ }
353
+ return { locked: false };
354
+ },
355
+
356
+ /**
357
+ * @param {string|null} principalKey
358
+ * @returns {{locked: boolean, retryAfterSec: number, denials: number}}
359
+ */
360
+ state(principalKey) {
361
+ return lockState(identityValue(principalKey));
362
+ },
363
+
364
+ /** @returns {{trackedPrincipals: number, maxKeys: number}} */
365
+ stats() {
366
+ return { trackedPrincipals: denials.size, maxKeys: keyBound };
367
+ },
368
+ });
369
+ }
370
+
371
+ /**
372
+ * Bounded in-process usage ledger.
373
+ *
374
+ * Decision records carry `decision` (`allow` / `deny`), `reason`,
375
+ * `requestId` (the frame id, only when dispatched), and a `decisionId`.
376
+ * Receipt records carry `outcome`, `status`, cost signals, the same
377
+ * `requestId`, and the `decisionId` they settle. Records are frozen.
378
+ *
379
+ * @param {object} [options]
380
+ * @param {() => number} [options.now]
381
+ * @param {number} [options.maxRecords] Oldest rows are dropped beyond this.
382
+ * @returns {object}
383
+ */
384
+ export function createUsageLedger({
385
+ now = () => Date.now(),
386
+ maxRecords = DEFAULT_MAX_RECORDS,
387
+ } = {}) {
388
+ const bound = positiveInt(maxRecords);
389
+ if (bound === null) {
390
+ throw new TypeError("createUsageLedger requires maxRecords to be a positive integer.");
391
+ }
392
+ const rows = [];
393
+ let seq = 0;
394
+ let dropped = 0;
395
+
396
+ return {
397
+ get size() {
398
+ return rows.length;
399
+ },
400
+
401
+ /**
402
+ * @param {object} entry
403
+ * @returns {object} The frozen, stamped record.
404
+ */
405
+ record(entry) {
406
+ if (!isRecord(entry)) throw new TypeError("A usage record must be an object.");
407
+ const bag = new Map(Object.entries(entry));
408
+ const phase = bag.get("phase");
409
+ if (!PHASE_SET.has(phase)) throw new TypeError(`Unknown usage phase: ${String(phase)}`);
410
+ if (phase === "decision" && !DECISION_SET.has(bag.get("decision"))) {
411
+ throw new TypeError(`Unknown usage decision: ${String(bag.get("decision"))}`);
412
+ }
413
+ if (phase === "receipt" && !OUTCOME_SET.has(bag.get("outcome"))) {
414
+ throw new TypeError(`Unknown receipt outcome: ${String(bag.get("outcome"))}`);
415
+ }
416
+ seq += 1;
417
+ const existingDecision = bag.get("decisionId");
418
+ const stamped = {
419
+ ...entry,
420
+ seq,
421
+ at: new Date(now()).toISOString(),
422
+ decisionId: typeof existingDecision === "string" && existingDecision
423
+ ? existingDecision
424
+ : (phase === "decision" ? randomUUID() : null),
425
+ };
426
+ if (phase === "receipt") {
427
+ const existingReceipt = bag.get("receiptId");
428
+ stamped.receiptId = typeof existingReceipt === "string" && existingReceipt
429
+ ? existingReceipt
430
+ : randomUUID();
431
+ }
432
+ const frozen = Object.freeze(stamped);
433
+ rows.push(frozen);
434
+ if (rows.length > bound) {
435
+ rows.shift();
436
+ dropped += 1;
437
+ }
438
+ return frozen;
439
+ },
440
+
441
+ /** @returns {object[]} Every retained record, oldest first. */
442
+ records() {
443
+ return rows.slice();
444
+ },
445
+
446
+ /**
447
+ * One tenant partition, optionally narrowed to one principal. A missing
448
+ * tenant selects nothing — there is no all-tenants read on this surface.
449
+ *
450
+ * @param {{tenant?: string|null, principalKey?: string|null}} [params]
451
+ * @returns {object[]}
452
+ */
453
+ query({ tenant = null, principalKey = null } = {}) {
454
+ const tenantId = typeof tenant === "string" ? tenant.trim() : "";
455
+ if (!tenantId) return [];
456
+ const key = typeof principalKey === "string" && principalKey.trim()
457
+ ? principalKey.trim()
458
+ : null;
459
+ return rows.filter((row) => row.tenant === tenantId
460
+ && (key === null || row.principalKey === key));
461
+ },
462
+
463
+ /** @returns {{size: number, dropped: number, maxRecords: number}} */
464
+ stats() {
465
+ return { size: rows.length, dropped, maxRecords: bound };
466
+ },
467
+ };
468
+ }
469
+
470
+ const READ_DENIED = Object.freeze({ ok: false, reason: "not_entitled" });
471
+
472
+ /**
473
+ * Tenant-scoped usage read. The tenant is resolved from the caller's
474
+ * `auth.tenantGrants` row; a `tenant` argument is a confirming hint inside
475
+ * that grant, never authority. Without a grant table, without a ledger, for
476
+ * a principal with no grant, for a hint outside the grant, or for a
477
+ * multi-tenant grant with no hint, the read is `not_entitled` with no
478
+ * records.
479
+ *
480
+ * @param {object} params
481
+ * @param {object|null} [params.identity]
482
+ * @param {object|null} [params.tenantGrants]
483
+ * @param {string|null} [params.tenant]
484
+ * @param {string|null} [params.principalKey]
485
+ * @param {object|null} [params.ledger]
486
+ * @returns {{ok: true, tenant: string, records: object[]}|{ok: false, reason: "not_entitled"}}
487
+ */
488
+ export function readUsage({
489
+ identity = null,
490
+ tenantGrants = null,
491
+ tenant = null,
492
+ principalKey = null,
493
+ ledger = null,
494
+ } = {}) {
495
+ if (!ledger || typeof ledger.query !== "function") return READ_DENIED;
496
+ if (!isRecord(identity) || typeof identity.clientId !== "string" || !identity.clientId) {
497
+ return READ_DENIED;
498
+ }
499
+ if (!isRecord(tenantGrants)) return READ_DENIED;
500
+ const granted = grantIds(new Map(Object.entries(tenantGrants)).get(identity.clientId));
501
+ if (!granted.length) return READ_DENIED;
502
+ const hint = typeof tenant === "string" && tenant.trim() ? tenant.trim() : null;
503
+ if (hint && !granted.includes(hint)) return READ_DENIED;
504
+ if (!hint && granted.length > 1) return READ_DENIED;
505
+ const resolved = hint ?? granted[0];
506
+ const key = typeof principalKey === "string" && principalKey.trim() ? principalKey.trim() : null;
507
+ return { ok: true, tenant: resolved, records: ledger.query({ tenant: resolved, principalKey: key }) };
508
+ }
509
+
510
+ /**
511
+ * Reconcile a set of records into request / decision / receipt chains.
512
+ *
513
+ * - `settled`: one allow decision, one receipt, matching tenant and
514
+ * decision id, outcome known.
515
+ * - `denied`: a deny decision (never dispatched; no receipt expected).
516
+ * - `missing`: a dispatch with no receipt, or a receipt with no dispatch.
517
+ * - `duplicate`: more than one decision or more than one receipt for a
518
+ * request id.
519
+ * - `uncertain`: the receipt outcome is `unknown`, or the receipt disagrees
520
+ * with its decision (tenant or decision id).
521
+ *
522
+ * `truncated` is true when the ledger dropped rows; findings may then be
523
+ * incomplete and must not be read as a clean bill.
524
+ *
525
+ * @param {object[]} records
526
+ * @param {{dropped?: number}} [options]
527
+ * @returns {{findings: object[], summary: object}}
528
+ */
529
+ export function reconcileUsage(records, { dropped = 0 } = {}) {
530
+ const list = Array.isArray(records) ? records : [];
531
+ const order = [];
532
+ const groups = new Map();
533
+
534
+ for (const row of list) {
535
+ if (!isRecord(row) || !PHASE_SET.has(row.phase)) continue;
536
+ if (row.phase === "decision" && row.decision === "deny") {
537
+ const key = `deny:${row.decisionId ?? order.length}`;
538
+ order.push(key);
539
+ groups.set(key, { deny: row });
540
+ continue;
541
+ }
542
+ const requestId = typeof row.requestId === "string" && row.requestId ? row.requestId : null;
543
+ const key = requestId
544
+ ? `req:${requestId}`
545
+ : `orphan:${row.decisionId ?? row.receiptId ?? order.length}`;
546
+ if (!groups.has(key)) {
547
+ order.push(key);
548
+ groups.set(key, { requestId, decisions: [], receipts: [] });
549
+ }
550
+ const group = groups.get(key);
551
+ if (row.phase === "decision") group.decisions.push(row);
552
+ else group.receipts.push(row);
553
+ }
554
+
555
+ const findings = [];
556
+ const summary = {
557
+ total: 0, settled: 0, denied: 0, missing: 0, duplicate: 0, uncertain: 0, truncated: dropped > 0,
558
+ };
559
+ const tally = new Map(Object.entries(summary));
560
+
561
+ for (const key of order) {
562
+ const group = groups.get(key);
563
+ let finding;
564
+ if (group.deny) {
565
+ finding = {
566
+ requestId: group.deny.requestId ?? null,
567
+ decisionId: group.deny.decisionId ?? null,
568
+ state: "denied",
569
+ reason: group.deny.reason ?? null,
570
+ };
571
+ } else {
572
+ const decisionId = group.decisions[0]?.decisionId ?? group.receipts[0]?.decisionId ?? null;
573
+ let state;
574
+ let reason;
575
+ if (group.decisions.length > 1) {
576
+ state = "duplicate";
577
+ reason = "duplicate_decision";
578
+ } else if (group.receipts.length > 1) {
579
+ state = "duplicate";
580
+ reason = "duplicate_receipt";
581
+ } else if (group.decisions.length === 1 && group.receipts.length === 0) {
582
+ state = "missing";
583
+ reason = "receipt_missing";
584
+ } else if (group.decisions.length === 0) {
585
+ state = "missing";
586
+ reason = "decision_missing";
587
+ } else {
588
+ const decision = group.decisions[0];
589
+ const receipt = group.receipts[0];
590
+ if (receipt.outcome === "unknown") {
591
+ state = "uncertain";
592
+ reason = typeof receipt.reason === "string" && receipt.reason
593
+ ? receipt.reason
594
+ : "outcome_unknown";
595
+ } else if (receipt.tenant !== decision.tenant || receipt.decisionId !== decision.decisionId) {
596
+ state = "uncertain";
597
+ reason = "chain_mismatch";
598
+ } else {
599
+ state = "settled";
600
+ reason = null;
601
+ }
602
+ }
603
+ finding = { requestId: group.requestId, decisionId, state, reason };
604
+ }
605
+ findings.push(Object.freeze(finding));
606
+ tally.set(finding.state, tally.get(finding.state) + 1);
607
+ tally.set("total", tally.get("total") + 1);
608
+ }
609
+
610
+ return { findings, summary: Object.fromEntries(tally) };
611
+ }