Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8431120
feat(citations): add ambiguity-safe run-scoped resolution
drewstone Aug 17, 2026
cf2e142
test(citations): prove missing and ambiguous references fail closed
drewstone Aug 17, 2026
2909bf3
feat(stores): support external run lineage authority
drewstone Aug 17, 2026
2225002
test(stores): cover external lineage and conflicting reopen
drewstone Aug 17, 2026
dab5aee
feat(citations): make page citations first-class
drewstone Aug 17, 2026
cb5f148
feat(citations): parse stable page citations from frontmatter
drewstone Aug 17, 2026
bd59a72
feat(citations): project explicit citations into the knowledge graph
drewstone Aug 17, 2026
8b7185a
feat(citations): validate page citation identities
drewstone Aug 17, 2026
7cfe97f
feat(citations): export run-scoped resolution contracts
drewstone Aug 17, 2026
1f7b00b
test(citations): round-trip citation frontmatter into graph evidence
drewstone Aug 17, 2026
c9db0e4
feat(citations): audit persisted run-scoped citation chains
drewstone Aug 17, 2026
0bfeeee
test(citations): cover persisted qualifiers and chain audits
drewstone Aug 17, 2026
ce484e3
feat(citations): expose run-scoped citation lint findings
drewstone Aug 17, 2026
0522f76
feat(citations): export chain-aware lint adapter
drewstone Aug 17, 2026
d561a33
test(citations): map chain audit failures into blocking lint rows
drewstone Aug 17, 2026
16f6d8e
docs(citations): define run-scoped resolution and cutover semantics
drewstone Aug 17, 2026
bb5e414
style(citations): organize lint test imports
drewstone Aug 17, 2026
70aec57
style(citations): apply canonical test formatting
drewstone Aug 17, 2026
57aa491
fix(stores): refuse external lineage drift before filesystem mutation
drewstone Aug 17, 2026
e800adc
test(stores): prove lineage refusal has no filesystem side effect
drewstone Aug 17, 2026
6651c1b
fix(stores): resolve a lineage chain of exactly the declared bound
drewstone Aug 17, 2026
3c642d5
fix(graph): keep the citation edge for an origin-qualified reference
drewstone Aug 17, 2026
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
106 changes: 106 additions & 0 deletions docs/run-scoped-citations.md
Original file line number Diff line number Diff line change
@@ -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:<runId>`);
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.
48 changes: 48 additions & 0 deletions src/citation-lint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { knowledgeCitationAuditFindings } from './citation-lint'
import { auditKnowledgeCitations } from './citation-resolution'
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' },
],
})
})
})
59 changes: 59 additions & 0 deletions src/citation-lint.ts
Original file line number Diff line number Diff line change
@@ -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<KnowledgeLintFinding[]> {
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::<pageId>, inherited:<runId>::<pageId>, or shared::<pageId>.',
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,
},
}
}
169 changes: 169 additions & 0 deletions src/citation-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
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`, cites?: string[]): OriginatedPage {
const value: KnowledgePage = {
id,
path: `knowledge/${path}`,
title: id,
text: `knowledge for ${id}`,
frontmatter: { id },
sourceIds: [],
tags: [],
outLinks: [],
...(cites ? { cites } : {}),
}
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('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'),
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('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/)
})
})
Loading