Something went wrong. Try again.
A collaborative coding-agent orchestrator for atproto radl.app
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179// Divergence detection (design §12).//// Two operators folding the same records must reach the same index. Nothing in Radial makes that// true by construction — there is no consensus, no coordinator, and no shared database — so the only// honest way to check it is for both sides to compute a fingerprint of what their fold decided and// compare it. A difference is a SYNC GAP, not a disagreement about the rules: one of them has not// ingested something the other has (design §12). The per-section digests are what turn "we differ"// into "we differ about the open-request set", which is the difference between an alarm and a lead.//// Deliberately a pure function with no `node:crypto`: `core` is browser-safe (the UI runs the same// fold over IndexedDB), and a digest the web app cannot compute is a digest two operators cannot// compare by eye. FNV-1a at 128 bits is a FINGERPRINT, not a security primitive — it detects// accident, never an adversary, and it has no reason to: every record it summarises was already// admitted by trust, and a member who wants to make two indexes differ can simply write a record.
import type { MaterializedIndex } from './materializer.js'
/** 128-bit FNV-1a. Offset basis and prime from the FNV reference parameters. */const FNV_OFFSET_BASIS = 0x6c62272e07bb014262b821756295c58dnconst FNV_PRIME = 0x0000000001000000000000000000013bnconst MASK_128 = (1n << 128n) - 1n
const ENCODER = new TextEncoder()
/** A 32-hex-character fingerprint of `input`. Pure, isomorphic, and stable across releases. * BigInt per byte costs roughly 0.1ms per KB, which is nothing against the fold that produced the * input — but it is why this hashes a few summary lines per record rather than whole records. */export function fingerprint(input: string): string { const bytes = ENCODER.encode(input) let hash = FNV_OFFSET_BASIS for (const byte of bytes) { hash ^= BigInt(byte) hash = (hash * FNV_PRIME) & MASK_128 } return hash.toString(16).padStart(32, '0')}
export interface IndexDigest { /** One fingerprint over every section, in a fixed order — the number two operators compare. */ overall: string /** Per-section fingerprints, so a mismatch names the part of the fold that differs. */ sections: Record<IndexDigestSection, string>}
export type IndexDigestSection = | 'members' | 'devices' | 'openRequests' | 'winningClaims' | 'chains' | 'blessedComments' | 'records'
/** Section order is part of the contract: `overall` is a digest OF these, in this order. */export const INDEX_DIGEST_SECTIONS: readonly IndexDigestSection[] = [ 'members', 'devices', 'openRequests', 'winningClaims', 'chains', 'blessedComments', 'records',]
const refKey = (record: { uri: string; cid: string }): string => `${record.uri}#${record.cid}`
/** A section's lines, sorted and newline-joined: order-independent by construction. */const section = (lines: string[]): string => fingerprint([...lines].sort().join('\n'))
/** * A canonical, order-independent fingerprint of everything the fold decided: the trusted record * set, membership grants, the open-request set, the winning claims, and the version chains. Two * materializers that ingested the same records produce the same digest. * * **What `records` deliberately leaves out.** Claim records are summarised by `winningClaims` * (below) and NOT by their uri#cid, because a live lease is rewritten every `renewIntervalMs`: two * operators watching the same healthy claim legitimately hold different CIDs for it at any instant, * and a digest that flapped on every heartbeat would be a digest nobody compared. Everything else — * including a record whose author was later removed, which is absent from both folds — is in. * * **What `winningClaims` records.** The tie-break's *decision*: which DID holds each open request, * at which claim URI, with which `createdAt`. All three are stable across renewals; `expiresAt` and * the CID are not, and are excluded for the same reason. This is the section that answers the * question Phase 7 exists to ask — do both operators agree about who is working on what. */export function indexDigest(index: MaterializedIndex): IndexDigest { const targets = [...index.goals, ...index.projects]
const members = index.members.map( (member) => `${member.did} ${member.kind} ${member.role} ${member.active ? 'active' : 'removed'}` + ` ${member.membership ? refKey(member.membership) : ''}`, )
/** * Which signing keys count — the private path's analogue of `members`, and a section of its own * for the same reason. The address hint is excluded deliberately: it is * discovery metadata that nothing in the fold reads, it is rewritten whenever a device changes * network, and a digest that flapped on it would be a digest nobody compared — the same argument * that keeps a renewing claim's CID out of `winningClaims`. */ const devices = index.devices.map( (device) => `${device.did} ${device.deviceKeyId} ${device.publicKey}`, )
const openRequests = targets.flatMap((view) => view.openRequests.map((request) => `${refKey(request)} ${request.value.type} ${request.value.assignee ?? ''}`), )
const winningClaims = targets.flatMap((view) => Object.entries(view.winningClaims).map( ([requestUri, claim]) => `${requestUri} ${claim.did} ${claim.uri} ${claim.value.createdAt}`, ), )
const chains = targets.flatMap((view) => view.artifactChains.map((chain) => `${chain.root} ${chain.current} ${chain.versions.join(',')}`), )
/** * Which guest comments a member has vouched for and NOT since retracted — the one decision that * puts non-member prose inside a turn bundle, so it is the one worth being able to compare by eye. * * A section of its own for the same reason `openRequests` is one: the bless records themselves are * in `records` whether they were retracted or not (a retracted bless stays displayed, exactly as a * retracted request does), so without this a `retractBless` one operator had ingested and the other * had not would leave both digests identical while their bundles differed. Each line pins both * halves — the bless and the guest record it copied — because two blessings of the same comment by * different members are two distinct facts. */ const blessedComments = index.goals.flatMap((view) => view.blessedComments.map((bless) => `${refKey(bless)} ${refKey(bless.value.comment)} ${bless.did}`), )
const records = [ refKey(index.space), ...index.agents.map(refKey), ...index.joins.map(refKey), ...index.artifactTypes.map(refKey), ...index.members.flatMap((member) => (member.membership ? [refKey(member.membership)] : [])), ...targets.flatMap((view) => [ refKey(view.target), ...view.requests.map(refKey), ...view.artifacts.map(refKey), ...view.reviews.map(refKey), ...view.checkruns.map(refKey), ...view.merges.map(refKey), ...view.messages.map(refKey), ]), // Blessed guest comments, live and retracted alike — the same treatment `requests` gets, where a // retracted one stays in the digest because the record is still admitted and still displayed. // They are goal-only (a project has no thread a guest could comment in), so they are collected // here rather than in the `targets` walk above. ...index.goals.flatMap((view) => [ ...view.blessedComments.map(refKey), ...view.retractedBlessings.map(refKey), ]), ]
const sections: Record<IndexDigestSection, string> = { members: section(members), devices: section(devices), openRequests: section(openRequests), winningClaims: section(winningClaims), chains: section(chains), blessedComments: section(blessedComments), records: section(records), } return { overall: fingerprint(INDEX_DIGEST_SECTIONS.map((name) => `${name}:${sections[name]}`).join('\n')), sections, }}
/** The sections in which two digests differ, in `INDEX_DIGEST_SECTIONS` order. Empty when they agree. */export function digestDifferences(left: IndexDigest, right: IndexDigest): IndexDigestSection[] { return INDEX_DIGEST_SECTIONS.filter((name) => left.sections[name] !== right.sections[name])}