From a815ba0419e117ebc49338f02ca039e13cd062be Mon Sep 17 00:00:00 2001 From: heybeaux Date: Sun, 5 Jul 2026 06:49:19 -0700 Subject: [PATCH 1/2] feat(reconciliation): versioned facts + anti-entropy module (Spec 16 A) Content-addressed VersionedFact with verifyFact, reconcile() that kills first-write-wins (kept/adopted/healed/rejected_corrupt outcomes), and antiEntropySync() for pairwise neighbor repair. Pure TS, no NestJS deps, buildable to an importable CommonJS artifact for external harnesses. Tests cover the five Spec 16 A4 cases: heal early-hop corruption, corrupt never overwrites verified, anti-entropy repair+count, digest breaks on any single-token mutation, higher verified version supersedes. Co-Authored-By: Claude Opus 4 --- src/reconciliation/anti-entropy.ts | 56 +++++++++++ src/reconciliation/index.ts | 22 ++++ src/reconciliation/package.json | 14 +++ src/reconciliation/reconcile.ts | 68 +++++++++++++ src/reconciliation/reconciliation.spec.ts | 117 ++++++++++++++++++++++ src/reconciliation/tsconfig.build.json | 19 ++++ src/reconciliation/versioned-fact.ts | 81 +++++++++++++++ 7 files changed, 377 insertions(+) create mode 100644 src/reconciliation/anti-entropy.ts create mode 100644 src/reconciliation/index.ts create mode 100644 src/reconciliation/package.json create mode 100644 src/reconciliation/reconcile.ts create mode 100644 src/reconciliation/reconciliation.spec.ts create mode 100644 src/reconciliation/tsconfig.build.json create mode 100644 src/reconciliation/versioned-fact.ts diff --git a/src/reconciliation/anti-entropy.ts b/src/reconciliation/anti-entropy.ts new file mode 100644 index 0000000..7c342a0 --- /dev/null +++ b/src/reconciliation/anti-entropy.ts @@ -0,0 +1,56 @@ +import { reconcile } from './reconcile'; +import { type VersionedFact } from './versioned-fact'; + +export interface AntiEntropyResult { + /** Facts on side `a` that were healed (corrupt/absent → verified) by `b`. */ + aHealed: number; + /** Facts on side `b` that were healed by `a`. */ + bHealed: number; + /** Distinct fact_ids offered across the exchange. */ + exchanged: number; +} + +/** + * Pairwise anti-entropy: each side offers its held facts; both reconcile against + * what the other holds. This is periodic repair between neighbors — the + * mechanism that shortens the EFFECTIVE path from every node to a verified copy + * (the exp-08 directive: push fidelity by shortening paths, not damping spread). + * + * A "heal" is counted only when reconcile reports outcome `healed` — i.e. a side + * genuinely swapped a corrupt/stale copy for a verified, newer one. Adoption of + * a fact the side never held is not counted as healing (nothing was corrupt to + * repair); it still updates the map so the pair converges. + * + * Mutates both maps in place and also returns the counts. + */ +export function antiEntropySync( + a: Map, + b: Map, +): AntiEntropyResult { + let aHealed = 0; + let bHealed = 0; + + const factIds = new Set([...a.keys(), ...b.keys()]); + + for (const factId of factIds) { + const av = a.get(factId) ?? null; + const bv = b.get(factId) ?? null; + + // b offers its copy to a + if (bv !== null) { + const r = reconcile(av, bv); + if (r.outcome === 'healed') aHealed += 1; + if (r.result !== null) a.set(factId, r.result); + } + + // a offers its (possibly just-updated) copy to b + const avNow = a.get(factId) ?? null; + if (avNow !== null) { + const r = reconcile(bv, avNow); + if (r.outcome === 'healed') bHealed += 1; + if (r.result !== null) b.set(factId, r.result); + } + } + + return { aHealed, bHealed, exchanged: factIds.size }; +} diff --git a/src/reconciliation/index.ts b/src/reconciliation/index.ts new file mode 100644 index 0000000..1982c8a --- /dev/null +++ b/src/reconciliation/index.ts @@ -0,0 +1,22 @@ +/** + * Versioned facts + anti-entropy reconciliation (Spec 16). + * + * Pure TypeScript, no NestJS/runtime dependencies — importable from an external + * harness (e.g. the swarmlab exp-08 rumor-mill retest) via the built output or a + * `file:` dependency on this directory. + */ +export { + type VersionedFact, + makeVersionedFact, + verifyFact, + computeDigest, +} from './versioned-fact'; +export { + type ReconcileOutcome, + type ReconcileResult, + reconcile, +} from './reconcile'; +export { + type AntiEntropyResult, + antiEntropySync, +} from './anti-entropy'; diff --git a/src/reconciliation/package.json b/src/reconciliation/package.json new file mode 100644 index 0000000..aa8cfb5 --- /dev/null +++ b/src/reconciliation/package.json @@ -0,0 +1,14 @@ +{ + "name": "@openengram/reconciliation", + "version": "0.1.0", + "description": "Versioned facts + anti-entropy reconciliation (Spec 16). Pure TS, no runtime deps.", + "license": "Apache-2.0", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json" + } +} diff --git a/src/reconciliation/reconcile.ts b/src/reconciliation/reconcile.ts new file mode 100644 index 0000000..6e04ec6 --- /dev/null +++ b/src/reconciliation/reconcile.ts @@ -0,0 +1,68 @@ +import { verifyFact, type VersionedFact } from './versioned-fact'; + +/** + * Named outcomes of reconciling an incoming fact against a held one. Every + * decision is named — nothing is a silent drop — so a mesh can account for + * healing vs rejection per node (Spec 16 B2 healing accounting). + * + * - `kept` held copy retained; incoming brought nothing better. + * - `adopted` held was absent/unverifiable; incoming verified and was taken. + * - `healed` held was corrupt (or a stale verified copy) and incoming + * repaired it — the exp-08 first-write-wins villain, inverted. + * - `rejected_corrupt` incoming failed verification and was refused; corruption + * cannot re-infect an already-verified node. + */ +export type ReconcileOutcome = 'kept' | 'adopted' | 'healed' | 'rejected_corrupt'; + +export interface ReconcileResult { + result: VersionedFact | null; + outcome: ReconcileOutcome; +} + +/** + * Reconcile a held fact against an incoming one, killing first-write-wins. + * + * Rules (each is an exp-08 finding inverted): + * 1. Verifiable beats held. If `incoming` verifies and `held` does not (or is + * absent), adopt it — a later accurate write HEALS early-hop corruption + * instead of bouncing off a sticky first write. + * 2. Higher version beats lower, only when verifiable. A corrupt copy never + * overwrites a verified one regardless of version — `rejected_corrupt`. + * 3. Never adopt what fails verification while already holding a verified copy. + * Corruption cannot re-infect a healed node. + */ +export function reconcile( + held: VersionedFact | null, + incoming: VersionedFact, +): ReconcileResult { + const incomingOk = verifyFact(incoming); + const heldOk = held !== null && verifyFact(held); + + // No usable held copy yet. + if (held === null) { + return incomingOk + ? { result: incoming, outcome: 'adopted' } + : // Nothing held and the arrival is corrupt: refuse it rather than seed + // the node with a copy that can never verify. + { result: null, outcome: 'rejected_corrupt' }; + } + + // We hold a VERIFIED copy. Only a verified, strictly-newer version may replace + // it; anything corrupt is refused (Rule 3). A same-or-older verified copy is + // redundant — keep what we have. + if (heldOk) { + if (!incomingOk) return { result: held, outcome: 'rejected_corrupt' }; + if (incoming.version > held.version) { + return { result: incoming, outcome: 'healed' }; + } + return { result: held, outcome: 'kept' }; + } + + // We hold a CORRUPT copy. A verified arrival heals us (Rule 1) regardless of + // version — any verifiable copy is strictly better than an unverifiable one. + if (incomingOk) return { result: incoming, outcome: 'healed' }; + + // Both corrupt: nothing to heal from, keep the incumbent so accounting is + // stable (no thrash between two bad copies). + return { result: held, outcome: 'kept' }; +} diff --git a/src/reconciliation/reconciliation.spec.ts b/src/reconciliation/reconciliation.spec.ts new file mode 100644 index 0000000..81ab8ad --- /dev/null +++ b/src/reconciliation/reconciliation.spec.ts @@ -0,0 +1,117 @@ +import { + makeVersionedFact, + verifyFact, + reconcile, + antiEntropySync, + type VersionedFact, +} from './index'; + +/** A hop mutates content in transit without re-authoring — digest stays stale. */ +function corrupt(f: VersionedFact, mutated: string): VersionedFact { + return { ...f, content: mutated }; +} + +describe('versioned facts + anti-entropy (Spec 16)', () => { + const FACT_ID = 'fact-42'; + const ORIGIN = 'seed-node'; + const TRUTH = 'AAAAAAAAAAAA'; + + it('(d) digest breaks on any single-token mutation', () => { + const good = makeVersionedFact(FACT_ID, 1, ORIGIN, TRUTH); + expect(verifyFact(good)).toBe(true); + + // Flip exactly one token; verification must fail. + const oneOff = corrupt(good, 'AAAAABAAAAAA'); + expect(oneOff.content).not.toBe(good.content); + expect(verifyFact(oneOff)).toBe(false); + + // Every single-position mutation breaks it. + for (let i = 0; i < TRUTH.length; i += 1) { + const chars = TRUTH.split(''); + chars[i] = 'Z'; + expect(verifyFact(corrupt(good, chars.join('')))).toBe(false); + } + }); + + it('(a) exp-08 recipe: a corrupted early-hop copy is HEALED by a later verifiable retelling', () => { + // Node adopts a mangled early-hop version (first-write-wins would freeze this). + const mangled = corrupt(makeVersionedFact(FACT_ID, 1, ORIGIN, TRUTH), 'QAAQAAQAAQAA'); + const adopt = reconcile(null, mangled); + // A corrupt arrival with nothing held is refused rather than seeded... + expect(adopt.outcome).toBe('rejected_corrupt'); + + // ...but the honest sim path: the node first hears a *verifiable* (if noisy + // at origin) copy, then later a truer one. Model the sticky-corruption case + // directly: the node is holding a corrupt copy (as in the mesh) and a + // verifiable retelling arrives. + let held: VersionedFact | null = mangled; // node is stuck on a corrupt copy + const truth = makeVersionedFact(FACT_ID, 1, ORIGIN, TRUTH); + const r = reconcile(held, truth); + expect(r.outcome).toBe('healed'); + held = r.result; + expect(held && verifyFact(held)).toBe(true); + expect(held?.content).toBe(TRUTH); + }); + + it('(b) a corrupt copy never overwrites a verified one', () => { + const held = makeVersionedFact(FACT_ID, 1, ORIGIN, TRUTH); + // Corrupt incoming, even claiming a higher version, is rejected. + const attacker = corrupt( + makeVersionedFact(FACT_ID, 9, 'liar', TRUTH), + 'ZZZZZZZZZZZZ', + ); + expect(verifyFact(attacker)).toBe(false); + const r = reconcile(held, attacker); + expect(r.outcome).toBe('rejected_corrupt'); + expect(r.result).toBe(held); + expect(verifyFact(r.result as VersionedFact)).toBe(true); + }); + + it('(c) anti-entropy repairs a corrupted node from a verified neighbor and counts it', () => { + const truth = makeVersionedFact(FACT_ID, 1, ORIGIN, TRUTH); + const corrupted = new Map([ + [FACT_ID, corrupt(truth, 'BBBBBBBBBBBB')], + ]); + const verified = new Map([[FACT_ID, truth]]); + + const res = antiEntropySync(corrupted, verified); + expect(res.aHealed).toBe(1); + expect(res.bHealed).toBe(0); + expect(res.exchanged).toBe(1); + expect(verifyFact(corrupted.get(FACT_ID) as VersionedFact)).toBe(true); + expect(corrupted.get(FACT_ID)?.content).toBe(TRUTH); + }); + + it('(e) a higher verified version supersedes a lower verified one', () => { + const v1 = makeVersionedFact(FACT_ID, 1, ORIGIN, TRUTH); + const v2 = makeVersionedFact(FACT_ID, 2, ORIGIN, 'CCCCCCCCCCCC'); + const r = reconcile(v1, v2); + expect(r.outcome).toBe('healed'); + expect(r.result).toBe(v2); + + // Lower verified version does NOT supersede a held higher one. + const back = reconcile(v2, v1); + expect(back.outcome).toBe('kept'); + expect(back.result).toBe(v2); + }); + + it('adopts into an empty node when the arrival verifies', () => { + const truth = makeVersionedFact(FACT_ID, 1, ORIGIN, TRUTH); + const r = reconcile(null, truth); + expect(r.outcome).toBe('adopted'); + expect(r.result).toBe(truth); + }); + + it('anti-entropy is symmetric: a verified side heals a corrupt neighbor either way', () => { + const truth = makeVersionedFact(FACT_ID, 1, ORIGIN, TRUTH); + const verified = new Map([[FACT_ID, truth]]); + const corrupted = new Map([ + [FACT_ID, corrupt(truth, 'DDDDDDDDDDDD')], + ]); + // Order swapped vs test (c): verified is side a now. + const res = antiEntropySync(verified, corrupted); + expect(res.bHealed).toBe(1); + expect(res.aHealed).toBe(0); + expect(verifyFact(corrupted.get(FACT_ID) as VersionedFact)).toBe(true); + }); +}); diff --git a/src/reconciliation/tsconfig.build.json b/src/reconciliation/tsconfig.build.json new file mode 100644 index 0000000..cd3e39e --- /dev/null +++ b/src/reconciliation/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "lib": ["ES2022"], + "strict": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": ["versioned-fact.ts", "reconcile.ts", "anti-entropy.ts", "index.ts"], + "exclude": ["dist", "**/*.spec.ts"] +} diff --git a/src/reconciliation/versioned-fact.ts b/src/reconciliation/versioned-fact.ts new file mode 100644 index 0000000..4831725 --- /dev/null +++ b/src/reconciliation/versioned-fact.ts @@ -0,0 +1,81 @@ +import { createHash } from 'crypto'; + +/** + * A content-addressed, versioned fact for gossip/anti-entropy meshes. + * + * Integrity is a property of the fact itself, not trust in whoever handed it to + * you: the `digest` is a hash over (`fact_id`, `version`, `origin_id`, + * `content`). Any hop that mutates `content` in transit — a retelling — WITHOUT + * re-authoring at the origin breaks the digest, so `verifyFact` returns false. + * That is the seam Spec 16 needs: a node can detect that its held copy drifted + * from the origin write, independent of who relayed it. + */ +export interface VersionedFact { + /** Identity of the fact — what it is about. Stable across versions. */ + fact_id: string; + /** Monotonically increasing at the ORIGIN only. Relays never bump it. */ + version: number; + /** Who authored this version. */ + origin_id: string; + /** The payload (a token string in the sim). */ + content: string; + /** Content-addressed integrity: hash(fact_id, version, origin_id, content). */ + digest: string; +} + +/** + * Canonical serialization used for the digest. Length-prefixed field joining so + * no combination of field values can collide by shifting a delimiter (e.g. + * content containing the separator). `version` is a number and is rendered + * decimal. + */ +function canonical( + fact_id: string, + version: number, + origin_id: string, + content: string, +): string { + const parts = [fact_id, String(version), origin_id, content]; + return parts.map((p) => `${p.length}:${p}`).join('|'); +} + +/** Compute the content-addressed digest for the given fields. */ +export function computeDigest( + fact_id: string, + version: number, + origin_id: string, + content: string, +): string { + return createHash('sha256') + .update(canonical(fact_id, version, origin_id, content)) + .digest('hex'); +} + +/** + * Author a versioned fact at the origin. The digest is computed from the exact + * fields, so the returned fact always verifies until its content is mutated. + */ +export function makeVersionedFact( + fact_id: string, + version: number, + origin_id: string, + content: string, +): VersionedFact { + return { + fact_id, + version, + origin_id, + content, + digest: computeDigest(fact_id, version, origin_id, content), + }; +} + +/** + * True iff the fact's digest recomputes and matches. A hop-mutated retelling + * (content changed without re-authoring at origin) fails this check. + */ +export function verifyFact(f: VersionedFact): boolean { + return ( + f.digest === computeDigest(f.fact_id, f.version, f.origin_id, f.content) + ); +} From 3586f5d8b21d202ccdd5a12ee8864b48e6b6d3a5 Mon Sep 17 00:00:00 2001 From: heybeaux Date: Sun, 5 Jul 2026 06:59:12 -0700 Subject: [PATCH 2/2] fix(reconciliation): adopt-then-heal for empty nodes in a gossip mesh An empty node now adopts any arrival (verified or a provisional corrupt copy) so it becomes informed and stays healable, instead of refusing corrupt copies to null. rejected_corrupt is reserved for Rule 3 (holding a verified copy, refusing a corrupt one). This makes fidelity recover BY HEALING in the exp-08 retest rather than by damping spread, and keeps coverage/saturation intact. Co-Authored-By: Claude Opus 4 --- src/reconciliation/reconcile.ts | 16 +++++++++------- src/reconciliation/reconciliation.spec.ts | 15 +++++++-------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/reconciliation/reconcile.ts b/src/reconciliation/reconcile.ts index 6e04ec6..c3bc81d 100644 --- a/src/reconciliation/reconcile.ts +++ b/src/reconciliation/reconcile.ts @@ -6,7 +6,8 @@ import { verifyFact, type VersionedFact } from './versioned-fact'; * healing vs rejection per node (Spec 16 B2 healing accounting). * * - `kept` held copy retained; incoming brought nothing better. - * - `adopted` held was absent/unverifiable; incoming verified and was taken. + * - `adopted` node held nothing; incoming was taken (verified, or a + * provisional corrupt copy that stays healable). * - `healed` held was corrupt (or a stale verified copy) and incoming * repaired it — the exp-08 first-write-wins villain, inverted. * - `rejected_corrupt` incoming failed verification and was refused; corruption @@ -38,13 +39,14 @@ export function reconcile( const incomingOk = verifyFact(incoming); const heldOk = held !== null && verifyFact(held); - // No usable held copy yet. + // No held copy yet: adopt whatever arrives so the node becomes informed. A + // verified arrival is a clean adoption; a corrupt one is still adopted — the + // node holds a provisional, UNVERIFIED copy that a later verifiable retelling + // or an anti-entropy pass will HEAL. This is the exp-08 reality (nodes do adopt + // corrupt early-hop copies) made healable, instead of first-write-wins freezing + // it. Refusing here would strand the node uninformed and leave nothing to heal. if (held === null) { - return incomingOk - ? { result: incoming, outcome: 'adopted' } - : // Nothing held and the arrival is corrupt: refuse it rather than seed - // the node with a copy that can never verify. - { result: null, outcome: 'rejected_corrupt' }; + return { result: incoming, outcome: 'adopted' }; } // We hold a VERIFIED copy. Only a verified, strictly-newer version may replace diff --git a/src/reconciliation/reconciliation.spec.ts b/src/reconciliation/reconciliation.spec.ts index 81ab8ad..79495ec 100644 --- a/src/reconciliation/reconciliation.spec.ts +++ b/src/reconciliation/reconciliation.spec.ts @@ -34,17 +34,16 @@ describe('versioned facts + anti-entropy (Spec 16)', () => { }); it('(a) exp-08 recipe: a corrupted early-hop copy is HEALED by a later verifiable retelling', () => { - // Node adopts a mangled early-hop version (first-write-wins would freeze this). + // Node adopts a mangled early-hop version — first-write-wins would freeze this + // forever. Under reconcile the empty node still adopts (so it is informed and + // spreads), holding a provisional UNVERIFIED copy. const mangled = corrupt(makeVersionedFact(FACT_ID, 1, ORIGIN, TRUTH), 'QAAQAAQAAQAA'); const adopt = reconcile(null, mangled); - // A corrupt arrival with nothing held is refused rather than seeded... - expect(adopt.outcome).toBe('rejected_corrupt'); + expect(adopt.outcome).toBe('adopted'); + expect(verifyFact(adopt.result as VersionedFact)).toBe(false); // provisional, corrupt - // ...but the honest sim path: the node first hears a *verifiable* (if noisy - // at origin) copy, then later a truer one. Model the sticky-corruption case - // directly: the node is holding a corrupt copy (as in the mesh) and a - // verifiable retelling arrives. - let held: VersionedFact | null = mangled; // node is stuck on a corrupt copy + // Later, a verifiable retelling of the same fact arrives and HEALS the node. + let held: VersionedFact | null = adopt.result; // node is stuck on a corrupt copy const truth = makeVersionedFact(FACT_ID, 1, ORIGIN, TRUTH); const r = reconcile(held, truth); expect(r.outcome).toBe('healed');