diff --git a/api-surface.json b/api-surface.json index 72fad47..19c0622 100644 --- a/api-surface.json +++ b/api-surface.json @@ -472,7 +472,7 @@ "RunRetrievalImprovementLoopOptions": "value f7fa1827ff47", "RunRetrievalImprovementLoopResult": "value 21f0b2d53f0d", "RunScopedStores": "value 88be8b4eed2e", - "RunScopedStoresOptions": "value 5c0af586c64d", + "RunScopedStoresOptions": "value b04713afaad9", "RunSerializedKnowledgeOptimizationOptions": "value 648af65b3525", "RunSerializedKnowledgeOptimizationResult": "value 6159e2cc058c", "SCAFFOLD_PAGE_BASENAMES": "value 373728f5643d", diff --git a/docs/run-scoped-citations.md b/docs/run-scoped-citations.md index f88e2c7..2d29fae 100644 --- a/docs/run-scoped-citations.md +++ b/docs/run-scoped-citations.md @@ -86,6 +86,14 @@ A read-only authority must already contain the lineage before `init()` is called 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. +### Read budgets + +A finite lineage is not invalid merely because it contains many runs. Ancestry reads have no +implicit depth cutoff. Supply `maxAncestors` to `createRunScopedStores` when the caller needs a +read budget, including `0` for a root-only view. A chain exactly at that bound is accepted; a +longer chain is refused explicitly rather than truncated. Cycles, invalid run identities, +conflicting parents, and path-containment checks remain enforced independently of that budget. + ## 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. diff --git a/package.json b/package.json index 03af874..52abb6e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-knowledge", - "version": "16.0.1", + "version": "17.0.0", "description": "Build, search, evaluate, and improve source-backed knowledge bases.", "homepage": "https://github.com/tangle-network/agent-knowledge#readme", "repository": { diff --git a/src/run-scoped.test.ts b/src/run-scoped.test.ts index 7c5fbf6..037a246 100644 --- a/src/run-scoped.test.ts +++ b/src/run-scoped.test.ts @@ -166,6 +166,7 @@ describe('createRunScopedStores', () => { } const stores = createRunScopedStores({ root, + maxAncestors: 64, lineageAuthority: { async parentOf(runId) { return parents.get(runId) ?? null @@ -183,6 +184,7 @@ describe('createRunScopedStores', () => { } const stores = createRunScopedStores({ root, + maxAncestors: 64, lineageAuthority: { async parentOf(runId) { return parents.get(runId) ?? null @@ -190,6 +192,58 @@ describe('createRunScopedStores', () => { }, }) - await expect(stores.lineage('run-0')).rejects.toThrow(/exceeds 64 ancestors/) + await expect(stores.lineage('run-0')).rejects.toThrow(/exceeds caller maxAncestors=64/) + }) + + it('reads a long finite lineage without a source-level ancestry ceiling', async () => { + const stores = createRunScopedStores({ + root, + lineageAuthority: { + async parentOf(runId) { + const index = Number(runId.slice(4)) + return index === 256 ? null : `run-${index + 1}` + }, + }, + }) + const lineage = await stores.lineage('run-0') + expect(lineage).toHaveLength(256) + expect(lineage[255]).toBe('run-256') + }) + + it('reads an actual inherited page beyond the old 64-ancestor cutoff', async () => { + const stores = createRunScopedStores({ root }) + await Promise.all( + Array.from({ length: 66 }, (_, index) => + stores.init(`run-${index}`, { parentRunId: index === 65 ? null : `run-${index + 1}` }), + ), + ) + await addPage(stores.storePath('run-65'), 'retained.md', 'useful work from the first run') + const pages = await stores.loadChain('run-0') + expect(pages.find((entry) => entry.page.title === 'retained.md')).toMatchObject({ + origin: 'inherited:run-65', + }) + }) + + it.each([-1, 1.5, NaN, Infinity, null])( + 'rejects malformed caller ancestry bounds (%s)', + (maxAncestors) => { + expect(() => createRunScopedStores({ root, maxAncestors: maxAncestors as number })).toThrow( + /non-negative safe integer/, + ) + }, + ) + + it('allows zero ancestors only for a root run', async () => { + const stores = createRunScopedStores({ + root, + maxAncestors: 0, + lineageAuthority: { + async parentOf(id) { + return id === 'root' ? null : 'root' + }, + }, + }) + await expect(stores.lineage('root')).resolves.toEqual([]) + await expect(stores.lineage('child')).rejects.toThrow(/caller maxAncestors=0/) }) }) diff --git a/src/run-scoped.ts b/src/run-scoped.ts index 3f94cd1..8ad57dc 100644 --- a/src/run-scoped.ts +++ b/src/run-scoped.ts @@ -69,11 +69,11 @@ export interface RunScopedStoresOptions extends KnowledgePagesOptions { sharedRoot?: string /** External owner for run ancestry. Defaults to a record inside each run store. */ lineageAuthority?: RunLineageAuthority + /** Optional caller-owned bound on ancestor reads. Omit to read the complete finite chain. + * Cycles and invalid identities are always refused; long valid history is not corruption. */ + maxAncestors?: number } -/** Ancestry beyond this bound is invalid durable state. */ -const MAX_LINEAGE_HOPS = 64 - export interface RunScopedStores { /** Create or open a run store and bind it to one exact parent identity. */ init(runId: string, options?: { parentRunId?: string | null }): Promise @@ -103,6 +103,10 @@ export function createRunScopedStores(options: RunScopedStoresOptions): RunScope if (options.runStorePath !== undefined && typeof options.runStorePath !== 'function') { throw new TypeError('createRunScopedStores runStorePath must be a function when present') } + const maxAncestors = options.maxAncestors + if (maxAncestors !== undefined && (!Number.isSafeInteger(maxAncestors) || maxAncestors < 0)) { + throw new TypeError('createRunScopedStores maxAncestors must be a non-negative safe integer') + } if (options.lineageAuthority !== undefined) validateLineageAuthority(options.lineageAuthority) const pagesDirectory = normalizePagesDirectory(options.pagesDirectory) @@ -116,20 +120,21 @@ export function createRunScopedStores(options: RunScopedStoresOptions): RunScope const chain: string[] = [] const seen = new Set([runId]) let current = runId - // 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) { + // The terminating null is read even at an explicit bound: an exactly-full chain is valid. + for (;;) { 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})`) } + if (maxAncestors !== undefined && chain.length >= maxAncestors) { + throw new Error(`run lineage for '${runId}' exceeds caller maxAncestors=${maxAncestors}`) + } seen.add(parent) chain.push(parent) current = parent } - throw new Error(`run lineage for '${runId}' exceeds ${MAX_LINEAGE_HOPS} ancestors`) } return {