Skip to content

feat(promotion): promote run-scoped pages with their cited support - #167

Merged
drewstone merged 1 commit into
mainfrom
feat/promotion
Aug 21, 2026
Merged

feat(promotion): promote run-scoped pages with their cited support#167
drewstone merged 1 commit into
mainfrom
feat/promotion

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

Why

discovery-lab measured, on 2026-08-19: 64 of 867 citation edges dangled, and 23 of the 46 missing targets existed in a run-local store and were never promoted (#157, capability 2b; #151, the whole Promotion section).

Half the referential rot was not an authoring failure at all. The authors cited pages that existed and resolved at the time. The promotion step orphaned them: it moved a claim into shared scope and left the run-local pages the claim cites behind. Until promotion carries a claim's support, every promotion is a chance to manufacture a dangling edge.

There was also no promotion API at all: src/run-scoped.ts treated the shared root as read-only by convention, and nothing recorded what had been promoted, by whom, or why.

What

promoteRunScopedPages(stores, runId, { pageIds, sharedRoot, actor, reason }) is the one path from run scope into shared scope.

  • Support travels with the claim. The closure of run-local pages reachable through cites is carried along, resolved with the existing resolveKnowledgeCitation over the run's chain. A citation already pointing into shared needs no carrying.
  • Support keeps its own evidence. Carried pages move as-is: a rung-4 measurement stays rung 4 and a rung-2 claim stays rung 2. A promoted claim cannot inherit a confidence its support does not carry.
  • Refuses rather than orphans. Every citation of every travelling page is checked against the post-promotion shared view with the existing assertKnowledgeCitationsResolved. A citation qualified here:: or inherited: is refused too: after the move, here names a future reader's own store, so carrying that qualifier verbatim would point the citation at the wrong page rather than at none.
  • Bytes, not a re-render. A page is written from the bytes its own store holds, so its knowledgePageDigest is the same in both scopes and the record describes what a reader will load. That is why RunScopedStores gains storePath(runId); a chain read hides the root.
  • Identity conflicts are refused, both an id already held by a different shared path and a path already holding a different id.
  • One record per promotion at <shared>/.agent-knowledge/promotions/<digest>.json: source run, every page digest, which pages were requested and which were carried support, actor, reason, time. It is content-addressed via canonicalCandidateDigest, so re-running one promotion writes the same bytes at the same path.

Everything is composed: the existing resolver, the existing commitKnowledgeFileMutations under the existing lock, the existing knowledgePageDigest, the existing durable JSON writer. No second resolver, no second transaction, no new digest scheme.

Simplification

Simplification: sharedRoot was a read-only convention with no enforcement and no record. It is now a convention plus one API that owns every write into it and leaves an auditable record, which is what makes "runs cannot write shared scope" checkable instead of merely stated.
Net: +491 / -1 lines, 9 files, 0 new resolvers, transactions, or digest schemes; 1 read-only-by-comment rule replaced by 1 enforced path.
Not done here: no rollback API. The record is content-addressed and the promotion is re-runnable, so reversing one is a shared-scope write like any other, and an "unpromote" that must also decide what happens to pages promoted since is its own design. Not a CLI command either: promotion needs an actor and a reason from the system that decided, not from a shell.
Tests: +4 (a promoted claim carries the run-local measurement it cites, at that measurement's own rung, with the promoted file byte-identical to the source — the exact defect that produced 23 of the 46 missing targets; a citation that would dangle in shared refuses the promotion and leaves the shared store empty, which is the fail-closed gate over shared data; a page the run did not author is refused; the record round-trips from disk and a second identical promotion returns the same record, which is what makes a crash between the page write and the record write repairable), -0 deleted. No shape assertions.

Proof

pnpm run typecheck   pass (src + contracts)
pnpm run lint        pass, 239 files
pnpm run build       pass
pnpm api:surface     977 exports across 6 entry points, regenerated and committed
check:version-bump   10 export changes needing a minor bump, paid for by 10.4.0 -> 10.5.0
vitest src/promotion.test.ts   4 passed
pnpm test (full)     128 failed | 650 passed | 16 skipped (794)

128 local failures, the same number and the same files as the two merged PRs before this one and as clean origin/main on this macOS host. Every one raises knowledge transaction directory escaped its root from the /var -> /private/var symlink under tmpdir(). Linux CI has no such symlink; the new tests realpath their roots and are green locally.

Refs #157, refs #151

@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 — c47f1f58

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