Skip to content

[US-395] fix: cache slots keyed by source identity, not CLI version (shared-cache contamination) - #423

Open
rucka wants to merge 49 commits into
mainfrom
feature/US-395-cache-keying
Open

[US-395] fix: cache slots keyed by source identity, not CLI version (shared-cache contamination)#423
rucka wants to merge 49 commits into
mainfrom
feature/US-395-cache-keying

Conversation

@rucka

@rucka rucka commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

PR Information

Story: #395 · Type: Bug fix (P0 — shared-cache contamination) · Priority: P0 · Assignee: @rucka
Classification: risk:yellow · cost:green

Summary

Story Context

As a developer installing an external KB from a release ZIP I want install --source <zip> to install only what the ZIP ships, without touching the official KB's cache slot or manifest so that installing an external KB in one project cannot corrupt the KB of every other project on the machine (#395).

AC Where it is satisfied
AC1 — official slot + manifest unchanged after a ZIP install kb-installer.ts extracts into the source's own slot; kb-installer.test.ts US-395 block seeds a populated official slot and asserts it is byte-for-byte untouched after the install; scaffold-kb.sh asserts the same end-to-end
AC2 — project contains only the external KB's content same tests — no official files mixed into the destination
AC3 — kb-info in another project still reports the official KB kb-availability.test.ts "contaminated official slot self-heals"; slot separation means the official slot is never written by an external install
AC4 — cache location derived from source identity, for every source form cache-slot-key.ts: KBSource union + cacheSlotKey/getSourceCachePath cover the four forms that own a slot (official, remote URL, git, local ZIP); --url is namespaced too — it went through the same official-slot bug, guarded only by backup/restore — and since round 12 it also reaches that resolution: the flag names a source through the command's own config, so --url X and --source X are the same install. A --source directory owns no slot by design: it is installed from in place (round 2)
AC5 — an already-contaminated slot is detected and re-fetched, not trusted cache-manager.ts inspectSlot; kb-availability.ts resolves the source once, warns on a manifest-name mismatch and sets the slot aside (restored if the re-fetch fails); cache-manager.test.ts contamination block. Detection is name-only and scoped to the slot of the running CLI version — see the ADL
AC6 — scaffold-kb.sh pin flips from red-is-expected to a positive assertion, landed in this PR assert_pinned_bug replaced with positive assertions; isolated HOME kept, but reasoned as test hygiene, not as a workaround for the bug

What Changed

cache-manager.ts keyed every cache slot by CLI version only (~/.pair/kb/<cliVersion>/), so any source form writing through it — ZIP, remote URL — landed in the same slot as the official KB. A ZIP install rewrote the official manifest to the external KB's {name, version}, and every other project on the machine then read the external KB as if it were official.

The fix keys every slot by source identity:

  • ~/.pair/kb/<cliVersion>/ stays reserved for the official KB only.
  • Every other source gets ~/.pair/kb/external/<kind>-<label>-<hash12>/ (kind = zip/git/url, label derived from the source, hash12 = a 12-char digest of the resolved discriminator).
  • install --source <zip> now replaces its own slot wholesale (purgeSlot before extract) instead of writing into whatever slot version-keying handed it. install --source <dir> writes no slot at all — the directory is read in place, as it always was in practice (round 2 removed the docs and the dead code that claimed otherwise).
  • kb-availability.ts resolves the source once, dispatches every installer on that one identity, and if the resolved slot's manifest name doesn't match the expected source it warns and re-fetches (AC5) instead of serving the polluted content. The old slot is set aside, not deleted, so a failing re-fetch is never destructive.
  • The git-clone path already had its own slot (~/.pair/kb/git-<hash>/, never the official one). Moving it under external/ is a namespacing tidy-up, plus a latent-bug fix: it now purges the slot before cloning, where a second install of the same git source previously failed because git clone rejects a non-empty destination. It was never a contamination path — the ADL states this correctly; an earlier revision of this body overstated it. Round 2 moved that lifecycle out of config/kb-resolver.ts into kb-manager's installKBFromGit, so no slot mechanics live outside the module that owns slots.

Review Round 1 (commit 9dbf79e8)

Every finding from the first review is addressed on this branch; the durable record of each is above and in the ADL. In short: one classifier for ZIP-vs-directory (an uppercase KB.ZIP used to take a zip slot and the directory installer); a contaminated slot is set aside rather than deleted before the re-fetch; a slot with no manifest.json is re-fetched instead of served as a cache hit; getCachedKBPath no longer re-normalizes versions and rejects an empty key (which resolved to the cache root, one bad argument from rm -rf); absolute-path detection covers Windows paths; PAIR_KB_CACHE_DIR is honoured; pure key derivation split into cache-slot-key.ts with the public surface re-exported from the kb-manager barrel; a conformance test ties OFFICIAL_KB_NAME to the release script; docs corrected on the real scope of the self-heal and on the leftovers manual cleanup misses; the three deferrals filed as #427/#428/#429.

Review Round 2 (commits e1a49402b3f2c6580489ba99)

All 11 findings of the second review are addressed on this branch; none escalated. The one that changes behaviour of record:

  • A --source directory owns no cache slot. The cache-strategy table and the external-kb.mdx callout claimed a directory was copied into ~/.pair/kb/external/dir-{name}-{hash}/. No code path ever created that slot — resolveLocalDataset validates the directory and returns it in place, so edits to the source directory change the next install's result. The docs, the adoption records and the types were corrected to say so (directory is no longer a KBSource; LocalKBSource classifies a --source path and only zip owns a slot), rather than changing the code to match the docs — copying directories into the cache is a behaviour change nobody asked for. Its corollary, dead installKBFromLocalDirectory, is removed: its only caller was ensureKBAvailable with a local-directory customUrl, which no parser produces and bootstrap.ts short-circuits. ensureKBAvailable now rejects a local directory naming the layer that handles it.

The rest, in short: PAIR_KB_CACHE_DIR is validated (absolute, no ..) instead of trusted, and a slot key may not climb out of the cache root — the same reasoning as the existing empty-key guard, since the root prefixes every path purgeSlot deletes recursively; a source path is canonicalized before it is hashed, so /kb/./acme.zip, /kb/../kb/acme.zip and --source /kb/ stop forking one slot each; the git slot lifecycle moved into kb-manager (installKBFromGit), leaving kb-resolver a pure dispatcher and letting the barrel drop the four slot primitives it had been exporting for it; the barrel migration is finished — zero production deep imports remain (kb-installer, git-clone, cli-options all go through #kb-manager), which is what the previous revision of this body claimed prematurely; isKBCached is documented as the diagnostic-only predicate it is (the contaminated-slot re-fetch comes from ensureKBAvailable/inspectSlot); vitest.setup.ts clears PAIR_KB_CACHE_DIR before every test so the suites are hermetic with respect to the override this PR started honouring; and the ADL's wrong file reference for KBSource is fixed.

Test-first is now visible in git log (round 2's Question): e1a49402 is a RED-only commit carrying 7 failing tests for the cache-root and canonicalization defects, b3f2c658 is the fix that turns them green.

Review Round 3 (commits 51e5e88e944ce186f027deda)

All 8 findings of the third review are addressed on this branch; none escalated. They share a shape worth naming for the re-reviewer: each round-2 fix established an invariant, and each round-3 finding is a place where one code path did not follow the invariant the PR itself states.

  • The cache ROOT is judged by the host convention, a SOURCE path by either. getCacheRoot validated PAIR_KB_CACHE_DIR with posix.isAbsolute || win32.isAbsolute, so on POSIX C:\cache\kb passed and join('C:\cache\kb', '0.4.3') produced a relative path — every slot then resolving against the process cwd, the exact hazard the guard's own comment cites. The two guards look alike and point opposite ways: for a source, accepting a foreign absolute path prevents a join onto the cwd; for the root it causes one. path.isAbsolute now guards the root, the dual check stays in resolveSourcePath, and the test that pinned the permissive behaviour is flipped and platform-aware.
  • A failed git clone no longer empties the slot. installKBFromGit purged before it cloned, and cloneGitRepo rm -rf's the destination when git fails — so an offline clone left an empty slot where a working one had been, contradicting the ADL's bolded "a slot is never deleted before its replacement is in hand". The slot is now set aside and restored on throw (the set-aside also gives git the empty destination it requires, so it replaces the purge rather than adding to it), and the ADL + spec are qualified: the invariant covers content that comes over the network, with the local-ZIP path stated as the exception and why (its source is a file the user still has, so re-running is the recovery).
  • A downloaded ZIP is unwrapped like a local one. installKB — which this PR promotes to serving --url <remote-zip> as well as the official download — extracted straight into the slot, so an external KB packaged under a single root directory gave a dataset root one level too high. It now calls normalizeExtractedKB. Unwrap only: a negative result is deliberately not fatal there, because that path has never validated the downloaded structure and making a structure check fatal on the official download would be a behaviour change with no defect behind it. Recorded in the ADL and the spec, not left as an implicit choice.
  • The install dispatch is exhaustive. installFromSource handled zip and collapsed the rest into "remote URL, else the official release URL" — a git source would have downloaded the OFFICIAL KB into the git source's slot, the exact cross-source write this story exists to close, with no compile-time or runtime signal. It is now a switch on source.kind with a never default, and git delegates to installKBFromGit. Unreachable today (resolveSource cannot produce git), so the fix is a compile-time guard and carries no runtime test on purpose.
  • Smaller, same spirit: isKBCached is probed only under PAIR_DIAG (its answer feeds a [diag] line and nothing else, while it costs two existsSync + readFile + JSON.parse on the hot path of every command reaching the fallback resolver); the local-directory rejection now names --source <dir> instead of internal function names, since bootstrapEnvironment prints it to whoever typed the flag; the OFFICIAL_KB_NAME conformance test moved from packages/knowledge-hub into apps/pair-cli so it imports the constant instead of regexing the file as text (text matching survives only for the release script, which has no importable form); and the dead afterEach that restored PAIR_KB_CACHE_DIR after vitest.setup.ts had already cleared it is gone.

Test-first, again visible in git log: 51e5e88e is RED-only — 5 failing assertions across 4 files for the four findings that describe a defect — and 944ce186 turns them green.

Review Round 4 (commits 959c6854935b7af3ae864ace)

All 6 findings of the fourth review are addressed on this branch; none escalated, none deferred to a card. Two of them are one defect seen at two call sites, plus the detail that enables it:

  • A cleanup no longer reverts the install it follows. removeBackupKB ran INSIDE the try whose catch calls restoreCachedKB — so a failure to delete <slot>.bak after a successful install deleted the KB just written and reinstated the previous slot, then rethrew an unrelated fs error. When the previous slot was the contaminated official one, that undid the AC5 self-heal this PR exists to deliver. Both halves of the fix: the discard now runs after the try/catch in ensureKBAvailable and in installKBFromGit, and removeBackupKB is best-effort by contract — it swallows its own failure (debug log), because a leftover .bak is inert (the next backupCachedKB overwrites it) while a throw aborts work that already succeeded.
  • removeBackupKB was the only fs.rm in the module without force: true. Combined with its existsSync guard that is a check-then-act: a concurrent same-source install ([TECH-DEBT] KB cache: same-source concurrent installs are not atomic #428) deletes the .bak in between and rm throws ENOENT — the trigger for the revert above. force: true added, matching purgeSlot / backupCachedKB / restoreCachedKB. maxRetries (the reviewer's parenthetical for Windows EBUSY) was deliberately not added: FileSystemService.rm's options type is a content-ops interface shared by every caller, and widening it is a package-level change with no defect behind it — the best-effort catch covers that case where it mattered.
  • The external namespace is not a slot. getCachedKBPath rejected an empty key and a .. segment but not the bare namespace, so purgeSlot(officialSource('external')) would have rm -rf'd every external slot on the machine. Unreachable today (the official key is a semver) — defence-in-depth, added because a partial guard set reads as a complete one. The guard rejects the namespace directory itself, not a first-segment match: the latter would reject every real external/zip-… slot.
  • The slot label keeps no dangling separator. Stripping -/. before .slice(0, 32) let truncation reintroduce one (zip-some-long-label--<hash>), defeating the label's only purpose. Re-stripped after the slice.
  • The spec no longer over-promises. "A slot with no manifest.json is treated as an aborted download and re-fetched" holds for the official slot only: inspectSlot returns ready early for any source declaring no expected manifest name — every external source — which the suite already pinned. The sentence is scoped, and the external case stated positively: no expected name ⇒ not inspected ⇒ re-installed into its own slot on every use.
  • EXTERNAL_NAMESPACE is no longer exported (no caller outside its module, against this PR's own barrel rule). The test keeps the 'external' literal deliberately — it pins the layout a user sees on disk, which importing the constant would stop doing.

Test-first, again visible in git log: 959c6854 is RED-only — 6 failing assertions across 4 files — and 935b7af3 turns them green.

Review Round 5 (commits 498cd3cc57073b61d6a211ae)

All 4 findings of the fifth review are addressed on this branch; none escalated, none deferred to a card. Three of them are this story's own rule applied one layer further out:

  • The download STAGING file was still keyed by CLI version aloneinstallKB staged every download at <tmp>/kb-<version>.zip, and this PR is what made that function serve the official release and --url <remote zip>, so two sources shared one staging path. Not just a concurrency window: resume-manager.shouldResume() decides to resume from the existence and SIZE of <staging>.partial alone, with no binding to the URL that produced those bytes, then issues Range: bytes=<n>- against the new url — an interrupted official download followed by pair install --url … appended the second body onto the first and finalized the hybrid as one archive. Fixed where every other key is derived: downloadStagingName(version, url) in cache-slot-key.tskb-<version>-<hash12>.zip. The .partial sibling follows automatically, so a resume can only ever continue the SAME url.
  • The RESTORE half of "a cleanup must not undo the work it follows" (round 4 fixed the discard half). restoreCachedKB deleted the half-written slot and only then renamed the backup back — a failing recursive delete left BOTH on disk, and the next install's backupCachedKB deletes the .bak to make room, so a second failure left the user with no cache at all: the exact opposite of the ADL's bolded invariant. It also ran inside the catch, so an fs error there replaced the actionable HTTP/git failure. Now rename-first (backup back into place, half-written copy discarded afterwards, best-effort) and best-effort by contract like removeBackupKB. Deliberate deviation from the literal recommendation (a try/catch at each of the two call sites): the rule lives inside the function, so a third call site cannot get it wrong; the shared best-effort delete is one private discard().
  • An explicit --url no longer resolves to the monorepo dataset. getKnowledgeHubDatasetPathWithFallback consulted the monorepo shortcut before customUrl, so in a dev checkout the URL the user typed was silently ignored — the one named source form that never reached identity resolution (AC4). Answered as not intentional: --source and --git are honoured in a checkout, and the spec's own Precedence Order already put an explicit source first, so the code contradicted the spec. The shortcut now applies to the DEFAULT source only. Side effect worth having: the --url path is exercisable in a contributor's checkout instead of only in a released binary. Two existing tests asserted the old behaviour as a convenience and were rewritten — one of them (commands/update/handler.test.ts) had been asserting nothing about the URL at all, because its spy on getKnowledgeHubDatasetPathWithFallback never intercepted an intra-module call.
  • getCachedKBPath dropped from cache-manager's default export — a symbol that module only imports, with no production caller and one test call site (same surface-bloat rule round 4 applied to EXTERNAL_NAMESPACE).

Test-first, again visible in git log: 498cd3cc is RED-only — 6 failing assertions across 4 files — and 57073b61 turns them green.

Review Round 6 (commits 8d38a69e5204251ad08e73c89703fcba)

All 3 findings of the sixth review are addressed on this branch; none escalated, none deferred to a card. Round 5 made the restore safe; round 6 makes its failure visible — and finishes two rules this PR states about itself:

  • Best-effort is not the same as silent. When restoreCachedKB's second rename (.bak back into the slot) threw, it logged at log.debug and returned — but the slot had just been renamed aside, so the user was left with no cache while their only good copy sat at <slot>.bak under a name nothing points at (PAIR_DIAG-less debug is off by default). From the user's side that is indistinguishable from having lost it, and it is the exact run this PR's own round-5 test drives. The catch now (a) retries in the old orderrm -rf whatever occupies the slot, then rename again, since a recursive delete and a rename fail on different handles, so a transient hold is not terminal — and (b) only then gives up with a log.warn: "Your previous copy is kept at <slot>.bak — move it back to <slot> to recover it." It still never throws, so the HTTP/git error remains the one the user sees. Secondary fix in the same function: the set-aside name (.discarded-<ms>) was millisecond-resolution, so two same-source restores inside one millisecond collided on the rename and both took the give-up path — it now carries a process-monotonic counter (cross-PROCESS concurrency stays with [TECH-DEBT] KB cache: same-source concurrent installs are not atomic #428).
  • The leftovers table was complete again only until round 5. restoreCachedKB renames the half-written slot to <slot>.discarded-* and deletes it best-effort; when that delete fails the directory persists, and for the OFFICIAL slot it lives at ~/.pair/kb/<version>.discarded-* — outside the rm -rf ~/.pair/kb/external sweep the docs call sufficient, and it is a full KB tree. Added as a fourth row (with the note that an external slot's own .discarded-* is already inside that sweep), and the restore paragraph above it now states the retry and the warning.
  • A named source now reaches identity resolution at BOTH layers. Round 5 repaired the resolver; config/bootstrap.ts's pre-flight applied the monorepo shortcut regardless of --url and runs before the resolver is ever called, so in a dev checkout pair <cmd> --url https://… still short-circuited with a [diag] line and the typed URL ignored — the ADL's bolded clause over-claimed by one layer. A named customUrl is now answered in its own branch (local path ⇒ used in place, remote ⇒ falls through to the resolver) and the monorepo shortcut is reached by the DEFAULT source only. Written as one branch rather than the literal three-clause condition because that form put the function at complexity 11 (eslint max 10) — semantically identical. checkKnowledgeHubDatasetAccessible is deliberately NOT changed: it is a readability probe of the bundled dataset, not a second source resolution, and that scope is now written in its JSDoc and the ADL rather than changed on no evidence.

Test-first, again visible in git log: 8d38a69e is RED-only — 5 failing assertions across 3 files — and 5204251a turns them green.

Review Rounds 7-10 (no commits — escalated to the human merge gate)

Rounds 7-10 produced no code: each escalated its findings to the human at the merge gate
rather than fixing them (see the PR comments). The largest of them — InMemoryFileSystemService.rename
not registering the destination of a renamed directory, which made backup/restore fixtures seeded
with only nested files assert vacuously — is fixed as of round 16 (see that section); the
direct-child-manifest.json workaround it forced is gone with it.

Review Round 11 (commits cf608f1b -> 70bee6bd)

The single finding dispatched this round is fixed; none escalated, none deferred to a card.

Test-first, again visible in git log: cf608f1b is RED-only — the generated README must
offer the ZIP form and contain neither not yet equivalent nor issues/395, and no command's
--help metadata may claim the #395 limitation
(a registry-wide assertion in
commands/index.test.ts, so a future copy of the claim in any command's help fails the suite) —
and 70bee6bd turns them green.

Review Round 12 (commits c932a388c5c7370a1a4ecea9)

The single finding dispatched this round is fixed; none escalated, none deferred to a card.

  • The program-level --url named a source nothing read. --url is declared on the program
    and was consumed only by config/bootstrap.ts's pre-flight; the subcommand parsers dispatch on
    --source alone, so pair install --url <mirror> parsed to resolution: 'default'. Before
    this story that survived by accident — the pre-flight wrote the custom archive into
    getCachedKBPath(version), the OFFICIAL slot, and the command's default resolution then served
    it. Source-identity keying ends the accident by design: the fetch lands in
    ~/.pair/kb/external/url-<label>-<hash>/ while default resolution goes to the official slot, so
    the flag would install a different KB than the one the user typed, and nothing at all behind the
    firewall the mirror existed for. Fixed at the parser, not the resolver: namedSource()
    (config/cli.ts) returns the command's --source or, absent it, the program-level --url, and
    install, update and kb-info build their config.resolution from that one value. So
    --url X and --source X are the same command — same identity, same slot, same bytes — and a
    git/local/remote --url is classified by the code that already classifies --source. The
    alternative the finding also offered (thread a customUrl option down the dispatcher into
    resolveDatasetRoot's default case) was rejected: it re-introduces a second channel for
    "which source is in play" beside config.resolution, which is the ambiguity this PR's own
    decisions remove. Precedence: --source outranks --url (command-specific beats
    program-level); an empty --source still raises its own error.
  • architecture.md's fallback-chain line was falsecache hit → default GitHub release → custom URL (via --url flag) describes neither the old code nor the new one. Corrected: the
    chain belongs to the DEFAULT source, and a named source bypasses it into its own identity slot.
    Same rule stated in the resolution spec's Precedence Order (--url as step 2, with the
    equivalence given as a rule rather than a coincidence), in the CLI reference's Global Options
    table (where --url and --no-kb were both missing) and in the three commands' --help notes.
  • Found while wiring it, deliberately NOT fixed here — for the merge gate.
    bootstrapEnvironment is never called: cli.ts's preAction guard
    if (thisCommand === prog) return is always true, because Commander invokes a program-level
    hook as callback(hookedCommand, actionCommand)thisCommand IS the program for every
    subcommand. Proven, not inferred: with this round's fix reverted, pair update --url <mirror>
    issues zero HTTP requests (the pre-flight would have issued one); with it, exactly one
    download of the mirror plus its .sha256. Consequences worth naming, all recorded in the ADL:
    validateCliOptions's --url + --no-kb conflict error, the DatasetNotFoundError /
    DatasetAccessError accessibility check and round 6's shouldSkipKBDownload clause are all
    unreachable from the CLI today, and round 8's "--url double-downloads at the pre-flight"
    cannot occur while the guard stands. Correcting the guard makes every command resolve (and
    potentially download) a KB before it runs — a behaviour change with its own blast radius, and
    not what this finding asked for. The command path now honours --url on its own, which is what
    the flag promised.

Test-first, again visible in git log: c932a388 is RED-only — 13 assertions across 5
files, verified failing by reverting only the four source files — and c5c7370a turns them
green. It carries --no-verify on purpose, stated in its own commit body: the pre-commit
hook runs ts:check and the failing state names an API that does not exist yet. The end-to-end
pair the finding asked for: install/handler.test.ts "downloads the url and installs its
content, not the default KB" (a monorepo dataset is seeded so a disconnected flag fails loudly
with the dataset's content, never vacuously) and cli.test.ts "pair update --url
updates from the mirror" — argv to disk through runCli, the only test covering the
global/command option merge the flag depends on. Plus two CLI-level assertions in
scripts/smoke-tests/scenarios/source-resolution.sh (Tests 7-8: --url installs the named local
KB; --source wins over --url), where CLI behaviour belongs per the gate/tooling rule.

Review Rounds 14-15 (commits 36254dc9f125b23f7d333972dab4676749031c0713583b4d)

Two Major findings, both about the same dead path round 12 discovered. Both resolved in this PR
— nothing filed as a card, nothing deferred to a follow-up.
(Round 14's commits missed the push
that closed round 12; round 15 verified them against the findings, closed the gap they left, and
pushed the lot.)

  • --no-kb was documented as working while it is inert. Round 12's docs pass added a --no-kb
    row reading "Skip knowledge base download" to the same table where it corrected the false --url
    claim. kb === false is read only by config/bootstrap.ts and kb-manager/cli-options.ts, both
    reachable only from the pre-flight this PR documents as never called — so pair install --no-kb
    resolves and installs a KB anyway. Fixed in all three places that made the promise: the CLI
    reference row now reads "Currently a no-op" above a warn callout (what it does, why, that the flag
    stays registered so scripts passing it don't break, and where the open decision lives); the flag's
    own --help description is now Currently a no-op (was: skip knowledge base download); and the
    CLI contracts spec marks CliOptions.kb INERT with a pointer. Test-first for the help surface:
    cli.test.ts drives runCli(['--help']), captures the real help output and requires the --no-kb
    line to say no-op and not "Skip knowledge base download" — RED before 49031c07, green after.
  • The preAction guard is always true — so --log-level was dead too. Taken as the reviewer's
    option (b), plus a real fix for the part that carries no blast radius:
    • --log-level / --verbose are now LIVE. The handling sat below the always-true guard, so
      pair <cmd> --log-level debug silently did nothing and the only level ever applied was the
      module-level default. Nothing about a global log level needs the KB pre-flight, so it was hoisted
      above both guards (applyGlobalLogLevel) and now applies to every command; a command-level
      --log-level (package, update-link) still wins. Test-first at argv level through runCli
      (36254dc9 RED, expected 'INFO' to be 'DEBUG' twice; f125b23f green) — the only layer that
      proves the flag survives the hook.
    • Round 6's shouldSkipKBDownload change is REVERTED. The reviewer's point stands: shipping a
      fix to a function no CLI invocation reaches, with a green unit test beside it, makes the suite
      assert behaviour the user can never observe. config/bootstrap.ts's shouldSkipKBDownload and
      the checkKnowledgeHubDatasetAccessible SCOPE comment are byte-identical to main again, and
      config/bootstrap.test.ts is back to main entirely. The behaviour that branch reached for is
      delivered reachably by the round-12 parser change (namedSource), untouched.
    • The pre-flight is marked unreachable in code, at both ends. An ⚠️ UNREACHABLE FROM THE CLI TODAY banner at bootstrapEnvironment's entry point names the Commander semantics that kill it,
      says a green suite in that file proves nothing about user-visible behaviour, and points at the
      ADL; a matching ⚠️ THIS FUNCTION NEVER RUNS PAST ITS FIRST LINE — deliberately, not accidentally JSDoc sits on runKbPreflight. The hook body was split into applyGlobalLogLevel
      • runKbPreflight (the prose pushed it past max-lines-per-function), which also puts each
        rationale on the thing it explains.

What is NOT in this PR, and is a merge-gate call rather than a card. Option (a) — correcting the
guard to actionCommand === prog — would make every KB-requiring command resolve and potentially
download a KB before it runs (a second fetch on top of install/update's own resolution, exactly
the double-download round 8 flagged) and would re-enable validateCliOptions, the
DatasetNotFoundError/DatasetAccessError probe and shouldSkipKBDownload in one step, each
needing its own coverage. Plainly: the open choice is revive the pre-flight / delete it / retire
--no-kb with it.
Nothing here pre-empts it, and the deadness is now impossible to miss — in the
code, in --help, in both docs pages and in the ADL.

Review Round 16 (commits a7499e159eb777aeae97cca6)

One Major finding, fixed in place — nothing filed as a card, nothing deferred.

  • The shared in-memory FS double made this story's central assertion vacuous. rename of a
    DIRECTORY registered the destination as newPrefix — always trailing-slash — while existsSync
    is an exact match on the dir set, so existsSync('<dest>') was false unless a moved file landed
    directly under it (the file loop adds dirname(file)). restoreCachedKB and removeBackupKB
    both start with if (!fs.existsSync(backupPath)) return, so any backup/restore test seeded with
    only nested files no-opped and passed for the wrong reason — on the invariant this PR leans on
    hardest (a failing re-fetch leaves the user's cache intact). Fixed test-first: two RED cases in
    in-memory-fs-write.test.ts (an all-nested rename's destination is observable; the parent lists
    it exactly once — the phantom <dest>/ entry is the same bug from readdir's side), then the
    one-line fix rel === '' ? newDir : newPrefix + rel (the directory branch extracted into a
    private renameDirectory to stay under max-lines-per-function — extraction only). A third test
    in cache-manager.test.ts pins the non-vacuity where it matters: a backup + restore round-trip on
    a slot whose only file is <slot>/.pair/knowledge/guidelines/index.md, verified to FAIL with the
    fix reverted.
  • The blast radius was measured, not assumed — the finding's own condition for fixing it here
    rather than at the merge gate. pnpm turbo test across the whole monorepo is green, 10/10
    tasks
    : no suite anywhere was passing BECAUSE a renamed directory read as absent. One test in
    this package's suite was (kb-availability.test.ts counted 2 entries in external/ — the slot
    plus its phantom); it is green now for the right reason. So there is no accept/carve-out decision
    left for the human on this finding.
  • The absorbed [TECH-DEBT] KB cache: same-source concurrent installs are not atomic #428 atomic stage is now GREEN. Commits cc8104f4/ef44cbe2 were RED tests
    whose implementation had never been committed. 9eb777ae lands
    cacheManager.writeSlotAtomically — populate into <slot>.tmp-<pid>-<n> beside the slot, rename
    the slot into existence only when the stage is complete, sweep stages of DEAD pids (a LIVE pid's
    stage is a concurrent install in flight), remove its own stage and rethrow the ORIGINAL error on
    failure — wired into all three install forms (remote download, local ZIP, git clone). The unwrap
    and the structure check run on the stage too, so an invalid ZIP leaves neither slot nor stage.
  • The ADL was re-synced to the code (ae97cca6): local slots are CONTENT-keyed (the path-keyed
    shape moves to Alternatives as superseded, with why it was chosen first — the read API was
    text-mode until readFileBytes); stage-and-swap replaces "concurrency deferred", with the
    lock-file route and why it was rejected; and a new clause records the rule this round applied —
    a defect in a shared test double is fixed in the double, not worked around in the fixture.

Review Round 17 (commits 03da0d96a778591fd4480f4d)

Two Major doc findings, then the two largest behaviour changes of the whole PR.

  • Doc findings, both fixed: cache-slot-key.ts's header cited a pair kb-cache command and a
    cache-inventory.ts file that did not exist, and the ADL described a labelled
    zip-<basename>-<hash> slot the code never produced (it emits external/zip-<hash12>, no label —
    a label would re-smuggle the path into a content-keyed identity). Corrected here and in four other
    user/adoption-facing docs.
  • a778591f — the KB pre-flight was REVIVED. After three rounds of documenting it as dead and
    leaving the choice to the merge gate, this commit takes it: the preAction guard now tests the
    ACTION command (actionCommand === prog ⇒ no subcommand matched), and the Commander argument
    convention the fix rests on is pinned by its own test. Two things came with it, both deliberate:
    the command exemption list was inverted from a deny-list to an allow-list
    (commands/bootstrap-policy.ts — only install and update resolve a KB; with a deny-list every
    command added tomorrow would inherit a network round-trip by simply not being remembered there),
    and --no-kb became real — which also makes --url + --no-kb an error where it was
    previously accepted and silently ignored.
  • d4480f4dpair kb-cache list|prune (absorbing [TECH-DEBT] KB cache: no automatic eviction of external slots #427). list reports every cache entry with
    its size, its manifest label and whether prune would take it; prune (--dry-run, --json)
    reclaims superseded official slots, pre-external/ git clones and abandoned stages/backups. It
    never touches an external/ slot, the running version's slot, or anything it cannot classify.

Review Round 18 (commits 2fe7aa9d55a5888f0e1b3c628028f758)

Five actionable findings (1 Critical + 4 Major) — all five fixed in this PR, none deferred, none
escalated.
Four of them landed on round 17's two commits, which is the review catching exactly
where the risk was.

  • [Critical] The revived pre-flight aborted every RELEASED install. Reviving the hook made step 3
    (checkKnowledgeHubDatasetAccessible) execute for the first time, and it probed the bundled
    dataset path (getKnowledgeHubDatasetPathfindPackageJsonPath) instead of the cache slot step
    2 had just populated. In a published package that path does not merely miss — it throws:
    @pair/knowledge-hub is hoisted next to pair-cli under node_modules/@pair/ (npm and pnpm
    alike), never nested under it, and postbuild.js bundles no dataset. So a released
    pair install downloaded the KB successfully and then died with Unable to find @pair/knowledge-hub package. Nothing in the suite could see it: every unit fixture seeds a
    monorepo dataset, and the smoke suite runs dist from inside the monorepo where the pnpm symlink
    short-circuits steps 2-3. Fixed by making the two steps share ONE path —
    resolveDatasetForPreflight returns the path it resolved and checkDatasetAccessible(fs, path)
    takes it as an argument (shouldSkipKBDownload + hasLocalDataset folded into it, same order of
    precedence). Covered by a unit case where the bundled path THROWS, and verified empirically
    outside the repo
    : dist copied into a scratch package with @pair/knowledge-hub absent from a
    hoisted node_modules — the pre-fix binary printed the error above, the fixed one runs clean, with
    and without --no-kb. Rule recorded in the ADL: a check that derives its own subject does not
    verify the operation it follows.
  • [Major] Six surfaces still declared the pre-flight dead, two commits after it was revived: the
    UNREACHABLE banner on bootstrapEnvironment, the --no-kb help string, the ADL clause ("never
    runs — NOT revived here"), the CLI reference callout, the contracts spec ("INERT"), and a
    round-15 test that pinned the no-op help text. All six now describe what ships (plus
    config/cli.ts's JSDoc, stale the same way), and the pinned test is flipped. The
    --url + --no-kb rejection — a user-visible behaviour change with no defect behind it — is
    stated in --help, the CLI reference and the contracts spec, because that is where a user who
    hits it looks.
  • [Major] kb-cache prune deleted an install in flight. It classified every stage/backup as
    garbage from the NAME, while cache-manager.sweepOrphanedStages checks pid liveness for precisely
    that reason. Concrete loss: project B's prune rm -rfs project A's extraction mid-flight and, with
    it, the .bak that is A's only way back — the exact deletion this PR's bolded invariant forbids on
    a cache that is machine-wide by definition. The liveness predicate is now ONE function
    (isStageOwnerAlive, shared with the sweep through the barrel), and a .bak gets its own
    evidence: a backup whose sibling slot is ABSENT is the only copy of that KB and is spared;
    reclaimed once the slot is back. list labels a kept entry, so a leftover-shaped survivor does not
    read as a prune bug. Test-first, unit + on-disk.
  • [Major] This PR body was two commits stale — attesting a gate at ae97cca6 while the head
    carried the pre-flight revival and a new destructive command. Fixed by re-publishing at the new
    head: these two sections, plus a gate attested at 8028f758.
  • [Major] kb-cache was documented only in the CLI reference. Now recorded in the ADL (what
    prune reclaims and each of the five things it never touches, with the reason for each), in
    kb-source-resolution.mdx (the leftovers table gains a prune column plus a "what prune never
    deletes" paragraph) and in cache-slot-key.ts's header, which still called manual rm -rf the
    only cleanup.

Why This Change

P0: this is the only one of the three #391 follow-ups (with #396, #397) that writes outside the running project — a single ZIP install corrupts a shared, version-keyed cache that every other project on the machine treats as authoritative, with no signal connecting cause and effect. #396/#397 stay contained to the invoking project and are deliberately not touched here (maintainer decision 2026-08-05 keeps the cards separate — different file, different gravity; bundling them would have classified the critical fix by the worst dimension of the bundle).

Recorded in ADL 2026-08-11-kb-cache-slots-keyed-by-source-identity.md: slot = source identity (not CLI version); why the local discriminator is the archive's content (sha256 of the bytes, once FileSystemService grew a byte-mode readFileBytes — the text-mode-only read is why the path was hashed first, and hashing a lossily-decoded binary is not defensible in a security-adjacent path); the contamination policy (manifest name mismatch ⇒ contaminated ⇒ warn+purge+re-fetch; missing/unreadable/nameless manifest ⇒ inconclusive ⇒ still trusted, so a legacy cache is never deleted on a signal that says nothing); same-source concurrency made safe by stage-and-swap (and why not a lock file); no automatic cache eviction (rm -rf ~/.pair/kb/external is always safe).

Changes Made

Implementation Details

  • cache-slot-key.ts (new): pure identity → key → path derivation, no filesystem access — KBSource union (official | remote | git | zip — a directory owns no slot), cacheSlotKey, getCacheRoot (honours PAIR_KB_CACHE_DIR, and rejects a non-absolute or ..-bearing value), getCachedKBPath (takes a key verbatim; throws on an empty key, which would resolve to the cache ROOT, and on one containing ..), getSourceCachePath, localKBSource (the single ZIP-vs-directory classifier, case-insensitive), resolveSourcePath (absolute under posix OR win32 rules, canonicalized so one filesystem location maps to one slot), OFFICIAL_KB_NAME.
  • cache-manager.ts: slot lifecycle only — inspectSlot, purgeSlot, isKBCached, backup/restore/removeBackup (now taking a KBSource, and idempotent: a stale .bak or a half-written slot no longer makes the rename fail), and writeSlotAtomically (round 16 / [TECH-DEBT] KB cache: same-source concurrent installs are not atomic #428): populate a <slot>.tmp-<pid>-<n> stage beside the slot, rename it on whole, sweep dead-pid orphans, clean up its own stage and rethrow the original error on failure.
  • zip-source.ts (new): resolves a local archive AND hashes its bytes, so a local ZIP's slot is keyed by CONTENT ([TECH-DEBT] KB cache: a local source is discriminated by path, not content #429) — the same archive copied to two directories lands on one slot.
  • packages/content-ops: FileSystemService.readFileBytes (byte-mode read) on the real service and its in-memory double, which is what made content-keying possible; and the in-memory double's directory rename now registers the destination directory itself (round 16).
  • kb-manager/index.ts: the public surface is re-exported from the barrel and no production module outside kb-manager/ imports one of its internal modules (test files still do, deliberately, to spy on them). The barrel exports install entry points — installKBFromLocalZip, installKBFromGit — never slot primitives; every symbol on it has a caller outside the module.
  • kb-installer.ts: the local ZIP install resolves its own slot and populates it through the atomic stage (after verification, so a bad package never lands in a slot); the zip path is resolved via an injected cwd rather than process.cwd(). installKBFromGit owns the git slot's whole lifecycle and sets the old clone aside (restoring it when git throws) instead of purging first. installKB unwraps a ZIP nested under a single root directory, like the local-ZIP path. installKBFromLocalDirectory removed — dead code with no production caller.
  • kb-availability.ts: resolves the source once and dispatches installFromSource on the resolved kind (never on a second look at the raw string) through an exhaustive switch with a never default, so a new KBSource kind is a type error rather than a silent official-release download; backs the slot up before every rewrite and restores it on failure; the backup dance protects the resolved source's slot rather than always the official one.
  • config/kb-resolver.ts: a pure dispatcher — the git case is one call to installKBFromGit; local resolution classifies ZIP-vs-directory through localKBSource (ZIP → its own slot, directory → used in place). The isKBCached probe runs only under PAIR_DIAG, the only thing that consumes its answer.
  • apps/pair-cli/src/kb-manager/official-kb-name.test.ts (new): ties OFFICIAL_KB_NAME to the --name the release script passes — drift there would classify every official slot as contaminated. It lives next to the constant so it imports it; only the shell script, which has no importable form, is still matched as text.
  • scripts/smoke-tests/scenarios/scaffold-kb.sh: assert_pinned_bug "foomakers/pair#395" → positive assertions ("Official KB cache slot untouched by the external ZIP install (install --source <zip> extracts an external KB into the official KB's cache slot (shared-cache contamination) #395)", "Release ZIP installs into its own source-keyed cache slot"); pre-seeds a populated official slot in the isolated HOME.
  • apps/pair-cli/vitest.setup.ts (new): clears PAIR_KB_CACHE_DIR before every test — the suites assert homedir()-derived slot paths and must not depend on an ambient value of the variable this PR started honouring.
  • Docs: external-kb.mdx obsolete "ZIP install is not equivalent" warning replaced with the per-source-slot behaviour + self-heal, and corrected on the directory case; kb-source-resolution.mdx gets a cache-strategy table per source form (directory = no slot), corrected git-clone steps and the enforced PAIR_KB_CACHE_DIR constraint; architecture.md + the context-map glossary aligned.

Files Changed

  • Modified: apps/pair-cli/src/kb-manager/cache-manager.ts, kb-installer.ts, kb-availability.ts, index.ts, apps/pair-cli/src/config/kb-resolver.ts, apps/pair-cli/src/config/bootstrap.ts + their test files, apps/pair-cli/vitest.config.ts, apps/pair-cli/src/commands/scaffold-kb/templates/readme.ts, apps/pair-cli/src/commands/scaffold-kb/metadata.ts + commands/index.test.ts (round 11), apps/pair-cli/src/config/cli.ts, apps/pair-cli/src/cli.ts, apps/pair-cli/src/commands/{install,update,kb-info}/parser.ts + metadata.ts + their tests, apps/pair-cli/src/cli.test.ts (rounds 12+14+15), apps/website/content/docs/reference/cli/commands.mdx + apps/website/content/docs/reference/specs/cli-contracts.mdx (rounds 14-15), scripts/smoke-tests/scenarios/scaffold-kb.sh, scripts/smoke-tests/scenarios/source-resolution.sh (round 12), apps/website/content/docs/customization/external-kb.mdx, apps/website/content/docs/reference/specs/kb-source-resolution.mdx, apps/website/content/docs/reference/configuration.mdx, .pair/adoption/tech/architecture.md, .pair/adoption/tech/boundedcontext/integration-process-standardization.md
  • Added: apps/pair-cli/src/kb-manager/cache-slot-key.ts + cache-slot-key.test.ts, apps/pair-cli/src/kb-manager/zip-source.ts + zip-source.test.ts (rounds 16/[TECH-DEBT] KB cache: a local source is discriminated by path, not content #429), apps/pair-cli/vitest.setup.ts, apps/pair-cli/src/kb-manager/official-kb-name.test.ts (moved from packages/knowledge-hub/src/conformance/ in round 3), .pair/adoption/decision-log/2026-08-11-kb-cache-slots-keyed-by-source-identity.md
  • Modified in packages/content-ops (rounds 16 / [TECH-DEBT] KB cache: a local source is discriminated by path, not content #429): src/file-system/* (readFileBytes on the service interface + implementation) and src/test-utils/in-memory-fs/* (readFileBytes on the double; the directory-rename destination fix) + their test files

53 files changed, 3688 insertions(+), 467 deletions(-).

Testing

Test Coverage

Test-first: cache-manager.test.ts rewritten (26 tests) and 5 new US-395 tests added to kb-installer.test.ts reproducing "populated official slot + ZIP install ⇒ official manifest rewritten". Verified RED before the fix: 27 failures across the two suites (attested in this body). Round 2 makes the evidence checkable from history: commit e1a49402 is RED-only (7 failing tests), b3f2c658 turns them green.

  • AC1/AC2: kb-installer.test.ts US-395 block + scaffold-kb.sh

  • AC3/AC5: kb-availability.test.ts "contaminated official slot self-heals" + cache-manager.test.ts contamination block

  • AC4: cache-manager.test.ts keying block + "two different external ZIPs get two slots" installer test

  • AC6: scaffold-kb.sh positive assertions

  • Round 2 additions: cache-slot-key.test.ts cache-root validation + path-canonicalization blocks; kb-resolver.test.ts "local directory resolution creates no cache slot"; kb-availability.test.ts "a local directory is not a fetchable source"

  • Round 3 additions (51e5e88e, RED before 944ce186): cache-slot-key.test.ts "judges the override by the HOST convention"; kb-installer.test.ts "keeps the previous clone when the new clone fails", "replaces the slot wholesale on a successful clone", "unwraps a downloaded ZIP nested under a single root directory"; kb-resolver.test.ts "does not probe the cache when diagnostics are off" + its PAIR_DIAG=1 twin; kb-availability.test.ts extended to assert the rejection message names --source and not the internal functions

  • Round 4 additions (959c6854, RED before 935b7af3): cache-manager.test.ts "tolerates a .bak that vanishes between the check and the delete" + "never throws when the delete itself fails (EBUSY)"; kb-availability.test.ts "keeps a successful re-download when discarding the old slot fails"; kb-installer.test.ts "keeps a successful clone when discarding the set-aside one fails"; cache-slot-key.test.ts "refuses the external NAMESPACE as a key" + "still accepts a slot INSIDE the namespace" + the label-truncation assertion

  • Round 6 additions (8d38a69e, RED before 5204251a): cache-manager.test.ts "retries in the old order when the rename back fails once" + "warns and names the recoverable copy when it cannot put the backup back" + "gives each set-aside copy a distinct name within the same millisecond"; kb-availability.test.ts's round-5 restore test extended to assert the invariant it was driving past (the .bak survives AND is named in a warning, next to the unchanged "the 404 is the error you see"); bootstrap.test.ts "does not let the monorepo dataset outrank an explicit remote --url"

  • Round 12 additions (c932a388, RED before c5c7370a): config/cli.test.ts namedSource block (precedence, empty forms, absent); commands/{install,update,kb-info}/parser.test.ts "the program-level --url names the source when --source does not"; commands/install/handler.test.ts "pair install --url <mirror> installs what the mirror served" (end to end, against a seeded monorepo dataset); cli.test.ts "the program-level --url reaches the command" (argv → disk through runCli, asserting exactly one fetch of the mirror); source-resolution.sh Tests 7-8

  • Rounds 14-15 additions: cli.test.ts "US-395 round 14: the global --log-level actually takes effect" (2 assertions, RED in 36254dc9, green in f125b23f) and "US-395 round 15: pair --help does not advertise --no-kb as working" (RED in dab46767, green in 49031c07, asserted against the real captured help output). config/bootstrap.test.ts is back to main — the round-6 assertion it carried covered a path no CLI invocation reaches, and was removed with the change it covered.

  • Round 5 additions (498cd3cc, RED before 57073b61): kb-installer.test.ts "stages a download under a name keyed by the source URL, not by the CLI version alone" + "reports the clone failure, not a failure of the restore that follows it"; cache-manager.test.ts "restoreCachedKB puts the backup back even when the half-written slot cannot be deleted"; kb-availability.test.ts "reports the download failure, not a failure of the restore that follows it"; kb-resolver.test.ts "an explicit --url is not outranked by the monorepo dataset" + "remote resolution honours the url even inside a monorepo checkout"

  • Round 16 additions (a7499e15, RED before the fix in the same commit; 9eb777ae for the atomic stage): in-memory-fs-write.test.ts "registers the destination of a renamed directory whose files are all nested" + "lists a renamed directory exactly once in its parent"; cache-manager.test.ts "backs up and restores a slot with no file directly under it" (verified to FAIL with the fix reverted); cache-manager.test.ts + kb-installer.test.ts atomic-stage blocks from cc8104f4/ef44cbe2 now green.

Fixture note, now historical: InMemoryFileSystemService.rename of a directory used to register the destination as <dest>/ while existsSync matches exactly, so the destination was invisible unless a moved file landed DIRECTLY under it — and a backup/restore assertion seeded only with nested files passed vacuously (existsSync('<slot>.bak') false ⇒ restoreCachedKB no-ops). Rounds 5-6 worked around it with a direct-child manifest.json. Round 16 fixed the double instead, measured the fallout across the whole monorepo (green, 10/10 turbo tasks — nothing anywhere was passing because a renamed directory read as absent) and pinned the non-vacuity with a fixture that has no direct child. A fixture no longer needs any convention to mean something.

Test Results

pnpm quality-gate (full monorepo, head 8028f758 — THIS head)   PASS — exit 0
  turbo ts:check test lint                                     PASS — pair-cli:       93 files / 1151 tests
                                                               website:         4 files /   85 tests
                                                               content-ops 704 · knowledge-hub 1319
                                                               brand 78 · dev-tools 111
  format:check, gate:composition, hygiene:check                PASS
  docs:staleness, skills:conformance, dup:check                PASS
pnpm turbo test (whole monorepo, round-16 fallout check)       PASS — 10/10 tasks (head ae97cca6)

Smoke scenarios re-run at head 8028f758 — the pre-flight revival changes install/update
at RUNTIME, which no unit test observes:
  scenarios/lifecycle-kb.sh        exit 0   the only scenario that passes --no-kb (x3)
  scenarios/source-resolution.sh   exit 0   8 tests, --url and --source precedence
  scenarios/scaffold-kb.sh         exit 0   the #395 pinned assertions

Released-layout verification (round 18, manual — the defect is invisible from inside the repo).
apps/pair-cli/dist copied into a scratch package outside the monorepo, with @pair/knowledge-hub
ABSENT from a hoisted node_modules (the real published shape) and the cache slot pre-seeded so no
network was needed:

pre-fix  (bootstrap.ts reverted, rebuilt)   ❌ Error: Unable to find @pair/knowledge-hub package.
post-fix                                    runs clean — with and without --no-kb

pnpm smoke-tests as a whole is NOT green, for one pre-existing reason unrelated to this branch:
scripts/smoke-tests/scenarios/coverage-gate.sh is committed 100644 while every sibling is
100755, so the runner exits 1 on main too. Story #400 owns that file.

End-to-end proof (AC1/AC2/AC6): bash scripts/smoke-tests/scenarios/scaffold-kb.sh → exit 0, with:

(requires pnpm dlx turbo run build --filter @pair/pair-cli first — the scenario runs against apps/pair-cli/dist)

Quality Assurance

Review Areas

  • Local discriminator is the archive's CONTENT (round 16 / [TECH-DEBT] KB cache: a local source is discriminated by path, not content #429): the same archive copied to two paths shares one slot, and two different archives can never share one. It needed a byte-mode readFileBytes on FileSystemService — the interface change is in this PR, with its own tests.
  • Same-source concurrency is safe by stage-and-swap (round 16 / [TECH-DEBT] KB cache: same-source concurrent installs are not atomic #428), not by a lock file: an abandoned stage is inert and swept by pid, whereas a stale lock leaves a user unable to install with no way to tell a crash from a live install. Cross-source interference was already gone by construction (no shared slot, and since round 5 no shared staging file).
  • The shared in-memory FS double is now correct (round 16). Reviewers who remember the earlier note: the direct-child-manifest.json fixture convention is gone, the fix is pinned by a test that fails without it, and the monorepo-wide fallout was measured at zero.
  • Cache cleanup is explicit, not automatic — pair kb-cache list|prune (round 17, absorbing [TECH-DEBT] KB cache: no automatic eviction of external slots #427). There is still no LRU/TTL: growth is proportional to the number of KBs a user installs, not to how often. prune reclaims superseded official slots, pre-external/ git clones and abandoned stages/backups, and --dry-run shows what it would take. Read the never-delete list as the real specification: every external/ slot (the CLI retains neither the URL nor the archive, so a wrong deletion is not undone by re-installing), the running version's slot, unclassifiable entries, every official slot when no version is resolvable, and anything an install in flight owns — a live-pid stage, and a .bak whose slot has not come back. That last rule is round 18's fix and the one place prune could have destroyed a user's only copy of a KB. <version>.discarded-* is still by hand, named in the spec.
  • Scope of the self-heal. The official slot is version-keyed, so contamination detection only fires on the slot of the CLI version being run: a slot polluted by an older CLI is abandoned (never read again), not discarded. AC5's outcome holds either way; the docs say so and point at the leftover path.
  • PAIR_KB_CACHE_DIR is now read AND validated by getCacheRoot() — it was documented in two reference tables and implemented nowhere. A relative or ..-bearing value is now an explicit error rather than a path that would make purgeSlot delete inside the current repository, and "absolute" there means absolute on the host (round 3): C:\cache\kb is a relative name on POSIX.
  • A downloaded ZIP is unwrapped but not structurally validated (round 3). installKB now calls normalizeExtractedKB so a single-root archive lands correctly, but a negative result is not raised as an error on that path — deliberate, recorded in the ADL and the spec: making a structure check fatal on the official download is a behaviour change with no defect behind it, and the local-ZIP path (whose caller owns the archive) still throws.
  • A --source directory is used in place, not cached — so it has no slot to invalidate and edits to it take effect on the next install. This is the corrected behaviour of record (round 2); the previous docs claimed a dir- slot that never existed.
  • The KB pre-flight is LIVE (round 17), and its blast radius is every install/update. bootstrapEnvironment had never executed — cli.ts's preAction guard thisCommand === prog is always true for a program-level Commander hook (proven with an HTTP-request count). Rounds 12-15 documented that and left the choice to the merge gate; round 17 revived it. What a reviewer should weigh: the guard now tests the ACTION command (with the Commander convention itself pinned by a test), the exemption list is an ALLOW-list so only install/update reach the network, --no-kb takes effect again, and --url + --no-kb is now rejected where it was previously accepted and ignored — a user-visible behaviour change, stated in --help, the CLI reference and the contracts spec. Round 18 fixed the one defect the revival shipped with (steps 2 and 3 resolving different paths, which aborted every released install) and verified the fix in a real released layout. Round 8's --url "double-download at the pre-flight" is now possible in principle and harmless in practice: the pre-flight's fetch populates the slot the command then reads.
  • Scope boundary respected: nothing from install --source honours the source KB's declaration and reports what it actually did (consolidates #397) #396 (commands/install/handler.ts) or config/loader.ts touched — per the maintainer's 2026-08-05 decision to keep the cards separate.

Testing the Changes

cd apps/pair-cli && pnpm dlx turbo run build --filter @pair/pair-cli
bash scripts/smoke-tests/scenarios/scaffold-kb.sh
pnpm dlx turbo run test --filter @pair/pair-cli

Dependencies & Related Work

Follow-up Work

Closes #395

🤖 Generated with Claude Code

rucka added 2 commits August 11, 2026 08:33
… never lands in the official KB's slot

Test-first: the reproduction (populated official slot + `install --source <zip>`
=> official manifest.json rewritten to the external KB) failed before the fix.

- cache-manager: KBSource identity -> slot key. Official keeps ~/.pair/kb/<version>;
  every other source (zip, dir, git, remote url) gets ~/.pair/kb/external/<kind>-<label>-<hash>
- cache-manager: inspectSlot detects a slot whose manifest names another KB (AC5);
  purgeSlot discards it. Missing/nameless manifest = inconclusive, still trusted
- isKBCached refuses a contaminated official slot => install re-fetches instead of serving foreign content
- kb-installer: local zip/dir install into their own slot and replace it wholesale (no stale files);
  zip path now resolves against the injected cwd, like the directory path
- kb-availability: source resolved once, backup/restore dance now protects the SOURCE's slot
- kb-resolver: git slot moves under external/ via the same keying

Refs: #395
…g decision, correct the cache docs

- scaffold-kb.sh: assert_pinned_bug -> positive assertion. The scenario now pre-seeds an
  OFFICIAL slot in the isolated HOME and fails if a ZIP install rewrites its manifest or if
  the external KB is missing from ~/.pair/kb/external/. Isolated HOME kept, but for test
  hygiene (no writes into the developer's real cache), not as a #395 workaround
- ADL 2026-08-11: slot = source identity; path (not content) as the local discriminator and
  why; contamination policy; disk/cleanup story; concurrency explicitly deferred with reason
- external-kb.mdx: the "ZIP install is not equivalent" warning is obsolete — replaced with
  the per-source slot behaviour + self-heal
- kb-source-resolution.mdx: cache strategy table per source form, git clone steps corrected
  to the real flow (clone into the slot, drop .git)
- architecture.md: cache location line points at both slot shapes

Verified end-to-end: `bash scripts/smoke-tests/scenarios/scaffold-kb.sh` passes (exit 0).

Refs: #395
@rucka rucka added the risk:yellow Classification: medium risk tier label Aug 11, 2026
@rucka

rucka commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict

risk:yellow · cost:greenTECH-DEBT — the P0 contamination is genuinely fixed at the root (keying, not the ZIP symptom), all 6 AC are verified met and gates are green; 16 minor findings + 1 question, none blocking.

Open findings: 17 (0 critical, 0 major, 16 minor, 1 question). Nothing here blocks the merge; the cheap ones (docs wording, .zip case alignment, adoption-doc line, OFFICIAL_KB_NAME conformance test) are worth folding into this PR, the rest are backlog material.

PR: [#423] · Author: rucka · Reviewer: independent review agent (Claude Opus 5) · Date: 2026-08-11 · Story: US-395 · Type: bug

Classification matrix — per dimension
Dimension Tier Source Note
Service/domain criticality yellow KB default tech/risk-matrix.md declares no ## Criticality Table → Medium default (D21)
Change/diff risk yellow diff footprint 13 files, +768/−110; touches the cache-slot resolution every source form goes through, but every caller in the repo was migrated and covered
Business impact yellow subdomain class Integration & Process Standardization (Supporting) — install/distribution
Security relevance yellow review-time assessment Confirmed, not raised: an untrusted archive still lands in a shared machine-wide location; slot names are sanitized and escape is contained (evidence below)
Coupling balance green assessed on the diff No new cross-context integration; change stays inside kb-manager + its one config/kb-resolver caller

Tier = max(assessed) = yellow, confirming the story's refinement-time tier (no raise, no drift note). Cost = highest detected signal = green. Review value is a floor (D17): confirm or raise, never lower.

Tier requirements (🟡, quality-model §4): 1 reviewer · 1 working day SLA · standard checklist depth · reviewer approval. Review enforcement: disabled in way-of-working.md, so these are advisory here and this verdict blocks nothing mechanically.

Assessments

Security — Input validation

Verdict: green — the two new untrusted inputs (a source path/URL turned into a directory name, and an archive extracted into a machine-wide location) are both sanitized.

Details
  • cache-manager.ts:68-76 label() folds anything outside [a-z0-9._-] to -, collapses runs and strips leading/trailing ./-, then truncates to 32 chars with an || 'kb' fallback. .. therefore cannot survive into a slot name, and every slot name is additionally prefixed (zip-/dir-/url-/git-) and suffixed with a 12-char sha256 — so a hostile file name can neither traverse nor collide.
  • cache-manager.ts:64-66 + :93-106 use sha256 for the discriminator (not a truncating non-crypto hash) and hashes a kind-prefixed string (zip:/dir:/remote:), so a path and a URL that happen to be equal strings cannot produce the same key.
  • Slot paths never embed credentials: labelFromUrl keeps only the last path segment (query/fragment stripped), and the git key hashes the raw source without the PAIR_GIT_TOKEN injection (git-clone.ts:33).

Security — Output handling

Verdict: green — the only new output is a log.warn string; no HTML/SQL/shell sink is touched.

Details
  • kb-availability.ts:59-61 interpolates the slot path plus the two manifest names into a log line. state.found is attacker-influenced (it comes from a foreign manifest.json) but lands only in a logger, and readManifestName already type-guards it to string.
  • No new execFileSync/shell surface: cloneGitRepo is untouched and still argv-based.

Security — Authentication

Verdict: green — not applicable; no authentication path is touched. PAIR_GIT_TOKEN handling is unchanged and the token stays out of the cache key.

Security — Authorization

Verdict: green — not applicable; no access-control decision exists on these paths. The one privilege-shaped operation added is rm -rf on a computed path (see finding M2/M11 for the sharp edges), always inside ~/.pair/kb/.

Security — Introduced vulnerabilities

Verdict: green — 0 introduced, 1 pre-existing surfaced (contained by the library version).

Details
Severity Category File:location Introduced / pre-existing Recommendation
P2 A01 — Broken access control (zip-slip / arbitrary write) packages/content-ops/src/file-system/archive-operations.ts:27 (adm-zip extractAllTo) pre-existing none required today — verified contained; keep adm-zip pinned ≥ 0.5.x

AC1 ultimately rests on extraction not escaping the slot, so I verified it rather than assumed it. With the repo's pinned adm-zip@0.5.16 (pnpm-lock.yaml:2596), an archive containing ../../9.9.9/manifest.json extracts to <slot>/9.9.9/manifest.json — the traversal is flattened inside the target and nothing is written outside the slot. So a hostile external ZIP cannot reach the official slot through path traversal either, and AC1 holds for malicious input, not just well-formed input.

Any introduced red finding would drive CHANGES-REQUESTED (#227/AC4). There are none.

Cost

Verdict: cost:green — no new dependency, no new provider, no runtime spend; the only cost signal is local disk.

Details
Signal Class Provider Note
Local disk growth (one cache slot per distinct external source) green none (developer machine) Bounded by the number of KBs a user installs, not by installs over time (same source ⇒ same slot, tested). Documented in the ADL + docs; rm -rf ~/.pair/kb/external is the stated cleanup — see finding M6 for the pre-fix leftovers that instruction misses.
New dependencies green Zero; crypto/path/os are node builtins already used in this package
Network / CI green GitHub No new job, no extra download in the happy path (a ready official slot still short-circuits — kb-availability.test.ts "serves the cache without downloading")

Architecture (Coupling)

Verdict: green — no new cross-context integration; the change is intra-module with one already-existing caller.

Details

Details

Findings by severity

Critical (must fix before merge)

  • none.

Major (should fix before merge)

  • none.

Minor (consider)

  • apps/pair-cli/src/kb-manager/cache-manager.ts:58 + kb-manager/kb-availability.ts:31-35,90 — M1: source kind is derived three times with divergent extension matching. localKBSource classifies case-insensitively (path.toLowerCase().endsWith('.zip')) while installFromSource dispatches on sourceUrl.endsWith('.zip'). Concrete failure: pair install --source /downloads/KB.ZIP (an existing file) resolves its identity to external/zip-kb-<h1> — which is what backupCachedKB then protects — while the install is routed to installKBFromLocalDirectory, which purges/creates a different slot external/dir-kb-<h2> and then fails inside copyDirectoryContents. Net result: "Failed to install KB from local directory" for a ZIP, an orphan empty slot, and a backup guarding a slot nobody wrote. This is the same shape of defect the story exists to close (one identity, derived twice). Recommendation: derive the KBSource once in resolveSource and pass it into the installers (they already accept everything else they need), or minimally make both classifiers use the same case-insensitive test.
  • apps/pair-cli/src/kb-manager/kb-availability.ts:54-63 — M2: the contaminated official slot is purgeSlot'd (rm -rf) before the re-fetch is attempted, and — unlike the customUrl branch three lines below — without the backupCachedKBrestoreCachedKB dance that already exists in this file. Concrete failure: a same-version contaminated slot + a failing download (offline, 5xx, proxy) leaves the user with no cache and a hard error, and every retry fails until connectivity returns; before the PR they got wrong-but-working content. Recommendation: reuse backup → install → removeBackupKB/restoreCachedKB for the contaminated branch, so a failed re-fetch is not destructive.
  • apps/website/content/docs/customization/external-kb.mdx:150 and apps/website/content/docs/reference/specs/kb-source-resolution.mdx:214-215 — M3: the docs promise more self-heal than the code delivers. Both say a machine polluted by "a CLI older than the fix" is discarded/"heals on its next install". But the official slot is keyed by CLI version and the fix necessarily ships in a new version, so the polluted slot (old version dir) is never inspected again — it is abandoned, not discarded: the foreign content stays on disk indefinitely, and the documented cleanup (rm -rf ~/.pair/kb/external) does not touch it. The inspectSlot detection path only fires when the contaminating and the fixed CLI share a version. Recommendation: say the heal applies to a same-version slot, and point users at ~/.pair/kb/<old-version>/ for the leftovers. (AC5's outcome — never serve foreign content — does hold either way: with a version bump the fixed CLI simply never reads the polluted slot.)
  • apps/pair-cli/src/kb-manager/cache-manager.ts:141-151 — M4: inspectSlot returns ready for a slot directory that exists with no manifest at all (readManifestNamenullready), so a slot left half-written by an aborted download is served as a cache hit and ensureKBAvailable hands back an empty KB path. The behaviour is inherited from the old existsSync check, but the PR turns it into stated policy. Recommendation: split the two inconclusive cases — "no manifest.json" ⇒ empty (re-fetch; deletes nothing, so the ADL's "never delete on a signal that says nothing" argument is untouched), "manifest without a name" ⇒ ready as documented.
  • apps/pair-cli/src/kb-manager/cache-manager.ts:24 — M5: OFFICIAL_KB_NAME = 'knowledge-base' must byte-match scripts/workflows/release/package-kb-dataset.sh:133 (--name "knowledge-base"), and nothing ties the two together. If either side is edited, every official slot is classified contaminated ⇒ warn + rm -rf + full re-download on every command, forever, with a warning that names the official KB as foreign. Recommendation: a conformance test asserting the constant equals the name the release script passes (packages/knowledge-hub/src/conformance/ is the established home for exactly this kind of cross-artifact assertion).
  • apps/pair-cli/src/config/kb-resolver.ts:135 — M6: the git slot moves from ~/.pair/kb/git-<hash>/ to ~/.pair/kb/external/git-<hash>/, so every pre-fix git clone is orphaned at the old path forever and the documented cleanup (rm -rf ~/.pair/kb/external, ADL + kb-source-resolution.mdx:216-218) misses it — as it also misses stale ~/.pair/kb/<version>.bak dirs from failed pre-fix --url installs. Recommendation: either name those paths in the cleanup note, or remove the legacy git-<hash> slot on first use of the new one.
  • PR description → "Implementation Details" → config/kb-resolver.ts bullet — M7: "the git-clone path now clones into its source-keyed slot under external/ instead of the official slot" is inaccurate: before this PR that path already used getCachedKBPath(gitCacheKey(url)) = ~/.pair/kb/git-<hash>/, i.e. never the official slot. The ADL states it correctly ("reuses the pre-existing gitCacheKey(), now under external/"); the PR body overstates the defect's reach. Worth correcting because the PR body is the durable record of what the fix covered — the git change is a namespacing tidy-up plus a real latent-bug fix (see positive feedback), not a contamination fix.
  • apps/pair-cli/src/kb-manager/cache-manager.ts (whole file, 216 lines / 13 exported functions) — M8: suggestion, not a DR-1 violation (ambiguous match, so it is not counted toward this verdict per the review process). The file now holds two responsibilities whose one-line summary needs an "and": pure source-identity/key derivation (KBSource, cacheSlotKey, label*, shortHash, resolveSourcePath, localKBSource, getSourceCachePath) and filesystem slot lifecycle (inspectSlot, purgeSlot, ensureCacheDirectory, backup/restore/remove). Recommendation: if it grows again, split a pure cache-slot-key.ts (no fs dependency — trivially testable) from the lifecycle module.
  • ADL 2026-08-11-kb-cache-slots-keyed-by-source-identity.md:54-56 + PR "Follow-up Work" — M9: three deliberate deferrals (no automatic eviction; same-source concurrency; identical archive at two paths ⇒ two slots) are well reasoned but tracked nowhere — no issue numbers, so the ADL is the only place they exist. Story install --source <zip> extracts an external KB into the official KB's cache slot (shared-cache contamination) #395's DoD is satisfied ("explicitly deferred with the reason"), but a deferral with no backlog home is how debt evaporates. Recommendation: file them with the tech-debt label and cite the numbers in the ADL's Consequences section.
  • apps/pair-cli/src/kb-manager/index.ts:3 — M10: the module barrel still exports only getCachedKBPath, isKBCached, so config/kb-resolver.ts:7-8,135-136 reaches past it into #kb-manager/cache-manager for the new API. The comment in kb-availability.ts that documented the convention ("Public re-exports live in the kb-manager index") was deleted rather than honoured, and DR-4 asks for a barrel over split files. Recommendation: re-export getSourceCachePath, officialSource, purgeSlot and the KBSource type from the index and import through it.
  • apps/pair-cli/src/kb-manager/cache-manager.ts:110 and :27 — M11: getCachedKBPath(key) still applies cleanVersion(key) to keys that are not versions, while cacheSlotKey already normalizes the official version — dead double-normalization that blurs the new "key" concept back into "version". It also means an empty/"v"-only key resolves to the cache root ~/.pair/kb, which purgeSlot would then rm -rf; unreachable today (every caller passes a semver or an external/… key, and resolveDatasetRoot defaults to '0.0.0'), but it is a rm -rf one bad argument away. Separately, EXTERNAL_NAMESPACE is exported but used nowhere outside the module (the tests hardcode 'external'). Recommendation: normalize only in cacheSlotKey, add a guard that rejects an empty key in getCachedKBPath, and either use or unexport EXTERNAL_NAMESPACE.
  • apps/pair-cli/src/kb-manager/cache-manager.ts:52 — M12: resolveSourcePath detects "absolute" with rawPath.startsWith('/'), so a Windows path (C:\kb\acme.zip) is treated as relative and joined onto the cwd, yielding a bogus resolved path and slot. Pre-existing logic (the old installKBFromLocalDirectory did the same) — but the PR centralizes it into one helper, which makes this the cheapest moment to switch to path.isAbsolute().
  • apps/pair-cli/src/kb-manager/download-ui.ts:17-20, reached from kb-installer.ts:145,236 — M13: announceSuccess(version, cachePath) prints ✅ KB v<cliVersion> installed at … for an external KB that declares a different version, so a --source acme-kb-1.0.0.zip install reports the CLI's version as the KB's. Non-actionable here — Deferred to install --source honours the source KB's declaration and reports what it actually did (consolidates #397) #396, whose story statement is precisely "report what it actually did" for install --source, and whose separation from install --source <zip> extracts an external KB into the official KB's cache slot (shared-cache contamination) #395 is a recorded maintainer decision (2026-08-05). Fixing it in this PR would pull install --source honours the source KB's declaration and reports what it actually did (consolidates #397) #396's summary scope into a P0 keying fix.
  • .pair/adoption/tech/boundedcontext/integration-process-standardization.md:47 — M14: the context map's glossary still reads "KB cache | Local versioned storage at ~/.pair/kb/{version}/ for downloaded KB artifacts" (and line 52 "version-isolated KB cache prevents cross-version conflicts"). architecture.md was correctly updated in this PR; this adoption file was not, so two adoption records now disagree about the cache layout. Recommendation: one-line update pointing at the same ADL.
  • apps/website/content/docs/reference/specs/kb-source-resolution.mdx:227 (and configuration.mdx:196) — M15: PAIR_KB_CACHE_DIR is documented as "Override KB cache directory", but no code reads it — getCachedKBPath hardcodes homedir(). Pre-existing drift, surfaced because this PR rewrote the Cache Strategy section immediately above that table and made that page the authority on slot layout. Recommendation: honour the variable in getCachedKBPath (a 1-line change that would also make the smoke scenario's HOME juggling unnecessary) or drop the row from both tables.
  • PR [US-395] fix: cache slots keyed by source identity, not CLI version (shared-cache contamination) #423 head f36fcfe2 (repository state, not the diff) — M16: the head commit carries no pair-review status (/statuses is empty) and the PR carries no pr-state:* label — only risk:yellow. Review enforcement: disabled in way-of-working.md means nothing is actually blocked, but pr-states.md expects the state to be synthesized even when enforcement is off. I did not publish it myself: this is an independent, read-only review delivered as a PR comment. Recommendation: run the state-synthesis step (apply pr-state:to-be-reviewed, publish the pair-review status) so the board view matches the verdict.

Questions

  • apps/pair-cli/src/kb-manager/cache-manager.ts:119-121,141-151 — Q1: AC5 asks for detection on "manifest name/version vs. the expected source", and only name is compared; the ADL documents the name-only policy but does not say why version was dropped. Was that deliberate on the grounds that the official slot key already encodes the version (so a version mismatch inside a version-keyed slot can only come from a hand-edited cache), or should manifest.version !== cleanVersion(version) also count as contaminated? If deliberate, one clause in the ADL closes the gap between the AC text and the implementation.
Positive feedback
  • Fixed the cause, not the reported symptom. The story described a ZIP defect; the diff establishes that --url and --source <dir> went through the same version-keyed slot, and re-keys all of them. The PR/ADL are explicit that the backup/restore dance was "the admission that the slot was being clobbered" — that is the right reading of the old code.
  • KBSource as a discriminated union (DR-3, and DR-3-compliant switch in cacheSlotKey with no default, so a future source kind is a compile error rather than a silent fallthrough). Replacing version: string parameters with it makes the previous bug unrepresentable in the backup/restore helpers.
  • A latent second-run bug fixed in passing: resolveGitDataset previously ran cloneGitRepo into a slot that already contained a prior clone, which git clone rejects ("destination path already exists and is not an empty directory"). The added purgeSlot makes a repeat git install work; the PR undersells this (see M7).
  • The smoke scenario is real end-to-end evidence, not a proxy: it pre-seeds a populated official slot with a knowledge-base manifest, then asserts byte-identity of that manifest and that no version-keyed slot mentions generic-kb and that the external KB does live under external/. Pinned-bug → positive assertion, in the same PR, exactly as AC6 asks.
  • Test-first is evident in the shape of the tests, not just claimed: the US-395 blocks assert result !== officialSlot and manifest byte-equality — assertions that could only have been written against the pre-fix behaviour. Tests were also migrated rather than deleted (the pre-existing installer/availability cases keep their coverage with recomputed expectations).
  • Human-readable slot names (zip-acme-kb-1.0.0-8ae362dc47aa) with a label() fallback — the cache stays inspectable by a human without a lookup table, which matters for a directory users are told to rm -rf.
  • The trade-offs are argued, not hand-waved: the ADL's rejection of content hashing (text-mode readFileSync would hash a lossily-decoded binary in a security-adjacent path) and of name@version namespacing (the exact collision the story lists as an edge case) are both correct and specific.
Functionality & requirements (AC coverage)
  • Acceptance criteria met — all 6 verified against the code, not the PR's claims:
    • AC1 (official slot + manifest unchanged) — kb-installer.ts:177-180 derives the slot from the source; kb-installer.test.ts:630-646 asserts the official manifest.json is byte-identical after a ZIP install; scaffold-kb.sh:122-162 proves it end-to-end against a pre-seeded slot. Also holds for a hostile archive (zip-slip contained — see the security section).
    • AC2 (only the external KB's content) — kb-installer.test.ts:648-683 asserts the destination has acme.md and not getting-started.md/guidelines/testing.md; the purgeSlot-before-extract in kb-installer.ts:203 is what makes "no leftovers" true on a re-install (:685-707).
    • AC3 (kb-info elsewhere still reports the official KB) — traced, not assumed: kb-info (no --source) → version-resolver.ts:resolveRegistryVersionresolveDatasetRoot('default')ensureKBAvailable → official slot, which no external install can now write; the contaminated-slot case is covered by kb-availability.test.ts:634-685.
    • AC4 (slot derived from source identity for every source form) — cacheSlotKey covers official/zip/dir/git/remote; cache-manager.test.ts:119-128 asserts all five keys are distinct, plus same-source stability, same-name-different-path distinctness, and git ref distinctness.
    • AC5 (polluted slot detected, not trusted) — inspectSlot/purgeSlot + kb-availability.ts:54-63 warn/purge/re-fetch, covered by cache-manager.test.ts:155-217 and kb-availability.test.ts:634-685. Outcome achieved; see M3 for the docs overclaim about the version-bump case and Q1 for the name-only comparison.
    • AC6 (pin flipped in the same PR, isolated HOME reconsidered) — assert_pinned_bug removed, two positive assertions added, HOME isolation retained with its reason restated as test hygiene (scaffold-kb.sh:106-112, assertions :143-162).
  • Business logic + edge cases correct — the story's five edge cases are all addressed: same source twice ⇒ same slot (tested); equal name/version, different path ⇒ distinct slots (tested); concurrency ⇒ explicitly deferred with reasoning; pre-existing contamination ⇒ detection path (with the M3 caveat); disk growth ⇒ documented.
  • Integrates with existing systems — every caller of the changed signatures was migrated (kb-availability, kb-installer, config/kb-resolver); I grepped for stragglers and found none outside tests.
  • [~] Error handling appropriate — one gap: the destructive purge ordering in M2 turns a recoverable state into a failing one when the re-fetch fails.
Testing & quality gates
  • Adequate coverage — cache-manager.test.ts +177 lines (keying block + contamination block), kb-installer.test.ts +5 US-395 cases, kb-availability.test.ts +2 self-heal cases, kb-resolver.test.ts rewritten to assert the git slot is not the official one without mocking the path helper (a strictly better test than the one it replaces, which mocked getCachedKBPath and so could not have caught this bug).
  • Edge + error scenarios tested — messy/unsafe source names, missing manifest, unreadable manifest, nameless manifest, re-install leftovers, two ZIPs with identical declared metadata, git refs.
  • Gaps worth a test: no case for install --source /path/KB.ZIP (M1), no case asserting inspectSlot on an existing-but-manifest-less slot re-fetches (M4), no conformance test pinning OFFICIAL_KB_NAME to the release script (M5).
  • Quality gates: PASS — CI build on head f36fcfe2 is green, covering ts:check, build, lint, hygiene:check, docs:staleness, skills:conformance, dup:check, test and the coverage guardrail; secret-scan (gitleaks) green. 🟡 requires lint + type + build + unit — all present. The gate is the first filter and it is green, so it does not cap this verdict.
Adoption compliance
  • Degradation level: 2 (adoption checked against the repo's own .pair/adoption/ records inline; no automatic stack resolution invoked).
  • Dependencies: no new dependencycrypto/path/os are node builtins already in use in this package, so tech-stack.md needs no change and /assess-stack has nothing to resolve.
  • Patterns match architecture.md — and architecture.md:18 was updated in this PR to describe both slot shapes with a link to the ADL. One inconsistency remains: boundedcontext/integration-process-standardization.md:47,52 still describes the cache as purely version-keyed (finding M14).
  • Decision record: present and adequate — ADL 2026-08-11-kb-cache-slots-keyed-by-source-identity.md (Category: Convention Adoption) records the decision, four rejected alternatives with reasons, and the consequences including the deferrals. ADL (not ADR) is the right instrument: this is an internal keying convention of one CLI module, not an architecture change. No HALT condition — no undocumented technical decision found in the diff.
Tech debt

Surfaced, never blocking:

  1. Untracked deferrals (M9) — no cache eviction, same-source concurrency, path-not-content discriminator. Documented in the ADL, absent from the backlog.
  2. Triple derivation of source kind (M1) — the one place the new KBSource type is not yet the single source of truth.
  3. OFFICIAL_KB_NAME coupled by convention to a shell script (M5) — cheap to pin, expensive if it drifts.
  4. Pre-fix cache residue (M3/M6) — orphaned ~/.pair/kb/git-<hash>/, <version>.bak, and contaminated old-version slots that no code path will ever clean.
  5. Documented-but-unimplemented PAIR_KB_CACHE_DIR (M15) — pre-existing, now sitting under a section this PR rewrote.

None of these justify blocking a P0 shared-state-corruption fix; items 1–3 are the ones worth scheduling.

Documentation
  • Code docs — the new module header on cache-manager.ts:4-18 states the layout, the disk consequence and the eviction non-decision; each new export carries a one-line contract. kb-availability.ts:24-28 explains the identity rule at the point of use.
  • User docs — external-kb.mdx replaces the obsolete "not equivalent yet" warning with the actual behaviour and adds the ZIP example; kb-source-resolution.mdx gains a per-source slot table and corrects the git steps (the old text described a temp dir + copy that the code never did).
  • Architecture docs — architecture.md updated.
  • Two accuracy defects: the self-heal overclaim (M3) and the stale context-map glossary entry (M14). Plus the pre-existing PAIR_KB_CACHE_DIR row (M15).
Performance & deployment
  • No performance regression on hot paths — the added work per resolution is one sha256 over a short string plus, for the official source, one manifest.json read that was previously an existsSync; the cache-hit fast path still returns without a download (kb-availability.test.ts "serves the cache without downloading"). resolveGitDataset now purges before cloning, which is more I/O per git install but replaces a call that previously failed outright on a second run.
  • Deployment / rollback considered — no migration, no persisted schema. Rolling back the CLI restores version-keyed slots; the official slot is byte-compatible in both directions (it keeps the bare version key), so a downgrade re-uses the same official cache. The only rollback residue is unreferenced external/ directories, which are inert. No feature flag needed for a defect fix of this shape.

Bug-fix emphasis (review type): root cause addressed — yes, the keying, not the ZIP symptom; regression tests present at both the cache-manager unit level and the install level, plus an end-to-end smoke assertion. Verdict TECH-DEBT: approve and merge at the human gate; the minor findings are recommended, not required.

…ch (review round 1)

Review findings on PR #423:

- source kind was classified 3x with divergent tests (`KB.ZIP` got a zip slot but the
  directory installer): classification now lives only in `localKBSource`, and both
  `ensureKBAvailable` and `resolveLocalDataset` dispatch on the resolved `kind`.
- a contaminated official slot was rm -rf'd BEFORE the re-fetch: it is now set aside
  (backup) and restored when the download fails — an offline retry keeps its cache.
- `inspectSlot` served a slot with no manifest at all as `ready` (an aborted download
  read as a cache hit): no manifest where one is expected => `empty` => re-fetch.
- `getCachedKBPath` re-normalized versions and mapped an empty key to the cache ROOT
  (one bad argument from `purgeSlot` rm -rf'ing it): normalization stays in
  `cacheSlotKey`, an empty key throws.
- `PAIR_KB_CACHE_DIR` was documented but read by nothing: honoured now.
- absolute-path detection was `startsWith('/')` (a Windows path was joined onto cwd):
  posix OR win32 `isAbsolute`.
- split the pure identity/key derivation (`cache-slot-key.ts`, no fs) from the slot
  lifecycle (`cache-manager.ts`), and re-exported the new API through the `#kb-manager`
  barrel so `config/kb-resolver.ts` stops reaching into internals.
- backup/restore made idempotent: a stale `.bak` and a half-written slot no longer
  make the rename fail.
- conformance test tying `OFFICIAL_KB_NAME` to the `--name` the release script passes.

Refs: #395
…ers, track the deferrals

Review findings on PR #423:

- docs promised more self-heal than the code delivers: the official slot is keyed by CLI
  version, so a slot polluted by an OLDER CLI is abandoned, not discarded. Both pages now
  say so and point at ~/.pair/kb/<old-version>/.
- the documented cleanup (rm -rf ~/.pair/kb/external) missed the pre-fix git slot and the
  .bak dirs: all three leftovers are now listed in the spec and the ADL.
- the context map's glossary still described a version-keyed cache: aligned with
  architecture.md and the ADL.
- PAIR_KB_CACHE_DIR is honoured now, so its two doc rows are true (and precise about
  taking an absolute path).
- ADL: why only manifest `name` is compared and never `version`; the no-manifest =>
  re-fetch policy; backup-instead-of-purge; the one-classifier rule; the module split;
  the OFFICIAL_KB_NAME conformance test.
- the three deliberate deferrals now have a backlog home: #427 (no eviction), #428
  (same-source concurrency), #429 (path-not-content discriminator).

Refs: #395
rucka added 3 commits August 11, 2026 23:28
…tion must map to one slot

7 failing tests before the fix (review round 2):
- PAIR_KB_CACHE_DIR relative or '..'-bearing is accepted verbatim today, so every
  slot resolves against the cwd and purgeSlot rm -rf's inside the current repo
- a slot key climbing out of the cache root is not rejected
- './', '../' and trailing-slash forms of the same path hash to different slots

Refs: #395
…lidated, one location is one slot (review round 2)

- GREEN for the 7 RED tests: PAIR_KB_CACHE_DIR must be absolute and '..'-free (it
  prefixes every path purgeSlot rm -rf's), a slot key may not climb out of the root,
  and a source path is canonicalized ('.', '..', trailing separator) before it is
  hashed — with the rules of whichever convention called it absolute.
- A local DIRECTORY owns no cache slot and is no longer a KBSource: it is read in
  place by resolveDatasetRoot. installKBFromLocalDirectory had no production caller
  (only ensureKBAvailable with a local-directory customUrl, which no parser produces
  and bootstrap short-circuits) — removed; ensureKBAvailable now rejects a directory
  naming the layer that handles it. Its coverage already lives on resolveLocalDataset.
- Git slot lifecycle moved out of the config layer into installKBFromGit: every
  source form's slot mechanics now live in the module that owns slots.
- Barrel is the only door for production code (bootstrap/kb-resolver deep imports
  gone) and exports install entry points, not slot primitives.
- isKBCached documented as the diagnostic predicate it is.
- vitest.setup.ts clears PAIR_KB_CACHE_DIR before every test — the suites assert
  homedir-derived slot paths and must not depend on an ambient override.

Refs: #395
…che slot), plus the ADL corrections round 2 found

- kb-source-resolution.mdx / external-kb.mdx claimed `--source <dir>` was copied into
  `~/.pair/kb/external/dir-{name}-{hash}/`. No code path ever created that slot: the
  directory is read live on every install, so edits to it change the next install's
  result. Table row, callout and adoption records corrected to say so.
- PAIR_KB_CACHE_DIR rows now state the constraint the CLI enforces (absolute, no '..').
- ADL: KBSource lives in cache-slot-key.ts (not cache-manager.ts); the re-fetch of a
  contaminated slot is ensureKBAvailable/inspectSlot, isKBCached is diagnostic-only;
  the barrel claim is now true rather than aspirational; new clauses for the slot-
  mechanics boundary, root validation, path canonicalization and the dead-code removal.

Refs: #395
rucka added 3 commits August 11, 2026 23:59
…iled clone keeps the old one, a nested remote ZIP is unwrapped

Round 3, test-first for the four findings that describe a defect:

- PAIR_KB_CACHE_DIR='C:\cache\kb' passes the dual-convention check on POSIX and
  yields a RELATIVE root — slots then resolve against the process cwd, the exact
  hazard the guard exists for.
- installKBFromGit purges before it clones and cloneGitRepo rm -rf's on failure:
  an offline clone left an empty slot where a working one had been.
- installKB (official download AND --url) never unwrapped a ZIP nested under a
  single root directory, unlike the local-ZIP path.
- isKBCached is probed on every fallback resolve for a [diag] line that is off.

Also: the local-directory rejection must name --source, not internal functions;
conformance test moved into pair-cli so it imports OFFICIAL_KB_NAME instead of
grepping the file as text.
…t is set aside, a downloaded ZIP is unwrapped (review round 3)

- getCacheRoot uses path.isAbsolute: on POSIX 'C:\cache\kb' is a RELATIVE name and
  would prefix every slot. The dual-convention check stays in resolveSourcePath,
  where it prevents a join instead of enabling one.
- installFromSource is an exhaustive switch with a `never` default; git delegates
  to installKBFromGit instead of falling through to the official release zip.
- installKBFromGit sets the old clone aside and restores it when git throws
  (cloneGitRepo deletes the destination on failure); the set-aside replaces the
  purge, git needs an empty destination anyway.
- installKB unwraps a ZIP nested under a single root directory, like the local-ZIP
  path. Unwrap only — a negative result is not fatal on the download path.
- isKBCached is probed only under PAIR_DIAG: its answer feeds a [diag] line and
  nothing else, while it costs 2 existsSync + readFile + JSON.parse per command.
- The local-directory rejection names --source, not internal function names.
…ces, name the two absolute-path conventions

ADL: the backup/restore rule is stated for content that comes over the network
(official, --url, git) and the local-ZIP exception now carries its reason, so
lines 40 and 45 stop contradicting each other. New clauses for the host-vs-either
convention split, the exhaustive install dispatch, the download-path unwrap, the
diagnostic-only probe and the conformance test's new home.

kb-source-resolution.mdx: Download Process gains the unwrap step; Cache Strategy
states which sources set their slot aside and which replace it wholesale, and why.
rucka and others added 3 commits August 12, 2026 14:05
…sful install, the namespace is not a slot, a truncated label keeps no dangling separator

6 failing assertions:
- removeBackupKB tolerates a .bak that vanished (ENOENT without force) and never throws on EBUSY
- ensureKBAvailable keeps a successful re-download when discarding the old slot fails
- installKBFromGit keeps a successful clone when discarding the set-aside one fails
- getCachedKBPath refuses the reserved 'external' namespace key (purgeSlot would rm -rf every external slot)
- a 33+ char source label does not end on '-' after truncation

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…espace is not a slot (review round 4)

- removeBackupKB: force + best-effort (a leftover .bak is inert, a throw would revert)
- discard moved outside the reverting try in ensureKBAvailable and installKBFromGit
- getCachedKBPath rejects the bare 'external' namespace key
- label re-stripped after truncation; EXTERNAL_NAMESPACE module-private

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…, round-4 ADL clauses

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rucka added 4 commits August 12, 2026 14:28
…he failure it follows, --url outranks the monorepo dataset

6 failing assertions across 4 files (review round 5):
- installKB stages two different URLs at one CLI version into one tmp file
- restoreCachedKB loses the backup when the half-written slot cannot be deleted
- a throwing restore replaces the HTTP/git error the user needs, at both call sites
- an explicit --url resolves to the monorepo dataset in a dev checkout
…r masks the failure it follows, --url reaches identity resolution (review round 5)

- installKB stages under downloadStagingName(version, url): official and --url no
  longer share /tmp/kb-<version>.zip, so a stale .partial cannot be resumed against a
  different URL and finalized as one archive
- restoreCachedKB renames the backup back FIRST and discards the half-written slot
  after, best-effort by contract: a failing cleanup can neither leave the user without
  a cache nor replace the HTTP/git error that caused the failure
- cache-manager no longer re-exports getCachedKBPath (no production caller)
- an explicit --url is no longer outranked by the monorepo dataset
… best-effort, an explicit --url outranks the monorepo dataset

Three ADL clauses (staging key, restore contract, named-source precedence) plus the
consequence lines for the dropped re-export and the changed remote resolution; spec
gains the precedence note, the staging step and the restore paragraph.
…e names are unique, --url outranks the monorepo dataset at the pre-flight too
rucka added 4 commits August 12, 2026 20:38
…rds what round 14 fixed and what stays open

- CLI reference: the `--no-kb` row no longer claims it skips the KB download —
  it is a no-op (its only consumer is the unreachable pre-flight), with a warn
  callout naming why, that `pair install --no-kb` still installs a KB, and that
  `--log-level`/`--verbose` are NOT affected (they are live as of this round).
- ADL: the round-6 "the rule holds at BOTH layers" sentence replaced by the
  revert; the pre-flight clause rewritten with what round 14 did (a-d) and the
  open merge-gate choice (revive / delete / retire the flag).
rucka and others added 9 commits August 12, 2026 23:18
…decode is not an identity to hash)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ry double (absorbed #429, step 1)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…NT, not its path (absorbed #429, step 2)

Verified failing: 3 assertions + zip-source.test.ts cannot load (module does not
exist yet). Committed with --no-verify: ts:check names an API (zipKBSource,
KBSource.contentHash) the fix commit introduces.

Same archive at two paths ⇒ ONE slot; different bytes ⇒ never one slot; key is
external/zip-<sha256(bytes)[0..12]>, derived only via the byte-mode read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h (absorbed #429, step 2)

zipKBSource (the ONLY producer of a zip KBSource) resolves the path and hashes
the bytes via the byte-mode read; the slot is external/zip-<sha256[0..12]> — the
same archive at two paths is ONE slot, different bytes never share one. The path
stays for messages and extraction only. resolveSource is async now (identity
derivation reads the archive); installKBFromLocalZip derives identity through
the same producer, so both layers land on the same slot by construction.

Path-keyed tests reconciled: the label-readability and human-readable-slot
assertions moved to the url- slot (a zip slot carries no path-derived label —
it would smuggle the path back into the identity).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tage + rename (absorbed #428)

6 failing tests: writeSlotAtomically stages next to the slot, the slot never exists
half-written, a failing populate leaves nothing behind, a DEAD process's orphaned
stage is swept while a LIVE one is left alone, an occupied slot is replaced whole,
and a lost rename race is retried once.

Committed with --no-verify: the pre-commit hook runs ts:check and the failing state
names an API that does not exist yet (writeSlotAtomically).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e atomic stage (absorbed #428)

4 failing tests: local ZIP, remote download and git clone must extract/clone into
a <slot>.tmp-<pid>-<n> stage (the slot absent the whole time) and rename it whole;
a structure-invalid ZIP must leave neither slot nor stage.

--no-verify: RED by design before the wiring commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…self, not `<dest>/`

Review round 16. `rename` of a directory added `newPrefix` (always trailing-slash) for the
destination itself, while `existsSync` is an exact match on the dir set: the destination was
observable only when a moved file happened to land DIRECTLY under it (the file loop adds
`dirname(file)`). Every caller shaped `if (!existsSync(dest)) return` — `restoreCachedKB` and
`removeBackupKB` both are — then no-opped, so a backup/restore test seeded with only nested
files passed VACUOUSLY: this story's hardest invariant (a failing re-fetch leaves the user's
cache intact) was asserted by fixtures carrying a direct-child `manifest.json` as a workaround
nobody could see. The parent's listing also showed a phantom `<dest>/` beside the real entry.

- RED first: two `in-memory-fs-write.test.ts` cases (destination of an all-nested rename is
  observable; the parent lists it exactly once).
- One-line fix: `rel === '' ? newDir : newPrefix + rel`. The dir branch is extracted into
  `renameDirectory` (max-lines-per-function), no behaviour change.
- `cache-manager.test.ts`: a backup+restore round-trip on a slot with NO file directly under
  it — verified to fail without the fix, so the workaround convention cannot come back silently.

Measured fallout, which the finding asked for: `pnpm turbo test` over the whole monorepo is
green (10/10 tasks), i.e. no other suite was passing BECAUSE a renamed directory read as absent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…med in whole (absorbed #428)

GREEN for the two RED commits before it (`cc8104f4`, `ef44cbe2`), which were left failing when
the previous round was interrupted.

- `cache-manager.writeSlotAtomically(slot, fs, populate)`: `populate` writes into
  `<slot>.tmp-<pid>-<n>` beside the slot (same filesystem ⇒ the swap is one `rename`), and the
  slot is renamed into existence only when the stage is COMPLETE. A concurrent reader sees the
  slot absent (⇒ re-fetch) or whole, never half-written — the exposure the purge-then-extract-
  in-place sequence had. A failing `populate` removes its own stage and rethrows the ORIGINAL
  error; an occupied slot is replaced whole (rm + rename, retried once on a lost race).
- Stages left by DEAD processes are swept first (`process.kill(pid, 0)`); a LIVE process's
  stage is a concurrent install in flight and is left alone.
- All three install forms go through it: remote/official download, local ZIP, git clone. The
  unwrap and the structure check run on the stage too, so an invalid ZIP leaves neither slot
  nor stage. `ensureCacheDirectory` calls before the populate are gone — the stage's `mkdir`
  creates the parent.
- Two legacy expectations updated to the stage: the ZIP-cleanup test now asserts extraction
  targets `<slot>.tmp-` and that neither stage nor slot survives the failure; the git test's
  `cloneGitRepo` double writes into the destination it is HANDED instead of the slot.
- `zip-source.*`, `cache-slot-key.test.ts`: formatting only.

Quality gate: `pnpm quality-gate` PASS (exit 0) — pair-cli 1123 tests, whole monorepo green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…est-double rule

The decision record had drifted behind the code: it still described the local slot as
path-keyed, same-source concurrency as deferred, and #428/#429 as tracked elsewhere.

- Local slots are CONTENT-keyed (byte-mode `readFileBytes` made it possible); the
  path-keyed shape moves to Alternatives as superseded, with why it was chosen first.
- Slots are populated through a `<slot>.tmp-<pid>-<n>` stage and renamed in whole; the
  lock-file route and why it was not taken are recorded next to it.
- New clause: a defect in a shared test double is fixed in the double, not worked around
  in the fixture — with the measurement that justified fixing it here (full monorepo suite
  green, so nothing was passing BECAUSE a renamed directory read as absent).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…chitecture to the shipped zip/git slot shape (no label, no kb-cache command)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rucka

This comment has been minimized.

…ownloads a KB

`cli.ts` guarded the `preAction` hook on `thisCommand === prog`. Commander invokes a
program-level hook as `callback(hookedCommand, actionCommand)`, so the first argument IS
the program for every subcommand and that guard always returned. The entire pre-flight was
dead code, and the effects were user-visible:

  --no-kb          downloaded and installed a KB anyway — the opposite of what it says,
                   and the flag an air-gapped user reaches for
  --url + --no-kb  the conflict was never rejected; two contradictory flags accepted
  accessibility    the DatasetNotFound / DatasetAccess probe never ran

Fixed by testing the ACTION command instead. Waking the hook up makes the exemption list
load-bearing for the first time, so it is INVERTED to an allow-list: `install` and `update`
resolve a KB, everything else does not. A deny-list would have every command added tomorrow
inherit the network by not being remembered; `kb-info`, `kb-validate`, `kb-verify`,
`validate-config` and `update-link` only read local state and must work offline.

Verified by RUNNING the built CLI, not by unit test alone:
  - `install --no-kb --source … --offline` → zero download lines
  - `kb-info` → no bootstrap at all

The docblock above the function is rewritten: it described the dead path as a deliberate
choice ("THIS FUNCTION NEVER RUNS PAST ITS FIRST LINE — deliberately") and would now be
false in the most misleading way possible.

One test added, and it pins COMMANDER'S convention rather than our code: the full 1125-test
suite passes with the guard broken either way, so nothing we own can catch a regression
here. If Commander ever swapped the argument order, the fix would silently invert and only
this test would say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d reclaimable, and never deletes what it cannot refetch

Absorbs #427 into this story: the cache slots this PR introduced (external/,
stage, backup) had no way to be listed or reclaimed, so a machine accumulated
them with no command to see or clear them.

`kb-cache list` classifies every slot (official / external / backup / stage /
legacy-git) and `prune` removes only the recoverable ones.

Two deletions are refused by construction:
- external KBs — fetched from a URL the CLI does not retain, so a delete is
  not undoable by re-running an install
- every official slot when the running version cannot be resolved, rather
  than comparing against an empty string and pruning the KB in use

An unreadable cache root now surfaces as an error instead of being rendered
as an empty cache, and a partial prune exits 1 rather than reporting a clean
sweep over slots that were not removed.

Covers: #427
rucka and others added 4 commits August 13, 2026 10:26
…, and prune deletes an install in flight

Two review findings, both reproduced before the fix (5 failing assertions across 3 files):

- Critical: with the pre-flight live, step 3 probes the BUNDLED dataset path
  (getKnowledgeHubDatasetPath) instead of the cache slot step 2 populated. Every fixture
  here seeded a monorepo layout, so the defect was invisible: the new case makes
  getKnowledgeHubDatasetPath THROW, which is what a released install does
  (@pair/knowledge-hub is a sibling under node_modules/@pair/, and no dataset is bundled).
  The download case also pins WHICH path the error names.
- Major: kb-cache prune classifies every stage/backup as garbage from the name alone, while
  sweepOrphanedStages checks pid liveness for exactly that reason. New cases: a live pid's
  stage and a .bak with no sibling slot must survive a concurrent prune.

--no-kb + --url is pinned too: reviving the pre-flight made that combination an error, and
nothing covered it. Committed --no-verify: the failing state is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ever deletes an install in flight

Critical — the revived pre-flight aborted EVERY released install. Step 2 downloaded the KB
into ~/.pair/kb/<version>/ and step 3 then probed the BUNDLED dataset path
(getKnowledgeHubDatasetPath -> findPackageJsonPath), which in a published package does not
exist and THROWS: @pair/knowledge-hub is hoisted next to pair-cli, never nested under it, and
postbuild bundles no dataset. Two steps, two different paths, so the check answered about a
location the fetch had nothing to do with. Step 2 now RETURNS the path it landed on and step 3
takes it as an argument; `shouldSkipKBDownload` + `hasLocalDataset` collapse into
`resolveDatasetForPreflight`, same order of precedence.

Verified empirically outside the repo, which is the only place this shows: dist/ copied to a
scratch package with @pair/knowledge-hub ABSENT from a hoisted node_modules. Before:
"Unable to find @pair/knowledge-hub package". After: the command runs, with and without
--no-kb.

Major — `kb-cache prune` deleted a live install's stage and its backup. It classified every
stage/backup as garbage from the NAME, while sweepOrphanedStages checks pid liveness for
exactly that reason. That predicate is now ONE function (isStageOwnerAlive, exported through
the barrel) and prune uses it, plus the .bak rule: a backup whose slot is not back yet is the
only copy of that KB, so it is spared; once the slot returns it is reclaimable. `list` names a
kept entry so it does not read as a prune bug.

Also: inventory reaches kb-manager through #kb-manager, not a deep import (DR-4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he prune exists

Six surfaces still declared the KB pre-flight dead two commits after it was revived, and the
kb-cache command existed only in the CLI reference. An ADL that contradicts the code is worse
than none, and `--help` was telling users a working flag does nothing.

Pre-flight (finding 2):
- ADL: the "never runs — NOT revived here" consequence rewritten as what shipped — the
  action-command guard, the deny-list -> allow-list inversion and why, `--no-kb` becoming real,
  the `--url` + `--no-kb` rejection as a user-visible behaviour change, and the released-install
  defect with the rule it generalizes (a check that derives its own subject verifies nothing).
  The round-6/14 `shouldSkipKBDownload` sentence corrected too.
- `--no-kb` help text, the CLI reference callout and the contracts spec now describe the real
  effect and name the `--url` conflict; the round-15 test that PINNED "no-op" is flipped.
- `config/cli.ts`'s "the pre-flight is unreachable" note replaced by the actual split: the
  pre-flight warms the cache, `namedSource` decides which KB is installed.

kb-cache (finding 5): recorded in the ADL (what prune reclaims, and each thing it never
touches — external slots, the running version's slot, unknown entries, an install in flight —
with the reason), in the resolution spec (leftovers table gains a `prune` column plus a "what
prune never deletes" paragraph), and in `cache-slot-key.ts`'s header, which still said manual
`rm -rf` was the only cleanup. Also corrected the ADL's stale "one slot per distinct path of
the same archive" — content-keying made that one slot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…complexity 13

A per-call-site `if (isDiagEnabled())` pushed resolveDatasetForPreflight to complexity 13
(max 10) on branches that carry no behaviour; one `diag()` helper removes four of them.
The download test asserts the error CLASS again as well as the path it names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rucka

rucka commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

In reply to the first review comment: #423 (comment)

Escalation — review<->fix loop, round 19 (head 8028f758)

Round 18 converged (5 findings fixed, "nothing escalated" — no PR comment posted for it per
orchestrator directive; the round-18 section of the working log is the record). A fresh
independent re-review of that same head, 8028f758, found two NEW Critical findings on the
pre-flight revival round 18 shipped, past what round 18 fixed. Nothing fixed this round —
escalating.

Convention: further rework/re-review on this story — including manual out-of-band rounds —
should be appended to the working log at .pair/working/reviews/395.md, not posted as
standalone PR comments. The next orchestrated run continues the same cycle; its convergence
will synthesize ONE final remediation comment and minimize this one plus the round-17 flush it
superseded.

Note: that working log is an UNTRACKED file living only in the persistent authoring
worktree ../pair-worktrees/395 — it must be PRESERVED until merge. If that worktree is
pruned/recreated, the audit log is lost (this flush + the first-review comment still remain on
the PR, and the first-review comment still prevents a duplicate first review on the next run,
but the round-by-round detail would not be recoverable).

Still-open actionable findings (both Critical, both unattempted)

1. The revived pre-flight resolves a KB blind to the command's own source flags
apps/pair-cli/src/cli.ts:294-303 + apps/pair-cli/src/config/bootstrap.ts:76-114

runKbPreflight reads thisCommand.opts<{url,kb}>() — the PROGRAM's option set — so
--source, --git, --offline and --list-targets (declared on the install subcommand)
are invisible to it. In a released layout (no bundled dataset: pair-cli's files ships only
dist, @pair/knowledge-hub is hoisted as a sibling, findPackageJsonPath throws),
resolveDatasetForPreflight falls through to the network for every install that names its
own source. Proven against a fixture with no monorepo dataset: pair install --source <dir> --offline, pair install --source <git-url> and pair install --list-targets each issue
https://github.com/foomakers/pair/releases/download/v0.4.3/knowledge-base-0.4.3.zip; the
identical probe against origin/main in a separate detached worktree issues zero requests for
all three, because there the hook is dead.

Impact: (a) --offline is broken — install/metadata.ts:10 documents pair install --offline --source /local/kb as THE air-gapped path, and it now attempts a download and fails with
KnowledgeHubSetupError, --no-kb (undocumented as a requirement) the only escape; (b) every
--source/--git install downloads and stores a whole official KB it will never use —
bandwidth, disk, an extra cache slot; (c) pair install --list-targets, which only reads local
config (install/handler.ts:60,85-105), now requires network. No fixture covers this — every
unit fixture seeds a monorepo dataset and the smoke suite runs dist from inside the monorepo,
so the bundled branch short-circuits both, the same blind spot that produced the previous
round's Critical. Also contradicts this PR's own ADL clause 61 ("bundled-dataset branch is
reached only when no source was named").

Recommendation: merge actionCommand.opts() into what runKbPreflight reads and reuse
namedSource({source, url}) so the pre-flight returns before any fetch when the command names
a local/git source, when --offline is set, or when --list-targets is passed. Add a test
asserting zero HTTP calls for install --source <dir> --offline and for install --list-targets against a fixture with NO resolvable bundled dataset (the released shape), then
correct ADL clause 61.

2. --no-kb still downloads the KB — the opposite of what five rewritten surfaces now claim
apps/pair-cli/src/config/kb-resolver.ts:172-190 + apps/pair-cli/src/cli.ts:88-91 +
apps/website/content/docs/reference/cli/commands.mdx:594-604 +
apps/pair-cli/src/cli.test.ts:457-478

The pre-flight honours kb === false, but the command handler resolves the dataset through an
independent path: resolveDatasetRoot('default')resolveDefaultDataset, which calls
getKnowledgeHubDatasetPathWithFallback whenever an httpClient is present, with no reference to
kb. Proven: on a cold cache with no bundled dataset, pair install --no-kb . and pair update --no-kb . each fetch the release zip. So the newly written --help string ("Skip the knowledge
base download"), the commands.mdx callout ("nothing is fetched…", "pair install --no-kb no
longer downloads a KB"), the cli-contracts.mdx text, ADL clause 84 and the flipped round-18
test all assert behaviour the shipped code does not have — strictly worse than main, where the
flag was a no-op and was DOCUMENTED as a no-op. What --no-kb actually does today is skip the
pre-flight's warm fetch and make --url + --no-kb a hard error; "proceeds with whatever KB is
already on disk" holds only when the slot happens to be warm.

Recommendation: thread kb === false into the command path (resolveDatasetRoot /
resolveDefaultDataset) so default resolution uses only a cached/bundled KB and fails with an
actionable message instead of fetching, plus a test asserting zero HTTP calls for install --no-kb on an empty cache. If that is judged too large for this PR, narrow the
help/reference/contract/ADL wording to the effect the flag really has instead, and re-flip the
round-18 test — but code and documentation must agree.

Disposition

Carried to the merge gate, unresolved. Rounds 1-18's prior findings remain resolved or
previously-escalated as recorded in the working log; nothing from those rounds is reopened by
this comment.

…ever opens, and --no-kb reaches both readers

Round 17 revived a pre-flight that had never executed. Reviving it exposed two
defects it had been hiding, both proven by counting requests against a
released-shape fixture (no bundled dataset) rather than by reading the code.

1. The hook read `thisCommand.opts()` — the PROGRAM's option set for a
   program-level `preAction`. Every flag that names the command's own source is
   declared on the `install` SUBCOMMAND, so `--source`, `--offline` and
   `--list-targets` were invisible to it and it resolved the official KB anyway:
   `--offline` failed for exactly the air-gapped user it exists for, every
   `--source`/`--git` install downloaded a whole KB it never opened, and
   `--list-targets` — which reads local config only — required network.
   It now consults the action command's options through one named predicate.

2. `--no-kb` skipped the pre-flight's warm fetch and downloaded anyway: the
   command path resolves its dataset independently through `resolveDatasetRoot`,
   which fetched whenever an httpClient was present. Five surfaces (help text,
   CLI reference, contracts spec, ADL, a flipped test) had already been rewritten
   to promise a working skip, so the documentation shipped one round ahead of the
   behaviour. `kb` is now threaded into the resolver, which resolves from the
   bundled dataset or a populated cache slot and otherwise fails with the two
   ways out.

Every existing fixture seeds a monorepo dataset and the smoke suite runs `dist`
from inside the monorepo, so the bundled branch short-circuited in both — which
is how 1144 tests stayed green over this. The five new tests use the released
shape and assert zero requests; each was injection-tested against its own guard.

ADL clauses 61 and 84 corrected: both stated the behaviour this commit is what
actually delivers.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-state:to-be-reviewed risk:yellow Classification: medium risk tier

Projects

None yet

Development

Successfully merging this pull request may close these issues.

install --source <zip> extracts an external KB into the official KB's cache slot (shared-cache contamination)

1 participant