@ar-agents/mercadopago 0.17.2 → 0.18.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,545 @@
1
+ /**
2
+ * Recipe 25 — Sociedad-IA quarterly compliance report.
3
+ *
4
+ * # Pattern
5
+ *
6
+ * Every quarter, a sociedad-IA must produce — for AFIP/ARCA, for AAIP
7
+ * (data protection), and for whoever else asks — a self-audit summary
8
+ * covering the last 90 days of operational activity. Recipe 25 is the
9
+ * pure function that generates that report from the audit-log alone.
10
+ *
11
+ * Input: a list of sessionIds active in the quarter (e.g. one per
12
+ * customer interaction, or one per business day, depending on session
13
+ * granularity policy).
14
+ *
15
+ * Output: a single JSON document with:
16
+ *
17
+ * - Header (sociedad metadata, period, generation time, schema URL)
18
+ * - Per-session: full entry timeline + verification result
19
+ * - Aggregates: total entries, total verified, total tampered, total
20
+ * errored, governance breakdown (algorithm-only / audit-logged /
21
+ * mocked-upstream / requires-confirmation counts), p50/p95/p99
22
+ * duration per tool
23
+ * - Anomalies: late timestamps (clock skew > 5 min), governance
24
+ * class shifts mid-session (unusual), errored entries with
25
+ * governance "audit-logged" (LLM call failed; needed remediation)
26
+ * - Self-disclosure: any session with tampered entries is flagged
27
+ * and the report's HMAC over its own JSON makes the disclosure
28
+ * itself tamper-evident
29
+ *
30
+ * The report is shaped to be the answer to a single regulator question:
31
+ * "I want a complete picture of what your sociedad-IA did last quarter,
32
+ * verifiable end-to-end, with no expectation of me trusting your
33
+ * recollection." Hand them this JSON + the underlying live verify URLs;
34
+ * everything they need to forensically reconstruct is in the document.
35
+ *
36
+ * # Companion to RFC-004 § 9
37
+ *
38
+ * RFC-004 § 9 lists the four artifacts a regulator can demand without a
39
+ * court order: session inventory, full export, verification proof,
40
+ * operational narrative. Recipe 25 is the operational narrative — and
41
+ * it bundles in the session inventory + the verification proof, so the
42
+ * regulator gets one self-contained document instead of N HTTP fetches.
43
+ *
44
+ * # When to use
45
+ *
46
+ * - Quarterly self-audit cycle (calendar quarters Q1/Q2/Q3/Q4).
47
+ * - Regulator requests an ad-hoc window (parameterize start/end).
48
+ * - Customer requests a "what did your sociedad do for me?" extract
49
+ * (filter sessionIds to that customer's; reuse the same function).
50
+ * - Internal SOC/ops review (anomalies block on this report).
51
+ *
52
+ * # Edge Runtime
53
+ *
54
+ * Pure data shaping over the fetched audit; runs anywhere Node 18+ or
55
+ * Edge fetch is available. No filesystem access. Stateless.
56
+ */
57
+
58
+ import { fetchAudit } from "@ar-agents/incorporate";
59
+
60
+ // ─────────────────────────────────────────────────────────────────────────────
61
+ // Types
62
+ // ─────────────────────────────────────────────────────────────────────────────
63
+
64
+ /** Shape of a single audit entry per RFC-004 § 2. */
65
+ interface AuditEntry {
66
+ id: string;
67
+ sessionId: string;
68
+ ts: string;
69
+ tool: string;
70
+ governance:
71
+ | "algorithm-only"
72
+ | "audit-logged"
73
+ | "mocked-upstream"
74
+ | "requires-confirmation";
75
+ input: unknown;
76
+ output?: unknown;
77
+ errored?: boolean;
78
+ durationMs?: number;
79
+ hmac: string | null;
80
+ }
81
+
82
+ interface AuditPayload {
83
+ sessionId: string;
84
+ entries: AuditEntry[];
85
+ total?: number;
86
+ verified?: number;
87
+ tampered?: number;
88
+ hmacWired?: boolean;
89
+ }
90
+
91
+ /** Sociedad-IA self-disclosure metadata. */
92
+ interface SociedadMetadata {
93
+ denominacion: string;
94
+ operatorCuit: string;
95
+ jurisdiction: "AR";
96
+ rfcConformance: string[]; // e.g. ["rfc-001-v1", "rfc-004-draft"]
97
+ auditBaseUrl: string; // for re-fetch by the regulator
98
+ }
99
+
100
+ interface ReportInput {
101
+ sociedad: SociedadMetadata;
102
+ periodStart: string; // ISO-8601 UTC
103
+ periodEnd: string; // ISO-8601 UTC
104
+ sessionIds: string[]; // sessions active in the period
105
+ baseUrl?: string; // /arg deployment, default ar-agents.ar
106
+ }
107
+
108
+ interface SessionSummary {
109
+ sessionId: string;
110
+ entriesCount: number;
111
+ verified: number;
112
+ tampered: number;
113
+ errored: number;
114
+ durationMs: { p50: number; p95: number; p99: number } | null;
115
+ governanceBreakdown: Record<AuditEntry["governance"], number>;
116
+ firstTs: string | null;
117
+ lastTs: string | null;
118
+ toolUsage: Record<string, number>;
119
+ anomalies: Anomaly[];
120
+ }
121
+
122
+ type Anomaly =
123
+ | {
124
+ kind: "clock-skew";
125
+ entryId: string;
126
+ previousTs: string;
127
+ currentTs: string;
128
+ skewMs: number;
129
+ }
130
+ | {
131
+ kind: "governance-shift";
132
+ entryIdA: string;
133
+ governanceA: AuditEntry["governance"];
134
+ entryIdB: string;
135
+ governanceB: AuditEntry["governance"];
136
+ }
137
+ | {
138
+ kind: "llm-error-without-fallback";
139
+ entryId: string;
140
+ tool: string;
141
+ }
142
+ | {
143
+ kind: "tampered-entry";
144
+ entryId: string;
145
+ }
146
+ | {
147
+ kind: "missing-hmac-in-production";
148
+ entryId: string;
149
+ };
150
+
151
+ interface QuarterlyReport {
152
+ $schema: string;
153
+ generatedAt: string;
154
+ schemaVersion: "1.0";
155
+ sociedad: SociedadMetadata;
156
+ period: { start: string; end: string };
157
+ aggregates: {
158
+ sessionsCount: number;
159
+ entriesCount: number;
160
+ verified: number;
161
+ tampered: number;
162
+ errored: number;
163
+ governanceBreakdown: Record<AuditEntry["governance"], number>;
164
+ toolUsage: Record<string, number>;
165
+ durationMsByTool: Record<string, { p50: number; p95: number; p99: number }>;
166
+ };
167
+ sessions: SessionSummary[];
168
+ anomalies: Anomaly[];
169
+ conclusion: ReportConclusion;
170
+ reportHmac: string | null; // optional: HMAC over canonical(report-minus-this-field)
171
+ }
172
+
173
+ interface ReportConclusion {
174
+ /** "clean" | "anomalies-noted" | "tampering-detected" */
175
+ status: "clean" | "anomalies-noted" | "tampering-detected";
176
+ /** Human-readable summary. Short. Regulator opens this first. */
177
+ summary: string;
178
+ /** Concrete remediation items if status != "clean". */
179
+ remediation: string[];
180
+ }
181
+
182
+ // ─────────────────────────────────────────────────────────────────────────────
183
+ // Statistics helpers
184
+ // ─────────────────────────────────────────────────────────────────────────────
185
+
186
+ function percentile(sorted: number[], p: number): number {
187
+ if (sorted.length === 0) return 0;
188
+ const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
189
+ return sorted[idx];
190
+ }
191
+
192
+ function quantiles(values: number[]): { p50: number; p95: number; p99: number } {
193
+ const sorted = [...values].sort((a, b) => a - b);
194
+ return {
195
+ p50: percentile(sorted, 50),
196
+ p95: percentile(sorted, 95),
197
+ p99: percentile(sorted, 99),
198
+ };
199
+ }
200
+
201
+ function blankGovernance(): Record<AuditEntry["governance"], number> {
202
+ return {
203
+ "algorithm-only": 0,
204
+ "audit-logged": 0,
205
+ "mocked-upstream": 0,
206
+ "requires-confirmation": 0,
207
+ };
208
+ }
209
+
210
+ // ─────────────────────────────────────────────────────────────────────────────
211
+ // Per-session summarization
212
+ // ─────────────────────────────────────────────────────────────────────────────
213
+
214
+ const CLOCK_SKEW_THRESHOLD_MS = 5 * 60 * 1000; // 5 min
215
+
216
+ function summarizeSession(payload: AuditPayload): SessionSummary {
217
+ const entries = payload.entries;
218
+ const governance = blankGovernance();
219
+ const toolUsage: Record<string, number> = {};
220
+ const durations: number[] = [];
221
+ const anomalies: Anomaly[] = [];
222
+ let errored = 0;
223
+
224
+ let prev: AuditEntry | null = null;
225
+ for (const e of entries) {
226
+ governance[e.governance]++;
227
+ toolUsage[e.tool] = (toolUsage[e.tool] ?? 0) + 1;
228
+ if (e.durationMs !== undefined) durations.push(e.durationMs);
229
+ if (e.errored) errored++;
230
+
231
+ // Anomaly: clock skew
232
+ if (prev) {
233
+ const prevMs = Date.parse(prev.ts);
234
+ const curMs = Date.parse(e.ts);
235
+ if (Number.isFinite(prevMs) && Number.isFinite(curMs)) {
236
+ const skew = prevMs - curMs;
237
+ if (skew > CLOCK_SKEW_THRESHOLD_MS) {
238
+ anomalies.push({
239
+ kind: "clock-skew",
240
+ entryId: e.id,
241
+ previousTs: prev.ts,
242
+ currentTs: e.ts,
243
+ skewMs: skew,
244
+ });
245
+ }
246
+ }
247
+ // Anomaly: governance shift within session (allowed but worth flagging)
248
+ if (prev.governance !== e.governance) {
249
+ anomalies.push({
250
+ kind: "governance-shift",
251
+ entryIdA: prev.id,
252
+ governanceA: prev.governance,
253
+ entryIdB: e.id,
254
+ governanceB: e.governance,
255
+ });
256
+ }
257
+ }
258
+
259
+ // Anomaly: errored LLM call (audit-logged + errored = LLM failed,
260
+ // operator should have a fallback documented).
261
+ if (e.errored && e.governance === "audit-logged") {
262
+ anomalies.push({ kind: "llm-error-without-fallback", entryId: e.id, tool: e.tool });
263
+ }
264
+
265
+ // Anomaly: missing HMAC in production. (null hmac in dev OK; in prod
266
+ // it's a fatal misconfig per RFC-004 § 2.)
267
+ if (!e.hmac) {
268
+ anomalies.push({ kind: "missing-hmac-in-production", entryId: e.id });
269
+ }
270
+
271
+ prev = e;
272
+ }
273
+
274
+ // Tampering: derived from the verify endpoint output, not from anomalies.
275
+ // Each tampered entry is also enumerated as an anomaly so the regulator
276
+ // sees it in one list.
277
+ const tampered = payload.tampered ?? 0;
278
+ if (tampered > 0) {
279
+ // We don't know which entry IDs without re-verifying client-side. Flag
280
+ // the session collectively; downstream caller can drill in.
281
+ anomalies.push({
282
+ kind: "tampered-entry",
283
+ entryId: `(${tampered} entries in session ${payload.sessionId})`,
284
+ });
285
+ }
286
+
287
+ return {
288
+ sessionId: payload.sessionId,
289
+ entriesCount: entries.length,
290
+ verified: payload.verified ?? 0,
291
+ tampered,
292
+ errored,
293
+ durationMs: durations.length ? quantiles(durations) : null,
294
+ governanceBreakdown: governance,
295
+ firstTs: entries[0]?.ts ?? null,
296
+ lastTs: entries[entries.length - 1]?.ts ?? null,
297
+ toolUsage,
298
+ anomalies,
299
+ };
300
+ }
301
+
302
+ // ─────────────────────────────────────────────────────────────────────────────
303
+ // Cross-session aggregation
304
+ // ─────────────────────────────────────────────────────────────────────────────
305
+
306
+ function aggregate(
307
+ sessions: SessionSummary[],
308
+ payloads: AuditPayload[],
309
+ ): QuarterlyReport["aggregates"] {
310
+ const governance = blankGovernance();
311
+ const toolUsage: Record<string, number> = {};
312
+ const durationsByTool: Record<string, number[]> = {};
313
+ let entries = 0;
314
+ let verified = 0;
315
+ let tampered = 0;
316
+ let errored = 0;
317
+
318
+ for (const s of sessions) {
319
+ entries += s.entriesCount;
320
+ verified += s.verified;
321
+ tampered += s.tampered;
322
+ errored += s.errored;
323
+ for (const g of Object.keys(governance) as AuditEntry["governance"][]) {
324
+ governance[g] += s.governanceBreakdown[g];
325
+ }
326
+ for (const [tool, n] of Object.entries(s.toolUsage)) {
327
+ toolUsage[tool] = (toolUsage[tool] ?? 0) + n;
328
+ }
329
+ }
330
+
331
+ // Per-tool latency aggregation needs the raw entries.
332
+ for (const p of payloads) {
333
+ for (const e of p.entries) {
334
+ if (e.durationMs === undefined) continue;
335
+ const bucket = (durationsByTool[e.tool] ??= []);
336
+ bucket.push(e.durationMs);
337
+ }
338
+ }
339
+
340
+ const durationMsByTool: Record<string, { p50: number; p95: number; p99: number }> = {};
341
+ for (const [tool, ds] of Object.entries(durationsByTool)) {
342
+ durationMsByTool[tool] = quantiles(ds);
343
+ }
344
+
345
+ return {
346
+ sessionsCount: sessions.length,
347
+ entriesCount: entries,
348
+ verified,
349
+ tampered,
350
+ errored,
351
+ governanceBreakdown: governance,
352
+ toolUsage,
353
+ durationMsByTool,
354
+ };
355
+ }
356
+
357
+ function conclude(
358
+ aggregates: QuarterlyReport["aggregates"],
359
+ anomalies: Anomaly[],
360
+ ): ReportConclusion {
361
+ if (aggregates.tampered > 0) {
362
+ return {
363
+ status: "tampering-detected",
364
+ summary: `${aggregates.tampered} of ${aggregates.entriesCount} entries failed HMAC verification. This is a chain-of-custody breach; the sociedad-IA cannot represent its operating history as complete for the reported period without remediation.`,
365
+ remediation: [
366
+ "Identify which entries failed verification (drill in per session via /api/play/audit/{sessionId}?verify=1).",
367
+ "If verification failure is due to key rotation without re-signing, document the rotation event and re-sign the affected entries under the new key with an explicit re-signing audit entry.",
368
+ "If verification failure is due to actual tampering, treat as a security incident: identify the access path, rotate keys, notify counterparties per RFC-001 § 9.4.",
369
+ "File a written explanation with the requesting regulator.",
370
+ ],
371
+ };
372
+ }
373
+ if (anomalies.length > 0) {
374
+ const llmErrors = anomalies.filter(a => a.kind === "llm-error-without-fallback").length;
375
+ const skews = anomalies.filter(a => a.kind === "clock-skew").length;
376
+ const missingHmac = anomalies.filter(a => a.kind === "missing-hmac-in-production").length;
377
+ return {
378
+ status: "anomalies-noted",
379
+ summary: `No tampering detected (${aggregates.verified}/${aggregates.entriesCount} entries verified). ${anomalies.length} operational anomalies flagged for review (${llmErrors} LLM-call failures, ${skews} clock-skew events, ${missingHmac} missing-HMAC entries).`,
380
+ remediation: [
381
+ ...(llmErrors > 0
382
+ ? ["Review LLM-call failure paths; document fallback behavior for the errored tools."]
383
+ : []),
384
+ ...(skews > 0
385
+ ? ["Investigate clock-skew events (>5 min between consecutive entries within a session); typical cause is NTP drift or fork-then-merge."]
386
+ : []),
387
+ ...(missingHmac > 0
388
+ ? ["CRITICAL: missing-HMAC entries must not occur in production. Verify AUDIT_HMAC_SECRET is set in the production env and rotate."]
389
+ : []),
390
+ ],
391
+ };
392
+ }
393
+ return {
394
+ status: "clean",
395
+ summary: `${aggregates.verified}/${aggregates.entriesCount} entries verified across ${aggregates.sessionsCount} sessions. No anomalies. No tampering. ${aggregates.errored} errored tool-calls (within expected operational range; no remediation required).`,
396
+ remediation: [],
397
+ };
398
+ }
399
+
400
+ // ─────────────────────────────────────────────────────────────────────────────
401
+ // Optional: HMAC-sign the report itself (self-disclosure tamper-evidence)
402
+ // ─────────────────────────────────────────────────────────────────────────────
403
+
404
+ function canonical(value: unknown): string {
405
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
406
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
407
+ const obj = value as Record<string, unknown>;
408
+ const keys = Object.keys(obj).sort();
409
+ return `{${keys.map(k => `${JSON.stringify(k)}:${canonical(obj[k])}`).join(",")}}`;
410
+ }
411
+
412
+ async function signReport(
413
+ report: Omit<QuarterlyReport, "reportHmac">,
414
+ secret: string,
415
+ ): Promise<string> {
416
+ const enc = new TextEncoder();
417
+ const key = await crypto.subtle.importKey(
418
+ "raw",
419
+ enc.encode(secret),
420
+ { name: "HMAC", hash: "SHA-256" },
421
+ false,
422
+ ["sign"],
423
+ );
424
+ const sig = await crypto.subtle.sign("HMAC", key, enc.encode(canonical(report)));
425
+ const hex = Array.from(new Uint8Array(sig))
426
+ .map(b => b.toString(16).padStart(2, "0"))
427
+ .join("");
428
+ return `sha256:${hex}`;
429
+ }
430
+
431
+ // ─────────────────────────────────────────────────────────────────────────────
432
+ // Public API
433
+ // ─────────────────────────────────────────────────────────────────────────────
434
+
435
+ export async function generateQuarterlyComplianceReport(
436
+ input: ReportInput,
437
+ options: {
438
+ /** If set, the report is signed with this secret for tamper-evidence. */
439
+ reportSigningSecret?: string;
440
+ /** Override fetch impl (for testing). */
441
+ fetchImpl?: typeof fetch;
442
+ } = {},
443
+ ): Promise<QuarterlyReport> {
444
+ const baseUrl = input.baseUrl ?? "https://ar-agents.ar";
445
+
446
+ // 1. Pull every session's audit log + verification result, in parallel.
447
+ const payloads: AuditPayload[] = await Promise.all(
448
+ input.sessionIds.map(async (sessionId) => {
449
+ const data = (await fetchAudit(sessionId, {
450
+ baseUrl,
451
+ verify: true,
452
+ fetchImpl: options.fetchImpl,
453
+ })) as AuditPayload;
454
+ // Defensive: API may not return verify counts in dev (hmacWired=false).
455
+ return {
456
+ sessionId,
457
+ entries: Array.isArray(data.entries) ? data.entries : [],
458
+ total: data.total,
459
+ verified: data.verified,
460
+ tampered: data.tampered,
461
+ hmacWired: data.hmacWired,
462
+ };
463
+ }),
464
+ );
465
+
466
+ // 2. Summarize each session.
467
+ const sessions = payloads.map(summarizeSession);
468
+
469
+ // 3. Aggregate across sessions.
470
+ const aggregates = aggregate(sessions, payloads);
471
+
472
+ // 4. Roll up anomalies.
473
+ const allAnomalies = sessions.flatMap(s => s.anomalies);
474
+
475
+ // 5. Conclude.
476
+ const conclusion = conclude(aggregates, allAnomalies);
477
+
478
+ // 6. Assemble.
479
+ const base: Omit<QuarterlyReport, "reportHmac"> = {
480
+ $schema: "https://ar-agents.ar/schemas/quarterly-compliance.v1.json",
481
+ generatedAt: new Date().toISOString(),
482
+ schemaVersion: "1.0",
483
+ sociedad: input.sociedad,
484
+ period: { start: input.periodStart, end: input.periodEnd },
485
+ aggregates,
486
+ sessions,
487
+ anomalies: allAnomalies,
488
+ conclusion,
489
+ };
490
+
491
+ const reportHmac = options.reportSigningSecret
492
+ ? await signReport(base, options.reportSigningSecret)
493
+ : null;
494
+
495
+ return { ...base, reportHmac };
496
+ }
497
+
498
+ // ─────────────────────────────────────────────────────────────────────────────
499
+ // CLI entry — node 25-sociedad-ia-quarterly-compliance.ts <config.json>
500
+ //
501
+ // The config file is a JSON document with the ReportInput shape.
502
+ // Example:
503
+ // {
504
+ // "sociedad": {
505
+ // "denominacion": "Sociedad-IA Demo SAS",
506
+ // "operatorCuit": "20-41758101-5",
507
+ // "jurisdiction": "AR",
508
+ // "rfcConformance": ["rfc-001-v1", "rfc-004-draft"],
509
+ // "auditBaseUrl": "https://ar-agents.ar"
510
+ // },
511
+ // "periodStart": "2026-04-01T00:00:00.000Z",
512
+ // "periodEnd": "2026-06-30T23:59:59.999Z",
513
+ // "sessionIds": ["session-abc", "session-def", "session-ghi"],
514
+ // "baseUrl": "https://ar-agents.ar"
515
+ // }
516
+ // ─────────────────────────────────────────────────────────────────────────────
517
+
518
+ declare const process: { argv: string[] } | undefined;
519
+
520
+ async function main() {
521
+ if (typeof process === "undefined") return;
522
+ const configPath = process.argv[2];
523
+ if (!configPath) {
524
+ console.error("usage: tsx 25-sociedad-ia-quarterly-compliance.ts <config.json>");
525
+ return;
526
+ }
527
+ const fs = await import("node:fs/promises");
528
+ const cfg = JSON.parse(await fs.readFile(configPath, "utf8")) as ReportInput;
529
+ const secret = (globalThis as { process?: { env?: Record<string, string> } }).process?.env
530
+ ?.AUDIT_HMAC_SECRET;
531
+ const report = await generateQuarterlyComplianceReport(cfg, {
532
+ reportSigningSecret: secret,
533
+ });
534
+ console.log(JSON.stringify(report, null, 2));
535
+ }
536
+
537
+ const isMain = typeof require !== "undefined" && require.main === module;
538
+ if (isMain) {
539
+ main().catch((e) => {
540
+ console.error(e);
541
+ if (typeof process !== "undefined" && "exit" in process) {
542
+ (process as unknown as { exit: (code: number) => void }).exit(1);
543
+ }
544
+ });
545
+ }