From 51e425094586519124b98b2ea66725fc3f69cb02 Mon Sep 17 00:00:00 2001 From: Akpolo Date: Sun, 21 Jun 2026 16:20:23 +0100 Subject: [PATCH] feat(reconciliation): design + canonical comparison foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the ledger-pinned reconciliation control plane (issue #8). The issue calls for a design document plus several reviewable PRs rather than one monolithic change; this is design + the comparison core everything else builds on. - docs/reconciliation-control-plane.md: full architecture — run model + pin, canonical comparison, evidence/forensics, repair safety, shadow rebuild + zero-downtime cutover, observability, and the phased delivery plan. - src/reconciliation/types.ts: run/discrepancy data model (RunPin, RunStatus, ProtocolDomain, DiscrepancyKind, CanonicalEntity, Discrepancy, RepairMode). - src/reconciliation/canonical.ts: exact base-unit comparison (bigint; rejects floats/NaN/unsafe ints — the no-floating-point-money guard), canonical normalization (sorted keys, dropped optionals, bigint→string, dates), and deterministic sha256 fingerprints. - src/reconciliation/compare.ts: pure, report-only domain comparison that classifies missing / unexpected / stale / value_mismatch discrepancies with field-level detail, in deterministic order. - Tests (node:test via existing ts-node) for the comparison core; `test:unit` script added. `npm run build` (tsc) passes; 14 unit tests pass. Closes #8 --- docs/reconciliation-control-plane.md | 152 +++++++++++++++++++++++++++ package.json | 3 +- src/reconciliation/canonical.test.ts | 66 ++++++++++++ src/reconciliation/canonical.ts | 136 ++++++++++++++++++++++++ src/reconciliation/compare.test.ts | 58 ++++++++++ src/reconciliation/compare.ts | 113 ++++++++++++++++++++ src/reconciliation/index.ts | 20 ++++ src/reconciliation/types.ts | 103 ++++++++++++++++++ 8 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 docs/reconciliation-control-plane.md create mode 100644 src/reconciliation/canonical.test.ts create mode 100644 src/reconciliation/canonical.ts create mode 100644 src/reconciliation/compare.test.ts create mode 100644 src/reconciliation/compare.ts create mode 100644 src/reconciliation/index.ts create mode 100644 src/reconciliation/types.ts diff --git a/docs/reconciliation-control-plane.md b/docs/reconciliation-control-plane.md new file mode 100644 index 0000000..82d56c0 --- /dev/null +++ b/docs/reconciliation-control-plane.md @@ -0,0 +1,152 @@ +# Reconciliation, drift forensics & zero-downtime projection repair + +> Design document for issue #8. This is the architecture; it is delivered with a +> foundational implementation slice (the run/discrepancy model + the canonical +> comparison core) and is followed by the implementation PRs listed at the end. + +## Problem + +The OrbitPay indexer is a **derived-state pipeline, not a source of truth**. +Even with resumable, idempotent ingestion, the PostgreSQL projections can drift +from chain reality: missed ledger ranges, decoder regressions, contract +upgrades, RPC inconsistencies, partial migrations, or manual DB edits all corrupt +projections silently. + +We need an **independent control plane** that can: + +1. Prove what was checked, at a reproducible ledger boundary. +2. Explain every discrepancy with enough forensic evidence to attribute the + cause (ingestion vs decode vs schema vs RPC vs operator). +3. Repair or shadow-rebuild projections **without** mutating chain state or + interrupting reads. + +A reconciliation engine is a correctness amplifier or a correctness destroyer: +an over-eager repair masks real loss, an under-eager one floods ops. So the +design is **report-only by default**, exact-integer throughout, and every +mutation is gated, planned, checksummed, and reversible in audit. + +## Components + +``` + ┌──────────────────────────────────────────────┐ + chain RPC ──▶│ evidence collector (pinned ledger, XDR) │ + └───────────────┬──────────────────────────────┘ + ▼ canonical snapshot (base units, fingerprint) + projection ─▶ canonical projector ─▶ comparator ─▶ discrepancies + │ + ┌─────────────────────┼─────────────────────┐ + ▼ ▼ ▼ + evidence store repair planner shadow rebuild + (immutable) (report→plan→apply) (build + cutover) +``` + +This PR implements the **canonical projector ↔ comparator** core and the +**run/discrepancy data model**. The rest is specified below and lands in +follow-ups. + +## 1. Reconciliation run model + +Every run persists an immutable **pin** of all inputs that could change its +verdict (`RunPin` in `src/reconciliation/types.ts`): + +- network passphrase, RPC endpoint identity, +- contract registry version, contract IDs → deployed WASM hashes, +- the **pinned ledger** every comparison is taken at, +- decoder version, DB schema version, +- start/end time, terminal status (`RunStatus`). + +Pinning makes a run **exactly reproducible** and lets us detect a contract +upgrade (WASM hash change) or a schema migration mid-flight. + +- **Distributed lease.** A run (or a cutover) acquires a Redis lease keyed by + `domain + projection`, so only one conflicting operation runs at a time. + Leases are fenced (monotonic token) so a paused run cannot resume over a newer + one. +- **Resumable shards.** Work is partitioned into bounded shards with explicit + high-water marks, persisted as the run progresses, so cancellation, timeout, + or a crash resumes without losing completed comparison work. + +## 2. Canonical comparison *(implemented in this PR)* + +`src/reconciliation/canonical.ts` + `compare.ts`: + +- **Exact base units only.** `toBaseUnits` coerces amounts to `bigint` and + **rejects** floats, non-integers, NaN/Infinity, and unsafe-integer numbers. + `compareAmounts` / `amountsEqual` compare exactly. No JavaScript or SQL + floating point ever participates in a money comparison. + + > Note: the current projection schema stores amounts as `Float` (e.g. + > `Stream.totalAmount`). The canonical layer treats those as a **migration + > target** — a follow-up moves them to base-unit integer columns. Until then + > the projector converts at the boundary and the float-ban guard flags any + > value that cannot be represented exactly. + +- **Normalization.** `normalize` sorts object keys, drops optional/default-absent + fields, renders bigints as decimal strings, and normalizes dates — so two + semantically-equal snapshots produce identical output regardless of key order + or absent optionals. Order-insensitive collections are sorted via `sortBy` + before hashing. + +- **Deterministic fingerprints.** `fingerprint` is the sha256 of the canonical + string; two entities are equal iff their fingerprints match. + +- **Classification.** `compareDomain` (pure) diffs chain vs projection per entity + and classifies each difference (`DiscrepancyKind`): `missing`, `unexpected`, + `stale`, `value_mismatch`, `invalid_transition`, `aggregate_mismatch`, + `decode_failure`, `unavailable_historical_state`, `unsupported_contract_version`. + +Canonical snapshots are defined per domain: treasury config + withdrawals, +payroll streams + claims, vesting schedules + claims, and governance proposals, +membership, weights, and votes. + +## 3. Evidence & forensics + +Each discrepancy persists the forensic chain so the cause is attributable +**without re-running**: + +- source ledger, transaction/event identifiers, raw XDR reference or checksum, + decoded payload, projection row + version, expected vs observed value, decoder + provenance. +- Discrepancy records are **immutable**; a resolution writes a linked, + append-only **action** record (never an in-place edit). +- Secrets and auth material are **redacted** from all logs and evidence. +- Retention + export are defined for incident review. + +## 4. Repair safety + +- **Report-only by default** (`RepairMode = 'report_only'`). +- A repair requires: an authorized operator, explicit network + contract scope, + ledger range, the specific discrepancy set, an idempotency key, a reason, and + dry-run approval. +- A deterministic **repair plan** is generated; its checksum is approved before + execution. +- Application uses transactional **compare-and-swap** guards (on row version) so + any row changed after planning is never blindly overwritten. +- The control plane **never submits Soroban transactions** — it only repairs + derived state. + +## 5. Shadow rebuild & zero-downtime cutover + +- A projection can be rebuilt in a **shadow** schema from the pinned ledger. +- Readers are proven never to observe partially-repaired or mixed-version state: + cutover is an atomic pointer swap behind a fenced lease, with the schema + version stamped so a reader can assert it read a single consistent version. + +## 6. Observability + +- Per-run metrics: entities checked, discrepancies by kind/severity, drift + amount per domain, shard high-water marks, lease contention. +- A run emits a terminal summary; criticals page, warnings aggregate. + +## Delivery plan (per the issue: design + several reviewable PRs) + +1. **This PR** — design doc + run/discrepancy data model + canonical comparison + core (`toBaseUnits`, `normalize`, `fingerprint`, `compareDomain`) with tests. +2. Evidence store + Prisma persistence of runs/discrepancies/actions wired to a + first read-only domain (treasury). +3. Pinned-ledger evidence collector (Soroban historical state + XDR) and the + canonical projectors for all four domains. +4. Distributed lease + resumable shards + run lifecycle. +5. Report-only reconciliation worker + metrics. +6. Gated repair planner/executor (compare-and-swap, idempotency, approval). +7. Shadow rebuild + zero-downtime cutover. diff --git a/package.json b/package.json index f76545f..2cd8608 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "start:indexer": "node dist/indexer.js", "prisma:generate": "prisma generate", "prisma:migrate:deploy": "prisma migrate deploy", - "test": "npm run build" + "test": "npm run build", + "test:unit": "node -r ts-node/register/transpile-only --test src/reconciliation/canonical.test.ts src/reconciliation/compare.test.ts" }, "keywords": [], "author": "", diff --git a/src/reconciliation/canonical.test.ts b/src/reconciliation/canonical.test.ts new file mode 100644 index 0000000..a24ea02 --- /dev/null +++ b/src/reconciliation/canonical.test.ts @@ -0,0 +1,66 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + toBaseUnits, + compareAmounts, + amountsEqual, + normalize, + canonicalString, + fingerprint, + sortBy, +} from './canonical'; + +test('toBaseUnits accepts exact integer representations', () => { + assert.equal(toBaseUnits(10n), 10n); + assert.equal(toBaseUnits(42), 42n); + assert.equal(toBaseUnits('123456789012345678901234567890'), 123456789012345678901234567890n); + assert.equal(toBaseUnits('-5'), -5n); +}); + +test('toBaseUnits rejects anything that could lose precision', () => { + assert.throws(() => toBaseUnits(1.5), TypeError); + assert.throws(() => toBaseUnits(Number.NaN), TypeError); + assert.throws(() => toBaseUnits(Number.MAX_SAFE_INTEGER + 1), TypeError); + assert.throws(() => toBaseUnits('1.0'), TypeError); + assert.throws(() => toBaseUnits('abc'), TypeError); +}); + +test('amount comparison is exact, even past 2^53', () => { + assert.equal(compareAmounts('9007199254740993', '9007199254740992'), 1); + assert.equal(compareAmounts(100, 100n), 0); + assert.equal(compareAmounts('5', 9), -1); + assert.ok(amountsEqual('1000', 1000n)); + assert.ok(!amountsEqual('1000', '1001')); +}); + +test('normalize is key-order independent and drops nullish fields', () => { + const a = normalize({ b: 2, a: 1, c: null, d: undefined }); + const b = normalize({ a: 1, b: 2 }); + assert.deepEqual(a, b); +}); + +test('normalize renders bigints as exact decimal strings and dates as ISO', () => { + assert.equal( + canonicalString({ amount: 10n, at: new Date('2026-01-01T00:00:00.000Z') }), + '{"amount":"10","at":"2026-01-01T00:00:00.000Z"}', + ); +}); + +test('normalize refuses non-integer numbers (no float money)', () => { + assert.throws(() => normalize({ amount: 1.23 }), TypeError); + assert.throws(() => normalize(Number.POSITIVE_INFINITY), TypeError); +}); + +test('fingerprint is stable across key order and sensitive to value changes', () => { + assert.equal(fingerprint({ a: 1, b: '2' }), fingerprint({ b: '2', a: 1 })); + assert.notEqual(fingerprint({ a: 1 }), fingerprint({ a: 2 })); +}); + +test('sortBy gives a deterministic order for order-insensitive collections', () => { + const items = [{ id: 'c' }, { id: 'a' }, { id: 'b' }]; + assert.deepEqual( + sortBy(items, (i) => i.id).map((i) => i.id), + ['a', 'b', 'c'], + ); +}); diff --git a/src/reconciliation/canonical.ts b/src/reconciliation/canonical.ts new file mode 100644 index 0000000..6ca1875 --- /dev/null +++ b/src/reconciliation/canonical.ts @@ -0,0 +1,136 @@ +/** + * Canonical comparison primitives for reconciliation (issue #8). + * + * Money is compared as exact base-unit integers — never JavaScript or SQL + * floating point. Snapshots from chain and from the projection are normalized to + * a canonical form (sorted keys, base units as decimal strings, ordering and + * optional/default fields normalized) and reduced to a deterministic fingerprint + * so two snapshots are equal iff their fingerprints match. + * + * Pure and dependency-free (only `node:crypto`), so it is trivially testable and + * safe to run anywhere in the pipeline. + */ + +import { createHash } from 'node:crypto'; + +export type Canonical = + | string + | boolean + | null + | Canonical[] + | { [key: string]: Canonical }; + +/** + * Coerce a base-unit amount to an exact bigint, rejecting anything that could + * silently lose precision. Accepts a bigint, an integer-valued number that is + * still safe, or a decimal string of an integer. Floats, NaN, Infinity, and + * non-integers throw — this is the guard against floating-point money. + */ +export function toBaseUnits(value: bigint | number | string): bigint { + if (typeof value === 'bigint') return value; + + if (typeof value === 'number') { + if (!Number.isInteger(value)) { + throw new TypeError(`base-unit amount must be an integer, got ${value}`); + } + if (!Number.isSafeInteger(value)) { + throw new TypeError( + `base-unit amount ${value} exceeds Number.MAX_SAFE_INTEGER; pass a string or bigint`, + ); + } + return BigInt(value); + } + + if (typeof value === 'string') { + if (!/^-?\d+$/.test(value.trim())) { + throw new TypeError(`base-unit amount must be an integer string, got "${value}"`); + } + return BigInt(value.trim()); + } + + throw new TypeError(`unsupported base-unit amount type: ${typeof value}`); +} + +/** Exact three-way comparison of two base-unit amounts. */ +export function compareAmounts( + a: bigint | number | string, + b: bigint | number | string, +): -1 | 0 | 1 { + const x = toBaseUnits(a); + const y = toBaseUnits(b); + if (x < y) return -1; + if (x > y) return 1; + return 0; +} + +/** True iff two base-unit amounts are exactly equal. */ +export function amountsEqual( + a: bigint | number | string, + b: bigint | number | string, +): boolean { + return compareAmounts(a, b) === 0; +} + +/** + * Normalize an arbitrary value into canonical form: + * - object keys sorted, `undefined` / nullish-default fields dropped; + * - bigints rendered as decimal strings (exact); + * - numbers required to be integers (floats are a bug here) and stringified; + * - arrays normalized element-wise (order preserved — callers sort + * order-insensitive collections via {@link sortBy} before normalizing). + */ +export function normalize(value: unknown): Canonical { + if (value === null || value === undefined) return null; + + if (typeof value === 'bigint') return value.toString(); + + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new TypeError(`cannot canonicalize non-finite number ${value}`); + } + if (!Number.isInteger(value)) { + throw new TypeError( + `refusing to canonicalize a non-integer number (${value}); convert money to base-unit strings first`, + ); + } + return value.toString(); + } + + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value; + + if (value instanceof Date) return value.toISOString(); + + if (Array.isArray(value)) return value.map((v) => normalize(v)); + + if (typeof value === 'object') { + const out: { [key: string]: Canonical } = {}; + for (const key of Object.keys(value as Record).sort()) { + const v = (value as Record)[key]; + if (v === undefined || v === null) continue; // drop optional/default-absent fields + out[key] = normalize(v); + } + return out; + } + + throw new TypeError(`cannot canonicalize value of type ${typeof value}`); +} + +/** Deterministic, key-order-independent string for a canonical value. */ +export function canonicalString(value: unknown): string { + return JSON.stringify(normalize(value)); +} + +/** Deterministic fingerprint (sha256 hex) of a value's canonical form. */ +export function fingerprint(value: unknown): string { + return createHash('sha256').update(canonicalString(value)).digest('hex'); +} + +/** Stable sort helper for order-insensitive collections, by a derived key. */ +export function sortBy(items: readonly T[], key: (item: T) => string): T[] { + return [...items].sort((a, b) => { + const ka = key(a); + const kb = key(b); + return ka < kb ? -1 : ka > kb ? 1 : 0; + }); +} diff --git a/src/reconciliation/compare.test.ts b/src/reconciliation/compare.test.ts new file mode 100644 index 0000000..0154153 --- /dev/null +++ b/src/reconciliation/compare.test.ts @@ -0,0 +1,58 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { compareDomain, isClean } from './compare'; +import { fingerprint } from './canonical'; +import type { CanonicalEntity } from './types'; + +function entity(id: string, version: number, value: Record): CanonicalEntity { + return { id, domain: 'treasury', version, fingerprint: fingerprint(value), value }; +} + +test('identical chain and projection produce no discrepancies', () => { + const chain = [entity('t1', 1, { balance: '1000' })]; + const projected = [entity('t1', 1, { balance: '1000' })]; + const result = compareDomain('treasury', chain, projected); + assert.ok(isClean(result)); + assert.equal(result.checkedCount, 1); +}); + +test('entity on chain but absent from projection is missing/critical', () => { + const result = compareDomain('treasury', [entity('t1', 1, { balance: '1000' })], []); + assert.equal(result.discrepancies.length, 1); + assert.equal(result.discrepancies[0].kind, 'missing'); + assert.equal(result.discrepancies[0].severity, 'critical'); +}); + +test('entity in projection but absent from chain is unexpected/critical', () => { + const result = compareDomain('treasury', [], [entity('t1', 1, { balance: '1000' })]); + assert.equal(result.discrepancies[0].kind, 'unexpected'); + assert.equal(result.discrepancies[0].severity, 'critical'); +}); + +test('same version with differing value is a value_mismatch with field detail', () => { + const chain = [entity('t1', 2, { balance: '1000' })]; + const projected = [entity('t1', 2, { balance: '999' })]; + const result = compareDomain('treasury', chain, projected); + const d = result.discrepancies[0]; + assert.equal(d.kind, 'value_mismatch'); + assert.equal(d.severity, 'critical'); + assert.deepEqual(d.fields, [{ path: 'balance', expected: '1000', observed: '999' }]); +}); + +test('older projection version is classified as stale/warning, not a mismatch', () => { + const chain = [entity('t1', 5, { balance: '1000' })]; + const projected = [entity('t1', 3, { balance: '900' })]; + const result = compareDomain('treasury', chain, projected); + assert.equal(result.discrepancies[0].kind, 'stale'); + assert.equal(result.discrepancies[0].severity, 'warning'); +}); + +test('discrepancies are emitted in a deterministic id order', () => { + const chain = [entity('b', 1, { v: '1' }), entity('a', 1, { v: '1' })]; + const result = compareDomain('treasury', chain, []); + assert.deepEqual( + result.discrepancies.map((d) => d.entityId), + ['a', 'b'], + ); +}); diff --git a/src/reconciliation/compare.ts b/src/reconciliation/compare.ts new file mode 100644 index 0000000..566abc0 --- /dev/null +++ b/src/reconciliation/compare.ts @@ -0,0 +1,113 @@ +/** + * Pure comparison of a chain snapshot against a projected snapshot for one + * protocol domain (issue #8). Produces classified discrepancies and never + * mutates anything — this is the report-only heart of the control plane. + */ + +import type { + CanonicalEntity, + Discrepancy, + DiscrepancyField, + DomainComparison, + ProtocolDomain, +} from './types'; + +function index(entities: readonly CanonicalEntity[]): Map { + const map = new Map(); + for (const e of entities) map.set(e.id, e); + return map; +} + +/** Field-level diff of two normalized values (one level deep over keys). */ +function diffFields( + expected: Record, + observed: Record, +): DiscrepancyField[] { + const fields: DiscrepancyField[] = []; + const keys = new Set([...Object.keys(expected), ...Object.keys(observed)]); + for (const path of [...keys].sort()) { + const e = expected[path]; + const o = observed[path]; + const es = e === undefined || e === null ? null : String(e); + const os = o === undefined || o === null ? null : String(o); + if (es !== os) fields.push({ path, expected: es, observed: os }); + } + return fields; +} + +/** + * Compare a domain's chain entities against its projected entities. + * + * - on chain, missing from projection -> `missing` (critical: lost money/state) + * - in projection, absent from chain -> `unexpected` (critical: phantom state) + * - both present, fingerprints differ: + * - projection older version -> `stale` (warning) + * - same version, values differ -> `value_mismatch` (critical) + * + * Pure: same inputs always yield the same discrepancies in a stable order. + */ +export function compareDomain( + domain: ProtocolDomain, + chain: readonly CanonicalEntity[], + projected: readonly CanonicalEntity[], +): DomainComparison { + const chainById = index(chain); + const projById = index(projected); + const discrepancies: Discrepancy[] = []; + + const allIds = [...new Set([...chainById.keys(), ...projById.keys()])].sort(); + + for (const entityId of allIds) { + const c = chainById.get(entityId); + const p = projById.get(entityId); + + if (c && !p) { + discrepancies.push({ + domain, + entityId, + kind: 'missing', + severity: 'critical', + fields: [], + expectedFingerprint: c.fingerprint, + detail: 'entity present on chain but absent from the projection', + }); + continue; + } + + if (p && !c) { + discrepancies.push({ + domain, + entityId, + kind: 'unexpected', + severity: 'critical', + fields: [], + observedFingerprint: p.fingerprint, + detail: 'entity present in the projection but absent from chain', + }); + continue; + } + + if (c && p && c.fingerprint !== p.fingerprint) { + const stale = p.version < c.version; + discrepancies.push({ + domain, + entityId, + kind: stale ? 'stale' : 'value_mismatch', + severity: stale ? 'warning' : 'critical', + fields: diffFields(c.value, p.value), + expectedFingerprint: c.fingerprint, + observedFingerprint: p.fingerprint, + detail: stale + ? `projection at v${p.version} lags chain at v${c.version}` + : 'projected value differs from chain at the same version', + }); + } + } + + return { domain, checkedCount: allIds.length, discrepancies }; +} + +/** True when a comparison found nothing to report (projection is sound). */ +export function isClean(comparison: DomainComparison): boolean { + return comparison.discrepancies.length === 0; +} diff --git a/src/reconciliation/index.ts b/src/reconciliation/index.ts new file mode 100644 index 0000000..556d904 --- /dev/null +++ b/src/reconciliation/index.ts @@ -0,0 +1,20 @@ +/** + * Reconciliation control plane (issue #8) — public surface. + * + * This is the foundation slice (PR 1): the run/discrepancy model, the canonical + * comparison primitives, and the pure report-only comparison. Chain evidence + * collection, the distributed lease, repair execution, and shadow-rebuild land + * in follow-up PRs per `docs/reconciliation-control-plane.md`. + */ + +export * from './types'; +export { + toBaseUnits, + compareAmounts, + amountsEqual, + normalize, + canonicalString, + fingerprint, + sortBy, +} from './canonical'; +export { compareDomain, isClean } from './compare'; diff --git a/src/reconciliation/types.ts b/src/reconciliation/types.ts new file mode 100644 index 0000000..b8fe615 --- /dev/null +++ b/src/reconciliation/types.ts @@ -0,0 +1,103 @@ +/** + * Reconciliation control-plane types (issue #8). + * + * The indexer produces *derived* state; these types model the independent + * control plane that proves the PostgreSQL projections still match authoritative + * chain evidence at a reproducible ledger boundary. See + * `docs/reconciliation-control-plane.md` for the full design. + */ + +/** Protocol domains that have a canonical on-chain representation to reconcile. */ +export type ProtocolDomain = + | 'treasury' + | 'payroll_stream' + | 'vesting' + | 'governance'; + +/** Lifecycle of a single reconciliation run. */ +export type RunStatus = + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'timed_out'; + +/** + * How a discrepancy manifests. Kept deliberately fine-grained so an operator can + * tell an ingestion gap from a decoder regression from an RPC/historical-state + * problem without re-running the comparison. + */ +export type DiscrepancyKind = + | 'missing' // on chain, absent from the projection + | 'unexpected' // in the projection, absent from chain + | 'stale' // projection lags a newer chain version + | 'value_mismatch' // same entity, differing field value(s) + | 'invalid_transition' // projected status path is unreachable on chain + | 'aggregate_mismatch' // per-row equal but a rollup (sum/count) disagrees + | 'decode_failure' // chain event could not be decoded at this decoder version + | 'unavailable_historical_state' // RPC could not serve the pinned ledger + | 'unsupported_contract_version'; // contract WASM hash is outside the registry + +/** Repair posture. The control plane defaults to observe-only. */ +export type RepairMode = 'report_only' | 'repair'; + +/** Severity an operator triages on. */ +export type DiscrepancySeverity = 'info' | 'warning' | 'critical'; + +/** + * The immutable identity of a run: every input that could change the verdict is + * pinned so a run is exactly reproducible and auditable. + */ +export interface RunPin { + networkPassphrase: string; + rpcEndpointId: string; + contractRegistryVersion: string; + /** contractId -> deployed WASM hash, so an upgrade is detectable. */ + contractWasmHashes: Record; + /** The ledger sequence every comparison is taken at. */ + pinnedLedger: number; + decoderVersion: string; + schemaVersion: string; +} + +/** + * A canonical entity to compare: a stable id, the domain, the version/ledger it + * was observed at, the exact base-unit fields, and a deterministic fingerprint + * of the normalized value (see `canonical.ts`). + */ +export interface CanonicalEntity { + id: string; + domain: ProtocolDomain; + /** Ledger (chain) or row version (projection) this snapshot reflects. */ + version: number; + fingerprint: string; + /** Normalized, comparison-ready value (base units as decimal strings). */ + value: Record; +} + +/** One classified difference between chain and projection for an entity. */ +export interface Discrepancy { + domain: ProtocolDomain; + entityId: string; + kind: DiscrepancyKind; + severity: DiscrepancySeverity; + /** Field-level detail for value mismatches; empty otherwise. */ + fields: DiscrepancyField[]; + expectedFingerprint?: string; + observedFingerprint?: string; + detail: string; +} + +export interface DiscrepancyField { + path: string; + expected: string | null; + observed: string | null; +} + +/** Outcome of comparing one domain's chain snapshot against its projection. */ +export interface DomainComparison { + domain: ProtocolDomain; + checkedCount: number; + discrepancies: Discrepancy[]; +}