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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<shared>/.agent-knowledge/promotions/<digest>.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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
82 changes: 81 additions & 1 deletion src/promotion.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 })
})
Expand Down Expand Up @@ -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.')
Expand Down
25 changes: 17 additions & 8 deletions src/promotion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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,
Expand Down Expand Up @@ -283,10 +283,19 @@ async function readPageBytes(
stores: RunScopedStores,
runId: string,
entry: OriginatedPage,
pagesDirectory: string,
): Promise<string> {
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 {
Expand Down