← Back to list
01Issue
BugShippedSwamp CLIPublic
Assigneeshammz
Relationships
#2490 Extension catalog keeps stale rows after install migration and rm; model type describe crashes with ENOENT
Opened by hammz · 9/24/2026· Shipped 9/29/2026
Description
Split from swamp-club#2483 (bug 3 there). It blocks swamp-club#2429.
Stale extension-catalog rows survive an install migration and an extension rm. swamp model type describe then crashes with ENOENT.
Repro (reproduced on 20260924.122519.0)
- Filesystem datastore outside the repo. Pull
@swamp/aws/curwhile managedConfig is still false. - Run
swamp datastore config migrate. - Run
swamp extension install --json. It reportsorphans_pruned, theninstalled: 1.- It re-extracts to
<repo>/.swamp/config/pulled-extensions/…and deletes the old.swamp/pulled-extensions/…sources. - The catalog is unchanged: the old
Indexedrow remains.
- It re-extracts to
- Run
swamp model type describe @swamp/aws/cur/report-definition. It exits 1 withNo such file or directory (os error 2): readfile '<repo>/.swamp/pulled-extensions/@swamp/aws/cur/models/report_definition.ts', viabundleExtension (bundle.ts:636)←ExtensionLoader.recoverMissingBundle←importBundleByPath←loadSingleType.- A second
describesucceeds. - The new row under
.swamp/config/pulled-extensionshas an emptyextension_name.
- A second
- Run
swamp extension rm @swamp/aws/cur, thendescribeagain. It exits 1 with ENOENT on the.swamp/config/pulled-extensions/…path, because the empty-identity row survived the rm.
Root causes
extension installuses the free-functioninstallExtensionwith no catalog (create_extension_install_deps.ts~80-96). Its orphan prune (pull.ts~1185) deletes sources whose rows stayIndexed. The deletion comes from the orphan prune, notsweepLegacyPaths.deriveExtensionIdentity(derive_extension_identity.ts~109) only knows.swamp/pulled-extensions/. Rows under.swamp/config/pulled-extensions/get no identity, so rm'sloadByNamemisses them.registerLazyFromCatalog(extension_loader.ts~899-924) registersIndexedrows without checking the source. The cold path never removes missing-source rows, becausecatalog.invalidateonly clears a flag.- A stale local-origin row makes
resolveOriginConflictsclear the new pulled row'stype_normalized(extension_catalog_store.ts~1361-1394). That clear is sticky, so the type can stay unregistered even after the stale row is gone. Catalogs already damaged this way need a one-time heal. datastore config migratecopies the old tree (it does not move it) and copies the lockfile verbatim. Duplicate old-root rows then share(kind, type)with new-root rows, which trips I-Repo-1 on everysaveAll(rolling back unrelated pulls) and lets first-wins registration pick the stale row.
Decision already made (on #2483)
Heal on the loader side. extension install stays on the free-function path; the catalog heals itself instead of install being routed through InstallExtensionService.
Design notes from the #2483 adversarial reviews
Findings ADV-2 to ADV-4, 19-21, 30 and 34-37/43-45/65-67 are recorded on the #2483 lifecycle.
- Identity only.
- Teach
deriveExtensionIdentitythe managed prefix, checked before the local/extensions/<kind>/rule so@org/extensionsis not treated as local. - Do not change
computeExtensionRootper row. Mixed roots within one name::version would throwSourceExtensionRootMismatch(invariant I1) at startup reconcile, in rm, in install and in the loader.
- Teach
- One prune rule, called at the top of
resolveOriginConflicts(all kinds, before itshasPulledearly return). It covers the loader cold and warm paths andsaveAll.- It drops rows whose source is missing (NotFound only), or which sit under the inactive pulled root.
- It honours
protectedPathsand exempts onlyOrphanedBundleOnly. - It deletes by raw source path, because
canonicalizePathon Windows does not match keys written under WSL.
- Loader-only scanned-set prune: active-root rows outside every enumerated
<root>/<name>/<kind>dir, applied only when the lockfile view is resolved. - Heal already-damaged catalogs:
- add a one-time per-kind heal marker in
bundle_meta, not aBUNDLE_LAYOUT_VERSIONbump, which evicts every bundle; - when
findStaleFilesdeletes a typed row on the warm path, fall through to the cold path so the shadowed types are re-upserted.
- add a one-time per-kind heal marker in
- Type-load time:
- check the source exists before importing, whether or not the bundle exists (a surviving bundle silently imports old code, and serve dispatch then fails in
bundleSourceFactory); - remove stale rows in a loop until a live row appears or none remain, then return quietly;
- make the vault, datastore, report and webhook registries drop the lazy entry when it is not promoted, as
ModelRegistrydoes; - apply the same to extension-kind rows (
findExtensionsForType→importAndExtendBundle) andattachPendingExtensionsForType.
- check the source exists before importing, whether or not the bundle exists (a surviving bundle silently imports old code, and serve dispatch then fails in
- Serve hot reload: skip and remove missing-source rows before re-bundle and
invalidateType. isGhostRowbecomes dead code.- The auto-resolver's and the
loadUserModelsfallback'sExtensionRepositoryuse an empty lockfile snapshot, so give them the real one. With #2483 that is the tiered view.
Expected
- No typed row with a missing source survives an install migration or an rm.
- A stale row never crashes a command.
- An already-damaged catalog heals on the first run after upgrading.
Tests
- Forced cold path and a pre-corrupted warm-path catalog.
- Fixtures that copy the old tree.
- gen-1 to current.
- Per-kind registry cases.
describeresolves the type from the new root.
02Bog Flow
Shipped
Click a lifecycle step above to view its details.
03Sludge Pulse
Sign in to post a ripple.