Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/reconciliation/anti-entropy.ts
Original file line number Diff line number Diff line change
@@ -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<string, VersionedFact>,
b: Map<string, VersionedFact>,
): AntiEntropyResult {
let aHealed = 0;
let bHealed = 0;

const factIds = new Set<string>([...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 };
}
22 changes: 22 additions & 0 deletions src/reconciliation/index.ts
Original file line number Diff line number Diff line change
@@ -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';
14 changes: 14 additions & 0 deletions src/reconciliation/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
70 changes: 70 additions & 0 deletions src/reconciliation/reconcile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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` 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
* 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 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 { result: incoming, outcome: 'adopted' };
}

// 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' };
}
116 changes: 116 additions & 0 deletions src/reconciliation/reconciliation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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
// 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);
expect(adopt.outcome).toBe('adopted');
expect(verifyFact(adopt.result as VersionedFact)).toBe(false); // provisional, corrupt

// 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');
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<string, VersionedFact>([
[FACT_ID, corrupt(truth, 'BBBBBBBBBBBB')],
]);
const verified = new Map<string, VersionedFact>([[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<string, VersionedFact>([[FACT_ID, truth]]);
const corrupted = new Map<string, VersionedFact>([
[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);
});
});
19 changes: 19 additions & 0 deletions src/reconciliation/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -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"]
}
81 changes: 81 additions & 0 deletions src/reconciliation/versioned-fact.ts
Original file line number Diff line number Diff line change
@@ -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)
);
}
Loading