From dd7266bf4a18f806a04f6754ecd37a9a6522fdfe Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sat, 12 Sep 2026 16:00:22 -0700 Subject: [PATCH] fix(knowledge): bind promotions to verified source snapshots --- CHANGELOG.md | 5 +++ README.md | 3 ++ package.json | 2 +- src/promotion.test.ts | 82 ++++++++++++++++++++++++++++++++++++++++++- src/promotion.ts | 25 ++++++++----- 5 files changed, 107 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9db37b6..b5342a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 16.0.1 — 2026-09-12 + +Run-scoped promotion verifies and freezes source pages, including cited support, before writing the shared store. +A source with changed parsed identity after the visibility snapshot produces a `path-conflict` refusal instead of copying uninspected content under an outdated digest. + ## 16.0.0 — 2026-09-09 Completed Knowledge write transactions may opt into durable before/after history with `retainHistory: true`. diff --git a/README.md b/README.md index eccd3f9..bd2b350 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,9 @@ const record = await promoteRunScopedPages(stores, runId, { A claim's cited support travels with it. Promoting a claim and leaving the run-local pages it cites behind is what turns a resolved citation into a dangling one, so the closure of cited pages is carried, each keeping its own evidence fields exactly as written — a promoted claim cannot inherit a confidence its support does not carry. The promotion is refused when any citation would not resolve in the shared store, including a citation qualified with `here::` or `inherited:`, whose scope does not exist there. Pages travel as the bytes their store holds, so a promoted page has one digest in both scopes. +Promotion freezes each source page and its cited support before transfer. +If either page's parsed identity changed after the visibility snapshot, promotion refuses with `path-conflict` before writing shared pages. +Later source edits do not replace the captured version during promotion. The record lands at `/.agent-knowledge/promotions/.json` with the source run, every page digest, which pages were requested and which were carried support, the actor, the reason, and the time. Re-running the same promotion writes the same record at the same path. ## Brief a run before its first token diff --git a/package.json b/package.json index 98f8144..03af874 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-knowledge", - "version": "16.0.0", + "version": "16.0.1", "description": "Build, search, evaluate, and improve source-backed knowledge bases.", "homepage": "https://github.com/tangle-network/agent-knowledge#readme", "repository": { diff --git a/src/promotion.test.ts b/src/promotion.test.ts index c432293..90c957e 100644 --- a/src/promotion.test.ts +++ b/src/promotion.test.ts @@ -1,14 +1,17 @@ import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { KnowledgeCitationResolutionError } from './citation-resolution' +import { knowledgePageDigest } from './knowledge-use-receipts' import { KnowledgePromotionError, loadKnowledgePromotionRecord, promoteRunScopedPages, } from './promotion' +import { applyKnowledgeWriteBlocks } from './proposals' import { createRunScopedStores, type RunScopedStores } from './run-scoped' +import * as store from './store' import { loadKnowledgePages } from './store' let root: string @@ -21,6 +24,7 @@ beforeEach(async () => { stores = createRunScopedStores({ root, sharedRoot: shared }) }) afterEach(async () => { + vi.restoreAllMocks() await rm(root, { recursive: true, force: true }) await rm(shared, { recursive: true, force: true }) }) @@ -58,11 +62,87 @@ describe('promoteRunScopedPages', () => { expect(promoted.map((page) => page.id).sort()).toEqual(['claim', 'measurement']) expect(promoted.find((page) => page.id === 'measurement')!.frontmatter.rung).toBe(4) expect(promoted.find((page) => page.id === 'claim')!.frontmatter.rung).toBe(2) + for (const entry of record.entries) { + expect(entry.pageDigest).toBe( + knowledgePageDigest(promoted.find((page) => page.id === entry.pageId)!), + ) + } expect(await readFile(join(shared, 'knowledge', 'claim.md'), 'utf8')).toBe( await readFile(join(stores.storePath('run-a'), 'knowledge', 'claim.md'), 'utf8'), ) }) + it.each(['claim', 'measurement'])( + 'refuses changed source bytes in %s after reading the citation closure', + async (changedId) => { + await stores.init('run-a') + await addPage('run-a', 'measurement', '', 'The measured latency was 32 ms.') + await addPage('run-a', 'claim', 'cites: [measurement]\n', 'Latency is the dominant term.') + const concurrentStores: RunScopedStores = { + ...stores, + async loadChain(runId) { + const snapshot = await stores.loadChain(runId) + await applyKnowledgeWriteBlocks( + stores.storePath(runId), + `---FILE: knowledge/${changedId}.md---\n---\nid: ${changedId}\ncites: [uninspected-source]\n---\nChanged after the promotion snapshot.\n---END FILE---`, + ) + return snapshot + }, + } + + await expect( + promoteRunScopedPages(concurrentStores, 'run-a', { + pageIds: ['claim'], + sharedRoot: shared, + actor: 'drew', + reason: 'testing concurrent source edits', + }), + ).rejects.toThrow(/source page .* changed after the promotion snapshot/) + expect(await loadKnowledgePages(shared)).toEqual([]) + }, + ) + + it('promotes the inspected frozen pages when the source changes before destination initialization', async () => { + await stores.init('run-a') + await addPage('run-a', 'measurement', '', 'The measured latency was 32 ms.') + await addPage('run-a', 'claim', 'cites: [measurement]\n', 'Latency is the dominant term.') + await addPage('run-a', 'unselected', '', 'Unrelated research remains local.') + const inspected = await loadKnowledgePages(stores.storePath('run-a')) + const unselected = await readFile( + join(stores.storePath('run-a'), 'knowledge', 'unselected.md'), + 'utf8', + ) + const initialize = store.initKnowledgeBase + vi.spyOn(store, 'initKnowledgeBase').mockImplementationOnce(async (destination) => { + await applyKnowledgeWriteBlocks( + stores.storePath('run-a'), + '---FILE: knowledge/claim.md---\n---\nid: claim\ncites: [uninspected-source]\n---\nChanged after source capture.\n---END FILE---', + ) + return initialize(destination) + }) + + const record = await promoteRunScopedPages(stores, 'run-a', { + pageIds: ['claim'], + sharedRoot: shared, + actor: 'drew', + reason: 'testing frozen source bytes', + }) + + const promoted = await loadKnowledgePages(shared) + expect(promoted).toEqual(inspected.filter((page) => page.id !== 'unselected')) + for (const entry of record.entries) { + expect(entry.pageDigest).toBe( + knowledgePageDigest(promoted.find((page) => page.id === entry.pageId)!), + ) + } + expect( + await readFile(join(stores.storePath('run-a'), 'knowledge', 'claim.md'), 'utf8'), + ).toContain('Changed after source capture.') + expect( + await readFile(join(stores.storePath('run-a'), 'knowledge', 'unselected.md'), 'utf8'), + ).toBe(unselected) + }) + it('refuses a promotion whose citation would resolve to nothing in the shared store', async () => { await stores.init('run-a') await addPage('run-a', 'claim', 'cites:\n - absent\n', 'Built on a page that does not exist.') diff --git a/src/promotion.ts b/src/promotion.ts index 7457405..64e7317 100644 --- a/src/promotion.ts +++ b/src/promotion.ts @@ -28,7 +28,7 @@ import { type PageOrigin, type RunScopedStores, } from './run-scoped' -import { initKnowledgeBase, loadKnowledgePages } from './store' +import { initKnowledgeBase, knowledgePageFromMarkdown, loadKnowledgePages } from './store' import type { KnowledgeId, KnowledgePage } from './types' export const KNOWLEDGE_PROMOTION_SCHEMA_VERSION = '1.0.0' as const @@ -116,6 +116,12 @@ export async function promoteRunScopedPages( const chain = await stores.loadChain(runId) const travellers = collectTravellers(chain, options.pageIds) + const mutations = await Promise.all( + travellers.map(async (traveller) => ({ + path: traveller.entry.page.path, + content: await readPageBytes(stores, runId, traveller.entry, pagesDirectory), + })), + ) await initKnowledgeBase(sharedRoot) return withKnowledgeMutation(sharedRoot, async (lock) => { @@ -139,12 +145,6 @@ export async function promoteRunScopedPages( ), ) - const mutations = await Promise.all( - travellers.map(async (traveller) => ({ - path: traveller.entry.page.path, - content: await readPageBytes(stores, runId, traveller.entry), - })), - ) await commitKnowledgeFileMutations({ root: sharedRoot, transactionRoot: lock.transactionRoot, @@ -283,10 +283,19 @@ async function readPageBytes( stores: RunScopedStores, runId: string, entry: OriginatedPage, + pagesDirectory: string, ): Promise { const sourceRunId = sourceRunOf(entry.origin, runId) const snapshot = await readRegularFileWithinRoot(stores.storePath(sourceRunId), entry.page.path) - return snapshot.bytes.toString('utf8') + const content = snapshot.bytes.toString('utf8') + const page = knowledgePageFromMarkdown(entry.page.path, content, pagesDirectory) + if (knowledgePageDigest(page) !== knowledgePageDigest(entry.page)) { + throw new KnowledgePromotionError( + 'path-conflict', + `source page "${entry.page.path}" in run "${sourceRunId}" changed after the promotion snapshot`, + ) + } + return content } function sourceRunOf(origin: PageOrigin, runId: string): string {