Skip to content

feat(invalidation): propagate a page invalidation to every page that cites it - #165

Merged
drewstone merged 1 commit into
mainfrom
feat/invalidation-propagation
Aug 21, 2026
Merged

feat(invalidation): propagate a page invalidation to every page that cites it#165
drewstone merged 1 commit into
mainfrom
feat/invalidation-propagation

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

Why

discovery-lab measured, on 2026-08-19: 10 invalidated claims still live with no mark, 22 citation edges pointing into them, and 3 citing pages created the day AFTER the overturn (#157, capability 3).

The verdict lives on the refuted page. A reader who arrives through a citation never meets it. Nothing in the store carried the overturn one hop outward, so an agent could cite a dead claim in good faith a day after it died.

What

planInvalidationPropagation(visiblePages) is pure and plans a diff, not a rewrite:

  • for every page authored in the target store, resolve each cites over the whole chain with the existing resolveKnowledgeCitation, and collect the ids of the resolved pages carrying an invalidation;
  • emit a stamp only when that sorted list differs from the citesInvalidated the page already holds. A store already stamped plans nothing, so the pass can run after every grading round.
  • a citation whose target was revalidated has its stamp removed, not left behind. Propagation runs both directions or it is just accretion.
  • only here pages are stamped: a run does not write the stores it inherits or shares.

formatKnowledgeInvalidationProposal(plan) renders the plan as write blocks for the existing applyKnowledgeWriteBlocks, through the existing formatFrontmatter. Only the stamped field changes; there is no body banner, because rendering belongs to the consumer.

Two read-side surfaces close the loop: the cites-invalidated lint warning names every live citation into a refuted page, and searchKnowledge(..., { excludeInvalidated: true }) drops refuted pages from a result set. excludeInvalidated defaults to false — a caller reading history needs those pages, and silently changing what search returns is worse than an explicit option.

No graph walk. citesInvalidated names pages this page cites, which is one hop by definition, so walk/neighbors would add a traversal where a direct resolution is the whole rule.

Simplification

Simplification: pages.map(page => ({ page, origin: 'here' })) had grown a second copy with the intake gate; it is now originatedPages(pages, origin?) in run-scoped.ts, the module that owns OriginatedPage, and proposals.ts plus the intake tests route through it. Citation resolution, the intake gate, and this pass now take one page shape whether or not the caller runs run-scoped stores.
Net: +324 / -22 lines, 14 files, 2 hand-rolled origin lifts collapsed to 1 owner, 0 new resolvers or graph walks.
Not done here: the pass renders frontmatter through formatFrontmatter, so a page whose on-disk frontmatter uses YAML that the simple parser does not model is rewritten in the parser's normalized form. Widening that writer is its own PR against frontmatter.ts, not a change smuggled into a propagation pass. No CLI subcommand: agent-knowledge lint already reports the finding, and the pass has an API consumer, not an operator one.
Tests: +3 (a citer of an invalidated page gets stamped and a revalidated target clears the stamp; an inherited page is never stamped, which is the "runs cannot write shared scope" invariant; two passes over one store through the real write path leave byte-identical files, which is the property the whole diff design exists for), -0 deleted. No shape assertions.

Proof

pnpm run typecheck   pass (src + contracts)
pnpm run lint        pass, 235 files
pnpm run build       pass, 35 files
pnpm api:surface     962 exports across 6 entry points, regenerated and committed
check:version-bump   6 export changes needing a minor bump, paid for by 10.2.0 -> 10.3.0
vitest src/invalidation-propagation.test.ts   3 passed
pnpm test (full)     22 failed files | 57 passed | 3 skipped (82)

Every local failure is the same macOS /var -> /private/var symlink under tmpdir() raising knowledge transaction directory escaped its root, identical on clean origin/main on this host. Linux CI has no such symlink. The new tests realpath their temp root, so they are green locally too.

Refs #157

@tangletools tangletools left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Auto-approved drewstone PR — c96a903f

This PR was opened by the trusted drewstone account.

This approval is provisional and was applied by the local stand-in because the pr-reviewer webhook host is unreachable (2026-08-21). CI on this head is fully green. The full PR reviewer audit re-runs via the resweep when the service returns and will publish findings if it detects issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants