@sanning/proof 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -14,12 +14,15 @@
14
14
  // `payloadHashOk === null` (a withheld record) is semantics-undetermined and
15
15
  // surfaces as a per-event note — it does NOT fail the run.
16
16
 
17
- import { readFile, writeFile } from "node:fs/promises";
17
+ import { readFile, readdir, stat, writeFile } from "node:fs/promises";
18
18
  import { realpathSync } from "node:fs";
19
+ import { join } from "node:path";
19
20
  import { fileURLToPath } from "node:url";
20
21
 
21
22
  import { isAgentProofSpec, verifyAgentProofBundle } from "./agent-proof.js";
22
23
  import type { AgentProofResult } from "./agent-proof.js";
24
+ import { CHAIN_READING_SCOPE, readChains, renderChainFinding } from "./chain.js";
25
+ import type { ChainReading } from "./chain.js";
23
26
  import { composeExport } from "./compose.js";
24
27
  import type { ComposeExportOptions } from "./compose.js";
25
28
  import { utf8 } from "./crypto.js";
@@ -84,7 +87,7 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
84
87
  return EXIT_MALFORMED;
85
88
  }
86
89
 
87
- // Pull the optional `--logs <file>` flag out of the args; the rest are
90
+ // Pull the optional `--logs <path>` flag out of the args; the rest are
88
91
  // positional (<bundle> [gateways]). --logs feeds disclosed raw-log bytes for
89
92
  // content verification (the evidence-bundle path only).
90
93
  let logsPath: string | undefined;
@@ -94,7 +97,7 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
94
97
  if (a === "--logs") {
95
98
  const next = rest[i + 1];
96
99
  if (next === undefined) {
97
- io.err("verify: --logs requires a file path");
100
+ io.err("verify: --logs requires a path (a logs directory, or a JSON map file)");
98
101
  printUsage(io);
99
102
  return EXIT_MALFORMED;
100
103
  }
@@ -116,31 +119,13 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
116
119
  .map((g) => g.trim())
117
120
  .filter((g) => g.length > 0);
118
121
 
119
- let content: Record<string, Uint8Array | string> | undefined;
122
+ let logs: LoadedLogs | undefined;
120
123
  if (logsPath !== undefined) {
121
- let logsRaw: string;
122
- try {
123
- logsRaw = await readFile(logsPath, "utf8");
124
- } catch (e) {
125
- io.err(`cannot read --logs ${logsPath}: ${e instanceof Error ? e.message : String(e)}`);
126
- return EXIT_MALFORMED;
127
- }
128
- let logsParsed: unknown;
129
- try {
130
- logsParsed = JSON.parse(logsRaw);
131
- } catch (e) {
132
- io.err(`--logs ${logsPath} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
133
- return EXIT_MALFORMED;
134
- }
135
- if (logsParsed === null || typeof logsParsed !== "object" || Array.isArray(logsParsed)) {
136
- io.err(`--logs ${logsPath} must be a JSON object mapping event_id → disclosed bytes`);
137
- return EXIT_MALFORMED;
138
- }
139
- content = {};
140
- for (const [k, v] of Object.entries(logsParsed as Record<string, unknown>)) {
141
- if (typeof v === "string") content[k] = decodeLogValue(v);
142
- }
124
+ logs = await loadLogs(logsPath, io);
125
+ if (logs === undefined) return EXIT_MALFORMED;
126
+ for (const n of logs.notes) io.err(`note: ${n}`);
143
127
  }
128
+ const content = logs?.content;
144
129
 
145
130
  let raw: string;
146
131
  try {
@@ -184,7 +169,16 @@ export async function runCli(argv: string[], io: Cli): Promise<number> {
184
169
  ...(content ? { content } : {}),
185
170
  ...(io.fetchImpl ? { fetchImpl: io.fetchImpl } : {}),
186
171
  });
187
- return reportEvidence(result, bundlePath, gateways, io);
172
+ // Read the chains from the SAME parsed bundle the verifier saw. Findings are
173
+ // evidence about the records in front of us, not a verdict: they are printed
174
+ // and they never move `exit`, because a chain is not a completeness proof and
175
+ // an exit code is exactly the kind of thing people read as one.
176
+ const chains = await readChains(parsed);
177
+ const exit = reportEvidence(result, bundlePath, gateways, io, chains);
178
+ // Coverage is reported only once the bundle parsed far enough to have events
179
+ // to compare against; over a MALFORMED bundle there is nothing to say.
180
+ if (logs && result.status !== "malformed") reportLogsCoverage(logs, result, io);
181
+ return exit;
188
182
  }
189
183
 
190
184
  // `proof bundle <source-bundle.json> --attestations <f> --key <f> [-o <out>]`
@@ -390,10 +384,252 @@ async function readExporterKey(path: string, io: Cli): Promise<string | undefine
390
384
  return seed.toLowerCase();
391
385
  }
392
386
 
393
- // The --logs side-input lane decides each disclosed value's encoding: a clean,
387
+ // ─── the `--logs` side input ────────────────────────────────────────────────
388
+ //
389
+ // Two shapes, one flag (proof#27):
390
+ //
391
+ // DIRECTORY `logs/<event_id>.json`, each file's RAW BYTES being the
392
+ // disclosure. This is the layout `materializeBundle` writes
393
+ // (bundle.json + logs/) and the layout the verify page hashes,
394
+ // so a pack verifies with NO side-input construction step.
395
+ // JSON MAP `{ event_id: <even-length lowercase hex | utf8 text> }`, the
396
+ // original form. Unchanged.
397
+ //
398
+ // **Why the directory form exists at all.** Handing a pack to the CLI used to
399
+ // require the caller to read `logs/` and hex-encode it into a map, and that
400
+ // read-and-encode step has been got wrong twice in shipped code: claims-demo#9
401
+ // wrote the hex TEXT into the log files (SHA-256 of hex text is not SHA-256 of
402
+ // the bytes it encodes, so genuine evidence failed), and a runbook handed the
403
+ // directory straight to `--logs` and got EISDIR, exit 2. The directory form
404
+ // removes the encoding decision from the caller entirely — bytes in, bytes
405
+ // hashed — so that whole class stops being reachable from this direction.
406
+ //
407
+ // **Nothing is re-encoded, re-parsed or re-serialised on the way in.** A log
408
+ // file's bytes are passed to the verifier as a `Uint8Array`, which the verifier
409
+ // uses as-is; only the map form's *strings* go through `decodeLogValue`. That
410
+ // is the invariant this feature exists to hold, and it is what makes the CLI
411
+ // and the verify page agree about one pack.
412
+ interface LoadedLogs {
413
+ // Keyed by event id. A `Uint8Array` is raw bytes (directory form); a `string`
414
+ // is lowercase hex (map form). Those are exactly the two the verifier
415
+ // distinguishes.
416
+ //
417
+ // A `Map`, NOT a plain object, and that is load-bearing rather than stylistic.
418
+ // An event id is producer-chosen — here it arrives as a FILE NAME or a JSON
419
+ // key — and `content["__proto__"] = <bytes>` on an object literal does not
420
+ // store anything: it REPLACES the map's prototype with the Uint8Array. The
421
+ // disclosed bytes are then silently lost (the holder disclosed them and we
422
+ // report the record undisclosed), and every later lookup on that map resolves
423
+ // through `Uint8Array.prototype`, so an event named `slice` or `set` finds an
424
+ // inherited function and an event named `byteLength` or `buffer` THROWS a
425
+ // TypeError out of the verifier. A Map keys on the string itself and has none
426
+ // of that.
427
+ content: Map<string, Uint8Array | string>;
428
+ // Event ids the side input actually supplied — for the post-verify coverage
429
+ // cross-check, which needs to know what was offered, not just what bound.
430
+ suppliedIds: string[];
431
+ // Advisory lines about the INPUT ITSELF (skipped entries, a directory that
432
+ // disclosed nothing). None of them moves a verdict or an exit code.
433
+ notes: string[];
434
+ }
435
+
436
+ // The suffix the directory form reads, matched exactly — lowercase, which is
437
+ // what `materializeBundle` writes. Anything else is skipped and NAMED rather
438
+ // than guessed at, so a `.JSON` or extensionless file shows up as a note
439
+ // instead of a mysteriously undisclosed event.
440
+ const LOG_SUFFIX = ".json";
441
+
442
+ // Which shape was passed is decided by `stat`, never by the extension: a pack's
443
+ // log directory can be called anything, and `logs.json` is a legal directory
444
+ // name. `stat` follows symlinks, so a symlinked logs directory works.
445
+ async function loadLogs(logsPath: string, io: Cli): Promise<LoadedLogs | undefined> {
446
+ let isDirectory: boolean;
447
+ try {
448
+ isDirectory = (await stat(logsPath)).isDirectory();
449
+ } catch (e) {
450
+ io.err(`cannot read --logs ${logsPath}: ${errMsg(e)}`);
451
+ return undefined;
452
+ }
453
+ return isDirectory ? loadLogsDirectory(logsPath, io) : loadLogsMap(logsPath, io);
454
+ }
455
+
456
+ // The JSON-map form, unchanged: `{ event_id: <hex | utf8 text> }`.
457
+ async function loadLogsMap(logsPath: string, io: Cli): Promise<LoadedLogs | undefined> {
458
+ let logsRaw: string;
459
+ try {
460
+ logsRaw = await readFile(logsPath, "utf8");
461
+ } catch (e) {
462
+ io.err(`cannot read --logs ${logsPath}: ${errMsg(e)}`);
463
+ return undefined;
464
+ }
465
+ let logsParsed: unknown;
466
+ try {
467
+ logsParsed = JSON.parse(logsRaw);
468
+ } catch (e) {
469
+ io.err(`--logs ${logsPath} is not valid JSON: ${errMsg(e)}`);
470
+ return undefined;
471
+ }
472
+ if (logsParsed === null || typeof logsParsed !== "object" || Array.isArray(logsParsed)) {
473
+ io.err(
474
+ `--logs ${logsPath} must be a JSON object mapping event_id → disclosed bytes, ` +
475
+ `or a directory of <event_id>${LOG_SUFFIX} files`,
476
+ );
477
+ return undefined;
478
+ }
479
+ const content = new Map<string, Uint8Array | string>();
480
+ const suppliedIds: string[] = [];
481
+ for (const [k, v] of Object.entries(logsParsed as Record<string, unknown>)) {
482
+ if (typeof v !== "string") continue;
483
+ content.set(k, decodeLogValue(v));
484
+ suppliedIds.push(k);
485
+ }
486
+ return { content, suppliedIds, notes: [] };
487
+ }
488
+
489
+ // The directory form. Flat, one file per event, `<event_id>.json`.
490
+ //
491
+ // The decisions this pins, none of which is a verification failure — an absent
492
+ // or unusable disclosure is UNDISCLOSED, and absence is not evidence of
493
+ // tampering (evidence-bundle.md §5.1):
494
+ //
495
+ // - a file whose name is not a known event id → loaded, binds to nothing,
496
+ // named in a post-verify note (silence is how a typo'd filename becomes a
497
+ // mysteriously undisclosed event)
498
+ // - an event with no corresponding file → undetermined (`~`), noted
499
+ // - an empty directory → valid; discloses nothing,
500
+ // noted, exit code unchanged
501
+ // - a subdirectory → skipped, never recursed,
502
+ // noted (this is the "you pointed at the pack root, not its logs/" case)
503
+ // - anything not named `<event_id>.json`, and anything that is not a regular
504
+ // file → skipped, noted
505
+ //
506
+ // The one hard stop is a file we SELECTED but cannot read: we were pointed at
507
+ // it, so a permissions error must not read as minimal disclosure. That exits 2.
508
+ async function loadLogsDirectory(dirPath: string, io: Cli): Promise<LoadedLogs | undefined> {
509
+ let names: string[];
510
+ try {
511
+ names = await readdir(dirPath);
512
+ } catch (e) {
513
+ io.err(`cannot read --logs directory ${dirPath}: ${errMsg(e)}`);
514
+ return undefined;
515
+ }
516
+
517
+ const content = new Map<string, Uint8Array | string>();
518
+ const suppliedIds: string[] = [];
519
+ const skippedDirs: string[] = [];
520
+ const skippedNames: string[] = [];
521
+
522
+ for (const name of [...names].sort()) {
523
+ const p = join(dirPath, name);
524
+ let isDirectory: boolean;
525
+ let isFile: boolean;
526
+ try {
527
+ // `stat`, not the dirent kind: a symlinked log file is a log file, and a
528
+ // symlink to a directory is a directory.
529
+ const s = await stat(p);
530
+ isDirectory = s.isDirectory();
531
+ isFile = s.isFile();
532
+ } catch (e) {
533
+ io.err(`cannot read --logs entry ${p}: ${errMsg(e)}`);
534
+ return undefined;
535
+ }
536
+ if (isDirectory) {
537
+ skippedDirs.push(name);
538
+ continue;
539
+ }
540
+ // Not a regular file (device, fifo, socket), or not named
541
+ // `<event_id>.json` — including a file named exactly `.json`, whose event
542
+ // id would be the empty string.
543
+ if (!isFile || !name.endsWith(LOG_SUFFIX) || name.length === LOG_SUFFIX.length) {
544
+ skippedNames.push(name);
545
+ continue;
546
+ }
547
+ const eventId = name.slice(0, -LOG_SUFFIX.length);
548
+ try {
549
+ // No encoding argument: bytes, exactly as they sit on disk. Copied out of
550
+ // Buffer's shared pool so the array handed to the verifier owns its own
551
+ // memory rather than a window onto a reused block.
552
+ content.set(eventId, new Uint8Array(await readFile(p)));
553
+ } catch (e) {
554
+ io.err(`cannot read --logs file ${p}: ${errMsg(e)}`);
555
+ return undefined;
556
+ }
557
+ suppliedIds.push(eventId);
558
+ }
559
+
560
+ const notes: string[] = [];
561
+ if (skippedDirs.length > 0) {
562
+ notes.push(
563
+ `--logs ${dirPath}: skipped ${count(skippedDirs.length, "subdirectory", "subdirectories")} ` +
564
+ `(${sample(skippedDirs)}) — the directory form reads <event_id>${LOG_SUFFIX} files ` +
565
+ `and does not recurse`,
566
+ );
567
+ }
568
+ if (skippedNames.length > 0) {
569
+ notes.push(
570
+ `--logs ${dirPath}: skipped ${count(skippedNames.length, "entry", "entries")} ` +
571
+ `not named <event_id>${LOG_SUFFIX} (${sample(skippedNames)})`,
572
+ );
573
+ }
574
+ if (suppliedIds.length === 0) {
575
+ notes.push(
576
+ `--logs ${dirPath} disclosed nothing (no <event_id>${LOG_SUFFIX} files) — every event ` +
577
+ `stays undisclosed, which is undetermined, not failed`,
578
+ );
579
+ }
580
+ return { content, suppliedIds, notes };
581
+ }
582
+
583
+ // What the side input covered, reported once the bundle's own event ids are
584
+ // known. Both directions are ADVISORY — neither moves the verdict or the exit
585
+ // code — and both exist because the silent version of each has already cost
586
+ // somebody an afternoon:
587
+ //
588
+ // - an entry matching no event binds to nothing. A typo'd id, or a directory
589
+ // scoped to a different bundle, otherwise looks identical to minimal
590
+ // disclosure.
591
+ // - an event whose content stayed undetermined disclosed no usable bytes.
592
+ // That is UNDISCLOSED, never a failure (evidence-bundle.md §5.1): absence
593
+ // of a disclosure is not evidence of tampering.
594
+ function reportLogsCoverage(logs: LoadedLogs, result: EvidenceBundleResult, io: Cli): void {
595
+ const eventIds = new Set(result.events.map((e) => e.eventId));
596
+ const unmatched = logs.suppliedIds.filter((id) => !eventIds.has(id));
597
+ if (unmatched.length > 0) {
598
+ io.err(
599
+ `note: --logs supplied ${count(unmatched.length, "entry", "entries")} matching no event ` +
600
+ `in the bundle, ignored: ${sample(unmatched)}`,
601
+ );
602
+ }
603
+ const undetermined = result.events.filter((e) => e.contentOk === null);
604
+ if (undetermined.length > 0) {
605
+ io.err(
606
+ `note: ${undetermined.length} of ${result.events.length} event(s) have undetermined ` +
607
+ `content — no bytes disclosed, or no committed content_hash to bind them to. ` +
608
+ `Undisclosed, not failed: ${sample(undetermined.map((e) => e.eventId))}`,
609
+ );
610
+ }
611
+ }
612
+
613
+ function errMsg(e: unknown): string {
614
+ return e instanceof Error ? e.message : String(e);
615
+ }
616
+
617
+ function count(n: number, one: string, many: string): string {
618
+ return `${n} ${n === 1 ? one : many}`;
619
+ }
620
+
621
+ // First few names, quoted, with a tail count — enough to recognise the mistake
622
+ // without pasting a whole directory into the terminal.
623
+ function sample(names: string[], max = 3): string {
624
+ const shown = names.slice(0, max).map((n) => JSON.stringify(n)).join(", ");
625
+ return names.length > max ? `${shown}, +${names.length - max} more` : shown;
626
+ }
627
+
628
+ // The --logs MAP lane decides each disclosed value's encoding: a clean,
394
629
  // even-length, lowercase-hex string is hex bytes; anything else is utf8 text.
395
630
  // (The verifier itself only distinguishes "string ⇒ hex" from "Uint8Array ⇒
396
- // raw bytes" — the human-facing encoding choice is made here.)
631
+ // raw bytes" — the human-facing encoding choice is made here. The DIRECTORY
632
+ // lane never reaches this: its bytes are already bytes.)
397
633
  function decodeLogValue(v: string): Uint8Array | string {
398
634
  if (v.length > 0 && v.length % 2 === 0 && /^[0-9a-f]+$/.test(v)) return v; // hex passthrough
399
635
  return utf8(v); // raw utf8 bytes
@@ -403,7 +639,7 @@ function printUsage(io: Cli): void {
403
639
  io.out("Usage: npx @sanning/proof <command> [args]");
404
640
  io.out("");
405
641
  io.out("Commands:");
406
- io.out(" verify <bundle.json> [gateways] [--logs <logs.json>] Verify a bundle");
642
+ io.out(" verify <bundle.json> [gateways] [--logs <path>] Verify a bundle");
407
643
  io.out(" bundle <source.json> --attestations <f> --key <f> [-o] Create an attested bundle");
408
644
  io.out("");
409
645
  io.out("Verify a sanning.evidence/v1 (sanning.anchor.trace/v1) bundle, an");
@@ -411,10 +647,21 @@ function printUsage(io: Cli): void {
411
647
  io.out("inclusion bundle, fully offline. Supply a comma-separated gateway list");
412
648
  io.out("to also re-fetch each checkpoint on-chain.");
413
649
  io.out("");
414
- io.out("--logs <logs.json> Bind disclosed raw logs to each event's committed");
415
- io.out(" content_hash. JSON object mapping event_id → bytes");
416
- io.out(" (even-length lowercase hex ⇒ hex, else utf8 text).");
417
- io.out(" In-body events[].content is verified automatically.");
650
+ io.out("--logs <path> Bind disclosed raw logs to each event's committed");
651
+ io.out(" content_hash. Either shape works, and which one you");
652
+ io.out(" passed is decided by stat(), not by the extension:");
653
+ io.out("");
654
+ io.out(" a DIRECTORY of <event_id>.json files — the file's");
655
+ io.out(" raw bytes ARE the content, nothing is decoded.");
656
+ io.out(" This is a materialized pack's logs/ directory:");
657
+ io.out(" proof verify <pack>/bundle.json --logs <pack>/logs");
658
+ io.out("");
659
+ io.out(" a JSON FILE mapping event_id → bytes (an");
660
+ io.out(" even-length lowercase-hex value ⇒ hex, else utf8).");
661
+ io.out("");
662
+ io.out(" In-body events[].content is verified automatically");
663
+ io.out(" and takes precedence. An event with no disclosed");
664
+ io.out(" bytes is undetermined (~), never a failure.");
418
665
  io.out("");
419
666
  io.out("Exit codes: 0 verified · 1 failed · 2 malformed · 3 gateway-unavailable");
420
667
  }
@@ -424,6 +671,7 @@ function reportEvidence(
424
671
  path: string,
425
672
  gateways: string[],
426
673
  io: Cli,
674
+ chains: ChainReading,
427
675
  ): number {
428
676
  if (result.status === "malformed") {
429
677
  io.err(paint("MALFORMED", R) + ` ${path}`);
@@ -443,7 +691,7 @@ function reportEvidence(
443
691
  for (const cp of result.checkpoints) {
444
692
  const onChain = cp.onChainOk === null ? "" : ` on-chain ${mark(cp.onChainOk)}`;
445
693
  io.out(
446
- ` ${mark(cp.ok)} ${shortTx(cp.txId)} envelope ${mark(cp.envelopeOk)} root ${mark(cp.merkleRootOk)}${onChain}`,
694
+ ` ${mark(cp.ok)} ${shortTx(cp.checkpointRef)} envelope ${mark(cp.envelopeOk)} root ${mark(cp.merkleRootOk)}${onChain}`,
447
695
  );
448
696
  for (const e of cp.errors) io.out(paint(` - ${e}`, R));
449
697
  }
@@ -468,6 +716,8 @@ function reportEvidence(
468
716
  }
469
717
  }
470
718
 
719
+ reportChains(chains, io);
720
+
471
721
  if (result.export) {
472
722
  reportExport(result.export, io);
473
723
  }
@@ -485,6 +735,45 @@ function reportEvidence(
485
735
  return mapStatusToExit(result.status, result.errors);
486
736
  }
487
737
 
738
+ /**
739
+ * The chain block: what following each record's pointer to the record before it
740
+ * showed, over the records THIS bundle presents.
741
+ *
742
+ * Three deliberate choices, all of them about not overclaiming.
743
+ *
744
+ * 1. It prints the chains it READ, not just the findings. "3 chains, nothing to
745
+ * report" and silence are different statements, and a reader that quietly
746
+ * matched no records at all would produce the second one while covering
747
+ * nothing.
748
+ * 2. It prints the scope sentence whenever it read a chain, findings or not.
749
+ * "Delete an event and the next pointer dangles" is true; "so the bundle is
750
+ * complete" does not follow, and two customer-facing documents had already
751
+ * made that leap. The limit travels with the result or it gets dropped.
752
+ * 3. It does not touch the exit code. Findings are evidence; the exit code is
753
+ * the verdict surface, and the pinned 0/1/2/3 contract stays exactly what it
754
+ * was. A dangling link is something for a person to look at, not a machine
755
+ * decision this check is entitled to make.
756
+ */
757
+ function reportChains(reading: ChainReading, io: Cli): void {
758
+ if (reading.chains.length === 0) return;
759
+
760
+ io.out("");
761
+ io.out(paint(" Event chains", DIM));
762
+ for (const c of reading.chains) {
763
+ const where = c.namespace === undefined ? "record" : `${c.namespace} metadata`;
764
+ io.out(
765
+ paint(
766
+ ` ${c.chainKey} ${count(c.events, "record", "records")} present (linked in ${where})`,
767
+ DIM,
768
+ ),
769
+ );
770
+ }
771
+ for (const f of reading.findings) {
772
+ io.out(paint(` ! ${renderChainFinding(f, "this bundle")}`, Y));
773
+ }
774
+ io.out(paint(` ${CHAIN_READING_SCOPE}`, DIM));
775
+ }
776
+
488
777
  // Render the export-specific block (evidence-export.md §5): source-bundle
489
778
  // linkage, cached-vs-recomputed verdict agreement, and each embedded operator
490
779
  // attestation (RSA-PSS sig · operator-address binding · data_hash binding).
package/src/compose.ts CHANGED
@@ -94,7 +94,7 @@ export function attestationSignatureToHex(signature: string): string {
94
94
 
95
95
  // Normalize one attestation record for embedding: deep-clone (never mutate the
96
96
  // caller's input) and transcode its signature to lowercase hex. Everything else
97
- // — payload, public_key, signature_alg, checkpoint_tx_id — rides verbatim so the
97
+ // — payload, public_key, signature_alg, checkpoint_ref — rides verbatim so the
98
98
  // operator's RSA-PSS signature over JCS(payload) remains valid.
99
99
  function normalizeAttestation(rec: AttestationRecord): AttestationRecord {
100
100
  const cloned = JSON.parse(JSON.stringify(rec)) as AttestationRecord;