From 8431120b78e43e815eb8302b8903dc3ab0a5c903 Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:27:24 -0700 Subject: [PATCH 01/22] feat(citations): add ambiguity-safe run-scoped resolution --- src/citation-resolution.ts | 163 +++++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 src/citation-resolution.ts diff --git a/src/citation-resolution.ts b/src/citation-resolution.ts new file mode 100644 index 0000000..3397fbe --- /dev/null +++ b/src/citation-resolution.ts @@ -0,0 +1,163 @@ +import type { OriginatedPage, PageOrigin, RunScopedStores } from './run-scoped' +import type { KnowledgeId, KnowledgePage } from './types' + +/** + * A citation into the knowledge visible to one run. + * + * An unqualified reference is accepted only when exactly one visible page has + * the requested id. Callers can qualify a reference by origin when a current, + * inherited, or shared page intentionally reuses the same stable id. + */ +export interface KnowledgeCitationReference { + readonly pageId: KnowledgeId + readonly origin?: PageOrigin +} + +export interface KnowledgeCitationCandidate { + readonly pageId: KnowledgeId + readonly origin: PageOrigin + readonly page: KnowledgePage +} + +export type KnowledgeCitationResolutionStatus = 'resolved' | 'missing' | 'ambiguous' + +export interface KnowledgeCitationResolution { + readonly reference: KnowledgeCitationReference + readonly status: KnowledgeCitationResolutionStatus + readonly candidates: readonly KnowledgeCitationCandidate[] + readonly resolved?: KnowledgeCitationCandidate +} + +/** A batch contains at least one citation that is missing or ambiguous. */ +export class KnowledgeCitationResolutionError extends Error { + readonly resolutions: readonly KnowledgeCitationResolution[] + + constructor(resolutions: readonly KnowledgeCitationResolution[]) { + const unresolved = resolutions.filter((resolution) => resolution.status !== 'resolved') + const missing = unresolved + .filter((resolution) => resolution.status === 'missing') + .map(renderReference) + const ambiguous = unresolved + .filter((resolution) => resolution.status === 'ambiguous') + .map((resolution) => `${renderReference(resolution)} (${resolution.candidates.length} matches)`) + const parts = [ + missing.length > 0 ? `missing: ${missing.join(', ')}` : '', + ambiguous.length > 0 ? `ambiguous: ${ambiguous.join(', ')}` : '', + ].filter(Boolean) + super(`knowledge citation resolution failed: ${parts.join('; ')}`) + this.name = 'KnowledgeCitationResolutionError' + this.resolutions = Object.freeze([...unresolved]) + } +} + +/** Resolve one reference against an already materialized visibility chain. */ +export function resolveKnowledgeCitation( + visiblePages: readonly OriginatedPage[], + reference: KnowledgeCitationReference, +): KnowledgeCitationResolution { + const normalized = normalizeReference(reference) + const candidates = visiblePages + .filter( + (entry) => + entry.page.id === normalized.pageId && + (normalized.origin === undefined || entry.origin === normalized.origin), + ) + .map((entry) => + Object.freeze({ + pageId: entry.page.id, + origin: entry.origin, + page: entry.page, + }), + ) + const status: KnowledgeCitationResolutionStatus = + candidates.length === 0 ? 'missing' : candidates.length === 1 ? 'resolved' : 'ambiguous' + return Object.freeze({ + reference: normalized, + status, + candidates: Object.freeze(candidates), + ...(status === 'resolved' ? { resolved: candidates[0]! } : {}), + }) +} + +/** Resolve a batch without discarding missing or ambiguous rows. */ +export function resolveKnowledgeCitations( + visiblePages: readonly OriginatedPage[], + references: readonly KnowledgeCitationReference[], +): readonly KnowledgeCitationResolution[] { + return Object.freeze( + references.map((reference) => resolveKnowledgeCitation(visiblePages, reference)), + ) +} + +/** + * Resolve a batch and fail closed unless every reference names exactly one + * visible page. Returned candidates preserve the caller's reference order. + */ +export function assertKnowledgeCitationsResolved( + visiblePages: readonly OriginatedPage[], + references: readonly KnowledgeCitationReference[], +): readonly KnowledgeCitationCandidate[] { + const resolutions = resolveKnowledgeCitations(visiblePages, references) + if (resolutions.some((resolution) => resolution.status !== 'resolved')) { + throw new KnowledgeCitationResolutionError(resolutions) + } + return Object.freeze(resolutions.map((resolution) => resolution.resolved!)) +} + +/** Resolve one citation using a run-scoped store's declared visibility chain. */ +export async function resolveRunScopedCitation( + stores: RunScopedStores, + runId: string, + reference: KnowledgeCitationReference, +): Promise { + return resolveKnowledgeCitation(await stores.loadChain(runId), reference) +} + +/** Resolve citations using one chain read so every row sees the same snapshot. */ +export async function resolveRunScopedCitations( + stores: RunScopedStores, + runId: string, + references: readonly KnowledgeCitationReference[], +): Promise { + return resolveKnowledgeCitations(await stores.loadChain(runId), references) +} + +/** Resolve a run-scoped batch and fail closed on any missing or ambiguous id. */ +export async function assertRunScopedCitationsResolved( + stores: RunScopedStores, + runId: string, + references: readonly KnowledgeCitationReference[], +): Promise { + return assertKnowledgeCitationsResolved(await stores.loadChain(runId), references) +} + +function normalizeReference(reference: KnowledgeCitationReference): KnowledgeCitationReference { + if (!reference || typeof reference !== 'object') { + throw new TypeError('knowledge citation reference must be an object') + } + if (typeof reference.pageId !== 'string' || reference.pageId.trim().length === 0) { + throw new TypeError('knowledge citation pageId must be a non-empty string') + } + if (reference.origin !== undefined && !isPageOrigin(reference.origin)) { + throw new TypeError(`knowledge citation origin is invalid: ${String(reference.origin)}`) + } + return Object.freeze({ + pageId: reference.pageId.trim(), + ...(reference.origin === undefined ? {} : { origin: reference.origin }), + }) +} + +function isPageOrigin(value: unknown): value is PageOrigin { + if (value === 'here' || value === 'shared') return true + return ( + typeof value === 'string' && + value.startsWith('inherited:') && + value.slice('inherited:'.length).trim().length > 0 + ) +} + +function renderReference(resolution: KnowledgeCitationResolution): string { + return resolution.reference.origin === undefined + ? resolution.reference.pageId + : `${resolution.reference.origin}::${resolution.reference.pageId}` +} From cf2e14272ab9ca98b3f372d6295b38bbf3b9ef4d Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:27:56 -0700 Subject: [PATCH 02/22] test(citations): prove missing and ambiguous references fail closed --- src/citation-resolution.test.ts | 117 ++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/citation-resolution.test.ts diff --git a/src/citation-resolution.test.ts b/src/citation-resolution.test.ts new file mode 100644 index 0000000..a73fb65 --- /dev/null +++ b/src/citation-resolution.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' +import { + assertKnowledgeCitationsResolved, + KnowledgeCitationResolutionError, + resolveKnowledgeCitation, + resolveKnowledgeCitations, +} from './citation-resolution' +import type { OriginatedPage, PageOrigin } from './run-scoped' +import type { KnowledgePage } from './types' + +function page(id: string, origin: PageOrigin, path = `${id}.md`): OriginatedPage { + const value: KnowledgePage = { + id, + path: `knowledge/${path}`, + title: id, + text: `knowledge for ${id}`, + frontmatter: { id }, + sourceIds: [], + tags: [], + outLinks: [], + } + return { page: value, origin } +} + +describe('knowledge citation resolution', () => { + it('resolves an unqualified id only when one visible page owns it', () => { + const resolution = resolveKnowledgeCitation( + [page('current', 'here'), page('parent', 'inherited:run-a'), page('prior', 'shared')], + { pageId: 'parent' }, + ) + + expect(resolution).toMatchObject({ + status: 'resolved', + reference: { pageId: 'parent' }, + resolved: { pageId: 'parent', origin: 'inherited:run-a' }, + }) + expect(resolution.candidates).toHaveLength(1) + expect(Object.isFrozen(resolution)).toBe(true) + }) + + it('retains a missing row instead of silently dropping it', () => { + const resolution = resolveKnowledgeCitation([page('known', 'here')], { pageId: 'missing' }) + + expect(resolution.status).toBe('missing') + expect(resolution.candidates).toEqual([]) + expect(Object.hasOwn(resolution, 'resolved')).toBe(false) + }) + + it('reports an unqualified id as ambiguous across visible stores', () => { + const visible = [ + page('reused', 'here', 'current.md'), + page('reused', 'inherited:run-a', 'ancestor.md'), + page('reused', 'shared', 'shared.md'), + ] + + const resolution = resolveKnowledgeCitation(visible, { pageId: 'reused' }) + + expect(resolution.status).toBe('ambiguous') + expect(resolution.candidates.map((candidate) => candidate.origin)).toEqual([ + 'here', + 'inherited:run-a', + 'shared', + ]) + }) + + it('uses an explicit origin to disambiguate an intentional id reuse', () => { + const visible = [ + page('reused', 'here', 'current.md'), + page('reused', 'inherited:run-a', 'ancestor.md'), + page('reused', 'shared', 'shared.md'), + ] + + const resolution = resolveKnowledgeCitation(visible, { + pageId: 'reused', + origin: 'inherited:run-a', + }) + + expect(resolution).toMatchObject({ + status: 'resolved', + resolved: { pageId: 'reused', origin: 'inherited:run-a' }, + }) + }) + + it('fails a batch with exact missing and ambiguity diagnostics', () => { + const visible = [ + page('unique', 'here'), + page('reused', 'here', 'current.md'), + page('reused', 'shared', 'shared.md'), + ] + const references = [{ pageId: 'unique' }, { pageId: 'missing' }, { pageId: 'reused' }] + + expect(resolveKnowledgeCitations(visible, references).map((row) => row.status)).toEqual([ + 'resolved', + 'missing', + 'ambiguous', + ]) + expect(() => assertKnowledgeCitationsResolved(visible, references)).toThrow( + /missing: missing; ambiguous: reused \(2 matches\)/, + ) + try { + assertKnowledgeCitationsResolved(visible, references) + throw new Error('expected citation resolution to fail') + } catch (error) { + expect(error).toBeInstanceOf(KnowledgeCitationResolutionError) + expect((error as KnowledgeCitationResolutionError).resolutions.map((row) => row.status)).toEqual( + ['missing', 'ambiguous'], + ) + } + }) + + it('refuses malformed references before matching', () => { + expect(() => resolveKnowledgeCitation([], { pageId: ' ' })).toThrow(/non-empty string/) + expect(() => + resolveKnowledgeCitation([], { pageId: 'known', origin: 'inherited:' as never }), + ).toThrow(/origin is invalid/) + }) +}) From 2909bf3c20528484a788f71d15cfdf60adb61272 Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:28:58 -0700 Subject: [PATCH 03/22] feat(stores): support external run lineage authority --- src/run-scoped.ts | 226 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 179 insertions(+), 47 deletions(-) diff --git a/src/run-scoped.ts b/src/run-scoped.ts index f6b0e2d..c9c9018 100644 --- a/src/run-scoped.ts +++ b/src/run-scoped.ts @@ -1,20 +1,13 @@ /** * Run-scoped knowledge stores with lineage-chain reads. * - * One store per run, physically isolated, with inheritance only by declared ancestry. The design - * answers a failure mode a discovery campaign paid for twice: when every run writes one shared - * store, a false "measured" claim from one arm becomes the next arm's settled provenance — twice - * in that campaign a wrong claim crossed run boundaries and became standing instruction before - * anyone re-derived it. Isolation makes "what did THIS run learn" a directory listing, and the - * knowledge delta between two runs a diff of two directories. - * - * A branch still inherits: reads walk the run's own store, then each ancestor up a recorded - * parent chain, then an optional shared store — every page labeled with where it came from, - * because "I established this", "an earlier attempt believed this", and "the lab curates this" - * are three different provenance claims and a reader must be able to weigh them differently. + * One store exists per run, physically isolated, with inheritance only through + * declared ancestry. Reads expose the current store, every declared ancestor, + * and an optional curated shared store with an explicit origin label. */ import { join } from 'node:path' import { isMissingFile, readRegularFileWithinRoot } from './durable-fs' +import { withKnowledgeMutation } from './mutation-lock' import type { KnowledgeLayout } from './store' import { initKnowledgeBase, loadKnowledgePages, writeJson } from './store' import type { KnowledgePage } from './types' @@ -33,6 +26,19 @@ export interface RunLineageRecord { createdAt: string } +/** + * Durable authority for run ancestry. + * + * A product whose run manifest already owns lineage can supply an authority + * backed by that manifest. `record` is optional for read-only authorities; in + * that case `init` verifies that the existing authority agrees with the parent + * requested by the caller. + */ +export interface RunLineageAuthority { + parentOf(runId: string): Promise + record?(record: RunLineageRecord): Promise +} + export const RUN_LINEAGE_BASENAME = 'lineage.json' export interface RunScopedStoresOptions { @@ -41,73 +47,94 @@ export interface RunScopedStoresOptions { /** Store path for a run; defaults to `//knowledge-base`. */ runStorePath?: (runId: string) => string /** - * The curated store every run may read but no run writes — promotion into it is a deliberate - * act outside this module. Searched last, labeled `shared`. + * The curated store every run may read but no run writes. Searched last and + * labeled `shared`. */ sharedRoot?: string + /** External owner for run ancestry. Defaults to a record inside each run store. */ + lineageAuthority?: RunLineageAuthority } -/** Ancestry chains longer than this indicate a cycle or a runaway, not a real lineage. */ +/** Ancestry beyond this bound is invalid durable state. */ const MAX_LINEAGE_HOPS = 64 export interface RunScopedStores { - /** Create (or open) a run's store, recording its parent at creation time. */ + /** Create or open a run store and bind it to one exact parent identity. */ init(runId: string, options?: { parentRunId?: string | null }): Promise - /** The ancestor chain of a run, nearest first, resolved from records written at init. */ + /** The ancestor chain of a run, nearest first. */ lineage(runId: string): Promise /** - * Every page visible to a run: its own, then each ancestor's, then the shared store's, each - * labeled with its origin. Later stores never shadow earlier ones — a reader sees both copies - * of a twice-recorded claim and the labels that distinguish them. + * Every page visible to a run: current, ancestors, then shared. A repeated + * page id is retained at every origin so citation resolution can report the + * ambiguity instead of silently shadowing one page. */ loadChain(runId: string): Promise } export function createRunScopedStores(options: RunScopedStoresOptions): RunScopedStores { + if (!options || typeof options !== 'object') { + throw new TypeError('createRunScopedStores options are required') + } + if (typeof options.root !== 'string' || options.root.trim().length === 0) { + throw new TypeError('createRunScopedStores root must be a non-empty string') + } + if (options.runStorePath !== undefined && typeof options.runStorePath !== 'function') { + throw new TypeError('createRunScopedStores runStorePath must be a function when present') + } + if (options.lineageAuthority !== undefined) validateLineageAuthority(options.lineageAuthority) + const storePath = options.runStorePath ?? ((runId: string) => join(options.root, runId, 'knowledge-base')) + const internalAuthority = createFileRunLineageAuthority(storePath) + const authority = options.lineageAuthority ?? internalAuthority - async function parentOf(runId: string): Promise { - try { - const snapshot = await readRegularFileWithinRoot(storePath(runId), RUN_LINEAGE_BASENAME) - return (JSON.parse(snapshot.bytes.toString('utf8')) as RunLineageRecord).parentRunId - } catch (error) { - // A run created before lineage recording (or outside it) simply ends the chain: absent - // ancestry is absent, not an error — the chain is only as deep as what was declared. - if (isMissingFile(error)) return null - throw error + const resolveLineage = async (runId: string): Promise => { + assertRunId(runId) + const chain: string[] = [] + const seen = new Set([runId]) + let current = runId + for (let hop = 0; hop < MAX_LINEAGE_HOPS; hop += 1) { + const parent = await authority.parentOf(current) + if (parent === null) return chain + assertRunId(parent, `parent of '${current}'`) + if (seen.has(parent)) { + throw new Error(`run lineage cycle: ${parent} is its own ancestor (via ${runId})`) + } + seen.add(parent) + chain.push(parent) + current = parent } + throw new Error(`run lineage for '${runId}' exceeds ${MAX_LINEAGE_HOPS} ancestors`) } return { async init(runId, initOptions = {}) { + assertRunId(runId) + const parentRunId = initOptions.parentRunId ?? null + if (parentRunId !== null) assertRunId(parentRunId, `parent of '${runId}'`) const layout = await initKnowledgeBase(storePath(runId)) - const record: RunLineageRecord = { + const record = Object.freeze({ runId, - parentRunId: initOptions.parentRunId ?? null, + parentRunId, createdAt: new Date().toISOString(), + }) + if (authority.record) { + await authority.record(record) + } else { + const existingParent = await authority.parentOf(runId) + if (existingParent !== parentRunId) { + throw new Error( + `external lineage authority disagrees for '${runId}': expected ${renderParent(parentRunId)}, observed ${renderParent(existingParent)}`, + ) + } } - await writeJson(join(storePath(runId), RUN_LINEAGE_BASENAME), record) return layout }, - async lineage(runId) { - const chain: string[] = [] - const seen = new Set([runId]) - let current: string | null = runId - for (let hop = 0; hop < MAX_LINEAGE_HOPS && current !== null; hop += 1) { - current = await parentOf(current) - if (current === null) break - if (seen.has(current)) { - throw new Error(`run lineage cycle: ${current} is its own ancestor (via ${runId})`) - } - seen.add(current) - chain.push(current) - } - return chain - }, + lineage: resolveLineage, async loadChain(runId) { + assertRunId(runId) const out: OriginatedPage[] = [] const readInto = async (root: string, origin: PageOrigin) => { let pages: KnowledgePage[] @@ -120,7 +147,7 @@ export function createRunScopedStores(options: RunScopedStoresOptions): RunScope for (const page of pages) out.push({ page, origin }) } await readInto(storePath(runId), 'here') - for (const ancestor of await this.lineage(runId)) { + for (const ancestor of await resolveLineage(runId)) { await readInto(storePath(ancestor), `inherited:${ancestor}`) } if (options.sharedRoot) await readInto(options.sharedRoot, 'shared') @@ -128,3 +155,108 @@ export function createRunScopedStores(options: RunScopedStoresOptions): RunScope }, } } + +/** File-backed authority used when the product has no separate run manifest. */ +export function createFileRunLineageAuthority( + runStorePath: (runId: string) => string, +): RunLineageAuthority { + if (typeof runStorePath !== 'function') { + throw new TypeError('createFileRunLineageAuthority requires a runStorePath function') + } + + const readRecord = async (runId: string): Promise => { + assertRunId(runId) + const root = runStorePath(runId) + try { + const snapshot = await readRegularFileWithinRoot(root, RUN_LINEAGE_BASENAME) + return parseLineageRecord(snapshot.bytes.toString('utf8'), runId) + } catch (error) { + if (isMissingFile(error)) return null + throw error + } + } + + return { + async parentOf(runId) { + return (await readRecord(runId))?.parentRunId ?? null + }, + + async record(record) { + const validated = validateLineageRecord(record) + const root = runStorePath(validated.runId) + await withKnowledgeMutation(root, async () => { + const existing = await readRecord(validated.runId) + if (existing) { + if (existing.parentRunId !== validated.parentRunId) { + throw new Error( + `run lineage conflict for '${validated.runId}': existing parent ${renderParent(existing.parentRunId)}, requested ${renderParent(validated.parentRunId)}`, + ) + } + return + } + await writeJson(join(root, RUN_LINEAGE_BASENAME), validated) + }) + }, + } +} + +function validateLineageAuthority(authority: RunLineageAuthority): void { + if (!authority || typeof authority !== 'object') { + throw new TypeError('lineageAuthority must be an object') + } + if (typeof authority.parentOf !== 'function') { + throw new TypeError('lineageAuthority.parentOf must be a function') + } + if (authority.record !== undefined && typeof authority.record !== 'function') { + throw new TypeError('lineageAuthority.record must be a function when present') + } +} + +function parseLineageRecord(text: string, expectedRunId: string): RunLineageRecord { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch (cause) { + throw new Error(`invalid lineage JSON for '${expectedRunId}'`, { cause }) + } + const record = validateLineageRecord(parsed) + if (record.runId !== expectedRunId) { + throw new Error( + `lineage record identity mismatch: expected '${expectedRunId}', found '${record.runId}'`, + ) + } + return record +} + +function validateLineageRecord(value: unknown): RunLineageRecord { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError('run lineage record must be an object') + } + const record = value as Record + assertRunId(record.runId, 'lineage record runId') + if (record.parentRunId !== null) { + assertRunId(record.parentRunId, `parent of '${record.runId}'`) + } + if ( + typeof record.createdAt !== 'string' || + record.createdAt.trim().length === 0 || + !Number.isFinite(Date.parse(record.createdAt)) + ) { + throw new TypeError('run lineage record createdAt must be an ISO timestamp') + } + return { + runId: record.runId, + parentRunId: record.parentRunId, + createdAt: record.createdAt, + } +} + +function assertRunId(value: unknown, label = 'runId'): asserts value is string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new TypeError(`${label} must be a non-empty string`) + } +} + +function renderParent(parentRunId: string | null): string { + return parentRunId === null ? 'none' : `'${parentRunId}'` +} From 22250028eb9a85f77ff23bb722b7938215be52e6 Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:29:38 -0700 Subject: [PATCH 04/22] test(stores): cover external lineage and conflicting reopen --- src/run-scoped.test.ts | 65 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/src/run-scoped.test.ts b/src/run-scoped.test.ts index 88e46c3..8804621 100644 --- a/src/run-scoped.test.ts +++ b/src/run-scoped.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, 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 { createRunScopedStores } from './run-scoped' +import { createRunScopedStores, type RunLineageAuthority } from './run-scoped' let root: string let shared: string @@ -67,10 +67,73 @@ describe('createRunScopedStores', () => { await expect(stores.loadChain('child')).resolves.toEqual([]) }) + it('reopening a run is idempotent and refuses a different parent', async () => { + const stores = createRunScopedStores({ root }) + await stores.init('child', { parentRunId: 'parent-a' }) + await expect(stores.init('child', { parentRunId: 'parent-a' })).resolves.toBeDefined() + await expect(stores.init('child', { parentRunId: 'parent-b' })).rejects.toThrow( + /lineage conflict/, + ) + }) + + it('reads ancestry from an external product-owned authority', async () => { + const parents = new Map([ + ['parent', null], + ['child', 'parent'], + ]) + const authority: RunLineageAuthority = { + async parentOf(runId) { + return parents.get(runId) ?? null + }, + } + const stores = createRunScopedStores({ root, lineageAuthority: authority }) + await stores.init('parent') + await addPage(join(root, 'parent', 'knowledge-base'), 'parent-page.md', 'owned by parent') + await stores.init('child', { parentRunId: 'parent' }) + await addPage(join(root, 'child', 'knowledge-base'), 'child-page.md', 'owned by child') + + await expect(stores.lineage('child')).resolves.toEqual(['parent']) + const chain = await stores.loadChain('child') + expect(chain.map((entry) => [entry.page.title, entry.origin])).toEqual([ + ['child-page.md', 'here'], + ['parent-page.md', 'inherited:parent'], + ]) + }) + + it('fails closed when a read-only external authority disagrees at initialization', async () => { + const authority: RunLineageAuthority = { + async parentOf() { + return 'registered-parent' + }, + } + const stores = createRunScopedStores({ root, lineageAuthority: authority }) + + await expect(stores.init('child', { parentRunId: 'different-parent' })).rejects.toThrow( + /external lineage authority disagrees/, + ) + }) + it('refuses a lineage cycle loudly', async () => { const stores = createRunScopedStores({ root }) await stores.init('a', { parentRunId: 'b' }) await stores.init('b', { parentRunId: 'a' }) await expect(stores.lineage('a')).rejects.toThrow(/cycle/) }) + + it('refuses an ancestry chain beyond the declared safety bound', async () => { + const parents = new Map() + for (let index = 0; index < 66; index++) { + parents.set(`run-${index}`, index === 65 ? null : `run-${index + 1}`) + } + const stores = createRunScopedStores({ + root, + lineageAuthority: { + async parentOf(runId) { + return parents.get(runId) ?? null + }, + }, + }) + + await expect(stores.lineage('run-0')).rejects.toThrow(/exceeds 64 ancestors/) + }) }) From dab5aee515aa68694958be95d87fb2da669a8ee3 Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:31:18 -0700 Subject: [PATCH 05/22] feat(citations): make page citations first-class --- src/types.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/types.ts b/src/types.ts index 79b9ae7..fcffab3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,6 +95,8 @@ export interface KnowledgePage { sourceIds: string[] tags: string[] outLinks: string[] + /** Stable page ids this page explicitly builds on. */ + cites?: KnowledgeId[] /** Page ids this page explicitly refutes. */ contradicts?: KnowledgeId[] /** Present only when this page's own evidence has refuted the page. */ @@ -158,6 +160,8 @@ export interface KnowledgeSearchResult { export interface KnowledgeLintFinding { type: | 'broken-link' + | 'broken-citation' + | 'ambiguous-citation' | 'orphan' | 'no-outlinks' | 'uncited-claim' From cb5f1487dda8968274f57e07634deecf9bc03082 Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:31:56 -0700 Subject: [PATCH 06/22] feat(citations): parse stable page citations from frontmatter --- src/store.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/store.ts b/src/store.ts index 8959a33..a46a02b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -121,6 +121,7 @@ async function loadKnowledgePagesUnlocked( rel.split('/').pop()!.replace(/\.md$/, '') const sourceIds = arrayField(frontmatter.sources) const tags = arrayField(frontmatter.tags) + const cites = idListField(frontmatter.cites) const contradicts = idListField(frontmatter.contradicts) const invalidation = KnowledgePageInvalidationSchema.safeParse(frontmatter.invalidation) const pageRelativePath = rel.startsWith(pagesPrefix) ? rel.slice(pagesPrefix.length) : rel @@ -133,6 +134,7 @@ async function loadKnowledgePagesUnlocked( sourceIds, tags, outLinks: extractWikilinks(body).map(normalizeLinkTarget), + ...(cites.length > 0 ? { cites } : {}), ...(contradicts.length > 0 ? { contradicts } : {}), ...(invalidation.success ? { invalidation: invalidation.data } : {}), }) From bd59a722b36c8edc71ed3faaab440946098ee2c6 Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:32:28 -0700 Subject: [PATCH 07/22] feat(citations): project explicit citations into the knowledge graph --- src/graph.ts | 49 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/src/graph.ts b/src/graph.ts index 44c45a4..f359b71 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -2,10 +2,10 @@ import type { KnowledgeGraph, KnowledgeGraphEdge, KnowledgeGraphNode, KnowledgeP import { normalizeLinkTarget } from './wikilinks' export function buildKnowledgeGraph(pages: KnowledgePage[]): KnowledgeGraph { - const byId = new Map() const bySlug = new Map() + const byId = new Map() for (const page of pages) { - byId.set(page.id, page) + byId.set(page.id, [...(byId.get(page.id) ?? []), page]) bySlug.set(normalizeLinkTarget(page.id), page) bySlug.set(normalizeLinkTarget(page.title), page) bySlug.set(normalizeLinkTarget(page.path.split('/').pop()!.replace(/\.md$/, '')), page) @@ -23,18 +23,12 @@ export function buildKnowledgeGraph(pages: KnowledgePage[]): KnowledgeGraph { for (const raw of page.outLinks) { const target = bySlug.get(normalizeLinkTarget(raw)) if (!target || target.id === page.id) continue - const key = `${page.id}->${target.id}` - const edge = edgesByKey.get(key) - if (edge) edge.weight += 1 - else - edgesByKey.set(key, { - source: page.id, - target: target.id, - weight: 1, - reasons: ['wikilink'], - }) - outgoing.set(page.id, (outgoing.get(page.id) ?? 0) + 1) - incoming.set(target.id, (incoming.get(target.id) ?? 0) + 1) + addDirectedEdge(page, target, 'wikilink', edgesByKey, incoming, outgoing) + } + for (const citedId of page.cites ?? []) { + const targets = byId.get(citedId) ?? [] + if (targets.length !== 1 || targets[0]!.id === page.id) continue + addDirectedEdge(page, targets[0]!, 'citation', edgesByKey, incoming, outgoing) } } @@ -52,6 +46,31 @@ export function buildKnowledgeGraph(pages: KnowledgePage[]): KnowledgeGraph { return { nodes, edges: [...edgesByKey.values()].sort((a, b) => b.weight - a.weight) } } +function addDirectedEdge( + source: KnowledgePage, + target: KnowledgePage, + reason: string, + edges: Map, + incoming: Map, + outgoing: Map, +): void { + const key = `${source.id}->${target.id}` + const edge = edges.get(key) + if (edge) { + edge.weight += 1 + if (!edge.reasons.includes(reason)) edge.reasons.push(reason) + } else { + edges.set(key, { + source: source.id, + target: target.id, + weight: 1, + reasons: [reason], + }) + } + outgoing.set(source.id, (outgoing.get(source.id) ?? 0) + 1) + incoming.set(target.id, (incoming.get(target.id) ?? 0) + 1) +} + function addSourceOverlapEdges( pages: KnowledgePage[], edges: Map, @@ -66,7 +85,7 @@ function addSourceOverlapEdges( const edge = edges.get(key) if (edge) { edge.weight += overlap.length * 0.5 - edge.reasons.push('shared-source') + if (!edge.reasons.includes('shared-source')) edge.reasons.push('shared-source') } else { edges.set(key, { source: a.id, From 8b7185ad610770096ca93e81c1066c2c484633b8 Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:34:31 -0700 Subject: [PATCH 08/22] feat(citations): validate page citation identities --- src/schemas.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/schemas.ts b/src/schemas.ts index 28334bb..e428c30 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -54,6 +54,7 @@ export const KnowledgePageSchema = z.object({ sourceIds: z.array(z.string()), tags: z.array(z.string()), outLinks: z.array(z.string()), + cites: z.array(z.string().min(1)).optional(), contradicts: z.array(z.string().min(1)).optional(), invalidation: KnowledgePageInvalidationSchema.optional(), }) From 7cfe97fed5a6c9312af7b84af14fc43213ed012a Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:34:53 -0700 Subject: [PATCH 09/22] feat(citations): export run-scoped resolution contracts --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index f483522..1cfa122 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export * from './agent-candidate' export * from './benchmarks/index' export * from './changes' export * from './chunking' +export * from './citation-resolution' export * from './claim-evidence' export * from './claim-grounding' export * from './claim-ledger' From 1f7b00b37675e7903d5024f7ef4e994b2e592029 Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:35:24 -0700 Subject: [PATCH 10/22] test(citations): round-trip citation frontmatter into graph evidence --- src/citation-storage.test.ts | 97 ++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/citation-storage.test.ts diff --git a/src/citation-storage.test.ts b/src/citation-storage.test.ts new file mode 100644 index 0000000..586f8b8 --- /dev/null +++ b/src/citation-storage.test.ts @@ -0,0 +1,97 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { buildKnowledgeGraph } from './graph' +import { KnowledgeIndexSchema } from './schemas' +import { initKnowledgeBase, loadKnowledgePages } from './store' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('knowledge page citations', () => { + it('parses stable ids and projects citation edges into the validated index', async () => { + const root = await mkdtemp(join(tmpdir(), 'knowledge-citations-')) + roots.push(root) + await initKnowledgeBase(root) + await writeFile( + join(root, 'knowledge', 'prior.md'), + ['---', 'id: prior-result', 'title: Prior result', '---', 'The measured prior.', ''].join( + '\n', + ), + ) + await writeFile( + join(root, 'knowledge', 'new.md'), + [ + '---', + 'id: new-result', + 'title: New result', + 'cites:', + ' - prior-result', + ' - prior-result', + '---', + 'This result builds on the prior.', + '', + ].join('\n'), + ) + + const pages = await loadKnowledgePages(root) + const current = pages.find((page) => page.id === 'new-result') + expect(current?.cites).toEqual(['prior-result']) + + const graph = buildKnowledgeGraph(pages) + expect(graph.edges).toContainEqual({ + source: 'new-result', + target: 'prior-result', + weight: 1, + reasons: ['citation'], + }) + expect(graph.nodes.find((node) => node.id === 'new-result')?.outDegree).toBe(1) + expect(graph.nodes.find((node) => node.id === 'prior-result')?.inDegree).toBe(1) + + expect(() => + KnowledgeIndexSchema.parse({ + root, + generatedAt: '2026-08-17T00:00:00.000Z', + sources: [], + pages, + graph, + }), + ).not.toThrow() + }) + + it('does not project an ambiguous duplicate id into a false graph edge', async () => { + const root = await mkdtemp(join(tmpdir(), 'knowledge-citations-')) + roots.push(root) + await initKnowledgeBase(root) + await writeFile( + join(root, 'knowledge', 'first.md'), + ['---', 'id: reused', 'title: First', '---', 'First page.', ''].join('\n'), + ) + await writeFile( + join(root, 'knowledge', 'second.md'), + ['---', 'id: reused', 'title: Second', '---', 'Second page.', ''].join('\n'), + ) + await writeFile( + join(root, 'knowledge', 'consumer.md'), + [ + '---', + 'id: consumer', + 'title: Consumer', + 'cites:', + ' - reused', + '---', + 'Ambiguous citation.', + '', + ].join('\n'), + ) + + const graph = buildKnowledgeGraph(await loadKnowledgePages(root)) + expect(graph.edges.some((edge) => edge.source === 'consumer' && edge.target === 'reused')).toBe( + false, + ) + }) +}) From c9db0e45b4b0af98f2a9244bb22c69b3c5afb8e2 Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:36:39 -0700 Subject: [PATCH 11/22] feat(citations): audit persisted run-scoped citation chains --- src/citation-resolution.ts | 166 ++++++++++++++++++++++++++++++++++--- 1 file changed, 155 insertions(+), 11 deletions(-) diff --git a/src/citation-resolution.ts b/src/citation-resolution.ts index 3397fbe..66c45c0 100644 --- a/src/citation-resolution.ts +++ b/src/citation-resolution.ts @@ -28,6 +28,30 @@ export interface KnowledgeCitationResolution { readonly resolved?: KnowledgeCitationCandidate } +export type KnowledgeCitationAuditIssueKind = 'self' | 'missing' | 'ambiguous' + +export interface KnowledgeCitationAuditIssue { + readonly kind: KnowledgeCitationAuditIssueKind + readonly sourcePageId: KnowledgeId + readonly sourcePath: string + readonly sourceOrigin: PageOrigin + readonly persistedCitation: string + readonly reference: KnowledgeCitationReference + readonly candidates: readonly KnowledgeCitationCandidate[] +} + +export interface KnowledgeCitationAuditReport { + readonly ok: boolean + readonly checkedPages: number + readonly checkedCitations: number + readonly issues: readonly KnowledgeCitationAuditIssue[] +} + +export interface AuditKnowledgeCitationsOptions { + /** Limit audited source pages to these origins. All visible origins are checked by default. */ + readonly sourceOrigins?: readonly PageOrigin[] +} + /** A batch contains at least one citation that is missing or ambiguous. */ export class KnowledgeCitationResolutionError extends Error { readonly resolutions: readonly KnowledgeCitationResolution[] @@ -36,10 +60,13 @@ export class KnowledgeCitationResolutionError extends Error { const unresolved = resolutions.filter((resolution) => resolution.status !== 'resolved') const missing = unresolved .filter((resolution) => resolution.status === 'missing') - .map(renderReference) + .map((resolution) => formatKnowledgeCitationReference(resolution.reference)) const ambiguous = unresolved .filter((resolution) => resolution.status === 'ambiguous') - .map((resolution) => `${renderReference(resolution)} (${resolution.candidates.length} matches)`) + .map( + (resolution) => + `${formatKnowledgeCitationReference(resolution.reference)} (${resolution.candidates.length} matches)`, + ) const parts = [ missing.length > 0 ? `missing: ${missing.join(', ')}` : '', ambiguous.length > 0 ? `ambiguous: ${ambiguous.join(', ')}` : '', @@ -50,6 +77,49 @@ export class KnowledgeCitationResolutionError extends Error { } } +/** A persisted page contains a self, missing, or ambiguous citation. */ +export class KnowledgeCitationAuditError extends Error { + readonly report: KnowledgeCitationAuditReport + + constructor(report: KnowledgeCitationAuditReport) { + const counts = new Map() + for (const issue of report.issues) counts.set(issue.kind, (counts.get(issue.kind) ?? 0) + 1) + const summary = [...counts.entries()].map(([kind, count]) => `${kind}=${count}`).join(', ') + super(`knowledge citation audit failed: ${summary}`) + this.name = 'KnowledgeCitationAuditError' + this.report = report + } +} + +/** + * Parse the persisted citation form. + * + * `page-id` is unqualified. `here::page-id`, `shared::page-id`, and + * `inherited:::page-id` bind an intentional duplicate to one origin. + */ +export function parseKnowledgeCitationReference(value: string): KnowledgeCitationReference { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new TypeError('persisted knowledge citation must be a non-empty string') + } + const normalized = value.trim() + const separator = normalized.indexOf('::') + if (separator < 0) return Object.freeze({ pageId: normalized }) + const possibleOrigin = normalized.slice(0, separator) + const pageId = normalized.slice(separator + 2) + if (!isPageOrigin(possibleOrigin)) { + return Object.freeze({ pageId: normalized }) + } + return normalizeReference({ pageId, origin: possibleOrigin }) +} + +/** Serialize one reference into the canonical frontmatter representation. */ +export function formatKnowledgeCitationReference(reference: KnowledgeCitationReference): string { + const normalized = normalizeReference(reference) + return normalized.origin === undefined + ? normalized.pageId + : `${normalized.origin}::${normalized.pageId}` +} + /** Resolve one reference against an already materialized visibility chain. */ export function resolveKnowledgeCitation( visiblePages: readonly OriginatedPage[], @@ -104,6 +174,63 @@ export function assertKnowledgeCitationsResolved( return Object.freeze(resolutions.map((resolution) => resolution.resolved!)) } +/** Audit persisted `cites` relations against one immutable visibility snapshot. */ +export function auditKnowledgeCitations( + visiblePages: readonly OriginatedPage[], + options: AuditKnowledgeCitationsOptions = {}, +): KnowledgeCitationAuditReport { + const origins = options.sourceOrigins + ? new Set(options.sourceOrigins.map((origin) => validatePageOrigin(origin))) + : null + const sources = visiblePages.filter((entry) => origins === null || origins.has(entry.origin)) + const issues: KnowledgeCitationAuditIssue[] = [] + let checkedCitations = 0 + + for (const source of sources) { + for (const persistedCitation of new Set(source.page.cites ?? [])) { + checkedCitations += 1 + const reference = parseKnowledgeCitationReference(persistedCitation) + const resolution = resolveKnowledgeCitation(visiblePages, reference) + const self = resolution.candidates.some( + (candidate) => + candidate.page === source.page || + (candidate.origin === source.origin && candidate.page.path === source.page.path), + ) + const kind: KnowledgeCitationAuditIssueKind | null = self + ? 'self' + : resolution.status === 'missing' + ? 'missing' + : resolution.status === 'ambiguous' + ? 'ambiguous' + : null + if (kind === null) continue + issues.push( + Object.freeze({ + kind, + sourcePageId: source.page.id, + sourcePath: source.page.path, + sourceOrigin: source.origin, + persistedCitation, + reference, + candidates: resolution.candidates, + }), + ) + } + } + + return Object.freeze({ + ok: issues.length === 0, + checkedPages: sources.length, + checkedCitations, + issues: Object.freeze(issues), + }) +} + +/** Audit and fail closed when any persisted citation is unresolved. */ +export function assertKnowledgeCitationAudit(report: KnowledgeCitationAuditReport): void { + if (!report.ok) throw new KnowledgeCitationAuditError(report) +} + /** Resolve one citation using a run-scoped store's declared visibility chain. */ export async function resolveRunScopedCitation( stores: RunScopedStores, @@ -131,6 +258,24 @@ export async function assertRunScopedCitationsResolved( return assertKnowledgeCitationsResolved(await stores.loadChain(runId), references) } +/** Audit only pages authored in the current run against the full visible chain. */ +export async function auditCurrentRunCitations( + stores: RunScopedStores, + runId: string, +): Promise { + return auditKnowledgeCitations(await stores.loadChain(runId), { sourceOrigins: ['here'] }) +} + +/** Audit current-run citations and fail closed on every unresolved relation. */ +export async function assertCurrentRunCitationsResolved( + stores: RunScopedStores, + runId: string, +): Promise { + const report = await auditCurrentRunCitations(stores, runId) + assertKnowledgeCitationAudit(report) + return report +} + function normalizeReference(reference: KnowledgeCitationReference): KnowledgeCitationReference { if (!reference || typeof reference !== 'object') { throw new TypeError('knowledge citation reference must be an object') @@ -138,15 +283,20 @@ function normalizeReference(reference: KnowledgeCitationReference): KnowledgeCit if (typeof reference.pageId !== 'string' || reference.pageId.trim().length === 0) { throw new TypeError('knowledge citation pageId must be a non-empty string') } - if (reference.origin !== undefined && !isPageOrigin(reference.origin)) { - throw new TypeError(`knowledge citation origin is invalid: ${String(reference.origin)}`) - } + if (reference.origin !== undefined) validatePageOrigin(reference.origin) return Object.freeze({ pageId: reference.pageId.trim(), ...(reference.origin === undefined ? {} : { origin: reference.origin }), }) } +function validatePageOrigin(value: PageOrigin): PageOrigin { + if (!isPageOrigin(value)) { + throw new TypeError(`knowledge citation origin is invalid: ${String(value)}`) + } + return value +} + function isPageOrigin(value: unknown): value is PageOrigin { if (value === 'here' || value === 'shared') return true return ( @@ -155,9 +305,3 @@ function isPageOrigin(value: unknown): value is PageOrigin { value.slice('inherited:'.length).trim().length > 0 ) } - -function renderReference(resolution: KnowledgeCitationResolution): string { - return resolution.reference.origin === undefined - ? resolution.reference.pageId - : `${resolution.reference.origin}::${resolution.reference.pageId}` -} From 0bfeeeeaa1a7a4c597e2994897c8516b3a366fd8 Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:37:28 -0700 Subject: [PATCH 12/22] test(citations): cover persisted qualifiers and chain audits --- src/citation-resolution.test.ts | 59 ++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src/citation-resolution.test.ts b/src/citation-resolution.test.ts index a73fb65..0f60381 100644 --- a/src/citation-resolution.test.ts +++ b/src/citation-resolution.test.ts @@ -1,14 +1,24 @@ import { describe, expect, it } from 'vitest' import { + assertKnowledgeCitationAudit, assertKnowledgeCitationsResolved, + auditKnowledgeCitations, + formatKnowledgeCitationReference, + KnowledgeCitationAuditError, KnowledgeCitationResolutionError, + parseKnowledgeCitationReference, resolveKnowledgeCitation, resolveKnowledgeCitations, } from './citation-resolution' import type { OriginatedPage, PageOrigin } from './run-scoped' import type { KnowledgePage } from './types' -function page(id: string, origin: PageOrigin, path = `${id}.md`): OriginatedPage { +function page( + id: string, + origin: PageOrigin, + path = `${id}.md`, + cites?: string[], +): OriginatedPage { const value: KnowledgePage = { id, path: `knowledge/${path}`, @@ -18,6 +28,7 @@ function page(id: string, origin: PageOrigin, path = `${id}.md`): OriginatedPage sourceIds: [], tags: [], outLinks: [], + ...(cites ? { cites } : {}), } return { page: value, origin } } @@ -81,6 +92,24 @@ describe('knowledge citation resolution', () => { }) }) + it('round-trips persisted origin qualifiers', () => { + const references = [ + { pageId: 'plain' }, + { pageId: 'current', origin: 'here' as const }, + { pageId: 'prior', origin: 'shared' as const }, + { pageId: 'parent', origin: 'inherited:run-a' as const }, + ] + + expect( + references.map((reference) => + parseKnowledgeCitationReference(formatKnowledgeCitationReference(reference)), + ), + ).toEqual(references) + expect(parseKnowledgeCitationReference('unknown-prefix::still-one-page-id')).toEqual({ + pageId: 'unknown-prefix::still-one-page-id', + }) + }) + it('fails a batch with exact missing and ambiguity diagnostics', () => { const visible = [ page('unique', 'here'), @@ -108,10 +137,38 @@ describe('knowledge citation resolution', () => { } }) + it('audits persisted citations without silently shadowing duplicate ids', () => { + const visible = [ + page('current', 'here', 'current.md', [ + 'unique', + 'missing', + 'reused', + 'shared::reused', + 'current', + ]), + page('unique', 'inherited:run-a'), + page('reused', 'inherited:run-a', 'ancestor.md'), + page('reused', 'shared', 'shared.md'), + ] + + const report = auditKnowledgeCitations(visible, { sourceOrigins: ['here'] }) + + expect(report.checkedPages).toBe(1) + expect(report.checkedCitations).toBe(5) + expect(report.issues.map((issue) => [issue.persistedCitation, issue.kind])).toEqual([ + ['missing', 'missing'], + ['reused', 'ambiguous'], + ['current', 'self'], + ]) + expect(report.ok).toBe(false) + expect(() => assertKnowledgeCitationAudit(report)).toThrow(KnowledgeCitationAuditError) + }) + it('refuses malformed references before matching', () => { expect(() => resolveKnowledgeCitation([], { pageId: ' ' })).toThrow(/non-empty string/) expect(() => resolveKnowledgeCitation([], { pageId: 'known', origin: 'inherited:' as never }), ).toThrow(/origin is invalid/) + expect(() => parseKnowledgeCitationReference(' ')).toThrow(/non-empty string/) }) }) From ce484e3e3fe3654447327d5c9a04c4402d3b197d Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:38:24 -0700 Subject: [PATCH 13/22] feat(citations): expose run-scoped citation lint findings --- src/citation-lint.ts | 59 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/citation-lint.ts diff --git a/src/citation-lint.ts b/src/citation-lint.ts new file mode 100644 index 0000000..53c066a --- /dev/null +++ b/src/citation-lint.ts @@ -0,0 +1,59 @@ +import { + auditCurrentRunCitations, + type KnowledgeCitationAuditIssue, + type KnowledgeCitationAuditReport, +} from './citation-resolution' +import type { RunScopedStores } from './run-scoped' +import type { KnowledgeLintFinding } from './types' + +/** Convert a chain-aware citation audit into the package lint vocabulary. */ +export function knowledgeCitationAuditFindings( + report: KnowledgeCitationAuditReport, +): KnowledgeLintFinding[] { + return report.issues.map(issueToFinding) +} + +/** Lint only current-run pages against their complete declared visibility chain. */ +export async function lintCurrentRunCitations( + stores: RunScopedStores, + runId: string, +): Promise { + return knowledgeCitationAuditFindings(await auditCurrentRunCitations(stores, runId)) +} + +function issueToFinding(issue: KnowledgeCitationAuditIssue): KnowledgeLintFinding { + const candidates = issue.candidates.map((candidate) => ({ + pageId: candidate.pageId, + path: candidate.page.path, + origin: candidate.origin, + })) + if (issue.kind === 'ambiguous') { + return { + type: 'ambiguous-citation', + severity: 'error', + page: issue.sourcePath, + message: + `Citation "${issue.persistedCitation}" resolves to ${issue.candidates.length} visible pages; ` + + 'qualify it as here::, inherited:::, or shared::.', + metadata: { + sourcePageId: issue.sourcePageId, + sourceOrigin: issue.sourceOrigin, + candidates, + }, + } + } + return { + type: 'broken-citation', + severity: 'error', + page: issue.sourcePath, + message: + issue.kind === 'self' + ? `Page "${issue.sourcePageId}" cites itself through "${issue.persistedCitation}".` + : `Citation "${issue.persistedCitation}" resolves to no visible page.`, + metadata: { + sourcePageId: issue.sourcePageId, + sourceOrigin: issue.sourceOrigin, + candidates, + }, + } +} From 0522f76972bc59867f00b02c29c11afdd744386f Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:38:54 -0700 Subject: [PATCH 14/22] feat(citations): export chain-aware lint adapter --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index 1cfa122..921c532 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export * from './agent-candidate' export * from './benchmarks/index' export * from './changes' export * from './chunking' +export * from './citation-lint' export * from './citation-resolution' export * from './claim-evidence' export * from './claim-grounding' From d561a33c15dd9ffc6bedbe00edd1b539f57cf75a Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:39:12 -0700 Subject: [PATCH 15/22] test(citations): map chain audit failures into blocking lint rows --- src/citation-lint.test.ts | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/citation-lint.test.ts diff --git a/src/citation-lint.test.ts b/src/citation-lint.test.ts new file mode 100644 index 0000000..dbc43c4 --- /dev/null +++ b/src/citation-lint.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { auditKnowledgeCitations } from './citation-resolution' +import { knowledgeCitationAuditFindings } from './citation-lint' +import type { OriginatedPage, PageOrigin } from './run-scoped' +import type { KnowledgePage } from './types' + +function page(id: string, origin: PageOrigin, path: string, cites?: string[]): OriginatedPage { + const value: KnowledgePage = { + id, + path, + title: id, + text: id, + frontmatter: { id }, + sourceIds: [], + tags: [], + outLinks: [], + ...(cites ? { cites } : {}), + } + return { page: value, origin } +} + +describe('knowledgeCitationAuditFindings', () => { + it('produces blocking missing, ambiguous, and self-citation findings', () => { + const visible = [ + page('author', 'here', 'knowledge/author.md', ['missing', 'reused', 'author']), + page('reused', 'inherited:parent', 'knowledge/parent.md'), + page('reused', 'shared', 'knowledge/shared.md'), + ] + + const findings = knowledgeCitationAuditFindings( + auditKnowledgeCitations(visible, { sourceOrigins: ['here'] }), + ) + + expect(findings.map((finding) => [finding.type, finding.severity])).toEqual([ + ['broken-citation', 'error'], + ['ambiguous-citation', 'error'], + ['broken-citation', 'error'], + ]) + expect(findings[1]?.message).toMatch(/qualify it as here::/) + expect(findings[1]?.metadata).toMatchObject({ + sourcePageId: 'author', + candidates: [ + { origin: 'inherited:parent', path: 'knowledge/parent.md' }, + { origin: 'shared', path: 'knowledge/shared.md' }, + ], + }) + }) +}) From 16f6d8ea7e19052e1cf26ea1f70df05e4de17296 Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:41:00 -0700 Subject: [PATCH 16/22] docs(citations): define run-scoped resolution and cutover semantics --- docs/run-scoped-citations.md | 106 +++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/run-scoped-citations.md diff --git a/docs/run-scoped-citations.md b/docs/run-scoped-citations.md new file mode 100644 index 0000000..f88e2c7 --- /dev/null +++ b/docs/run-scoped-citations.md @@ -0,0 +1,106 @@ +# Run-scoped citations + +A run-scoped knowledge chain contains three visibility classes: + +1. pages written by the current run (`here`); +2. pages written by declared ancestors (`inherited:`); +3. pages in an optional curated shared store (`shared`). + +`createRunScopedStores()` preserves every visible page and its origin. It does not shadow a page merely because a nearer store has the same stable id. + +## Persisted citation form + +A page records stable page references in `cites` frontmatter: + +```yaml +--- +id: later-result +cites: + - prior-result +--- +``` + +An unqualified id is valid only when exactly one visible page has that id. When duplicate ids are intentional, qualify the origin: + +```yaml +cites: + - here::current-result + - inherited:run-2026-08-16::prior-result + - shared::instrument-calibration +``` + +Use `parseKnowledgeCitationReference()` and `formatKnowledgeCitationReference()` rather than assembling qualified strings in application code. + +## Resolution + +```ts +import { + assertCurrentRunCitationsResolved, + createRunScopedStores, + resolveRunScopedCitation, +} from '@tangle-network/agent-knowledge' + +const stores = createRunScopedStores({ + root: './runs', + sharedRoot: './curated-knowledge', +}) + +const resolved = await resolveRunScopedCitation(stores, 'run-b', { + pageId: 'prior-result', +}) + +if (resolved.status !== 'resolved') { + console.error(resolved.status, resolved.candidates) +} + +await assertCurrentRunCitationsResolved(stores, 'run-b') +``` + +Resolution has three non-coercing outcomes: + +- `resolved`: exactly one visible page matches; +- `missing`: no visible page matches; +- `ambiguous`: more than one visible page matches. + +Missing and ambiguous references remain explicit. They never select the nearest page, the newest page, or the shared page by default. + +## Product-owned lineage + +A product that already owns run ancestry should provide a `RunLineageAuthority` rather than copying its manifest into `lineage.json`: + +```ts +const stores = createRunScopedStores({ + root: './runs', + runStorePath: (runId) => `./runs/${runId}/knowledge-base`, + sharedRoot: './curated-knowledge', + lineageAuthority: { + async parentOf(runId) { + const manifest = await readRunManifest(runId) + return manifest.parentRunId + }, + }, +}) +``` + +A read-only authority must already contain the lineage before `init()` is called. `init()` verifies the requested parent against that authority and fails on disagreement. An authority that also implements `record()` may durably create the lineage itself. + +The default file-backed authority is idempotent. Reopening a run with the same parent is accepted; reopening it with another parent is a lineage conflict. + +## Lint and graph behavior + +`auditCurrentRunCitations()` checks current-run pages against one materialized visibility chain. `lintCurrentRunCitations()` converts missing, ambiguous, and self-citations into blocking package lint findings. + +Within one knowledge index, unambiguous `cites` relations become graph edges with reason `citation`. Duplicate target ids do not produce a guessed edge; the relation remains unresolved until it is qualified or the duplicate is removed. + +## Migration rule + +For an existing application-owned store: + +1. freeze the old reader and writer behavior with golden fixtures; +2. expose the existing run manifest as a `RunLineageAuthority`; +3. dual-read the same frozen corpus through both implementations; +4. classify every mismatch without coercion; +5. switch new reads and writes only after parity is demonstrated; +6. retain historical bytes and delete the duplicate live owner. + +A migration is not complete while two implementations can independently write lineage, page identities, or citation relations. From bb5e414ef801abf71ed110c97a6a98db53b8ad74 Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:44:16 -0700 Subject: [PATCH 17/22] style(citations): organize lint test imports --- src/citation-lint.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/citation-lint.test.ts b/src/citation-lint.test.ts index dbc43c4..073451d 100644 --- a/src/citation-lint.test.ts +++ b/src/citation-lint.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { auditKnowledgeCitations } from './citation-resolution' import { knowledgeCitationAuditFindings } from './citation-lint' +import { auditKnowledgeCitations } from './citation-resolution' import type { OriginatedPage, PageOrigin } from './run-scoped' import type { KnowledgePage } from './types' From 70aec577fb7a878c5ba55fab1b2c2e1ea03cdcba Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 05:45:07 -0700 Subject: [PATCH 18/22] style(citations): apply canonical test formatting --- src/citation-resolution.test.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/citation-resolution.test.ts b/src/citation-resolution.test.ts index 0f60381..465a04e 100644 --- a/src/citation-resolution.test.ts +++ b/src/citation-resolution.test.ts @@ -13,12 +13,7 @@ import { import type { OriginatedPage, PageOrigin } from './run-scoped' import type { KnowledgePage } from './types' -function page( - id: string, - origin: PageOrigin, - path = `${id}.md`, - cites?: string[], -): OriginatedPage { +function page(id: string, origin: PageOrigin, path = `${id}.md`, cites?: string[]): OriginatedPage { const value: KnowledgePage = { id, path: `knowledge/${path}`, @@ -131,9 +126,9 @@ describe('knowledge citation resolution', () => { throw new Error('expected citation resolution to fail') } catch (error) { expect(error).toBeInstanceOf(KnowledgeCitationResolutionError) - expect((error as KnowledgeCitationResolutionError).resolutions.map((row) => row.status)).toEqual( - ['missing', 'ambiguous'], - ) + expect( + (error as KnowledgeCitationResolutionError).resolutions.map((row) => row.status), + ).toEqual(['missing', 'ambiguous']) } }) From 57aa491d5d002cbcae8d6de880ab0d3c8ebc1b92 Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 06:07:55 -0700 Subject: [PATCH 19/22] fix(stores): refuse external lineage drift before filesystem mutation --- src/run-scoped.ts | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/run-scoped.ts b/src/run-scoped.ts index c9c9018..0bad42b 100644 --- a/src/run-scoped.ts +++ b/src/run-scoped.ts @@ -32,7 +32,7 @@ export interface RunLineageRecord { * A product whose run manifest already owns lineage can supply an authority * backed by that manifest. `record` is optional for read-only authorities; in * that case `init` verifies that the existing authority agrees with the parent - * requested by the caller. + * requested by the caller before creating any store files. */ export interface RunLineageAuthority { parentOf(runId: string): Promise @@ -112,15 +112,11 @@ export function createRunScopedStores(options: RunScopedStoresOptions): RunScope assertRunId(runId) const parentRunId = initOptions.parentRunId ?? null if (parentRunId !== null) assertRunId(parentRunId, `parent of '${runId}'`) - const layout = await initKnowledgeBase(storePath(runId)) - const record = Object.freeze({ - runId, - parentRunId, - createdAt: new Date().toISOString(), - }) - if (authority.record) { - await authority.record(record) - } else { + + // A read-only product authority already owns the identity. Verify it + // before `initKnowledgeBase` creates directories or scaffold files, so a + // rejected lineage request leaves no partial knowledge store behind. + if (!authority.record) { const existingParent = await authority.parentOf(runId) if (existingParent !== parentRunId) { throw new Error( @@ -128,6 +124,17 @@ export function createRunScopedStores(options: RunScopedStoresOptions): RunScope ) } } + + const layout = await initKnowledgeBase(storePath(runId)) + if (authority.record) { + await authority.record( + Object.freeze({ + runId, + parentRunId, + createdAt: new Date().toISOString(), + }), + ) + } return layout }, From e800adc5a3bde080058e27bcdbb18d4ba936c0bf Mon Sep 17 00:00:00 2001 From: drewstone Date: Mon, 17 Aug 2026 06:08:42 -0700 Subject: [PATCH 20/22] test(stores): prove lineage refusal has no filesystem side effect --- src/run-scoped.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/run-scoped.test.ts b/src/run-scoped.test.ts index 8804621..8d45d67 100644 --- a/src/run-scoped.test.ts +++ b/src/run-scoped.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { access, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -100,17 +100,19 @@ describe('createRunScopedStores', () => { ]) }) - it('fails closed when a read-only external authority disagrees at initialization', async () => { + it('fails closed without creating files when a read-only authority disagrees', async () => { const authority: RunLineageAuthority = { async parentOf() { return 'registered-parent' }, } const stores = createRunScopedStores({ root, lineageAuthority: authority }) + const rejectedStore = join(root, 'child', 'knowledge-base') await expect(stores.init('child', { parentRunId: 'different-parent' })).rejects.toThrow( /external lineage authority disagrees/, ) + await expect(access(rejectedStore)).rejects.toMatchObject({ code: 'ENOENT' }) }) it('refuses a lineage cycle loudly', async () => { From 6651c1b376e3d4cd8948d5fcd4fba393eed5e8b7 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 17 Aug 2026 11:22:25 -0600 Subject: [PATCH 21/22] fix(stores): resolve a lineage chain of exactly the declared bound The loop ran one parentOf query per allowed ancestor, so a valid chain of exactly 64 ancestors could not reach its terminating null query and threw. One extra query proves the chain ends. Run ids that contain path separators or dot segments are now rejected before they can escape the store root. --- src/run-scoped.test.ts | 24 ++++++++++++++++++++++++ src/run-scoped.ts | 10 +++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/run-scoped.test.ts b/src/run-scoped.test.ts index 8d45d67..ec88c1a 100644 --- a/src/run-scoped.test.ts +++ b/src/run-scoped.test.ts @@ -122,6 +122,30 @@ describe('createRunScopedStores', () => { await expect(stores.lineage('a')).rejects.toThrow(/cycle/) }) + it('rejects a run id that would escape the store root', async () => { + const stores = createRunScopedStores({ root }) + await expect(stores.init('../escaped-run')).rejects.toThrow(/path separators or dot segments/) + await expect(stores.init('..')).rejects.toThrow(/path separators or dot segments/) + await expect(access(join(root, '..', 'escaped-run'))).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('resolves a chain of exactly the declared safety bound', async () => { + const parents = new Map() + for (let index = 0; index < 65; index++) { + parents.set(`run-${index}`, index === 64 ? null : `run-${index + 1}`) + } + const stores = createRunScopedStores({ + root, + lineageAuthority: { + async parentOf(runId) { + return parents.get(runId) ?? null + }, + }, + }) + + await expect(stores.lineage('run-0')).resolves.toHaveLength(64) + }) + it('refuses an ancestry chain beyond the declared safety bound', async () => { const parents = new Map() for (let index = 0; index < 66; index++) { diff --git a/src/run-scoped.ts b/src/run-scoped.ts index 0bad42b..af832fa 100644 --- a/src/run-scoped.ts +++ b/src/run-scoped.ts @@ -93,7 +93,9 @@ export function createRunScopedStores(options: RunScopedStoresOptions): RunScope const chain: string[] = [] const seen = new Set([runId]) let current = runId - for (let hop = 0; hop < MAX_LINEAGE_HOPS; hop += 1) { + // One query more than the bound: a chain of exactly MAX_LINEAGE_HOPS + // ancestors needs the terminating null query to prove it ends. + for (let hop = 0; hop <= MAX_LINEAGE_HOPS; hop += 1) { const parent = await authority.parentOf(current) if (parent === null) return chain assertRunId(parent, `parent of '${current}'`) @@ -262,6 +264,12 @@ function assertRunId(value: unknown, label = 'runId'): asserts value is string { if (typeof value !== 'string' || value.trim().length === 0) { throw new TypeError(`${label} must be a non-empty string`) } + // A run id becomes a path segment under the store root. A separator or dot + // segment would escape the root and break the physical-isolation promise. + const trimmed = value.trim() + if (trimmed === '.' || trimmed === '..' || /[/\\\0]/.test(value)) { + throw new TypeError(`${label} must not contain path separators or dot segments`) + } } function renderParent(parentRunId: string | null): string { From 3c642d5e04ac73cde9159c10e387fa6a313741c4 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Mon, 17 Aug 2026 11:22:26 -0600 Subject: [PATCH 22/22] fix(graph): keep the citation edge for an origin-qualified reference buildKnowledgeGraph looked the persisted string up verbatim, so the qualified forms the ambiguity lint recommends produced no edge. The graph now resolves the parsed page id and keeps a malformed value as a literal so an index build never fails on stored data. --- src/citation-storage.test.ts | 55 ++++++++++++++++++++++++++++++++++++ src/graph.ts | 19 +++++++++++-- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/citation-storage.test.ts b/src/citation-storage.test.ts index 586f8b8..f5be302 100644 --- a/src/citation-storage.test.ts +++ b/src/citation-storage.test.ts @@ -63,6 +63,61 @@ describe('knowledge page citations', () => { ).not.toThrow() }) + it('keeps the graph edge when a citation is origin-qualified', async () => { + const root = await mkdtemp(join(tmpdir(), 'knowledge-citations-')) + roots.push(root) + await initKnowledgeBase(root) + await writeFile( + join(root, 'knowledge', 'prior.md'), + ['---', 'id: prior-result', 'title: Prior result', '---', 'The measured prior.', ''].join( + '\n', + ), + ) + await writeFile( + join(root, 'knowledge', 'new.md'), + [ + '---', + 'id: new-result', + 'title: New result', + 'cites:', + ' - here::prior-result', + '---', + 'Qualified per the ambiguity remedy.', + '', + ].join('\n'), + ) + + const graph = buildKnowledgeGraph(await loadKnowledgePages(root)) + expect(graph.edges).toContainEqual({ + source: 'new-result', + target: 'prior-result', + weight: 1, + reasons: ['citation'], + }) + }) + + it('drops a malformed citation without failing the graph build', async () => { + const root = await mkdtemp(join(tmpdir(), 'knowledge-citations-')) + roots.push(root) + await initKnowledgeBase(root) + await writeFile( + join(root, 'knowledge', 'broken.md'), + [ + '---', + 'id: broken-citer', + 'title: Broken citer', + 'cites:', + ' - "here::"', + '---', + 'Origin with an empty page id.', + '', + ].join('\n'), + ) + + const graph = buildKnowledgeGraph(await loadKnowledgePages(root)) + expect(graph.edges.filter((edge) => edge.source === 'broken-citer')).toEqual([]) + }) + it('does not project an ambiguous duplicate id into a false graph edge', async () => { const root = await mkdtemp(join(tmpdir(), 'knowledge-citations-')) roots.push(root) diff --git a/src/graph.ts b/src/graph.ts index f359b71..3a1fe2a 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -1,3 +1,4 @@ +import { parseKnowledgeCitationReference } from './citation-resolution' import type { KnowledgeGraph, KnowledgeGraphEdge, KnowledgeGraphNode, KnowledgePage } from './types' import { normalizeLinkTarget } from './wikilinks' @@ -25,8 +26,8 @@ export function buildKnowledgeGraph(pages: KnowledgePage[]): KnowledgeGraph { if (!target || target.id === page.id) continue addDirectedEdge(page, target, 'wikilink', edgesByKey, incoming, outgoing) } - for (const citedId of page.cites ?? []) { - const targets = byId.get(citedId) ?? [] + for (const persisted of page.cites ?? []) { + const targets = byId.get(citedPageId(persisted)) ?? [] if (targets.length !== 1 || targets[0]!.id === page.id) continue addDirectedEdge(page, targets[0]!, 'citation', edgesByKey, incoming, outgoing) } @@ -46,6 +47,20 @@ export function buildKnowledgeGraph(pages: KnowledgePage[]): KnowledgeGraph { return { nodes, edges: [...edgesByKey.values()].sort((a, b) => b.weight - a.weight) } } +/** + * An origin-qualified citation (`here::x`, `inherited:::x`, `shared::x`) + * must keep its graph edge: qualification is the documented remedy for an + * ambiguous id, so it cannot cost the citation signal. A value the parser + * rejects stays a literal page id so index builds never fail on stored data. + */ +function citedPageId(persisted: string): string { + try { + return parseKnowledgeCitationReference(persisted).pageId + } catch { + return persisted + } +} + function addDirectedEdge( source: KnowledgePage, target: KnowledgePage,