From 72433c6cc1256b57a94a76e97ff5ca2637bb7711 Mon Sep 17 00:00:00 2001 From: drewstone Date: Sun, 16 Aug 2026 18:52:45 -0700 Subject: [PATCH 1/9] feat(pages): model asymmetric invalidation and evidence lint findings --- src/types.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/types.ts b/src/types.ts index 39df5b2..79b9ae7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -69,6 +69,23 @@ export interface KnowledgeUnit { updatedAt?: string } +/** + * A page that its own independently executed evidence has refuted. + * + * This is deliberately asymmetric. A competing page can remain live while this + * page is dead because the check recorded on this page contradicted it. An + * unresolved disagreement belongs in the claim ledger's symmetric `contested` + * relation instead and must not be encoded as an invalidation. + */ +export interface KnowledgePageInvalidation { + verdict: 'contradicted' + observedAt: string + reason: string + evidencePath?: string + grader?: string + metadata?: Record +} + export interface KnowledgePage { id: KnowledgeId path: string @@ -78,6 +95,10 @@ export interface KnowledgePage { sourceIds: string[] tags: string[] outLinks: string[] + /** Page ids this page explicitly refutes. */ + contradicts?: KnowledgeId[] + /** Present only when this page's own evidence has refuted the page. */ + invalidation?: KnowledgePageInvalidation } export interface KnowledgeGraphNode { @@ -145,6 +166,11 @@ export interface KnowledgeLintFinding { | 'duplicate-page-id' | 'duplicate-source-hash' | 'missing-frontmatter' + | 'ungradeable-evidence' + | 'missing-evidence-path' + | 'nonportable-evidence' + | 'broken-contradiction' + | 'invalid-invalidation' severity: 'info' | 'warning' | 'error' page?: string message: string From 4643f9fc8a40066b1a6f31d8ac9287b8cacdbe35 Mon Sep 17 00:00:00 2001 From: drewstone Date: Sun, 16 Aug 2026 18:53:42 -0700 Subject: [PATCH 2/9] feat(pages): validate contradiction and invalidation records --- src/schemas.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/schemas.ts b/src/schemas.ts index e7c7ac5..28334bb 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -34,6 +34,17 @@ export const SourceRecordSchema = z.object({ createdAt: z.string().min(1), }) +export const KnowledgePageInvalidationSchema = z + .object({ + verdict: z.literal('contradicted'), + observedAt: z.iso.datetime(), + reason: z.string().trim().min(1), + evidencePath: z.string().trim().min(1).optional(), + grader: z.string().trim().min(1).optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .strict() + export const KnowledgePageSchema = z.object({ id: z.string().min(1), path: z.string().min(1), @@ -43,6 +54,8 @@ export const KnowledgePageSchema = z.object({ sourceIds: z.array(z.string()), tags: z.array(z.string()), outLinks: z.array(z.string()), + contradicts: z.array(z.string().min(1)).optional(), + invalidation: KnowledgePageInvalidationSchema.optional(), }) export const KnowledgeGraphNodeSchema = z.object({ From fd9c499fab55c2784c1f536402dc7c4218f9f442 Mon Sep 17 00:00:00 2001 From: drewstone Date: Sun, 16 Aug 2026 18:54:45 -0700 Subject: [PATCH 3/9] feat(store): load page contradiction and invalidation metadata --- src/store.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/store.ts b/src/store.ts index 9459188..8959a33 100644 --- a/src/store.ts +++ b/src/store.ts @@ -10,6 +10,7 @@ import { import { parseFrontmatter } from './frontmatter' import { slugify } from './ids' import { withKnowledgeMutation, withKnowledgeRead } from './mutation-lock' +import { KnowledgePageInvalidationSchema } from './schemas' import type { KnowledgePage } from './types' import { extractWikilinks, normalizeLinkTarget } from './wikilinks' @@ -120,6 +121,8 @@ async function loadKnowledgePagesUnlocked( rel.split('/').pop()!.replace(/\.md$/, '') const sourceIds = arrayField(frontmatter.sources) const tags = arrayField(frontmatter.tags) + const contradicts = idListField(frontmatter.contradicts) + const invalidation = KnowledgePageInvalidationSchema.safeParse(frontmatter.invalidation) const pageRelativePath = rel.startsWith(pagesPrefix) ? rel.slice(pagesPrefix.length) : rel pages.push({ id: stringField(frontmatter.id) ?? slugify(pageRelativePath.replace(/\.md$/, '')), @@ -130,6 +133,8 @@ async function loadKnowledgePagesUnlocked( sourceIds, tags, outLinks: extractWikilinks(body).map(normalizeLinkTarget), + ...(contradicts.length > 0 ? { contradicts } : {}), + ...(invalidation.success ? { invalidation: invalidation.data } : {}), }) } pages.sort((a, b) => a.path.localeCompare(b.path)) @@ -182,6 +187,11 @@ function arrayField(value: unknown): string[] { : [] } +function idListField(value: unknown): string[] { + const values = typeof value === 'string' ? [value] : arrayField(value) + return [...new Set(values.map((item) => item.trim()).filter(Boolean))] +} + function firstHeading(body: string): string | undefined { return /^#\s+(.+)$/m.exec(body)?.[1]?.trim() } From bbf95057a742ce6eda0ead259165c6b6ac6cdbcd Mon Sep 17 00:00:00 2001 From: drewstone Date: Sun, 16 Aug 2026 18:55:59 -0700 Subject: [PATCH 4/9] feat(lint): enforce page evidence, contradiction, and invalidation integrity --- src/lint.ts | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/src/lint.ts b/src/lint.ts index 8170107..70dc4d3 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -1,7 +1,18 @@ +import { + assertGradeableEvidence, + CHECKABLE_RUNG_THRESHOLD, + type ClaimEvidence, + type EvidenceRung, + UncheckableClaimError, +} from './claim-evidence' +import { KnowledgePageInvalidationSchema } from './schemas' import { isScaffoldPath } from './store' -import type { KnowledgeIndex, KnowledgeLintFinding } from './types' +import type { KnowledgeIndex, KnowledgeLintFinding, KnowledgePage } from './types' import { normalizeLinkTarget } from './wikilinks' +const ABSOLUTE_PATH_TOKEN = + /(?:^|[\s"'=(])(?:\/(?!dev\/null\b)[^\s"'();]+|[A-Za-z]:\\[^\s"'();]+)/m + export function lintKnowledgeIndex(index: KnowledgeIndex): KnowledgeLintFinding[] { const findings: KnowledgeLintFinding[] = [] const byTarget = new Set() @@ -102,6 +113,9 @@ export function lintKnowledgeIndex(index: KnowledgeIndex): KnowledgeLintFinding[ }) } } + findings.push(...lintPageEvidence(page)) + findings.push(...lintPageContradictions(page, pageIds)) + findings.push(...lintPageInvalidation(page)) } for (const [title, paths] of titles) { @@ -137,6 +151,107 @@ export function lintKnowledgeIndex(index: KnowledgeIndex): KnowledgeLintFinding[ return findings } +function lintPageEvidence(page: KnowledgePage): KnowledgeLintFinding[] { + const rung = evidenceRung(page.frontmatter.rung) + if (rung === undefined) return [] + const evidence: ClaimEvidence = { + rung, + ...(stringValue(page.frontmatter.check) ? { check: stringValue(page.frontmatter.check) } : {}), + ...(stringValue(page.frontmatter.expect) + ? { expect: stringValue(page.frontmatter.expect) } + : {}), + ...(stringValue(page.frontmatter.evidencePath) + ? { evidencePath: stringValue(page.frontmatter.evidencePath) } + : {}), + } + const findings: KnowledgeLintFinding[] = [] + try { + assertGradeableEvidence(evidence) + } catch (error) { + if (!(error instanceof UncheckableClaimError)) throw error + findings.push({ + type: 'ungradeable-evidence', + severity: 'error', + page: page.path, + message: error.note, + metadata: { rung: error.rung }, + }) + } + if (rung >= CHECKABLE_RUNG_THRESHOLD && !evidence.evidencePath) { + findings.push({ + type: 'missing-evidence-path', + severity: 'warning', + page: page.path, + message: `Rung ${rung} evidence has no evidencePath for a human to inspect.`, + metadata: { rung }, + }) + } + for (const [field, value] of [ + ['check', evidence.check], + ['evidencePath', evidence.evidencePath], + ] as const) { + if (value && ABSOLUTE_PATH_TOKEN.test(value)) { + findings.push({ + type: 'nonportable-evidence', + severity: 'warning', + page: page.path, + message: `${field} contains an absolute path and may not re-run outside the author machine.`, + metadata: { rung, field }, + }) + } + } + return findings +} + +function lintPageContradictions( + page: KnowledgePage, + pageIds: ReadonlyMap, +): KnowledgeLintFinding[] { + const findings: KnowledgeLintFinding[] = [] + for (const targetId of page.contradicts ?? []) { + const selfReference = targetId === page.id + if (selfReference || !pageIds.has(targetId)) { + findings.push({ + type: 'broken-contradiction', + severity: 'error', + page: page.path, + message: selfReference + ? `Page "${page.id}" cannot contradict itself.` + : `Page contradicts unknown page id "${targetId}".`, + metadata: { targetId }, + }) + } + } + return findings +} + +function lintPageInvalidation(page: KnowledgePage): KnowledgeLintFinding[] { + if (page.frontmatter.invalidation === undefined) return [] + const parsed = KnowledgePageInvalidationSchema.safeParse(page.frontmatter.invalidation) + if (parsed.success) return [] + return [ + { + type: 'invalid-invalidation', + severity: 'error', + page: page.path, + message: + 'Page invalidation must record verdict=contradicted, an ISO observedAt timestamp, and a non-empty reason.', + metadata: { issues: parsed.error.issues }, + }, + ] +} + +function evidenceRung(value: unknown): EvidenceRung | undefined { + const parsed = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value + return parsed === 1 || parsed === 2 || parsed === 3 || parsed === 4 || parsed === 5 + ? parsed + : undefined +} + +function stringValue(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value : undefined +} + function extractSourceRefs(text: string): Array<{ sourceId: string; anchorId?: string }> { const refs: Array<{ sourceId: string; anchorId?: string }> = [] const regex = /\[\^([A-Za-z0-9_-]+)(?:#([A-Za-z0-9_.:-]+))?\]/g From fbad21b2a5ff59a8701b9e1ac467f5732b59476c Mon Sep 17 00:00:00 2001 From: drewstone Date: Sun, 16 Aug 2026 18:57:18 -0700 Subject: [PATCH 5/9] test(pages): lock evidence, contradiction, and invalidation integrity --- src/page-evidence-integrity.test.ts | 181 ++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 src/page-evidence-integrity.test.ts diff --git a/src/page-evidence-integrity.test.ts b/src/page-evidence-integrity.test.ts new file mode 100644 index 0000000..43ad4d0 --- /dev/null +++ b/src/page-evidence-integrity.test.ts @@ -0,0 +1,181 @@ +import { mkdir, 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 { formatFrontmatter } from './frontmatter' +import { lintKnowledgeIndex } from './lint' +import { initKnowledgeBase, loadKnowledgePages } from './store' +import type { KnowledgeIndex, KnowledgePage } from './types' + +function page( + id: string, + frontmatter: Record = {}, + overrides: Partial = {}, +): KnowledgePage { + return { + id, + path: `knowledge/${id}.md`, + title: id, + text: 'Measured result.', + frontmatter: { id, ...frontmatter }, + sourceIds: [], + tags: [], + outLinks: [], + ...overrides, + } +} + +function index(pages: KnowledgePage[]): KnowledgeIndex { + return { + root: '/kb', + generatedAt: '2026-08-17T00:00:00.000Z', + sources: [], + pages, + graph: { nodes: [], edges: [] }, + } +} + +function findingTypes(pages: KnowledgePage[]): string[] { + return lintKnowledgeIndex(index(pages)).map((finding) => finding.type) +} + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'page-evidence-integrity-')) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('page evidence lint', () => { + it('accepts gradeable, portable rung-four evidence', () => { + const findings = lintKnowledgeIndex( + index([ + page('verified', { + rung: 4, + check: 'python3 checks/result.py', + expect: 'value=42', + evidencePath: 'results/value.json', + }), + ]), + ) + + expect(findings.filter((finding) => finding.type.includes('evidence'))).toEqual([]) + }) + + it('reports evidence that claims a checkable rung without gradeable fields', () => { + const findings = lintKnowledgeIndex(index([page('self-graded', { rung: 5 })])) + + expect(findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'ungradeable-evidence', severity: 'error' }), + expect.objectContaining({ type: 'missing-evidence-path', severity: 'warning' }), + ]), + ) + }) + + it('reports author-machine absolute paths without confusing them with a contradiction', () => { + const findings = lintKnowledgeIndex( + index([ + page('nonportable', { + rung: 4, + check: 'python3 /Users/example/work/check.py', + expect: 'value=42', + evidencePath: '/tmp/run/result.json', + }), + ]), + ) + + expect(findings.filter((finding) => finding.type === 'nonportable-evidence')).toHaveLength(2) + expect(findings.some((finding) => finding.type === 'ungradeable-evidence')).toBe(false) + }) +}) + +describe('page contradiction and invalidation lint', () => { + it('accepts an existing contradiction target and a calibrated invalidation', () => { + const invalidation = { + verdict: 'contradicted' as const, + observedAt: '2026-08-17T00:00:00.000Z', + reason: 'The independently executed check printed value=43, not value=42.', + evidencePath: 'oracle/claim.json', + grader: 'blind-oracle-v1', + } + const target = page('old-claim', { invalidation }, { invalidation }) + const refuter = page('new-claim', { contradicts: ['old-claim'] }, { contradicts: ['old-claim'] }) + + const types = findingTypes([target, refuter]) + + expect(types).not.toContain('broken-contradiction') + expect(types).not.toContain('invalid-invalidation') + }) + + it('refuses missing and self contradiction targets', () => { + const findings = lintKnowledgeIndex( + index([ + page( + 'claim-a', + { contradicts: ['claim-a', 'missing'] }, + { contradicts: ['claim-a', 'missing'] }, + ), + ]), + ) + + expect(findings.filter((finding) => finding.type === 'broken-contradiction')).toHaveLength(2) + }) + + it('refuses an invalidation that does not record an actual contradiction', () => { + const findings = lintKnowledgeIndex( + index([ + page('unknown-claim', { + invalidation: { + verdict: 'unrunnable', + observedAt: 'yesterday', + reason: '', + }, + }), + ]), + ) + + expect(findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'invalid-invalidation', severity: 'error' }), + ]), + ) + }) +}) + +describe('page metadata loading', () => { + it('loads string or array contradiction pointers and a typed invalidation', async () => { + await initKnowledgeBase(root) + await mkdir(join(root, 'knowledge', 'line'), { recursive: true }) + const invalidation = { + verdict: 'contradicted' as const, + observedAt: '2026-08-17T00:00:00.000Z', + reason: 'Independent check refuted the page.', + grader: 'blind-oracle-v1', + } + await writeFile( + join(root, 'knowledge', 'line', 'claim.md'), + formatFrontmatter( + { + id: 'claim', + title: 'Claim', + contradicts: 'older-claim', + invalidation, + }, + '# Claim\n', + ), + ) + + const pages = await loadKnowledgePages(root) + + expect(pages).toHaveLength(1) + expect(pages[0]).toMatchObject({ + id: 'claim', + contradicts: ['older-claim'], + invalidation, + }) + }) +}) From 33d771d2a8eb2cb8c5bbed3fdad1a6597e3cd142 Mon Sep 17 00:00:00 2001 From: drewstone Date: Sun, 16 Aug 2026 19:02:35 -0700 Subject: [PATCH 6/9] fix(frontmatter): remove useless character-class escapes From 3382941b7c604b7bb6b7c67beb311d3c61222a33 Mon Sep 17 00:00:00 2001 From: drewstone Date: Sun, 16 Aug 2026 19:03:26 -0700 Subject: [PATCH 7/9] fix(frontmatter): unescape opening bracket inside character class --- src/frontmatter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frontmatter.ts b/src/frontmatter.ts index 3c3be68..886c657 100644 --- a/src/frontmatter.ts +++ b/src/frontmatter.ts @@ -95,7 +95,7 @@ function stringNeedsJsonEncoding(value: string): boolean { if (value.length === 0 || value !== value.trim()) return true if (value.includes('\n') || value.includes('\r')) return true if (value === 'true' || value === 'false' || /^-?\d+(?:\.\d+)?$/.test(value)) return true - return /^[\[{"']/.test(value) || /["']$/.test(value) + return /^[[{"']/.test(value) || /["']$/.test(value) } function unquote(value: string): string { From 8e3c6f2dadc08ec58a8c90a4b7a926dc82b75835 Mon Sep 17 00:00:00 2001 From: drewstone Date: Sun, 16 Aug 2026 19:04:32 -0700 Subject: [PATCH 8/9] style(lint): format evidence integrity checks and normalize fields once --- src/lint.ts | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/lint.ts b/src/lint.ts index 70dc4d3..9c7410a 100644 --- a/src/lint.ts +++ b/src/lint.ts @@ -10,8 +10,7 @@ import { isScaffoldPath } from './store' import type { KnowledgeIndex, KnowledgeLintFinding, KnowledgePage } from './types' import { normalizeLinkTarget } from './wikilinks' -const ABSOLUTE_PATH_TOKEN = - /(?:^|[\s"'=(])(?:\/(?!dev\/null\b)[^\s"'();]+|[A-Za-z]:\\[^\s"'();]+)/m +const ABSOLUTE_PATH_TOKEN = /(?:^|[\s"'=(])(?:\/(?!dev\/null\b)[^\s"'();]+|[A-Za-z]:\\[^\s"'();]+)/m export function lintKnowledgeIndex(index: KnowledgeIndex): KnowledgeLintFinding[] { const findings: KnowledgeLintFinding[] = [] @@ -152,17 +151,27 @@ export function lintKnowledgeIndex(index: KnowledgeIndex): KnowledgeLintFinding[ } function lintPageEvidence(page: KnowledgePage): KnowledgeLintFinding[] { + if (page.frontmatter.rung === undefined) return [] const rung = evidenceRung(page.frontmatter.rung) - if (rung === undefined) return [] + if (rung === undefined) { + return [ + { + type: 'ungradeable-evidence', + severity: 'error', + page: page.path, + message: 'Evidence rung must be an integer from 1 through 5.', + metadata: { rung: page.frontmatter.rung }, + }, + ] + } + const check = stringValue(page.frontmatter.check) + const expect = stringValue(page.frontmatter.expect) + const evidencePath = stringValue(page.frontmatter.evidencePath) const evidence: ClaimEvidence = { rung, - ...(stringValue(page.frontmatter.check) ? { check: stringValue(page.frontmatter.check) } : {}), - ...(stringValue(page.frontmatter.expect) - ? { expect: stringValue(page.frontmatter.expect) } - : {}), - ...(stringValue(page.frontmatter.evidencePath) - ? { evidencePath: stringValue(page.frontmatter.evidencePath) } - : {}), + ...(check ? { check } : {}), + ...(expect ? { expect } : {}), + ...(evidencePath ? { evidencePath } : {}), } const findings: KnowledgeLintFinding[] = [] try { From f76ff0b729c8a30625e72000bc828c4601ce7083 Mon Sep 17 00:00:00 2001 From: drewstone Date: Sun, 16 Aug 2026 19:05:25 -0700 Subject: [PATCH 9/9] style(pages): format integrity tests and cover invalid rungs --- src/page-evidence-integrity.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/page-evidence-integrity.test.ts b/src/page-evidence-integrity.test.ts index 43ad4d0..485111f 100644 --- a/src/page-evidence-integrity.test.ts +++ b/src/page-evidence-integrity.test.ts @@ -76,6 +76,18 @@ describe('page evidence lint', () => { ) }) + it('refuses an invalid evidence rung instead of ignoring it', () => { + expect(lintKnowledgeIndex(index([page('bad-rung', { rung: 6 })]))).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'ungradeable-evidence', + severity: 'error', + message: expect.stringMatching(/integer from 1 through 5/), + }), + ]), + ) + }) + it('reports author-machine absolute paths without confusing them with a contradiction', () => { const findings = lintKnowledgeIndex( index([ @@ -103,7 +115,11 @@ describe('page contradiction and invalidation lint', () => { grader: 'blind-oracle-v1', } const target = page('old-claim', { invalidation }, { invalidation }) - const refuter = page('new-claim', { contradicts: ['old-claim'] }, { contradicts: ['old-claim'] }) + const refuter = page( + 'new-claim', + { contradicts: ['old-claim'] }, + { contradicts: ['old-claim'] }, + ) const types = findingTypes([target, refuter])