@axtary/ledger 0.1.0 → 0.3.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/dist/index.js CHANGED
@@ -1,11 +1,21 @@
1
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
2
3
  import { dirname } from "node:path";
3
- import { AxtaryDecisionSchema, hashJson, LedgerExecutionOutcomeSchema, LedgerRecordSchema, recordDecision, } from "@axtary/actionpass";
4
+ import { AxtaryDecisionSchema, hashAction, hashJson, hashPayload, LedgerExecutionOutcomeSchema, LedgerRecordSchema, parseNormalizedAction, recordActionPassRevocationEvent, recordDecision, StatusListClient, verifyActionPassArtifact, } from "@axtary/actionpass";
5
+ import { decodeProtectedHeader, importJWK, jwtVerify, SignJWT, } from "jose";
6
+ import { lock } from "proper-lockfile";
4
7
  import { z } from "zod";
8
+ import { LEDGER_PACKAGE_VERSION } from "./version.js";
9
+ import { merkleConsistencyProof, merkleInclusionProof, merkleLeafHash, merkleNodeHash, merkleTreeRoot, verifyMerkleConsistency, verifyMerkleInclusion, } from "./merkle.js";
10
+ export { merkleConsistencyProof, merkleInclusionProof, merkleLeafHash, merkleNodeHash, merkleTreeRoot, verifyMerkleConsistency, verifyMerkleInclusion, };
11
+ export { BEHAVIOR_STORE_VERSION, BehaviorStoreSchema, InMemoryBehaviorStore, FileBehaviorStore, deriveBehaviorSignals, recordBehaviorObservation, } from "./behavior.js";
12
+ export { LEDGER_FORENSICS_VERSION, analyzeForensics, reconstructIncident, } from "./forensics.js";
5
13
  export const LOCAL_LEDGER_SCHEMA_VERSION = "axtary.local-ledger.v0";
14
+ export const LOCAL_LEDGER_HEAD_SCHEMA_VERSION = "axtary.local-ledger-head.v0";
6
15
  export const LEDGER_EXPORT_SCHEMA_VERSION = "axtary.ledger_export.v0";
7
16
  export const LEDGER_SYNC_SCHEMA_VERSION = "axtary.ledger_sync.v0";
8
17
  export const LEDGER_SIEM_EVENT_SCHEMA_VERSION = "axtary.siem_event.v0";
18
+ export const LEDGER_OTEL_EXPORT_SCHEMA_VERSION = "axtary.otel_trace_export.v0";
9
19
  export const LedgerAppendResultSchema = z.object({
10
20
  record: LedgerRecordSchema,
11
21
  lineNumber: z.number().int().positive(),
@@ -60,7 +70,18 @@ export const LedgerSiemEventSchema = z.object({
60
70
  }),
61
71
  provider: z
62
72
  .object({
63
- name: z.enum(["github", "slack", "linear", "docs", "mcp", "aws", "gcp", "jira"]),
73
+ name: z.enum([
74
+ "github",
75
+ "slack",
76
+ "linear",
77
+ "docs",
78
+ "drive",
79
+ "mcp",
80
+ "aws",
81
+ "gcp",
82
+ "jira",
83
+ "postgres",
84
+ ]),
64
85
  tool: z.string().min(1),
65
86
  operation: z.string().min(1),
66
87
  resourceKind: z.string().min(1),
@@ -78,58 +99,81 @@ export const LedgerSyncResultSchema = z.object({
78
99
  lastLedgerHash: z.string().regex(/^sha256:[a-f0-9]{64}$/).nullable(),
79
100
  response: z.unknown().nullable(),
80
101
  });
102
+ const LedgerHeadStateSchema = z.object({
103
+ schemaVersion: z.literal(LOCAL_LEDGER_HEAD_SCHEMA_VERSION),
104
+ fileSize: z.number().int().nonnegative(),
105
+ lineNumber: z.number().int().nonnegative(),
106
+ lastLedgerHash: z.string().regex(/^sha256:[a-f0-9]{64}$/).nullable(),
107
+ });
81
108
  export class LocalJsonlLedger {
82
109
  filePath;
83
- constructor(filePath) {
110
+ headPath;
111
+ options;
112
+ constructor(filePath, options = {}) {
84
113
  this.filePath = filePath;
114
+ this.headPath = `${filePath}.head.json`;
115
+ this.options = options;
85
116
  }
86
117
  async appendDecision(input) {
87
- const records = await this.readRecords();
88
- const previousLedgerHash = records.at(-1)?.ledgerHash ?? null;
89
- const record = recordDecision({
90
- action: input.action,
91
- decision: input.decision,
92
- actionPassId: input.actionPassId ?? null,
93
- previousLedgerHash,
94
- occurredAt: input.occurredAt,
118
+ const result = await this.withAppendLock(async (head) => {
119
+ const record = recordDecision({
120
+ action: input.action,
121
+ decision: input.decision,
122
+ actionPassId: input.actionPassId ?? null,
123
+ delegation: input.delegation,
124
+ budget: input.budget,
125
+ previousLedgerHash: head.lastLedgerHash,
126
+ occurredAt: input.occurredAt,
127
+ });
128
+ return this.appendLocked(record, head);
95
129
  });
96
- return this.appendRecord(record);
130
+ await this.exportTelemetry(result.record);
131
+ return result;
97
132
  }
98
133
  async appendExecutionOutcome(input) {
99
- const records = await this.readRecords();
100
- const previousLedgerHash = records.at(-1)?.ledgerHash ?? null;
101
- const executionOutcome = LedgerExecutionOutcomeSchema.parse(input.executionOutcome);
102
- const record = recordDecision({
103
- action: input.action,
104
- decision: input.decision,
105
- actionPassId: input.actionPassId ?? null,
106
- previousLedgerHash,
107
- occurredAt: input.occurredAt,
108
- traceId: input.traceId,
109
- executionOutcome,
110
- correlationId: input.correlationId,
134
+ const result = await this.withAppendLock(async (head) => {
135
+ const executionOutcome = LedgerExecutionOutcomeSchema.parse(input.executionOutcome);
136
+ const record = recordDecision({
137
+ action: input.action,
138
+ decision: input.decision,
139
+ actionPassId: input.actionPassId ?? null,
140
+ delegation: input.delegation,
141
+ budget: input.budget,
142
+ previousLedgerHash: head.lastLedgerHash,
143
+ occurredAt: input.occurredAt,
144
+ traceId: input.traceId,
145
+ executionOutcome,
146
+ correlationId: input.correlationId,
147
+ });
148
+ return this.appendLocked(record, head);
111
149
  });
112
- return this.appendRecord(record);
150
+ await this.exportTelemetry(result.record);
151
+ return result;
152
+ }
153
+ async appendRevocation(input) {
154
+ const result = await this.withAppendLock(async (head) => {
155
+ const record = recordActionPassRevocationEvent({
156
+ revokedRecord: input.revokedRecord,
157
+ revokedBy: input.revokedBy,
158
+ reason: input.reason,
159
+ occurredAt: input.occurredAt,
160
+ previousLedgerHash: head.lastLedgerHash,
161
+ });
162
+ return this.appendLocked(record, head);
163
+ });
164
+ await this.exportTelemetry(result.record);
165
+ return result;
113
166
  }
114
167
  async appendRecord(recordInput) {
115
- const records = await this.readRecords();
116
- const previousRecord = records.at(-1) ?? null;
117
168
  const record = LedgerRecordSchema.parse(recordInput);
118
- if (record.previousLedgerHash !== (previousRecord?.ledgerHash ?? null)) {
119
- throw new Error("ledger_previous_hash_mismatch");
120
- }
121
- assertRecordHash(record);
122
- await mkdir(dirname(this.filePath), { recursive: true });
123
- const nextContent = [...records, record]
124
- .map((entry) => JSON.stringify(entry))
125
- .join("\n");
126
- const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
127
- await writeFile(tmpPath, `${nextContent}\n`, { encoding: "utf8", flag: "wx" });
128
- await rename(tmpPath, this.filePath);
129
- return LedgerAppendResultSchema.parse({
130
- record,
131
- lineNumber: records.length + 1,
169
+ const result = await this.withAppendLock(async (head) => {
170
+ if (record.previousLedgerHash !== head.lastLedgerHash) {
171
+ throw new Error("ledger_previous_hash_mismatch");
172
+ }
173
+ return this.appendLocked(record, head);
132
174
  });
175
+ await this.exportTelemetry(result.record);
176
+ return result;
133
177
  }
134
178
  async readRecords() {
135
179
  const text = await readLedgerFile(this.filePath);
@@ -187,10 +231,133 @@ export class LocalJsonlLedger {
187
231
  lastLedgerHash: records.at(-1)?.ledgerHash ?? null,
188
232
  };
189
233
  }
234
+ async withAppendLock(operation) {
235
+ await mkdir(dirname(this.filePath), { recursive: true });
236
+ let compromisedReason = null;
237
+ const lockOptions = this.options.lock ?? {};
238
+ const release = await lock(this.filePath, {
239
+ realpath: false,
240
+ stale: lockOptions.staleMs ?? 10_000,
241
+ update: lockOptions.updateMs ?? 5_000,
242
+ retries: {
243
+ retries: lockOptions.retries ?? 100,
244
+ factor: 1.2,
245
+ minTimeout: lockOptions.minTimeoutMs ?? 10,
246
+ maxTimeout: lockOptions.maxTimeoutMs ?? 100,
247
+ },
248
+ onCompromised: (error) => {
249
+ compromisedReason = error.message;
250
+ },
251
+ });
252
+ try {
253
+ if (compromisedReason !== null) {
254
+ throw new Error(`ledger_lock_compromised:${compromisedReason}`);
255
+ }
256
+ const head = await loadLedgerHead(this.filePath, this.headPath);
257
+ const result = await operation(head);
258
+ if (compromisedReason !== null) {
259
+ throw new Error(`ledger_lock_compromised:${compromisedReason}`);
260
+ }
261
+ return result;
262
+ }
263
+ finally {
264
+ await release();
265
+ }
266
+ }
267
+ async appendLocked(recordInput, head) {
268
+ const record = LedgerRecordSchema.parse(recordInput);
269
+ if (record.previousLedgerHash !== head.lastLedgerHash) {
270
+ throw new Error("ledger_previous_hash_mismatch");
271
+ }
272
+ assertRecordHash(record);
273
+ const line = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
274
+ const handle = await open(this.filePath, "a+", 0o600);
275
+ let nextSize;
276
+ try {
277
+ await handle.chmod(0o600);
278
+ const before = await handle.stat();
279
+ if (before.size !== head.fileSize) {
280
+ throw new Error("ledger_concurrent_write_detected");
281
+ }
282
+ await writeAll(handle, line);
283
+ await handle.sync();
284
+ const after = await handle.stat();
285
+ nextSize = after.size;
286
+ if (nextSize !== before.size + line.byteLength) {
287
+ throw new Error("ledger_concurrent_write_detected");
288
+ }
289
+ }
290
+ finally {
291
+ await handle.close();
292
+ }
293
+ const nextHead = LedgerHeadStateSchema.parse({
294
+ schemaVersion: LOCAL_LEDGER_HEAD_SCHEMA_VERSION,
295
+ fileSize: nextSize,
296
+ lineNumber: head.lineNumber + 1,
297
+ lastLedgerHash: record.ledgerHash,
298
+ });
299
+ await persistLedgerHead(this.headPath, nextHead);
300
+ await syncDirectory(dirname(this.filePath));
301
+ return LedgerAppendResultSchema.parse({
302
+ record,
303
+ lineNumber: nextHead.lineNumber,
304
+ });
305
+ }
306
+ async exportTelemetry(record) {
307
+ await this.options.telemetry?.exportRecord(record);
308
+ }
309
+ }
310
+ export class OtlpHttpTraceExporter {
311
+ options;
312
+ constructor(options) {
313
+ this.options = options;
314
+ }
315
+ async exportRecord(record) {
316
+ await exportLedgerRecordsToOtlp({
317
+ ...this.options,
318
+ records: [record],
319
+ });
320
+ }
190
321
  }
191
322
  export async function readLedgerRecords(filePath) {
192
323
  return new LocalJsonlLedger(filePath).readRecords();
193
324
  }
325
+ /**
326
+ * Revoke a pass through the single honest path used by both the CLI and the
327
+ * dashboard: first write the durable trust-store revocation (the enforcement
328
+ * authority the proxy/adapters/MCP check), then append a tamper-evident
329
+ * revocation event to the hash-chained ledger bound to the most recent record
330
+ * naming that pass. Enforcement is written first so a later audit-append
331
+ * failure still leaves the pass actually revoked. When no ledger record names
332
+ * the pass (e.g. it never ran locally), the revocation is still authoritative
333
+ * and `ledgerEvent` is null — never faked.
334
+ */
335
+ export async function revokeActionPassWithLedgerEvent(input) {
336
+ const revocation = await input.trustStore.revokeActionPass({
337
+ passId: input.passId,
338
+ revokedBy: input.revokedBy,
339
+ reason: input.reason,
340
+ revokedAt: input.occurredAt,
341
+ });
342
+ const records = await input.ledger.readRecords();
343
+ const revokedRecord = [...records]
344
+ .reverse()
345
+ .find((record) => record.actionPassId === input.passId);
346
+ let ledgerEvent = null;
347
+ if (revokedRecord) {
348
+ ledgerEvent = await input.ledger.appendRevocation({
349
+ revokedRecord,
350
+ revokedBy: input.revokedBy,
351
+ reason: input.reason,
352
+ occurredAt: input.occurredAt,
353
+ });
354
+ }
355
+ return {
356
+ revocation,
357
+ ledgerEvent,
358
+ revokedRecordId: revokedRecord?.id ?? null,
359
+ };
360
+ }
194
361
  export async function verifyLedgerFile(filePath) {
195
362
  return new LocalJsonlLedger(filePath).verify();
196
363
  }
@@ -226,8 +393,46 @@ export async function exportLedgerRecords(input) {
226
393
  records,
227
394
  });
228
395
  }
396
+ export async function exportLedgerRecordsToOtlp(input) {
397
+ if (input.records.length === 0) {
398
+ throw new Error("otel_export_requires_records");
399
+ }
400
+ // Scanner hygiene: never fall back to the ambient global fetch in library
401
+ // code — callers must inject the transport, and export fails closed without it.
402
+ const fetchImpl = input.fetch;
403
+ if (!fetchImpl) {
404
+ throw new Error("otel_export_fetch_required");
405
+ }
406
+ const records = input.records.map((record) => LedgerRecordSchema.parse(record));
407
+ const body = buildOtlpTraceRequest({
408
+ records,
409
+ serviceName: input.serviceName,
410
+ serviceVersion: input.serviceVersion,
411
+ });
412
+ const response = await fetchWithTimeout(fetchImpl, input.endpoint, {
413
+ method: "POST",
414
+ headers: {
415
+ "content-type": "application/json",
416
+ accept: "application/json",
417
+ ...(input.headers ?? {}),
418
+ },
419
+ body: JSON.stringify(body),
420
+ }, input.timeoutMs);
421
+ await response.text();
422
+ if (!response.ok) {
423
+ throw new Error(`otel_export_failed:${response.status}`);
424
+ }
425
+ return {
426
+ schemaVersion: LEDGER_OTEL_EXPORT_SCHEMA_VERSION,
427
+ endpoint: input.endpoint,
428
+ exportedAt: new Date().toISOString(),
429
+ records: records.length,
430
+ spans: records.length,
431
+ status: response.status,
432
+ };
433
+ }
229
434
  export async function syncLedgerExport(input) {
230
- const fetchImpl = input.fetch ?? globalThis.fetch;
435
+ const fetchImpl = input.fetch;
231
436
  if (!fetchImpl) {
232
437
  throw new Error("ledger_sync_fetch_required");
233
438
  }
@@ -274,6 +479,716 @@ export function formatLedgerExport(input) {
274
479
  export function computeLedgerHash(record) {
275
480
  return hashJson(record);
276
481
  }
482
+ /** Recompute a record's `ledgerHash` from its own content (spec §9.1 step 2). */
483
+ function recomputeRecordLedgerHash(record) {
484
+ const { ledgerHash, ...recordWithoutHash } = record;
485
+ void ledgerHash;
486
+ return computeLedgerHash(recordWithoutHash);
487
+ }
488
+ // ---------------------------------------------------------------------------
489
+ // Ledger attestation (axtary.ledger_attestation.v0)
490
+ //
491
+ // A detached, signed attestation over a ledger export: a JWS whose claims pin
492
+ // the SHA-256 digest of the canonical (JCS) export plus the chain head/tail and
493
+ // record count. An independent verifier needs only this artifact, the export,
494
+ // and the issuer's public key (plus a JOSE library) to confirm the export was
495
+ // signed by the issuer and has not been altered by a single byte — with no
496
+ // Axtary internals. See spec/actionpass-v0.md §9.
497
+ // ---------------------------------------------------------------------------
498
+ export const LEDGER_ATTESTATION_VERSION = "axtary.ledger_attestation.v0";
499
+ const LEDGER_ATTESTATION_TYP = "axtary-ledger-attestation+jwt";
500
+ const LEDGER_ATTESTATION_ALGORITHMS = ["ES256", "EdDSA"];
501
+ const SHA256_HASH = z.string().regex(/^sha256:[a-f0-9]{64}$/);
502
+ export const LedgerAttestationClaimsSchema = z.object({
503
+ lav: z.literal(LEDGER_ATTESTATION_VERSION),
504
+ iss: z.string().min(1),
505
+ iat: z.number().int().positive(),
506
+ tenant: z.string().min(1).optional(),
507
+ exportDigest: SHA256_HASH,
508
+ recordCount: z.number().int().nonnegative(),
509
+ firstLedgerHash: SHA256_HASH.nullable(),
510
+ segmentHead: SHA256_HASH.nullable(),
511
+ sourceLastLedgerHash: SHA256_HASH.nullable(),
512
+ range: LedgerExportFilterSchema,
513
+ // RFC 6962 signed tree head (optional; older v0 attestations omit these and
514
+ // older verifiers ignore them — the exportDigest still binds the full export).
515
+ // `treeSize` equals `recordCount`; `merkleRoot` is the MTH over the per-record
516
+ // `ledgerHash` leaves, enabling inclusion/consistency proofs (spec §9.4).
517
+ merkleRoot: SHA256_HASH.optional(),
518
+ treeSize: z.number().int().nonnegative().optional(),
519
+ });
520
+ /** SHA-256 digest of the canonical (JCS) ledger export — the content binding. */
521
+ export function computeLedgerExportDigest(exportInput) {
522
+ return hashJson(LedgerExportSchema.parse(exportInput));
523
+ }
524
+ /**
525
+ * RFC 6962 leaves for an export: one leaf per record, committing to that
526
+ * record's `ledgerHash`. Because a verifier recomputes each `ledgerHash` from
527
+ * record content (spec §9.1), binding leaves to `ledgerHash` transitively binds
528
+ * the full record content — a one-byte rewrite changes the leaf and the root.
529
+ */
530
+ export function ledgerExportLeafHashes(exportInput) {
531
+ const exported = LedgerExportSchema.parse(exportInput);
532
+ return exported.records.map((record) => merkleLeafHash(Buffer.from(record.ledgerHash, "utf8")));
533
+ }
534
+ /** Merkle Tree Hash (RFC 6962) over the export's record leaves, as `sha256:hex`. */
535
+ export function computeLedgerExportMerkleRoot(exportInput) {
536
+ const root = merkleTreeRoot(ledgerExportLeafHashes(exportInput));
537
+ return `sha256:${root.toString("hex")}`;
538
+ }
539
+ function hexFromSha256(value) {
540
+ return Buffer.from(value.replace(/^sha256:/, ""), "hex");
541
+ }
542
+ export async function attestLedgerExport(input) {
543
+ const exported = LedgerExportSchema.parse(input.export);
544
+ const now = input.now ?? new Date();
545
+ const claims = LedgerAttestationClaimsSchema.parse({
546
+ lav: LEDGER_ATTESTATION_VERSION,
547
+ iss: input.issuer,
548
+ iat: Math.floor(now.getTime() / 1000),
549
+ tenant: input.tenant,
550
+ exportDigest: hashJson(exported),
551
+ recordCount: exported.records.length,
552
+ firstLedgerHash: exported.records[0]?.ledgerHash ?? null,
553
+ segmentHead: exported.records.at(-1)?.ledgerHash ?? null,
554
+ sourceLastLedgerHash: exported.source.lastLedgerHash,
555
+ range: exported.filters,
556
+ merkleRoot: computeLedgerExportMerkleRoot(exported),
557
+ treeSize: exported.records.length,
558
+ });
559
+ const token = await new SignJWT(claims)
560
+ .setProtectedHeader({
561
+ alg: input.algorithm ?? "ES256",
562
+ typ: LEDGER_ATTESTATION_TYP,
563
+ kid: input.keyId,
564
+ })
565
+ .sign(input.signingKey);
566
+ return { schemaVersion: LEDGER_ATTESTATION_VERSION, token, claims };
567
+ }
568
+ /**
569
+ * Standalone verifier: confirm a ledger export carries a valid issuer signature
570
+ * and has not been altered. Needs only the export, the attestation token, and
571
+ * the issuer public key — re-derivable from the spec alone.
572
+ */
573
+ export async function verifyLedgerAttestation(input) {
574
+ try {
575
+ const exported = LedgerExportSchema.parse(input.export);
576
+ const token = typeof input.attestation === "string"
577
+ ? input.attestation
578
+ : input.attestation.token;
579
+ const algorithms = input.algorithms ?? LEDGER_ATTESTATION_ALGORITHMS;
580
+ const header = decodeProtectedHeader(token);
581
+ if (header.typ !== LEDGER_ATTESTATION_TYP) {
582
+ return { valid: false, reason: "attestation_type_mismatch" };
583
+ }
584
+ const verificationKey = await resolveAttestationKey(input, header.kid, algorithms);
585
+ const { payload } = await jwtVerify(token, verificationKey, {
586
+ issuer: input.issuer,
587
+ algorithms,
588
+ typ: LEDGER_ATTESTATION_TYP,
589
+ currentDate: input.currentDate,
590
+ });
591
+ const claims = LedgerAttestationClaimsSchema.parse(payload);
592
+ const chainError = verifyExportRecordChain(exported);
593
+ if (chainError) {
594
+ return { valid: false, reason: chainError };
595
+ }
596
+ // Approval↔execution equivalence is part of export verification: a
597
+ // record claiming an approval-bound execution whose hashes disagree is a
598
+ // tampered or bypassed execution, and the export must not verify.
599
+ const equivalenceError = verifyEquivalence(exported);
600
+ if (equivalenceError) {
601
+ return { valid: false, reason: equivalenceError };
602
+ }
603
+ if (hashJson(exported) !== claims.exportDigest) {
604
+ return { valid: false, reason: "export_digest_mismatch" };
605
+ }
606
+ if (claims.recordCount !== exported.records.length) {
607
+ return { valid: false, reason: "export_record_count_mismatch" };
608
+ }
609
+ return { valid: true, claims, recomputedDigest: claims.exportDigest };
610
+ }
611
+ catch (error) {
612
+ return {
613
+ valid: false,
614
+ reason: error instanceof Error ? error.message : "attestation_verification_failed",
615
+ };
616
+ }
617
+ }
618
+ export function buildOtlpTraceRequest(input) {
619
+ return {
620
+ resourceSpans: [
621
+ {
622
+ resource: {
623
+ attributes: otlpAttributes({
624
+ "service.name": input.serviceName ?? "axtary-local",
625
+ "service.version": input.serviceVersion ?? LEDGER_PACKAGE_VERSION,
626
+ "axtary.exporter": "ledger-otlp",
627
+ }),
628
+ },
629
+ scopeSpans: [
630
+ {
631
+ scope: {
632
+ name: "@axtary/ledger",
633
+ version: LEDGER_PACKAGE_VERSION,
634
+ },
635
+ spans: input.records.map(ledgerRecordToOtlpSpan),
636
+ },
637
+ ],
638
+ },
639
+ ],
640
+ };
641
+ }
642
+ function ledgerRecordToOtlpSpan(record) {
643
+ const startedAt = dateToUnixNanos(record.occurredAt);
644
+ const endedAt = startedAt + BigInt(1_000_000);
645
+ const toolName = record.auditContext?.tool ?? record.providerEvidence?.tool ?? "unknown";
646
+ const status = otlpSpanStatus(record);
647
+ return {
648
+ traceId: traceIdForRecord(record),
649
+ spanId: spanIdForRecord(record),
650
+ name: `execute_tool ${toolName}`,
651
+ kind: "SPAN_KIND_INTERNAL",
652
+ startTimeUnixNano: startedAt.toString(),
653
+ endTimeUnixNano: endedAt.toString(),
654
+ attributes: otlpAttributes({
655
+ "gen_ai.operation.name": "execute_tool",
656
+ "gen_ai.tool.name": toolName,
657
+ "gen_ai.tool.type": "function",
658
+ "axtary.schema_version": record.schemaVersion,
659
+ "axtary.ledger.record_id": record.id,
660
+ "axtary.ledger.hash": record.ledgerHash,
661
+ "axtary.ledger.previous_hash": record.previousLedgerHash,
662
+ "axtary.action.hash": record.actionHash,
663
+ "axtary.action.payload_hash": record.payloadHash,
664
+ "axtary.action.pass_id": record.actionPassId,
665
+ "axtary.trace.id": record.traceId ?? null,
666
+ "axtary.correlation_id": record.correlationId ?? null,
667
+ "axtary.decision": record.decision,
668
+ "axtary.decision.reasons": record.reasons,
669
+ "axtary.policy.native_rule": record.policy.nativeRule,
670
+ "axtary.policy.version": record.policy.version,
671
+ "axtary.audit.tenant": record.auditContext?.tenant ?? null,
672
+ "axtary.audit.agent_id": record.auditContext?.agentId ?? null,
673
+ "axtary.audit.human_owner": record.auditContext?.humanOwner ?? null,
674
+ "axtary.audit.task_id": record.auditContext?.taskId ?? null,
675
+ "axtary.audit.resource": record.auditContext?.resource ?? null,
676
+ "axtary.execution.status": record.executionOutcome?.status ?? null,
677
+ "axtary.execution.result_id": record.executionOutcome?.resultId ?? null,
678
+ "axtary.execution.failure_reason": record.executionOutcome?.failureReason ?? null,
679
+ }),
680
+ ...(status ? { status } : {}),
681
+ };
682
+ }
683
+ function otlpSpanStatus(record) {
684
+ if (record.decision === "deny" ||
685
+ record.decision === "step_up" ||
686
+ record.executionOutcome?.status === "failed") {
687
+ return {
688
+ code: "STATUS_CODE_ERROR",
689
+ message: record.executionOutcome?.failureReason ??
690
+ record.reasons[0] ??
691
+ record.decision,
692
+ };
693
+ }
694
+ if (record.executionOutcome?.status === "succeeded") {
695
+ return { code: "STATUS_CODE_OK" };
696
+ }
697
+ return null;
698
+ }
699
+ function otlpAttributes(values) {
700
+ const attributes = [];
701
+ for (const [key, value] of Object.entries(values)) {
702
+ const otlpValue = toOtlpAnyValue(value);
703
+ if (otlpValue) {
704
+ attributes.push({ key, value: otlpValue });
705
+ }
706
+ }
707
+ return attributes;
708
+ }
709
+ function toOtlpAnyValue(value) {
710
+ if (value === null || value === undefined)
711
+ return null;
712
+ if (Array.isArray(value)) {
713
+ return { arrayValue: { values: value.map((entry) => ({ stringValue: entry })) } };
714
+ }
715
+ if (typeof value === "boolean")
716
+ return { boolValue: value };
717
+ if (typeof value === "number")
718
+ return { intValue: Math.trunc(value).toString() };
719
+ return { stringValue: value };
720
+ }
721
+ function traceIdForRecord(record) {
722
+ const source = record.traceId ?? record.correlationId ?? record.id;
723
+ return hexBytes(`trace:${source}`, 16);
724
+ }
725
+ function spanIdForRecord(record) {
726
+ return hexBytes(`span:${record.id}`, 8);
727
+ }
728
+ function hexBytes(input, bytes) {
729
+ const hex = createHash("sha256").update(input).digest("hex").slice(0, bytes * 2);
730
+ return /^0+$/.test(hex) ? `1${hex.slice(1)}` : hex;
731
+ }
732
+ function dateToUnixNanos(isoDate) {
733
+ return BigInt(new Date(isoDate).getTime()) * BigInt(1_000_000);
734
+ }
735
+ /**
736
+ * Re-verify the exported records from the records alone: every record's own hash
737
+ * must recompute, and — for a full (unfiltered) export — `previousLedgerHash` must
738
+ * chain. Filtered exports are not contiguous, so only the per-record hash is
739
+ * asserted; reorder/truncation is still caught by the signed export digest.
740
+ */
741
+ export const LEDGER_EQUIVALENCE_REPORT_VERSION = "axtary.ledger_equivalence_report.v0";
742
+ /**
743
+ * The approval↔execution equivalence proof over execution-outcome records.
744
+ *
745
+ * `proved` means the record carries both hashes, the executed hash agrees
746
+ * with the record's own hash-chained `payloadHash`, and approved == executed.
747
+ * `unproven` means the execution carries no pair (no approval evidence, or a
748
+ * record written before the pair existed) — truthfully absent, never
749
+ * fake-proved. Any inconsistency is `failed`: an incomplete pair, an executed
750
+ * hash that disagrees with the record itself (a forged outcome block), or
751
+ * approved != executed (an execution that escaped enforcement).
752
+ */
753
+ export function evaluateLedgerEquivalence(records) {
754
+ const entries = [];
755
+ for (let index = 0; index < records.length; index += 1) {
756
+ const record = records[index];
757
+ const outcome = record.executionOutcome;
758
+ if (!outcome)
759
+ continue;
760
+ const approved = outcome.approvedPayloadHash ?? null;
761
+ const executed = outcome.executedPayloadHash ?? null;
762
+ let status = "unproven";
763
+ let failure = null;
764
+ if (approved !== null || executed !== null) {
765
+ if (approved === null || executed === null) {
766
+ status = "failed";
767
+ failure = "equivalence_pair_incomplete";
768
+ }
769
+ else if (executed !== record.payloadHash) {
770
+ status = "failed";
771
+ failure = "equivalence_executed_hash_mismatch";
772
+ }
773
+ else if (approved !== executed) {
774
+ status = "failed";
775
+ failure = "approval_execution_equivalence_failed";
776
+ }
777
+ else {
778
+ status = "proved";
779
+ }
780
+ }
781
+ entries.push({
782
+ line: index + 1,
783
+ recordId: record.id,
784
+ occurredAt: record.occurredAt,
785
+ tool: record.auditContext?.tool ?? record.providerEvidence?.tool ?? null,
786
+ decision: record.decision,
787
+ approvedPayloadHash: approved,
788
+ executedPayloadHash: executed,
789
+ recordPayloadHash: record.payloadHash,
790
+ status,
791
+ failure,
792
+ });
793
+ }
794
+ return {
795
+ schemaVersion: LEDGER_EQUIVALENCE_REPORT_VERSION,
796
+ executions: entries.length,
797
+ proved: entries.filter((entry) => entry.status === "proved").length,
798
+ failed: entries.filter((entry) => entry.status === "failed").length,
799
+ unproven: entries.filter((entry) => entry.status === "unproven").length,
800
+ entries,
801
+ };
802
+ }
803
+ function verifyEquivalence(exported) {
804
+ const report = evaluateLedgerEquivalence(exported.records);
805
+ const firstFailure = report.entries.find((entry) => entry.status === "failed");
806
+ return firstFailure ? `${firstFailure.failure}:${firstFailure.line}` : null;
807
+ }
808
+ function verifyExportRecordChain(exported) {
809
+ const isFull = exported.filters.from === null &&
810
+ exported.filters.to === null &&
811
+ exported.filters.decisions === null;
812
+ let previous = null;
813
+ for (let index = 0; index < exported.records.length; index += 1) {
814
+ const record = exported.records[index];
815
+ try {
816
+ assertRecordHash(record);
817
+ }
818
+ catch {
819
+ return `ledger_record_hash_mismatch:${index + 1}`;
820
+ }
821
+ if (isFull) {
822
+ if (record.previousLedgerHash !== previous) {
823
+ return `ledger_previous_hash_mismatch:${index + 1}`;
824
+ }
825
+ previous = record.ledgerHash;
826
+ }
827
+ }
828
+ return null;
829
+ }
830
+ async function resolveAttestationKey(input, keyId, algorithms) {
831
+ let key;
832
+ if (input.verificationKeys) {
833
+ key =
834
+ typeof input.verificationKeys === "function"
835
+ ? await input.verificationKeys(keyId)
836
+ : keyId
837
+ ? input.verificationKeys[keyId]
838
+ : undefined;
839
+ if (!key) {
840
+ throw new Error(keyId ? `verification_key_not_found:${keyId}` : "verification_key_id_required");
841
+ }
842
+ }
843
+ else {
844
+ if (!input.verificationKey) {
845
+ throw new Error("verification_key_required");
846
+ }
847
+ key = input.verificationKey;
848
+ }
849
+ return normalizeVerificationKey(key, algorithms);
850
+ }
851
+ async function normalizeVerificationKey(key, algorithms) {
852
+ if (isJwk(key)) {
853
+ const imported = await importJWK(key, key.alg ?? algorithms[0] ?? "ES256");
854
+ return imported;
855
+ }
856
+ return key;
857
+ }
858
+ function isJwk(key) {
859
+ return (typeof key === "object" &&
860
+ key !== null &&
861
+ !(key instanceof Uint8Array) &&
862
+ "kty" in key);
863
+ }
864
+ // ---------------------------------------------------------------------------
865
+ // Attestation bundle: a self-contained artifact (export + token + public key)
866
+ // that a third party can verify with nothing but a JOSE library and §3/§9 of
867
+ // the spec. This is what `axtary attest-ledger` emits and `verify-export` reads.
868
+ // ---------------------------------------------------------------------------
869
+ export const LEDGER_ATTESTATION_BUNDLE_VERSION = "axtary.ledger_attestation_bundle.v0";
870
+ export const LedgerAttestationBundleSchema = z.object({
871
+ schemaVersion: z.literal(LEDGER_ATTESTATION_BUNDLE_VERSION),
872
+ export: LedgerExportSchema,
873
+ attestation: z.object({
874
+ token: z.string().min(1),
875
+ claims: LedgerAttestationClaimsSchema,
876
+ }),
877
+ publicJwk: z.record(z.string(), z.unknown()),
878
+ keyId: z.string().min(1).nullable(),
879
+ });
880
+ /** Bundle an export, its attestation, and the public key into one artifact. */
881
+ export function buildLedgerAttestationBundle(input) {
882
+ return LedgerAttestationBundleSchema.parse({
883
+ schemaVersion: LEDGER_ATTESTATION_BUNDLE_VERSION,
884
+ export: input.export,
885
+ attestation: {
886
+ token: input.attestation.token,
887
+ claims: input.attestation.claims,
888
+ },
889
+ publicJwk: input.publicJwk,
890
+ keyId: input.keyId ?? null,
891
+ });
892
+ }
893
+ /** Verify a self-contained attestation bundle (export + token + embedded public key). */
894
+ export async function verifyLedgerAttestationBundle(bundleInput, options = {}) {
895
+ let bundle;
896
+ try {
897
+ bundle = LedgerAttestationBundleSchema.parse(bundleInput);
898
+ }
899
+ catch (error) {
900
+ return {
901
+ valid: false,
902
+ reason: error instanceof Error ? `invalid_bundle:${error.message}` : "invalid_bundle",
903
+ };
904
+ }
905
+ return verifyLedgerAttestation({
906
+ export: bundle.export,
907
+ attestation: bundle.attestation.token,
908
+ verificationKey: bundle.publicJwk,
909
+ issuer: options.issuer,
910
+ algorithms: options.algorithms,
911
+ currentDate: options.currentDate,
912
+ });
913
+ }
914
+ // ---------------------------------------------------------------------------
915
+ // RFC 6962 transparency-log proofs (spec/actionpass-v0.md §9.4)
916
+ //
917
+ // Inclusion and consistency proof artifacts over the export's Merkle tree. They
918
+ // are not signed themselves; trust flows from the signed tree head (the
919
+ // attestation's `merkleRoot`/`treeSize`). A verifier binds a proof to a signed
920
+ // head by passing the attestation's `merkleRoot` as the expected root.
921
+ // ---------------------------------------------------------------------------
922
+ const HEX_NODE = z.string().regex(/^[a-f0-9]{64}$/);
923
+ export const LEDGER_INCLUSION_PROOF_VERSION = "axtary.ledger_inclusion_proof.v0";
924
+ export const LedgerInclusionProofSchema = z.object({
925
+ schemaVersion: z.literal(LEDGER_INCLUSION_PROOF_VERSION),
926
+ recordId: z.string().min(1),
927
+ ledgerHash: SHA256_HASH,
928
+ leafIndex: z.number().int().nonnegative(),
929
+ treeSize: z.number().int().positive(),
930
+ merkleRoot: SHA256_HASH,
931
+ auditPath: z.array(HEX_NODE),
932
+ });
933
+ export const LEDGER_CONSISTENCY_PROOF_VERSION = "axtary.ledger_consistency_proof.v0";
934
+ export const LedgerConsistencyProofSchema = z.object({
935
+ schemaVersion: z.literal(LEDGER_CONSISTENCY_PROOF_VERSION),
936
+ firstSize: z.number().int().positive(),
937
+ secondSize: z.number().int().positive(),
938
+ firstRoot: SHA256_HASH,
939
+ secondRoot: SHA256_HASH,
940
+ proof: z.array(HEX_NODE),
941
+ });
942
+ /** Build an inclusion proof for one record (by id or 0-based index). */
943
+ export function proveLedgerInclusion(input) {
944
+ const exported = LedgerExportSchema.parse(input.export);
945
+ if (exported.records.length === 0) {
946
+ throw new Error("ledger_inclusion_empty_export");
947
+ }
948
+ const index = input.index ??
949
+ exported.records.findIndex((record) => record.id === input.recordId);
950
+ if (index < 0 || index >= exported.records.length) {
951
+ throw new Error("ledger_inclusion_record_not_found");
952
+ }
953
+ const record = exported.records[index];
954
+ const leaves = ledgerExportLeafHashes(exported);
955
+ const auditPath = merkleInclusionProof(leaves, index);
956
+ const root = merkleTreeRoot(leaves);
957
+ return LedgerInclusionProofSchema.parse({
958
+ schemaVersion: LEDGER_INCLUSION_PROOF_VERSION,
959
+ recordId: record.id,
960
+ ledgerHash: record.ledgerHash,
961
+ leafIndex: index,
962
+ treeSize: exported.records.length,
963
+ merkleRoot: `sha256:${root.toString("hex")}`,
964
+ auditPath: auditPath.map((node) => node.toString("hex")),
965
+ });
966
+ }
967
+ /**
968
+ * Verify an inclusion proof against a Merkle root. Pass `expectedRoot` (e.g. a
969
+ * signed attestation's `merkleRoot`) to bind the proof to a signed tree head.
970
+ * Pass `record` to additionally recompute the leaf from record content so a
971
+ * proof cannot smuggle a `ledgerHash` that does not match the record.
972
+ */
973
+ export function verifyLedgerInclusionProof(input) {
974
+ let proof;
975
+ try {
976
+ proof = LedgerInclusionProofSchema.parse(input.proof);
977
+ }
978
+ catch (error) {
979
+ return {
980
+ valid: false,
981
+ reason: error instanceof Error ? `invalid_proof:${error.message}` : "invalid_proof",
982
+ };
983
+ }
984
+ if (proof.leafIndex >= proof.treeSize) {
985
+ return { valid: false, reason: "leaf_index_out_of_range" };
986
+ }
987
+ if (input.expectedRoot && input.expectedRoot !== proof.merkleRoot) {
988
+ return { valid: false, reason: "merkle_root_mismatch" };
989
+ }
990
+ if (input.record) {
991
+ if (recomputeRecordLedgerHash(input.record) !== proof.ledgerHash) {
992
+ return { valid: false, reason: "ledger_record_hash_mismatch" };
993
+ }
994
+ if (input.record.id !== proof.recordId) {
995
+ return { valid: false, reason: "record_id_mismatch" };
996
+ }
997
+ }
998
+ const ok = verifyMerkleInclusion({
999
+ leafHash: merkleLeafHash(Buffer.from(proof.ledgerHash, "utf8")),
1000
+ index: proof.leafIndex,
1001
+ treeSize: proof.treeSize,
1002
+ proof: proof.auditPath.map((node) => Buffer.from(node, "hex")),
1003
+ root: hexFromSha256(proof.merkleRoot),
1004
+ });
1005
+ return ok ? { valid: true } : { valid: false, reason: "merkle_inclusion_invalid" };
1006
+ }
1007
+ /**
1008
+ * Build a consistency proof showing the `second` export append-only-extends the
1009
+ * `first`. Roots are computed from each export independently; if `first` is not
1010
+ * a genuine prefix, verification fails (the honest, fail-closed outcome).
1011
+ */
1012
+ export function proveLedgerConsistency(input) {
1013
+ const first = LedgerExportSchema.parse(input.first);
1014
+ const second = LedgerExportSchema.parse(input.second);
1015
+ const firstSize = first.records.length;
1016
+ const secondSize = second.records.length;
1017
+ if (firstSize === 0) {
1018
+ throw new Error("ledger_consistency_empty_first");
1019
+ }
1020
+ if (firstSize > secondSize) {
1021
+ throw new Error("ledger_consistency_first_larger");
1022
+ }
1023
+ const firstLeaves = ledgerExportLeafHashes(first);
1024
+ const secondLeaves = ledgerExportLeafHashes(second);
1025
+ const proof = merkleConsistencyProof(secondLeaves, firstSize);
1026
+ return LedgerConsistencyProofSchema.parse({
1027
+ schemaVersion: LEDGER_CONSISTENCY_PROOF_VERSION,
1028
+ firstSize,
1029
+ secondSize,
1030
+ firstRoot: `sha256:${merkleTreeRoot(firstLeaves).toString("hex")}`,
1031
+ secondRoot: `sha256:${merkleTreeRoot(secondLeaves).toString("hex")}`,
1032
+ proof: proof.map((node) => node.toString("hex")),
1033
+ });
1034
+ }
1035
+ /**
1036
+ * Verify a consistency proof. Pass `firstExpectedRoot`/`secondExpectedRoot`
1037
+ * (the two signed heads' `merkleRoot`s) to bind the proof to signed tree heads.
1038
+ */
1039
+ export function verifyLedgerConsistencyProof(input) {
1040
+ let proof;
1041
+ try {
1042
+ proof = LedgerConsistencyProofSchema.parse(input.proof);
1043
+ }
1044
+ catch (error) {
1045
+ return {
1046
+ valid: false,
1047
+ reason: error instanceof Error ? `invalid_proof:${error.message}` : "invalid_proof",
1048
+ };
1049
+ }
1050
+ if (proof.firstSize > proof.secondSize) {
1051
+ return { valid: false, reason: "first_size_exceeds_second" };
1052
+ }
1053
+ if (input.firstExpectedRoot && input.firstExpectedRoot !== proof.firstRoot) {
1054
+ return { valid: false, reason: "first_root_mismatch" };
1055
+ }
1056
+ if (input.secondExpectedRoot && input.secondExpectedRoot !== proof.secondRoot) {
1057
+ return { valid: false, reason: "second_root_mismatch" };
1058
+ }
1059
+ const ok = verifyMerkleConsistency({
1060
+ firstSize: proof.firstSize,
1061
+ secondSize: proof.secondSize,
1062
+ firstRoot: hexFromSha256(proof.firstRoot),
1063
+ secondRoot: hexFromSha256(proof.secondRoot),
1064
+ proof: proof.proof.map((node) => Buffer.from(node, "hex")),
1065
+ });
1066
+ return ok ? { valid: true } : { valid: false, reason: "merkle_consistency_invalid" };
1067
+ }
1068
+ // ---------------------------------------------------------------------------
1069
+ // M9.6 cross-issuer verification primitive
1070
+ //
1071
+ // This is a verify-only Axtary profile over existing primitives. It does not
1072
+ // authorize execution and does not implement general OpenID Federation trust
1073
+ // chain resolution. A verifier pins one issuer trust-root document, then checks
1074
+ // the issuer's ActionPass signature, ledger attestation signature, inclusion
1075
+ // proof, and fresh distributed revocation/status evidence against that root.
1076
+ // ---------------------------------------------------------------------------
1077
+ export const CROSS_ISSUER_TRUST_ROOT_VERSION = "axtary.cross_issuer_trust_root.v0";
1078
+ export const CROSS_ISSUER_VERIFICATION_REPORT_VERSION = "axtary.cross_issuer_verification_report.v0";
1079
+ export const CrossIssuerTrustRootSchema = z.object({
1080
+ schemaVersion: z.literal(CROSS_ISSUER_TRUST_ROOT_VERSION),
1081
+ issuer: z.string().url(),
1082
+ jwks: z.object({
1083
+ keys: z.array(z.record(z.string(), z.unknown())).min(1),
1084
+ }),
1085
+ statusListUri: z.string().url(),
1086
+ federationProfile: z
1087
+ .object({
1088
+ profile: z.literal("axtary.openid-federation-anchor.v0"),
1089
+ entityStatementTyp: z.literal("entity-statement+jwt"),
1090
+ source: z.string().url().optional(),
1091
+ })
1092
+ .optional(),
1093
+ });
1094
+ export async function verifyCrossIssuerActionPass(input) {
1095
+ let trustRoot;
1096
+ let bundle;
1097
+ try {
1098
+ trustRoot = CrossIssuerTrustRootSchema.parse(input.trustRoot);
1099
+ bundle = LedgerAttestationBundleSchema.parse(input.ledgerAttestationBundle);
1100
+ }
1101
+ catch (error) {
1102
+ return {
1103
+ valid: false,
1104
+ reason: error instanceof Error ? `invalid_cross_issuer_input:${error.message}` : "invalid_cross_issuer_input",
1105
+ };
1106
+ }
1107
+ const currentDate = input.currentDate ?? new Date();
1108
+ const keyForKid = (kid) => kid
1109
+ ? trustRoot.jwks.keys.find((key) => key.kid === kid)
1110
+ : undefined;
1111
+ const statusClient = new StatusListClient({
1112
+ issuer: trustRoot.issuer,
1113
+ verificationKeys: keyForKid,
1114
+ fetch: input.statusFetch,
1115
+ now: () => currentDate,
1116
+ });
1117
+ const pass = await verifyActionPassArtifact({
1118
+ token: input.actionPassToken,
1119
+ action: input.action,
1120
+ verificationKeys: keyForKid,
1121
+ issuer: trustRoot.issuer,
1122
+ currentDate,
1123
+ getStatus: async (claims) => {
1124
+ if (claims.status.status_list.uri !== trustRoot.statusListUri) {
1125
+ return "cross_issuer_status_uri_mismatch";
1126
+ }
1127
+ return statusClient.status(claims.status.status_list);
1128
+ },
1129
+ });
1130
+ if (!pass.valid) {
1131
+ return { valid: false, reason: `actionpass_invalid:${pass.reason}` };
1132
+ }
1133
+ if (bundle.keyId === null) {
1134
+ return { valid: false, reason: "ledger_attestation_key_id_required" };
1135
+ }
1136
+ const attestationKey = keyForKid(bundle.keyId);
1137
+ if (!attestationKey) {
1138
+ return {
1139
+ valid: false,
1140
+ reason: `ledger_attestation_key_not_trusted:${bundle.keyId}`,
1141
+ };
1142
+ }
1143
+ const attestation = await verifyLedgerAttestation({
1144
+ export: bundle.export,
1145
+ attestation: bundle.attestation.token,
1146
+ verificationKey: attestationKey,
1147
+ issuer: trustRoot.issuer,
1148
+ currentDate,
1149
+ });
1150
+ if (!attestation.valid) {
1151
+ return { valid: false, reason: `ledger_attestation_invalid:${attestation.reason}` };
1152
+ }
1153
+ if (!attestation.claims.merkleRoot || attestation.claims.treeSize === undefined) {
1154
+ return { valid: false, reason: "ledger_signed_head_missing" };
1155
+ }
1156
+ const action = parseNormalizedAction(input.action);
1157
+ const actionHash = hashAction(action);
1158
+ const payloadHash = hashPayload(action.capability.payload);
1159
+ const record = bundle.export.records.find((candidate) => candidate.actionPassId === pass.claims.jti &&
1160
+ candidate.actionHash === actionHash &&
1161
+ candidate.payloadHash === pass.payloadHash);
1162
+ if (!record) {
1163
+ return { valid: false, reason: "ledger_record_for_pass_not_found" };
1164
+ }
1165
+ if (record.payloadHash !== payloadHash) {
1166
+ return { valid: false, reason: "ledger_record_payload_hash_mismatch" };
1167
+ }
1168
+ const inclusion = verifyLedgerInclusionProof({
1169
+ proof: input.inclusionProof,
1170
+ expectedRoot: attestation.claims.merkleRoot,
1171
+ record,
1172
+ });
1173
+ if (!inclusion.valid) {
1174
+ return { valid: false, reason: `ledger_inclusion_invalid:${inclusion.reason}` };
1175
+ }
1176
+ return {
1177
+ valid: true,
1178
+ report: {
1179
+ schemaVersion: CROSS_ISSUER_VERIFICATION_REPORT_VERSION,
1180
+ issuer: trustRoot.issuer,
1181
+ passId: pass.claims.jti,
1182
+ recordId: record.id,
1183
+ payloadHash: pass.payloadHash,
1184
+ actionHash,
1185
+ ledgerHash: record.ledgerHash,
1186
+ merkleRoot: attestation.claims.merkleRoot,
1187
+ statusListUri: trustRoot.statusListUri,
1188
+ verifiedAt: currentDate.toISOString(),
1189
+ },
1190
+ };
1191
+ }
277
1192
  function toSiemEvent(record, exported) {
278
1193
  return {
279
1194
  "@timestamp": record.occurredAt,
@@ -368,6 +1283,175 @@ function assertRecordHash(record) {
368
1283
  throw new Error("ledger_hash_mismatch");
369
1284
  }
370
1285
  }
1286
+ async function loadLedgerHead(filePath, headPath) {
1287
+ const handle = await open(filePath, "a+", 0o600);
1288
+ try {
1289
+ const stats = await handle.stat();
1290
+ const persisted = await readLedgerHead(headPath);
1291
+ if (stats.size === 0) {
1292
+ if (persisted && persisted.fileSize > 0) {
1293
+ throw new Error("ledger_truncated");
1294
+ }
1295
+ return LedgerHeadStateSchema.parse({
1296
+ schemaVersion: LOCAL_LEDGER_HEAD_SCHEMA_VERSION,
1297
+ fileSize: 0,
1298
+ lineNumber: 0,
1299
+ lastLedgerHash: null,
1300
+ });
1301
+ }
1302
+ if (persisted && stats.size < persisted.fileSize) {
1303
+ throw new Error("ledger_truncated");
1304
+ }
1305
+ if (persisted && persisted.fileSize === stats.size) {
1306
+ const tail = await readLastLedgerRecord(handle, stats.size, persisted.lineNumber);
1307
+ assertRecordHash(tail);
1308
+ if (tail.ledgerHash !== persisted.lastLedgerHash) {
1309
+ throw new Error("ledger_head_hash_mismatch");
1310
+ }
1311
+ return persisted;
1312
+ }
1313
+ }
1314
+ finally {
1315
+ await handle.close();
1316
+ }
1317
+ return recoverLedgerHead(filePath);
1318
+ }
1319
+ async function readLedgerHead(headPath) {
1320
+ try {
1321
+ return LedgerHeadStateSchema.parse(JSON.parse(await readFile(headPath, "utf8")));
1322
+ }
1323
+ catch (error) {
1324
+ if (isNodeError(error) && error.code === "ENOENT") {
1325
+ return null;
1326
+ }
1327
+ throw error;
1328
+ }
1329
+ }
1330
+ async function recoverLedgerHead(filePath) {
1331
+ const text = await readLedgerFile(filePath);
1332
+ const lines = text.split("\n").filter((line) => line.trim().length > 0);
1333
+ let previousLedgerHash = null;
1334
+ for (let index = 0; index < lines.length; index += 1) {
1335
+ const record = parseLedgerLine(lines[index], index + 1);
1336
+ if (record.previousLedgerHash !== previousLedgerHash) {
1337
+ throw new Error(`ledger_previous_hash_mismatch:${index + 1}`);
1338
+ }
1339
+ assertRecordHash(record);
1340
+ previousLedgerHash = record.ledgerHash;
1341
+ }
1342
+ const stats = await open(filePath, "r").then(async (handle) => {
1343
+ try {
1344
+ return await handle.stat();
1345
+ }
1346
+ finally {
1347
+ await handle.close();
1348
+ }
1349
+ });
1350
+ return LedgerHeadStateSchema.parse({
1351
+ schemaVersion: LOCAL_LEDGER_HEAD_SCHEMA_VERSION,
1352
+ fileSize: stats.size,
1353
+ lineNumber: lines.length,
1354
+ lastLedgerHash: previousLedgerHash,
1355
+ });
1356
+ }
1357
+ async function readLastLedgerRecord(handle, fileSize, lineNumber) {
1358
+ const chunkSize = 64 * 1024;
1359
+ let cursor = fileSize;
1360
+ let suffix = Buffer.alloc(0);
1361
+ let trimmedEnd = fileSize;
1362
+ while (cursor > 0) {
1363
+ const length = Math.min(chunkSize, cursor);
1364
+ const position = cursor - length;
1365
+ const chunk = Buffer.allocUnsafe(length);
1366
+ const { bytesRead } = await handle.read(chunk, 0, length, position);
1367
+ const bytes = chunk.subarray(0, bytesRead);
1368
+ if (trimmedEnd === fileSize) {
1369
+ let index = bytes.length - 1;
1370
+ while (index >= 0 && (bytes[index] === 0x0a || bytes[index] === 0x0d)) {
1371
+ index -= 1;
1372
+ trimmedEnd -= 1;
1373
+ }
1374
+ suffix = Buffer.concat([bytes.subarray(index + 1, bytes.length), suffix]);
1375
+ if (index < 0) {
1376
+ cursor = position;
1377
+ continue;
1378
+ }
1379
+ const content = bytes.subarray(0, index + 1);
1380
+ const newline = content.lastIndexOf(0x0a);
1381
+ if (newline >= 0) {
1382
+ const line = Buffer.concat([content.subarray(newline + 1), suffix])
1383
+ .toString("utf8")
1384
+ .trim();
1385
+ return parseLedgerLine(line, lineNumber);
1386
+ }
1387
+ suffix = Buffer.concat([content, suffix]);
1388
+ }
1389
+ else {
1390
+ const newline = bytes.lastIndexOf(0x0a);
1391
+ if (newline >= 0) {
1392
+ const line = Buffer.concat([bytes.subarray(newline + 1), suffix])
1393
+ .toString("utf8")
1394
+ .trim();
1395
+ return parseLedgerLine(line, lineNumber);
1396
+ }
1397
+ suffix = Buffer.concat([bytes, suffix]);
1398
+ }
1399
+ cursor = position;
1400
+ }
1401
+ const line = suffix.toString("utf8").trim();
1402
+ if (!line) {
1403
+ throw new Error("ledger_tail_missing");
1404
+ }
1405
+ return parseLedgerLine(line, lineNumber);
1406
+ }
1407
+ async function writeAll(handle, data) {
1408
+ let offset = 0;
1409
+ while (offset < data.byteLength) {
1410
+ const { bytesWritten } = await handle.write(data, offset, data.byteLength - offset);
1411
+ if (bytesWritten < 1) {
1412
+ throw new Error("ledger_append_incomplete");
1413
+ }
1414
+ offset += bytesWritten;
1415
+ }
1416
+ }
1417
+ async function persistLedgerHead(headPath, head) {
1418
+ const tmpPath = `${headPath}.${process.pid}.${randomUUID()}.tmp`;
1419
+ let renamed = false;
1420
+ try {
1421
+ const handle = await open(tmpPath, "wx", 0o600);
1422
+ try {
1423
+ await writeAll(handle, Buffer.from(`${JSON.stringify(head)}\n`, "utf8"));
1424
+ await handle.sync();
1425
+ }
1426
+ finally {
1427
+ await handle.close();
1428
+ }
1429
+ await rename(tmpPath, headPath);
1430
+ renamed = true;
1431
+ }
1432
+ finally {
1433
+ if (!renamed) {
1434
+ await rm(tmpPath, { force: true });
1435
+ }
1436
+ }
1437
+ }
1438
+ async function syncDirectory(directory) {
1439
+ const handle = await open(directory, "r");
1440
+ try {
1441
+ await handle.sync();
1442
+ }
1443
+ catch (error) {
1444
+ if (process.platform === "win32" &&
1445
+ isNodeError(error) &&
1446
+ ["EISDIR", "EINVAL", "ENOTSUP", "EPERM"].includes(error.code ?? "")) {
1447
+ return;
1448
+ }
1449
+ throw error;
1450
+ }
1451
+ finally {
1452
+ await handle.close();
1453
+ }
1454
+ }
371
1455
  async function readLedgerFile(filePath) {
372
1456
  try {
373
1457
  return await readFile(filePath, "utf8");