From 7e5e8f30507b8646c088a388a4262428ed70e1fe Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 29 Jul 2026 07:11:15 -0600 Subject: [PATCH] fix(research): bind claims to exact source versions --- docs/architecture.md | 6 +- src/claim-ledger.ts | 169 +++++++++++++--- src/ids.ts | 5 + src/kb-store.ts | 100 ++++++++-- src/research-driving-driver.ts | 142 ++++++++++---- src/schemas.ts | 13 +- src/sources.ts | 27 ++- src/types.ts | 24 ++- src/verified-research-loop.ts | 30 +-- tests/claim-persistence.test.ts | 329 +++++++++++++++++++++++++++----- tests/core.test.ts | 53 +++++ 11 files changed, 759 insertions(+), 139 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index b78c1e9..8d51181 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,8 +51,10 @@ They reach it through `mergeClaimLedger(id, merge)`, which holds the mutation lo The combining rule is `mergeClaimLedgers`: support and contradiction edges union, `contested` and `addressed` latch on, `firstSeenRound` moves earlier, and every collection is sorted — so the merge is commutative, associative, and idempotent, and the bytes on disk depend on the evidence rather than on scheduling. Ledgers for two different goals refuse to merge (`ClaimLedgerGoalConflictError`) rather than pooling unrelated evidence into one corroboration count. The live driver exposes the published Set-based `TrackedClaim`; the ledger stores a separate `ResearchClaimRecord` with sorted arrays so JSON serialization cannot erase those sets. -Source verification first persists a `ResearchClaimEvidence` observation that cannot affect claim support or completion, then `runVerifiedResearchLoop` calls `commitSources` only after the source registry write succeeds. -The ledger unions those observations with exact confirmed original source URIs and materializes only their intersection, so a crash on either side resumes safely without treating an absent source as evidence or losing a registered source's claim. +Source verification snapshots the proposal and persists a `ResearchClaimEvidence` observation containing the expected registry id, original URI, and full content hash; that observation cannot affect claim support or completion by itself. +After the exact submitted bytes are durable, `runVerifiedResearchLoop` passes the resulting `SourceRecord` to `commitSources`. +The ledger materializes only observations whose complete source identity matches a confirmed record, so reusing one URI for different bytes cannot activate the wrong claims and a crash on either side resumes safely. +Unversioned URI-only ledgers cannot prove which bytes produced their observations; reads and writes fail with `ClaimLedgerMigrationRequiredError` and preserve the original file for an explicit archive-and-reverify migration. Before synchronous question generation, the persistent driver records `preparedRounds`; a resume reconstructs and checkpoints any prepared round whose questions were interrupted, and the loop publishes its `research.iteration` event only after that checkpoint succeeds. Every write in this layer goes through `durable-fs` (`writeFileDurable`, `writeJsonDurableWithinRoot`) — temp file, fsync, atomic rename, fsync parent, through `O_NOFOLLOW` descriptors anchored via `/proc/self/fd` so a directory swapped for a symlink mid-write cannot redirect it outside the root. diff --git a/src/claim-ledger.ts b/src/claim-ledger.ts index 3b6969d..6ca1c06 100644 --- a/src/claim-ledger.ts +++ b/src/claim-ledger.ts @@ -18,12 +18,13 @@ * produce a different ledger than a single write did. */ -import { sha256 } from './ids' +import { sha256, textSourceId } from './ids' import type { DeepQuestion, ResearchClaimEvidence, ResearchClaimLedger, ResearchClaimRecord, + ResearchSourceVersion, } from './types' /** @@ -38,13 +39,29 @@ export function claimId(text: string): string { /** Stable identity for one extracted claim/source/contradiction observation. */ export function claimEvidenceId( - claim: Pick, + claim: Pick< + ResearchClaimEvidence, + 'claimId' | 'sourceId' | 'sourceUri' | 'sourceContentHash' | 'contradictsClaimId' + >, ): string { return `e_${sha256( - JSON.stringify([claim.claimId, claim.sourceUri, claim.contradictsClaimId ?? null]), + JSON.stringify([ + claim.claimId, + claim.sourceId, + claim.sourceUri, + claim.sourceContentHash, + claim.contradictsClaimId ?? null, + ]), ).slice(0, 16)}` } +/** Canonical key for one exact registry-id + original-URI + hash source version. */ +export function researchSourceVersionKey( + source: Pick, +): string { + return JSON.stringify([source.sourceId, source.uri, source.contentHash]) +} + /** Case-, whitespace-, and stylistic-punctuation-insensitive claim identity form. */ export function normalizeClaimText(text: string): string { return ( @@ -108,6 +125,9 @@ export function assertTrackedClaimIntegrity(claim: ResearchClaimRecord): void { if (claim.text !== claim.text.trim()) { throw new Error(`claim '${claim.id}' text must not have surrounding whitespace`) } + if (claim.supportingUris.length === 0) { + throw new Error(`claim '${claim.id}' must have registered supporting evidence`) + } assertSortedUnique(`claim '${claim.id}' supportingUris`, claim.supportingUris) assertSortedUnique(`claim '${claim.id}' supportingHosts`, claim.supportingHosts) assertSortedUnique(`claim '${claim.id}' contradicts`, claim.contradicts) @@ -125,6 +145,9 @@ export function assertTrackedClaimIntegrity(claim: ResearchClaimRecord): void { if (claim.contradicts.length > 0 && !claim.contested) { throw new Error(`claim '${claim.id}' with a contradiction must be contested`) } + if (claim.contested && claim.contradicts.length === 0) { + throw new Error(`claim '${claim.id}' cannot be contested without a contradiction`) + } } /** Refuse a deep question whose stable identity or set fields are malformed. */ @@ -151,6 +174,15 @@ export function assertResearchClaimEvidenceIntegrity(evidence: ResearchClaimEvid if (evidence.text !== evidence.text.trim()) { throw new Error(`claim evidence '${evidence.id}' text must not have surrounding whitespace`) } + if (!/^[a-f0-9]{64}$/.test(evidence.sourceContentHash)) { + throw new Error(`claim evidence '${evidence.id}' sourceContentHash must be a SHA-256 digest`) + } + const expectedSourceId = textSourceId(evidence.sourceUri, evidence.sourceContentHash) + if (evidence.sourceId !== expectedSourceId) { + throw new Error( + `claim evidence '${evidence.id}' sourceId does not match URI-and-content identity`, + ) + } if (evidence.contradictsClaimId === evidence.claimId) { throw new Error(`claim evidence '${evidence.id}' cannot contradict its own claim`) } @@ -158,6 +190,9 @@ export function assertResearchClaimEvidenceIntegrity(evidence: ResearchClaimEvid /** Refuse a ledger that is not one canonical, internally consistent record. */ export function assertResearchClaimLedgerIntegrity(ledger: ResearchClaimLedger): void { + if (ledger.schemaVersion !== 2) { + throw new Error(`claim ledger '${ledger.id}' must use schema version 2`) + } if (ledger.preparedRounds !== undefined && ledger.preparedRounds <= ledger.rounds) { throw new Error( `claim ledger '${ledger.id}' preparedRounds must be greater than completed rounds`, @@ -167,9 +202,24 @@ export function assertResearchClaimLedgerIntegrity(ledger: ResearchClaimLedger): `claim ledger '${ledger.id}' claimEvidence`, ledger.claimEvidence.map((evidence) => evidence.id), ) + for (const source of ledger.registeredSources) { + if ( + source.sourceId.length === 0 || + source.uri.length === 0 || + !/^[a-f0-9]{64}$/.test(source.contentHash) + ) { + throw new Error(`claim ledger '${ledger.id}' contains an invalid registered source version`) + } + const expectedSourceId = textSourceId(source.uri, source.contentHash) + if (source.sourceId !== expectedSourceId) { + throw new Error( + `registered source '${source.sourceId}' does not match URI-and-content identity '${expectedSourceId}'`, + ) + } + } assertSortedUnique( - `claim ledger '${ledger.id}' registeredSourceUris`, - ledger.registeredSourceUris, + `claim ledger '${ledger.id}' registeredSources`, + ledger.registeredSources.map((source) => source.sourceId), ) assertSortedUnique( `claim ledger '${ledger.id}' claims`, @@ -180,21 +230,65 @@ export function assertResearchClaimLedgerIntegrity(ledger: ResearchClaimLedger): ledger.questions.map((question) => question.id), ) for (const evidence of ledger.claimEvidence) assertResearchClaimEvidenceIntegrity(evidence) - const registeredSourceUris = new Set(ledger.registeredSourceUris) + const registeredSources = new Set(ledger.registeredSources.map(researchSourceVersionKey)) + const registeredEvidence = ledger.claimEvidence.filter((evidence) => + registeredSources.has( + researchSourceVersionKey({ + sourceId: evidence.sourceId, + uri: evidence.sourceUri, + contentHash: evidence.sourceContentHash, + }), + ), + ) + const materializedClaims = new Map(ledger.claims.map((claim) => [claim.id, claim])) for (const claim of ledger.claims) { assertTrackedClaimIntegrity(claim) for (const sourceUri of claim.supportingUris) { - if (!registeredSourceUris.has(sourceUri)) { + if ( + !registeredEvidence.some( + (evidence) => evidence.claimId === claim.id && evidence.sourceUri === sourceUri, + ) + ) { throw new Error( - `claim '${claim.id}' counts source '${sourceUri}' before its registration is confirmed`, + `claim '${claim.id}' counts source '${sourceUri}' without exact registered evidence`, + ) + } + } + for (const contradictedClaimId of claim.contradicts) { + const contradictedClaim = materializedClaims.get(contradictedClaimId) + if (!contradictedClaim) { + throw new Error( + `claim '${claim.id}' contradicts unmaterialized claim '${contradictedClaimId}'`, + ) + } + if (!contradictedClaim.contradicts.includes(claim.id)) { + throw new Error( + `claim '${claim.id}' has an asymmetric contradiction with '${contradictedClaimId}'`, + ) + } + const hasRegisteredObservation = registeredEvidence.some( + (evidence) => + (evidence.claimId === claim.id && evidence.contradictsClaimId === contradictedClaimId) || + (evidence.claimId === contradictedClaimId && evidence.contradictsClaimId === claim.id), + ) + if (!hasRegisteredObservation) { + throw new Error( + `claim '${claim.id}' contradicts '${contradictedClaimId}' without exact registered evidence`, ) } } } - const claimsById = new Map(ledger.claims.map((claim) => [claim.id, claim])) + const claimsById = materializedClaims const claimIds = new Set(claimsById.keys()) for (const evidence of ledger.claimEvidence) { - if (!registeredSourceUris.has(evidence.sourceUri)) continue + const sourceRegistered = registeredSources.has( + researchSourceVersionKey({ + sourceId: evidence.sourceId, + uri: evidence.sourceUri, + contentHash: evidence.sourceContentHash, + }), + ) + if (!sourceRegistered) continue const claim = claimsById.get(evidence.claimId) if (!claim?.supportingUris.includes(evidence.sourceUri)) { throw new Error( @@ -226,12 +320,13 @@ export function assertResearchClaimLedgerIntegrity(ledger: ResearchClaimLedger): /** A ledger with nothing in it yet. */ export function emptyClaimLedger(id: string, goal?: string): ResearchClaimLedger { return { + schemaVersion: 2, id, ...(goal === undefined ? {} : { goal }), updatedAt: new Date(0).toISOString(), rounds: 0, claimEvidence: [], - registeredSourceUris: [], + registeredSources: [], claims: [], questions: [], } @@ -247,12 +342,12 @@ export function emptyClaimLedger(id: string, goal?: string): ResearchClaimLedger export function materializeRegisteredClaimEvidence( ledger: ResearchClaimLedger, ): ResearchClaimLedger { - const registered = new Set(ledger.registeredSourceUris) + const registered = new Set(ledger.registeredSources.map(researchSourceVersionKey)) const claims = new Map( ledger.claims.map((claim) => [claim.id, claim]), ) for (const evidence of ledger.claimEvidence) { - if (!registered.has(evidence.sourceUri)) continue + if (!registered.has(sourceVersionKeyOfEvidence(evidence))) continue const host = claimSourceHost(evidence.sourceUri) const observed: ResearchClaimRecord = { id: evidence.claimId, @@ -271,7 +366,9 @@ export function materializeRegisteredClaimEvidence( // automatically when that counterpart is materialized by a later merge. for (const evidence of ledger.claimEvidence) { const otherId = evidence.contradictsClaimId - if (!registered.has(evidence.sourceUri) || !otherId || !claims.has(otherId)) continue + if (!registered.has(sourceVersionKeyOfEvidence(evidence)) || !otherId || !claims.has(otherId)) { + continue + } const claim = claims.get(evidence.claimId) if (!claim) continue claims.set(claim.id, { @@ -286,6 +383,14 @@ export function materializeRegisteredClaimEvidence( }) } +function sourceVersionKeyOfEvidence(evidence: ResearchClaimEvidence): string { + return researchSourceVersionKey({ + sourceId: evidence.sourceId, + uri: evidence.sourceUri, + contentHash: evidence.sourceContentHash, + }) +} + /** * Raised when two ledgers that accumulated evidence for DIFFERENT goals are * combined. Merging them would pool two questions' evidence into one @@ -412,6 +517,7 @@ export function mergeClaimLedgers( incoming.preparedRounds ?? incoming.rounds, ) return materializeRegisteredClaimEvidence({ + schemaVersion: 2, id: base.id, ...(goal === undefined ? {} : { goal }), updatedAt: @@ -421,7 +527,7 @@ export function mergeClaimLedgers( claimEvidence: [...claimEvidence.values()].sort((left, right) => left.id.localeCompare(right.id), ), - registeredSourceUris: union(base.registeredSourceUris, incoming.registeredSourceUris), + registeredSources: mergeSourceVersions(base.registeredSources, incoming.registeredSources), // Sorted by id so the bytes on disk depend on the ledger's content and not // on the order two writers happened to arrive in. claims: [...claims.values()].sort((a, b) => a.id.localeCompare(b.id)), @@ -438,7 +544,9 @@ function mergeClaimEvidence( if ( base.id !== incoming.id || base.claimId !== incoming.claimId || + base.sourceId !== incoming.sourceId || base.sourceUri !== incoming.sourceUri || + base.sourceContentHash !== incoming.sourceContentHash || base.contradictsClaimId !== incoming.contradictsClaimId ) { throw new Error(`claim evidence '${base.id}' has conflicting immutable content`) @@ -458,6 +566,21 @@ function mergeClaimEvidence( } } +function mergeSourceVersions( + base: readonly ResearchSourceVersion[], + incoming: readonly ResearchSourceVersion[], +): ResearchSourceVersion[] { + const versions = new Map() + for (const source of [...base, ...incoming]) { + const existing = versions.get(source.sourceId) + if (existing && researchSourceVersionKey(existing) !== researchSourceVersionKey(source)) { + throw new Error(`registered source '${source.sourceId}' has conflicting immutable content`) + } + versions.set(source.sourceId, source) + } + return [...versions.values()].sort((left, right) => left.sourceId.localeCompare(right.sourceId)) +} + /** * Make every contradiction edge symmetric and mark both ends contested. * @@ -469,17 +592,17 @@ function mergeClaimEvidence( * the same rule stated over a whole ledger, for writers that assemble one from * events rather than from a live loop. * - * Idempotent and monotone like every other rule here: edges only appear and - * `contested` only latches on, so applying it twice changes nothing. An edge - * pointing at a claim this ledger does not hold is KEPT — the other side may - * arrive from another writer later, and discarding evidence of disagreement - * because the counterpart has not shown up yet is the failure this prevents. + * One-sided observations stay in `claimEvidence` until both claims are backed + * by registered source versions. The materialized claim projection contains + * only closed pairs, so it cannot report a lone weak claim as settled merely + * because its evidence named a claim that never arrived. */ export function linkClaimContradictions(ledger: ResearchClaimLedger): ResearchClaimLedger { + const claimIds = new Set(ledger.claims.map((claim) => claim.id)) const inbound = new Map() for (const claim of ledger.claims) { for (const other of claim.contradicts) { - if (other === claim.id) continue + if (other === claim.id || !claimIds.has(other)) continue const edges = inbound.get(other) if (edges) edges.push(claim.id) else inbound.set(other, [claim.id]) @@ -490,10 +613,10 @@ export function linkClaimContradictions(ledger: ResearchClaimLedger): ResearchCl claims: ledger.claims .map((claim) => { const contradicts = union( - claim.contradicts.filter((other) => other !== claim.id), + claim.contradicts.filter((other) => other !== claim.id && claimIds.has(other)), inbound.get(claim.id) ?? [], ) - return { ...claim, contradicts, contested: claim.contested || contradicts.length > 0 } + return { ...claim, contradicts, contested: contradicts.length > 0 } }) .sort((a, b) => a.id.localeCompare(b.id)), } diff --git a/src/ids.ts b/src/ids.ts index df23997..39a3f66 100644 --- a/src/ids.ts +++ b/src/ids.ts @@ -18,3 +18,8 @@ export function slugify(input: string): string { export function stableId(prefix: string, content: string): string { return `${prefix}_${sha256(content).slice(0, 16)}` } + +/** Canonical registry id for exact text submitted at one original URI. */ +export function textSourceId(uri: string, contentHash: string): string { + return stableId('src', `${contentHash}:${uri}`) +} diff --git a/src/kb-store.ts b/src/kb-store.ts index a6a296c..e776109 100644 --- a/src/kb-store.ts +++ b/src/kb-store.ts @@ -10,6 +10,7 @@ import type { KnowledgeEventQuery } from './events' import { buildKnowledgeGraph } from './graph' import { withKnowledgeMutation, withKnowledgeRead } from './mutation-lock' import { + DeepQuestionSchema, KnowledgeEventSchema, KnowledgeIndexSchema, KnowledgePageSchema, @@ -56,6 +57,20 @@ export function assertClaimLedgerId(id: string): string { return id } +/** A URI-only ledger cannot be upgraded without re-verifying its source bytes. */ +export class ClaimLedgerMigrationRequiredError extends Error { + constructor( + readonly ledgerId: string, + readonly foundSchemaVersion: number | 'unversioned', + ) { + super( + `claim ledger '${ledgerId}' uses source identity schema '${foundSchemaVersion}'; ` + + 'archive it and re-verify its sources into a new ledger before use', + ) + this.name = 'ClaimLedgerMigrationRequiredError' + } +} + export interface KbStore { putSource(source: SourceRecord): Promise getSource(id: string): Promise @@ -165,7 +180,7 @@ export class MemoryKbStore implements KbStore, ClaimLedgerStore { } async putClaimLedger(ledger: ResearchClaimLedger): Promise { - const parsed = ResearchClaimLedgerSchema.parse(ledger) as ResearchClaimLedger + const parsed = parseResearchClaimLedger(ledger) this.claimLedgers.set(assertClaimLedgerId(parsed.id), clone(parsed)) } @@ -310,11 +325,15 @@ export class FileSystemKbStore implements KbStore, ClaimLedgerStore { } async putClaimLedger(ledger: ResearchClaimLedger): Promise { - const parsed = ResearchClaimLedgerSchema.parse(ledger) as ResearchClaimLedger + const parsed = parseResearchClaimLedger(ledger) const path = this.claimLedgerPath(parsed.id) - await withKnowledgeMutation(this.root, () => - writeJsonDurableWithinRoot(this.root, path, parsed), - ) + await withKnowledgeMutation(this.root, async () => { + // Refuse to destroy a legacy ledger whose evidence cannot be bound to + // exact bytes. Reading through the version-aware parser preserves it for + // an explicit archive-and-reverify migration. + await readJsonFile(this.root, path, researchClaimLedgerParser) + await writeJsonDurableWithinRoot(this.root, path, parsed) + }) } async getClaimLedger(id: string): Promise { @@ -325,7 +344,7 @@ export class FileSystemKbStore implements KbStore, ClaimLedgerStore { readJsonFile( this.root, path, - ResearchClaimLedgerSchema, + researchClaimLedgerParser, ) as Promise, ) } @@ -342,11 +361,7 @@ export class FileSystemKbStore implements KbStore, ClaimLedgerStore { const ledgers: ResearchClaimLedger[] = [] for (const file of files) { if (!file.path.endsWith('.json')) continue - ledgers.push( - ResearchClaimLedgerSchema.parse( - JSON.parse(file.bytes.toString('utf8')), - ) as ResearchClaimLedger, - ) + ledgers.push(parseResearchClaimLedger(JSON.parse(file.bytes.toString('utf8')))) } return ledgers.sort((a, b) => a.id.localeCompare(b.id)) }) @@ -365,7 +380,7 @@ export class FileSystemKbStore implements KbStore, ClaimLedgerStore { const current = (await readJsonFile( this.root, this.claimLedgerPath(key), - ResearchClaimLedgerSchema, + researchClaimLedgerParser, )) as ResearchClaimLedger | null const next = assertMergedLedgerId(key, merge(current)) await this.putClaimLedger(next) @@ -424,7 +439,7 @@ function emptyIndex(root: string): KnowledgeIndex { async function readJsonFile( root: string, relativePath: string, - schema: z.ZodType, + schema: { parse(value: unknown): T }, ): Promise { try { const file = await readRegularFileWithinRoot(root, relativePath) @@ -435,6 +450,65 @@ async function readJsonFile( } } +const researchClaimLedgerParser = { + parse: parseResearchClaimLedger, +} + +const legacyResearchClaimLedgerSchema = z + .object({ + id: z.string().min(1), + goal: z.string().trim().min(1).optional(), + updatedAt: z.iso.datetime(), + rounds: z.number().int().nonnegative(), + preparedRounds: z.number().int().nonnegative().optional(), + claimEvidence: z.array( + z + .object({ + id: z.string().min(1), + claimId: z.string().min(1), + text: z.string().min(1), + sourceUri: z.string().min(1), + contradictsClaimId: z.string().min(1).optional(), + firstSeenRound: z.number().int().nonnegative(), + }) + .strict(), + ), + registeredSourceUris: z.array(z.string().min(1)), + claims: z.array( + z + .object({ + id: z.string().min(1), + text: z.string().min(1), + supportingHosts: z.array(z.string().min(1)), + supportingUris: z.array(z.string().min(1)), + contradicts: z.array(z.string().min(1)), + contested: z.boolean(), + firstSeenRound: z.number().int().nonnegative(), + }) + .strict(), + ), + questions: z.array(DeepQuestionSchema), + }) + .strict() + +function parseResearchClaimLedger(value: unknown): ResearchClaimLedger { + const legacy = legacyResearchClaimLedgerSchema.safeParse(value) + if (legacy.success) { + throw new ClaimLedgerMigrationRequiredError(legacy.data.id, 'unversioned') + } + if (isRecord(value) && value.schemaVersion !== 2 && 'registeredSourceUris' in value) { + throw new ClaimLedgerMigrationRequiredError( + typeof value.id === 'string' ? value.id : 'unknown', + typeof value.schemaVersion === 'number' ? value.schemaVersion : 'unversioned', + ) + } + return ResearchClaimLedgerSchema.parse(value) as ResearchClaimLedger +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function clone(value: T): T { return value == null ? value : (JSON.parse(JSON.stringify(value)) as T) } diff --git a/src/research-driving-driver.ts b/src/research-driving-driver.ts index c6544b0..c9fd87b 100644 --- a/src/research-driving-driver.ts +++ b/src/research-driving-driver.ts @@ -50,14 +50,19 @@ import { claimSourceHost as hostOf, mergeClaimLedgers, normalizeClaimText, + researchSourceVersionKey, } from './claim-ledger' +import { sha256, textSourceId } from './ids' import { assertClaimLedgerId, type ClaimLedgerStore } from './kb-store' +import { snapshotSourceTextInput } from './sources' import type { DeepQuestion, DeepQuestionKind, ResearchClaimEvidence, ResearchClaimLedger, ResearchClaimRecord, + ResearchSourceVersion, + SourceRecord, TrackedClaim, } from './types' import type { @@ -82,6 +87,7 @@ export type { ResearchClaimEvidence, ResearchClaimLedger, ResearchClaimRecord, + ResearchSourceVersion, TrackedClaim, } @@ -187,10 +193,11 @@ export interface ResearchDrivingDriver extends ResearchDriver { /** Durably announce the next synchronous fold before it begins. */ prepareFold(): Promise /** - * Confirm that source registration completed for these exact original URIs. - * Pending extracted evidence cannot affect claims or completion before this. + * Confirm exact registered source records. + * URI equality alone is insufficient: pending evidence is authorized only + * when its expected content hash matches the registered record. */ - commitSources(sourceUris: readonly string[]): Promise + commitSources(sources: readonly SourceRecord[]): Promise /** The ledger record as it would be written right now. */ toLedger(): ResearchClaimLedger } @@ -254,11 +261,13 @@ function buildDriver( (restored?.claims ?? []).map((claim) => [claim.id, fromRecord(claim)]), ) // Claim extraction and source registration are two separate durable writes. - // Evidence remains inert until its exact source URI is confirmed here. + // Evidence remains inert until its exact source version is confirmed here. const claimEvidence = new Map( (restored?.claimEvidence ?? []).map((evidence) => [evidence.id, evidence]), ) - const registeredSourceUris = new Set(restored?.registeredSourceUris ?? []) + const registeredSources = new Map( + (restored?.registeredSources ?? []).map((source) => [source.sourceId, source]), + ) // Every deep question raised, by id — so we can mark them addressed later. const questions = new Map( (restored?.questions ?? []).map((question) => [question.id, question]), @@ -286,6 +295,7 @@ function buildDriver( function toLedger(): ResearchClaimLedger { return { + schemaVersion: 2, id: persistence?.ledgerId ?? 'in-memory', ...(goal === undefined ? {} : { goal }), updatedAt: new Date().toISOString(), @@ -294,7 +304,9 @@ function buildDriver( claimEvidence: [...claimEvidence.values()].sort((left, right) => left.id.localeCompare(right.id), ), - registeredSourceUris: [...registeredSourceUris].sort(), + registeredSources: [...registeredSources.values()].sort((left, right) => + left.sourceId.localeCompare(right.sourceId), + ), claims: [...claims.values()] .map((claim) => ({ ...claim, @@ -335,8 +347,10 @@ function buildDriver( for (const claim of merged.claims) claims.set(claim.id, fromRecord(claim)) claimEvidence.clear() for (const evidence of merged.claimEvidence) claimEvidence.set(evidence.id, evidence) - registeredSourceUris.clear() - for (const sourceUri of merged.registeredSourceUris) registeredSourceUris.add(sourceUri) + registeredSources.clear() + for (const source of merged.registeredSources) { + registeredSources.set(source.sourceId, source) + } questions.clear() for (const question of merged.questions) questions.set(question.id, question) rounds = Math.max(rounds, merged.rounds) @@ -400,7 +414,7 @@ function buildDriver( /** Persist an extraction observation without treating its source as registered. */ function recordEvidence( extracted: ExtractedClaim, - sourceUri: string, + sourceVersion: ResearchSourceVersion, round: number, ): ResearchClaimEvidence { const text = extracted.text.trim() @@ -410,10 +424,18 @@ function buildDriver( ? undefined : extracted.contradictsExistingId const evidence: ResearchClaimEvidence = { - id: claimEvidenceId({ claimId: observedClaimId, sourceUri, contradictsClaimId }), + id: claimEvidenceId({ + claimId: observedClaimId, + sourceId: sourceVersion.sourceId, + sourceUri: sourceVersion.uri, + sourceContentHash: sourceVersion.contentHash, + contradictsClaimId, + }), claimId: observedClaimId, text, - sourceUri, + sourceId: sourceVersion.sourceId, + sourceUri: sourceVersion.uri, + sourceContentHash: sourceVersion.contentHash, ...(contradictsClaimId === undefined ? {} : { contradictsClaimId }), firstSeenRound: round, } @@ -435,9 +457,11 @@ function buildDriver( return merged } - /** Materialize evidence for newly confirmed source URIs into live claims. */ - function materializeEvidenceFor(sourceUris: ReadonlySet): string[] { - const evidence = [...claimEvidence.values()].filter((item) => sourceUris.has(item.sourceUri)) + /** Materialize evidence for newly confirmed exact source versions into live claims. */ + function materializeEvidenceFor(sourceVersions: ReadonlySet): string[] { + const evidence = [...claimEvidence.values()].filter((item) => + sourceVersions.has(sourceVersionKeyOfEvidence(item)), + ) const texts: string[] = [] for (const item of evidence) { recordClaim( @@ -456,13 +480,22 @@ function buildDriver( return texts } - async function commitSources(sourceUris: readonly string[]): Promise { + async function commitSources(sources: readonly SourceRecord[]): Promise { + const versions = sources.map(sourceVersionOfRecord) + const nextRegistered = new Map(registeredSources) const newlyRegistered = new Set() - for (const sourceUri of sourceUris) { - if (registeredSourceUris.has(sourceUri)) continue - registeredSourceUris.add(sourceUri) - newlyRegistered.add(sourceUri) + for (const version of versions) { + const key = researchSourceVersionKey(version) + const existing = nextRegistered.get(version.sourceId) + if (existing && researchSourceVersionKey(existing) !== key) { + throw new Error(`registered source '${version.sourceId}' has conflicting immutable content`) + } + if (existing) continue + nextRegistered.set(version.sourceId, version) + newlyRegistered.add(key) } + registeredSources.clear() + for (const [sourceId, version] of nextRegistered) registeredSources.set(sourceId, version) if (newlyRegistered.size > 0) { markAddressed(materializeEvidenceFor(newlyRegistered)) } @@ -553,8 +586,12 @@ function buildDriver( source: ResearchSourceProposal, ctx: SourceVerificationContext, ): Promise { - bindGoal(ctx.goal) - const extracted = await extractClaims(source, ctx) + const sourceSnapshot = snapshotSourceTextInput(source) + const goalSnapshot = ctx.goal + const roundSnapshot = ctx.round + const sourceVersion = sourceVersionOfProposal(sourceSnapshot) + bindGoal(goalSnapshot) + const extracted = await extractClaims(sourceSnapshot, goalSnapshot) if (extracted.length === 0) { return { accept: false, @@ -562,15 +599,22 @@ function buildDriver( } } for (const claim of extracted) { - recordEvidence(claim, source.uri, ctx.round) + recordEvidence(claim, sourceVersion, roundSnapshot) } if (!persistence) { - registeredSourceUris.add(source.uri) - markAddressed(materializeEvidenceFor(new Set([source.uri]))) - } else if (registeredSourceUris.has(source.uri)) { - // Direct callers can verify a URI already present in the registry. Its - // newly extracted evidence is safe to materialize immediately. - markAddressed(materializeEvidenceFor(new Set([source.uri]))) + const key = researchSourceVersionKey(sourceVersion) + registeredSources.set(sourceVersion.sourceId, sourceVersion) + markAddressed(materializeEvidenceFor(new Set([key]))) + } else { + const registered = registeredSources.get(sourceVersion.sourceId) + if ( + registered && + researchSourceVersionKey(registered) === researchSourceVersionKey(sourceVersion) + ) { + // Direct callers can verify an exact version already present in the + // registry. Its newly extracted evidence is safe to materialize now. + markAddressed(materializeEvidenceFor(new Set([researchSourceVersionKey(sourceVersion)]))) + } } // Persist the observation BEFORE accepting. It remains pending until the // loop confirms source registration through `commitSources`, closing both @@ -640,10 +684,10 @@ function buildDriver( async function extractClaims( source: ResearchSourceProposal, - ctx: SourceVerificationContext, + goal: string, ): Promise { const ledger = claimsForExtraction() - const fromLlm = await extractClaimsWithLlm(source, ctx, ledger) + const fromLlm = await extractClaimsWithLlm(source, goal, ledger) if (fromLlm.length > 0) return fromLlm.slice(0, maxClaimsPerSource) if (deterministicFallback) return deterministicClaims(source).slice(0, maxClaimsPerSource) return [] @@ -668,7 +712,7 @@ function buildDriver( async function extractClaimsWithLlm( source: ResearchSourceProposal, - ctx: SourceVerificationContext, + goal: string, ledger: TrackedClaim[], ): Promise { let router: RouterClient @@ -690,7 +734,7 @@ function buildDriver( 'contradicts is the bracketed [id] of a ledger claim this page DIRECTLY contradicts, else null. ' + `Return at most ${maxClaimsPerSource} claims. No prose.` const user = [ - `Research goal: ${ctx.goal}`, + `Research goal: ${goal}`, `Page title: ${source.title ?? '(none)'}`, ledgerLines ? `Claims already on the ledger:\n${ledgerLines}` : 'Ledger is empty.', `Page excerpt:\n${excerpt}`, @@ -815,6 +859,40 @@ function buildDriver( // pure helpers // --------------------------------------------------------------------------- +function sourceVersionOfProposal(source: ResearchSourceProposal): ResearchSourceVersion { + const contentHash = sha256(source.text) + return { + sourceId: textSourceId(source.uri, contentHash), + uri: source.uri, + contentHash, + } +} + +function sourceVersionOfRecord(source: SourceRecord): ResearchSourceVersion { + const originalUri = source.metadata?.originalUri + if (typeof originalUri !== 'string' || originalUri.length === 0) { + throw new Error(`registered source '${source.id}' has no originalUri`) + } + if (!/^[a-f0-9]{64}$/.test(source.contentHash)) { + throw new Error(`registered source '${source.id}' contentHash is not a SHA-256 digest`) + } + const expectedSourceId = textSourceId(originalUri, source.contentHash) + if (source.id !== expectedSourceId) { + throw new Error( + `registered source '${source.id}' does not match URI-and-content identity '${expectedSourceId}'`, + ) + } + return { sourceId: source.id, uri: originalUri, contentHash: source.contentHash } +} + +function sourceVersionKeyOfEvidence(evidence: ResearchClaimEvidence): string { + return researchSourceVersionKey({ + sourceId: evidence.sourceId, + uri: evidence.sourceUri, + contentHash: evidence.sourceContentHash, + }) +} + function makeQuestion( kind: DeepQuestionKind, text: string, diff --git a/src/schemas.ts b/src/schemas.ts index b2b27a6..e7c7ac5 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -114,12 +114,22 @@ export const ResearchClaimRecordSchema = z reportIntegrityError(context, () => assertTrackedClaimIntegrity(claim)) }) +export const ResearchSourceVersionSchema = z + .object({ + sourceId: z.string().min(1), + uri: z.string().min(1), + contentHash: z.string().regex(/^[a-f0-9]{64}$/), + }) + .strict() + export const ResearchClaimEvidenceSchema = z .object({ id: z.string().min(1), claimId: z.string().min(1), text: z.string().min(1), + sourceId: z.string().min(1), sourceUri: z.string().min(1), + sourceContentHash: z.string().regex(/^[a-f0-9]{64}$/), contradictsClaimId: z.string().min(1).optional(), firstSeenRound: z.number().int().nonnegative(), }) @@ -130,13 +140,14 @@ export const ResearchClaimEvidenceSchema = z export const ResearchClaimLedgerSchema = z .object({ + schemaVersion: z.literal(2), id: z.string().min(1), goal: z.string().trim().min(1).optional(), updatedAt: z.iso.datetime(), rounds: z.number().int().nonnegative(), preparedRounds: z.number().int().nonnegative().optional(), claimEvidence: z.array(ResearchClaimEvidenceSchema), - registeredSourceUris: z.array(z.string().min(1)), + registeredSources: z.array(ResearchSourceVersionSchema), claims: z.array(ResearchClaimRecordSchema), questions: z.array(DeepQuestionSchema), }) diff --git a/src/sources.ts b/src/sources.ts index e7eff1a..43f5e61 100644 --- a/src/sources.ts +++ b/src/sources.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import { type SourceAdapter, textSourceAdapter } from './adapters' import { readRegularFileWithinRoot, writeJsonDurableWithinRoot } from './durable-fs' import { commitKnowledgeFileMutations, type KnowledgeFileMutation } from './file-transaction' -import { sha256, slugify, stableId } from './ids' +import { sha256, slugify, stableId, textSourceId } from './ids' import { withKnowledgeMutation, withKnowledgeRead } from './mutation-lock' import { SourceRecordSchema } from './schemas' import { layoutFor } from './store' @@ -33,6 +33,11 @@ export interface AddSourceTextInput { metadata?: Record } +/** Copy and freeze an untrusted source proposal before any asynchronous work. */ +export function snapshotSourceTextInput(input: AddSourceTextInput): AddSourceTextInput { + return deepFreeze(structuredClone(input)) +} + export async function loadSourceRegistry(root: string): Promise { return withKnowledgeRead(root, () => loadSourceRegistryUnlocked(root)) } @@ -124,7 +129,12 @@ export async function addSourceText( input: AddSourceTextInput, options: Pick = {}, ): Promise { - const [record] = await commitSourceBatch(root, [await prepareTextSource(input, options)], options) + const snapshot = snapshotSourceTextInput(input) + const [record] = await commitSourceBatch( + root, + [await prepareTextSource(snapshot, options)], + options, + ) return record! } @@ -140,9 +150,9 @@ async function prepareTextSource( candidate.canLoad(adapterInput), ) const loaded = adapter ? await adapter.load(adapterInput) : {} - const id = stableId('src', `${contentHash}:${input.uri}`) + const id = textSourceId(input.uri, contentHash) const targetRel = rawSourcePath(fileName, contentHash, '.txt') - const rawContent = text.endsWith('\n') ? text : `${text}\n` + const rawContent = text return { record: { @@ -244,6 +254,13 @@ function sameMutation(left: KnowledgeFileMutation, right: KnowledgeFileMutation) return left.content === right.content } +function deepFreeze(value: T, seen = new WeakSet()): T { + if (typeof value !== 'object' || value === null || seen.has(value)) return value + seen.add(value) + for (const nested of Object.values(value)) deepFreeze(nested, seen) + return Object.freeze(value) +} + async function listSourceFiles(root: string): Promise> { const entries = await readdir(root, { withFileTypes: true }) const out: Array<{ path: string; mode: number }> = [] @@ -261,7 +278,7 @@ function rawSourcePath(fileName: string, contentHash: string, extension: string) return join( 'raw', 'sources', - `${slugify(fileName.replace(/\.[^.]+$/, ''))}-${contentHash.slice(0, 8)}${extension}`, + `${slugify(fileName.replace(/\.[^.]+$/, ''))}-${contentHash}${extension}`, ).replace(/\\/g, '/') } diff --git a/src/types.ts b/src/types.ts index 180ef99..39df5b2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -270,21 +270,35 @@ export interface ResearchClaimRecord { firstSeenRound: number } +/** Immutable identity of one exact version of an original source URI. */ +export interface ResearchSourceVersion { + /** Canonical source-registry id for this URI and content hash. */ + sourceId: KnowledgeId + /** The URI supplied before the source was copied into the raw-source store. */ + uri: string + /** SHA-256 of the exact submitted source text. */ + contentHash: string +} + /** * One immutable claim extraction observed while a source is being verified. * * An observation is deliberately separate from `ResearchClaimRecord`: source * verification happens before source registration, and a process can die in * between. The observation is durable immediately, but it contributes support - * to a claim only after `sourceUri` appears in the ledger's independently - * confirmed `registeredSourceUris` set. + * to a claim only after its expected registry id, original URI, and content hash + * appear together in the ledger's independently confirmed `registeredSources` set. */ export interface ResearchClaimEvidence { /** Stable identity of this claim/source/contradiction observation. */ id: string claimId: string text: string + /** Canonical source-registry id expected for this source version. */ + sourceId: KnowledgeId sourceUri: string + /** SHA-256 of the exact source text from which this observation was extracted. */ + sourceContentHash: string /** Existing claim this observation directly contradicts, when reported. */ contradictsClaimId?: string firstSeenRound: number @@ -299,6 +313,8 @@ export interface ResearchClaimEvidence { * overwrite each other, so the store addresses ledgers by this id. */ export interface ResearchClaimLedger { + /** Persistent contract version; version 2 binds evidence to source content. */ + schemaVersion: 2 id: string /** The research goal this ledger accumulated evidence for. */ goal?: string @@ -316,8 +332,8 @@ export interface ResearchClaimLedger { * not yet been confirmed. Pending observations never count toward claims. */ claimEvidence: ResearchClaimEvidence[] - /** Exact original source URIs confirmed present in the source registry. */ - registeredSourceUris: string[] + /** Exact source-registry identities confirmed after their raw bytes were stored. */ + registeredSources: ResearchSourceVersion[] claims: ResearchClaimRecord[] questions: DeepQuestion[] } diff --git a/src/verified-research-loop.ts b/src/verified-research-loop.ts index e3f6363..018ffb8 100644 --- a/src/verified-research-loop.ts +++ b/src/verified-research-loop.ts @@ -11,7 +11,12 @@ import { FileSystemKbStore } from './kb-store' import { applyKnowledgeWriteBlocks } from './proposals' import { readinessFor } from './readiness-helpers' import { searchKnowledge } from './search' -import { type AddSourceOptions, type AddSourceTextInput, addSourceText } from './sources' +import { + type AddSourceOptions, + type AddSourceTextInput, + addSourceText, + snapshotSourceTextInput, +} from './sources' import { initKnowledgeBase } from './store' import type { KnowledgeEvent, KnowledgeIndex, KnowledgeSearchResult, SourceRecord } from './types' @@ -123,7 +128,7 @@ export interface DriverResearchContext { * a synchronous hook produced is on disk before the next round can crash. * - `prepareFold` — durably announce the next synchronous fold before it runs, * so a crash between question generation and `checkpoint` can be recovered. - * - `commitSources` — confirm source records are durable after verification; + * - `commitSources` — confirm exact source records are durable after verification; * drivers with pending evidence must not count it before this callback. */ export interface ResearchDriver { @@ -134,7 +139,7 @@ export interface ResearchDriver { research?(ctx: DriverResearchContext): Promise | ResearchContribution foldGaps?(gaps: KnowledgeGap[]): string prepareFold?(): Promise | void - commitSources?(sourceUris: readonly string[]): Promise | void + commitSources?(sources: readonly SourceRecord[]): Promise | void checkpoint?(): Promise | void } @@ -227,9 +232,8 @@ export async function runVerifiedResearchLoop( const store = new FileSystemKbStore({ root: options.root }) const steps: VerifiedResearchRound[] = [] let index = await buildKnowledgeIndex(options.root) - // Reconcile a source write that completed before a previous process died - // while confirming it to the driver. Exact original URIs are the shared - // identity; stored `record.uri` values are rewritten raw-file paths. + // Reconcile source writes that completed before a previous process died while + // confirming them to the driver. The records carry both original URI and hash. await confirmRegisteredSources(options.driver, index.sources) let readiness = readinessFor(options, index) let ready = isReady(readiness?.report) @@ -264,7 +268,8 @@ export async function runVerifiedResearchLoop( typeof source.metadata?.originalUri === 'string' ? [source.metadata.originalUri] : [], ), ) - for (const source of workerContribution.sources ?? []) { + for (const proposedSource of workerContribution.sources ?? []) { + const source = snapshotSourceTextInput(proposedSource) if (isDuplicate(source, existingUris, accepted)) { rejectedWorkerSources.push({ source, reason: 'duplicate: already in the knowledge base' }) continue @@ -427,7 +432,8 @@ async function registerSources( sources: ResearchSourceProposal[], ): Promise { const records: SourceRecord[] = [] - for (const source of sources) { + for (const candidate of sources) { + const source = snapshotSourceTextInput(candidate) records.push(await addSourceText(options.root, source, options.sourceOptions)) } return records @@ -438,11 +444,9 @@ async function confirmRegisteredSources( sources: readonly SourceRecord[], ): Promise { if (!driver.commitSources) return - const originalUris = sources.flatMap((source) => - typeof source.metadata?.originalUri === 'string' ? [source.metadata.originalUri] : [], - ) - if (originalUris.length === 0) return - await driver.commitSources([...new Set(originalUris)].sort()) + const textSources = sources.filter((source) => typeof source.metadata?.originalUri === 'string') + if (textSources.length === 0) return + await driver.commitSources(textSources) } /** diff --git a/tests/claim-persistence.test.ts b/tests/claim-persistence.test.ts index 3a261cd..92b8bc6 100644 --- a/tests/claim-persistence.test.ts +++ b/tests/claim-persistence.test.ts @@ -1,4 +1,4 @@ -import { access, mkdir, mkdtemp, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { access, mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -7,11 +7,15 @@ import type { ResearchClaimLedger, ResearchClaimRecord, ResearchSourceProposal, + ResearchSourceVersion, + SourceRecord, SourceVerificationContext, } from '../src/index' import { + addSourcePath, buildKnowledgeIndex, ClaimLedgerGoalConflictError, + ClaimLedgerMigrationRequiredError, claimEvidenceId, claimId, createKnowledgeEvent, @@ -31,6 +35,8 @@ import { mergeClaimLedgers, ResearchClaimLedgerSchema, runVerifiedResearchLoop, + sha256, + textSourceId, withSafeDescendant, writeFileDurable, writeJsonDurableWithinRoot, @@ -92,6 +98,18 @@ function source(uri: string, text: string): ResearchSourceProposal { return { uri, text, title: uri } } +function registeredSource(uri: string, text: string): SourceRecord { + const contentHash = sha256(text) + return { + id: textSourceId(uri, contentHash), + uri: `raw/sources/${contentHash}.txt`, + contentHash, + text, + metadata: { originalUri: uri }, + createdAt: '2026-07-28T00:00:00.000Z', + } +} + const CLAIM_A = '[{"claim":"layer skipping gives a 1.73x speedup","contradicts":null}]' // =========================================================================== @@ -105,7 +123,7 @@ describe('research claim ledger — persistence', () => { const first = await createPersistentResearchDrivingDriver({ router, store, ledgerId: 'run-1' }) await first.verifySource(source('https://arxiv.org/a', 'PAGE-A body'), ctx(1)) - await first.commitSources(['https://arxiv.org/a']) + await first.commitSources([registeredSource('https://arxiv.org/a', 'PAGE-A body')]) await first.prepareFold() first.foldGaps?.([]) await first.checkpoint() @@ -133,7 +151,7 @@ describe('research claim ledger — persistence', () => { // And the resumed run keeps ACCUMULATING onto the restored ledger rather // than starting a second, parallel belief state. await resumed.verifySource(source('https://acm.org/b', 'PAGE-B body'), ctx(2)) - await resumed.commitSources(['https://acm.org/b']) + await resumed.commitSources([registeredSource('https://acm.org/b', 'PAGE-B body')]) const grown = resumed.researchState() expect(grown.claims).toHaveLength(1) expect([...(grown.claims[0]?.supportingHosts ?? [])].sort()).toEqual(['acm.org', 'arxiv.org']) @@ -156,20 +174,22 @@ describe('research claim ledger — persistence', () => { const pending = await store.getClaimLedger('mid-round') expect(pending?.claimEvidence).toHaveLength(1) - expect(pending?.registeredSourceUris).toEqual([]) + expect(pending?.registeredSources).toEqual([]) expect(pending?.claims).toEqual([]) expect(driver.isComplete()).toBe(false) - await driver.commitSources(['https://arxiv.org/a']) + await driver.commitSources([registeredSource('https://arxiv.org/a', 'PAGE-A body')]) const afterFirst = await store.getClaimLedger('mid-round') expect(afterFirst?.claims[0]?.supportingHosts).toEqual(['arxiv.org']) await driver.verifySource(source('https://acm.org/b', 'PAGE-B body'), ctx(1)) const secondPending = await store.getClaimLedger('mid-round') - expect(secondPending?.registeredSourceUris).toEqual(['https://arxiv.org/a']) + expect(secondPending?.registeredSources).toEqual([ + sourceVersion('https://arxiv.org/a', 'PAGE-A body'), + ]) expect(secondPending?.claims[0]?.supportingHosts).toEqual(['arxiv.org']) - await driver.commitSources(['https://acm.org/b']) + await driver.commitSources([registeredSource('https://acm.org/b', 'PAGE-B body')]) const afterSecond = await store.getClaimLedger('mid-round') expect([...(afterSecond?.claims[0]?.supportingHosts ?? [])].sort()).toEqual([ 'acm.org', @@ -185,6 +205,140 @@ describe('research claim ledger — persistence', () => { expect(resumed.researchState().corroborated).toHaveLength(1) }) + it('binds same-URI evidence to the exact source bytes that produced it', async () => { + const uri = 'https://example.org/result' + const textA = 'PAGE-A exact bytes' + const textB = 'PAGE-B changed bytes' + const store = new MemoryKbStore() + const driver = await createPersistentResearchDrivingDriver({ + store, + ledgerId: 'same-uri-versions', + router: stubRouter({ + 'PAGE-A': '[{"claim":"claim extracted from bytes A","contradicts":null}]', + 'PAGE-B': '[{"claim":"claim extracted from bytes B","contradicts":null}]', + }), + }) + + await driver.verifySource(source(uri, textA), ctx(1)) + await driver.verifySource(source(uri, textB), ctx(1)) + await driver.commitSources([registeredSource(uri, textA)]) + + expect(driver.researchState().claims.map((claim) => claim.text)).toEqual([ + 'claim extracted from bytes A', + ]) + const afterA = await store.getClaimLedger('same-uri-versions') + expect(afterA?.claimEvidence).toHaveLength(2) + expect(afterA?.registeredSources).toEqual([sourceVersion(uri, textA)]) + const reopened = await createPersistentResearchDrivingDriver({ + store, + ledgerId: 'same-uri-versions', + router: stubRouter({}), + }) + expect(reopened.researchState().claims.map((claim) => claim.text)).toEqual([ + 'claim extracted from bytes A', + ]) + + await reopened.commitSources([registeredSource(uri, textB)]) + expect( + reopened + .researchState() + .claims.map((claim) => claim.text) + .sort(), + ).toEqual(['claim extracted from bytes A', 'claim extracted from bytes B']) + }) + + it('merges concurrent writers that observe different versions of one URI', async () => { + const uri = 'https://example.org/live-result' + const store = new MemoryKbStore() + const router = stubRouter({ + VERSION_A: '[{"claim":"concurrent claim A","contradicts":null}]', + VERSION_B: '[{"claim":"concurrent claim B","contradicts":null}]', + }) + const [writerA, writerB] = await Promise.all([ + createPersistentResearchDrivingDriver({ store, ledgerId: 'concurrent-versions', router }), + createPersistentResearchDrivingDriver({ store, ledgerId: 'concurrent-versions', router }), + ]) + + await Promise.all([ + writerA.verifySource(source(uri, 'VERSION_A bytes'), ctx(1)), + writerB.verifySource(source(uri, 'VERSION_B bytes'), ctx(1)), + ]) + await Promise.all([ + writerA.commitSources([registeredSource(uri, 'VERSION_A bytes')]), + writerB.commitSources([registeredSource(uri, 'VERSION_B bytes')]), + ]) + + const reopened = await createPersistentResearchDrivingDriver({ + store, + ledgerId: 'concurrent-versions', + router, + }) + expect( + reopened + .researchState() + .claims.map((claim) => claim.text) + .sort(), + ).toEqual(['concurrent claim A', 'concurrent claim B']) + expect(reopened.toLedger().registeredSources).toHaveLength(2) + }) + + it('snapshots source identity before awaiting claim extraction', async () => { + let releaseExtraction: (() => void) | undefined + const extractionStarted = new Promise((resolve) => { + releaseExtraction = resolve + }) + let allowExtraction: (() => void) | undefined + const extractionBlocked = new Promise((resolve) => { + allowExtraction = resolve + }) + const driver = await createPersistentResearchDrivingDriver({ + store: new MemoryKbStore(), + ledgerId: 'immutable-intake', + router: { + ...stubRouter({}), + chat: async () => { + releaseExtraction?.() + await extractionBlocked + return '[{"claim":"claim from immutable bytes A","contradicts":null}]' + }, + }, + }) + const mutable = source('https://a.org/result', 'immutable PAGE-A bytes') + const verification = driver.verifySource(mutable, ctx(3)) + await extractionStarted + mutable.uri = 'https://b.org/result' + mutable.text = 'mutated PAGE-B bytes' + allowExtraction?.() + await verification + + await driver.commitSources([registeredSource('https://a.org/result', 'immutable PAGE-A bytes')]) + const ledger = driver.toLedger() + expect(ledger.claims.map((claim) => claim.text)).toEqual(['claim from immutable bytes A']) + expect(ledger.claimEvidence[0]).toMatchObject({ + sourceUri: 'https://a.org/result', + sourceContentHash: sha256('immutable PAGE-A bytes'), + }) + }) + + it('validates a source-confirmation batch before mutating live state', async () => { + const driver = await createPersistentResearchDrivingDriver({ + store: new MemoryKbStore(), + ledgerId: 'atomic-confirmation', + router: stubRouter({ PAGE: CLAIM_A }), + }) + await driver.verifySource(source('https://a.org/result', 'PAGE bytes'), ctx(1)) + const invalid = { + ...registeredSource('https://b.org/result', 'other bytes'), + id: 'src_forged', + } + + await expect( + driver.commitSources([registeredSource('https://a.org/result', 'PAGE bytes'), invalid]), + ).rejects.toThrow(/does not match URI-and-content identity/) + expect(driver.toLedger().registeredSources).toEqual([]) + expect(driver.researchState().claims).toEqual([]) + }) + it('recovers when sources register but the evidence confirmation crashes', async () => { await withRoot(async (root) => { const store = new FileSystemKbStore({ root }) @@ -196,11 +350,11 @@ describe('research claim ledger — persistence', () => { }) const interrupted = { ...driver, - commitSources: async (sourceUris: readonly string[]) => { - if (sourceUris.length > 0) { + commitSources: async (sources: readonly SourceRecord[]) => { + if (sources.length > 0) { throw new Error('simulated crash after source registration') } - await driver.commitSources(sourceUris) + await driver.commitSources(sources) }, } const readinessSpecs = [ @@ -237,7 +391,7 @@ describe('research claim ledger — persistence', () => { expect(registeredIndex.sources).toHaveLength(2) const pending = await store.getClaimLedger('source-confirmation-crash') expect(pending?.claimEvidence).toHaveLength(2) - expect(pending?.registeredSourceUris).toEqual([]) + expect(pending?.registeredSources).toEqual([]) expect(pending?.claims).toEqual([]) const afterCrash = await createPersistentResearchDrivingDriver({ router, @@ -247,6 +401,12 @@ describe('research claim ledger — persistence', () => { expect(afterCrash.isComplete()).toBe(false) await expect(store.listEvents({ type: 'research.iteration' })).resolves.toHaveLength(0) + // A normal knowledge base may also contain path-imported records, which + // have no originalUri and are unrelated to text-source reconciliation. + const pathSource = join(root, 'offline-source.txt') + await writeFile(pathSource, 'offline corpus bytes') + await addSourcePath(root, pathSource) + // A fresh loop reconciles exact original URIs from the source registry // before it decides readiness or launches another worker round. const resumed = await createPersistentResearchDrivingDriver({ @@ -271,7 +431,7 @@ describe('research claim ledger — persistence', () => { expect(completeBeforeWorker).toBe(true) expect(resumed.isComplete()).toBe(true) const recovered = await store.getClaimLedger('source-confirmation-crash') - expect(recovered?.registeredSourceUris).toEqual([ + expect(recovered?.registeredSources.map((source) => source.uri).sort()).toEqual([ 'https://a.org/result', 'https://b.org/result', ]) @@ -325,7 +485,7 @@ describe('research claim ledger — persistence', () => { const first = await createPersistentResearchDrivingDriver({ router, store, ledgerId: 'run-2' }) await first.verifySource(source('https://arxiv.org/a', 'PAGE-A body'), ctx(1)) - await first.commitSources(['https://arxiv.org/a']) + await first.commitSources([registeredSource('https://arxiv.org/a', 'PAGE-A body')]) // The questions this raises exist ONLY in the synchronous fold, so without a // checkpoint they would die here and the resumed run would call itself done. await first.prepareFold() @@ -344,7 +504,7 @@ describe('research claim ledger — persistence', () => { // Corroborating the claim settles the CLAIM half, and completion still // refuses while a question raised before the crash remains unanswered. await resumed.verifySource(source('https://acm.org/b', 'PAGE-B body'), ctx(2)) - await resumed.commitSources(['https://acm.org/b']) + await resumed.commitSources([registeredSource('https://acm.org/b', 'PAGE-B body')]) const settled = resumed.researchState() expect(settled.corroborated).toHaveLength(1) expect(settled.weaklySupported).toHaveLength(0) @@ -467,10 +627,10 @@ describe('research claim ledger — persistence', () => { const runA = await createPersistentResearchDrivingDriver({ router, store, ledgerId: 'run-a' }) await runA.verifySource(source('https://arxiv.org/a', 'PAGE-A body'), ctx(1, 'goal A')) - await runA.commitSources(['https://arxiv.org/a']) + await runA.commitSources([registeredSource('https://arxiv.org/a', 'PAGE-A body')]) const runB = await createPersistentResearchDrivingDriver({ router, store, ledgerId: 'run-b' }) await runB.verifySource(source('https://acm.org/c', 'PAGE-C body'), ctx(1, 'goal B')) - await runB.commitSources(['https://acm.org/c']) + await runB.commitSources([registeredSource('https://acm.org/c', 'PAGE-C body')]) const ledgers = await store.listClaimLedgers() expect(ledgers.map((ledger) => ledger.id)).toEqual(['run-a', 'run-b']) @@ -494,7 +654,7 @@ describe('research claim ledger — persistence', () => { ledgerId: 'run-disk', }) await driver.verifySource(source('https://arxiv.org/a', 'PAGE-A body'), ctx(1)) - await driver.commitSources(['https://arxiv.org/a']) + await driver.commitSources([registeredSource('https://arxiv.org/a', 'PAGE-A body')]) await driver.prepareFold() driver.foldGaps?.([]) await driver.checkpoint() @@ -678,13 +838,31 @@ function ledgerOf( claims: readonly ResearchClaimRecord[], goal = GOAL, ): ResearchClaimLedger { + const claimEvidence = claims + .flatMap((claim) => + claim.supportingUris.map((sourceUri) => + evidenceFrom(claim.text, sourceUri, claim.firstSeenRound, claim.contradicts[0]), + ), + ) + .sort((left, right) => left.id.localeCompare(right.id)) + const registeredSources = new Map() + for (const evidence of claimEvidence) { + registeredSources.set(evidence.sourceId, { + sourceId: evidence.sourceId, + uri: evidence.sourceUri, + contentHash: evidence.sourceContentHash, + }) + } return { + schemaVersion: 2, id, goal, updatedAt: '2026-07-28T00:00:00.000Z', rounds: 1, - claimEvidence: [], - registeredSourceUris: [...new Set(claims.flatMap((claim) => claim.supportingUris))].sort(), + claimEvidence, + registeredSources: [...registeredSources.values()].sort((a, b) => + a.sourceId.localeCompare(b.sourceId), + ), claims: [...claims].sort((a, b) => a.id.localeCompare(b.id)), questions: [], } @@ -709,16 +887,30 @@ function evidenceFrom( contradictsClaimId?: string, ): ResearchClaimEvidence { const observedClaimId = claimId(text) + const version = sourceVersion(sourceUri) return { - id: claimEvidenceId({ claimId: observedClaimId, sourceUri, contradictsClaimId }), + id: claimEvidenceId({ + claimId: observedClaimId, + sourceId: version.sourceId, + sourceUri, + sourceContentHash: version.contentHash, + contradictsClaimId, + }), claimId: observedClaimId, text, + sourceId: version.sourceId, sourceUri, + sourceContentHash: version.contentHash, ...(contradictsClaimId === undefined ? {} : { contradictsClaimId }), firstSeenRound: round, } } +function sourceVersion(uri: string, text = `source:${uri}`): ResearchSourceVersion { + const contentHash = sha256(text) + return { sourceId: textSourceId(uri, contentHash), uri, contentHash } +} + describe('claim ledger — concurrent accumulation', () => { /** * The negative control for the whole merge path. If `putClaimLedger` did not @@ -839,9 +1031,9 @@ describe('claim ledger — concurrent accumulation', () => { }) await workerThree.verifySource(source('https://arxiv.org/a', 'PAGE-A body'), ctx(1)) - await workerThree.commitSources(['https://arxiv.org/a']) + await workerThree.commitSources([registeredSource('https://arxiv.org/a', 'PAGE-A body')]) await workerForty.verifySource(source('https://acm.org/b', 'PAGE-B body'), ctx(1)) - await workerForty.commitSources(['https://acm.org/b']) + await workerForty.commitSources([registeredSource('https://acm.org/b', 'PAGE-B body')]) // Worker 40 wrote second and read worker 3's evidence back: one claim, // two independent hosts, corroborated. Under a whole-record write worker @@ -909,7 +1101,10 @@ describe('claim ledger — concurrent accumulation', () => { const sourceUri = 'https://a.org/result' const evidence = evidenceFrom('claim one', sourceUri) const observed = { ...ledgerOf('pursuit', []), claimEvidence: [evidence] } - const registered = { ...ledgerOf('pursuit', []), registeredSourceUris: [sourceUri] } + const registered = { + ...ledgerOf('pursuit', []), + registeredSources: [sourceVersion(sourceUri)], + } const evidenceThenRegistration = mergeClaimLedgers(observed, registered) const registrationThenEvidence = mergeClaimLedgers(registered, observed) @@ -933,11 +1128,11 @@ describe('claim ledger — concurrent accumulation', () => { } const firstRegistration = { ...ledgerOf('pursuit', []), - registeredSourceUris: [uriOne], + registeredSources: [sourceVersion(uriOne)], } const secondRegistration = { ...ledgerOf('pursuit', []), - registeredSourceUris: [uriTwo], + registeredSources: [sourceVersion(uriTwo)], } const left = mergeClaimLedgers( @@ -963,7 +1158,7 @@ describe('claim ledger — concurrent accumulation', () => { } const onlyRefuterRegistered = { ...ledgerOf('pursuit', []), - registeredSourceUris: [refuterUri], + registeredSources: [sourceVersion(refuterUri)], } const oneSided = mergeClaimLedgers(observed, onlyRefuterRegistered) @@ -973,7 +1168,7 @@ describe('claim ledger — concurrent accumulation', () => { const bothRegistered = mergeClaimLedgers(oneSided, { ...ledgerOf('pursuit', []), - registeredSourceUris: [originalUri], + registeredSources: [sourceVersion(originalUri)], }) expect(bothRegistered.claims).toHaveLength(2) expect(bothRegistered.claims.every((claim) => claim.contested)).toBe(true) @@ -1011,15 +1206,22 @@ describe('claim ledger — concurrent accumulation', () => { }) it('never clears a contradiction a later writer did not happen to see', () => { + const contrary = claimFrom('x slows down y', 'c.org') const contested: ResearchClaimRecord = { ...claimFrom('x speeds up y', 'a.org'), - contradicts: [claimId('x slows down y')], + contradicts: [contrary.id], contested: true, } + contrary.contradicts = [contested.id] + contrary.contested = true const oblivious = claimFrom('x speeds up y', 'b.org') - const merged = mergeClaimLedgers(ledgerOf('p', [contested]), ledgerOf('p', [oblivious])) - expect(merged.claims[0]?.contested).toBe(true) - expect(merged.claims[0]?.contradicts).toEqual([claimId('x slows down y')]) + const merged = mergeClaimLedgers( + ledgerOf('p', [contested, contrary]), + ledgerOf('p', [oblivious]), + ) + const retained = merged.claims.find((claim) => claim.id === contested.id) + expect(retained?.contested).toBe(true) + expect(retained?.contradicts).toEqual([contrary.id]) }) it('makes a one-sided contradiction symmetric and contests both ends', () => { @@ -1041,32 +1243,31 @@ describe('claim ledger — concurrent accumulation', () => { expect(linkClaimContradictions(linked)).toEqual(linked) }) - it('closes a one-sided contradiction when its counterpart arrives in a merge', () => { + it('refuses a materialized contradiction whose counterpart has not arrived', () => { const original = claimFrom('the speedup is 5x', 'a.org') const refuter: ResearchClaimRecord = { ...claimFrom('the speedup is only 2x', 'b.org'), contradicts: [original.id], contested: true, } - const merged = mergeClaimLedgers(ledgerOf('p', [refuter]), ledgerOf('p', [original])) - const byId = new Map(merged.claims.map((claim) => [claim.id, claim])) - - expect(byId.get(original.id)?.contested).toBe(true) - expect(byId.get(original.id)?.contradicts).toEqual([refuter.id]) - expect(byId.get(refuter.id)?.contradicts).toEqual([original.id]) + expect(() => mergeClaimLedgers(ledgerOf('p', [refuter]), ledgerOf('p', [original]))).toThrow( + /unmaterialized claim/, + ) }) - it('keeps an edge whose counterpart claim has not arrived yet', () => { + it('keeps an unclosed contradiction only as evidence until its counterpart arrives', () => { const orphan: ResearchClaimRecord = { ...claimFrom('x speeds up y', 'a.org'), contradicts: [claimId('nobody has written this down yet')], contested: true, } const linked = linkClaimContradictions(ledgerOf('p', [orphan])) - // Dropping the edge would report the claim as settled on the strength of - // the one writer that had not yet met its refutation. - expect(linked.claims[0]?.contradicts).toEqual([claimId('nobody has written this down yet')]) - expect(linked.claims[0]?.contested).toBe(true) + expect(linked.claims[0]?.contradicts).toEqual([]) + expect(linked.claims[0]?.contested).toBe(false) + expect(linked.claimEvidence[0]?.contradictsClaimId).toBe( + claimId('nobody has written this down yet'), + ) + expect(ResearchClaimLedgerSchema.parse(linked)).toEqual(linked) }) }) @@ -1140,16 +1341,16 @@ describe('claim ledger — record integrity', () => { const unregistered = { ...ledgerOf('pursuit', [claim]), - registeredSourceUris: [], + registeredSources: [], } expect(() => ResearchClaimLedgerSchema.parse(unregistered)).toThrow( - /before its registration is confirmed/, + /without exact registered evidence/, ) const unmaterialized = { ...ledgerOf('pursuit', []), claimEvidence: [evidence], - registeredSourceUris: [evidence.sourceUri], + registeredSources: [sourceVersion(evidence.sourceUri)], } expect(() => ResearchClaimLedgerSchema.parse(unmaterialized)).toThrow(/must be materialized/) }) @@ -1159,6 +1360,42 @@ describe('claim ledger — record integrity', () => { /kind-and-text identity/, ) }) + + it('preserves an unversioned ledger byte-for-byte until explicit re-verification', async () => { + await withRoot(async (root) => { + await initKnowledgeBase(root) + const directory = join(root, KB_CLAIM_LEDGER_DIR) + const path = join(directory, 'legacy.json') + await mkdir(directory, { recursive: true }) + const legacy = { + id: 'legacy', + goal: GOAL, + updatedAt: '2026-07-28T00:00:00.000Z', + rounds: 1, + claimEvidence: [], + registeredSourceUris: [], + claims: [], + questions: [], + } + const original = `${JSON.stringify(legacy, null, 2)}\n` + await writeFile(path, original) + const store = new FileSystemKbStore({ root }) + + await expect(store.getClaimLedger('legacy')).rejects.toBeInstanceOf( + ClaimLedgerMigrationRequiredError, + ) + await expect(store.listClaimLedgers()).rejects.toBeInstanceOf( + ClaimLedgerMigrationRequiredError, + ) + await expect( + store.mergeClaimLedger('legacy', () => ledgerOf('legacy', [])), + ).rejects.toBeInstanceOf(ClaimLedgerMigrationRequiredError) + await expect(store.putClaimLedger(ledgerOf('legacy', []))).rejects.toBeInstanceOf( + ClaimLedgerMigrationRequiredError, + ) + expect(await readFile(path, 'utf8')).toBe(original) + }) + }) }) async function findFiles(root: string, name: string): Promise { diff --git a/tests/core.test.ts b/tests/core.test.ts index f31f34e..c823cb5 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -28,6 +28,7 @@ import { reciprocalRankFusion, runKnowledgeResearchLoop, searchKnowledge, + sha256, validateKnowledgeIndex, writeSourceRegistry, } from '../src/index' @@ -93,6 +94,58 @@ describe('source registry integrity', () => { }) }) + it('stores the exact submitted bytes under a full-hash path', async () => { + await withProject(async (root) => { + const text = 'exact source bytes without a trailing newline' + const record = await addSourceText(root, { + uri: 'https://example.org/source.txt', + text, + }) + const raw = await readFile(join(root, record.uri)) + + expect(raw.equals(Buffer.from(text, 'utf8'))).toBe(true) + expect(record.contentHash).toBe(sha256(text)) + expect(record.uri).toContain(record.contentHash) + }) + }) + + it('snapshots a text source before an asynchronous adapter can observe mutation', async () => { + await withProject(async (root) => { + let adapterStarted: (() => void) | undefined + const started = new Promise((resolve) => { + adapterStarted = resolve + }) + let releaseAdapter: (() => void) | undefined + const blocked = new Promise((resolve) => { + releaseAdapter = resolve + }) + const mutable = { uri: 'memory://a', text: 'bytes A', metadata: { version: 'A' } } + const adding = addSourceText(root, mutable, { + adapters: [ + { + id: 'blocking-test-adapter', + canLoad: () => true, + load: async () => { + adapterStarted?.() + await blocked + return {} + }, + }, + ], + }) + await started + mutable.uri = 'memory://b' + mutable.text = 'bytes B' + mutable.metadata.version = 'B' + releaseAdapter?.() + const record = await adding + + expect(record.metadata?.originalUri).toBe('memory://a') + expect(record.contentHash).toBe(sha256('bytes A')) + expect(await readFile(join(root, record.uri), 'utf8')).toBe('bytes A') + }) + }) + it('does not replace a malformed filesystem index with generated data', async () => { await withProject(async (root) => { const storeRoot = join(root, '.store')