feat(extensions): W1a per-extension-aggregate-v3 schema migration (swamp-club#211)#1292
feat(extensions): W1a per-extension-aggregate-v3 schema migration (swamp-club#211)#1292
Conversation
…amp-club#211) Implements W1a of the extension catalog rearchitecture (swamp-club#211). Pure plumbing — no user-visible behaviour change. Groundwork for #201, not a fix for it. What lands - Three new bundle_types columns: state TEXT NOT NULL DEFAULT 'Indexed', extension_name TEXT NOT NULL DEFAULT '', extension_version TEXT NOT NULL DEFAULT ''. Schema-level changes are idempotent via the existing pragma_table_info probe pattern. - Single-transaction data migration with explicit ROLLBACK on post-condition failure. Ordered TS-driven per-row UPDATEs: canonicalize source_path → backfill state from validation_failed → backfill extension_name via deriveExtensionIdentity → drop unmatched rows → verify post-condition (extension_name non-empty). On failure: cold-start rebuild (DELETE all rows, clear populated:* keys, mark migration applied) so loaders repopulate from disk on next access. A migration_applied:per-extension-aggregate-v3 marker key in bundle_meta makes subsequent process restarts skip the data phase. - Two new helpers under src/infrastructure/persistence/: canonicalizePath (lowercased + forward-slash on Windows; raw on POSIX) and deriveExtensionIdentity (path → {name, version} for pulled, local, source-mounted; null for unmatched). - BUNDLE_LAYOUT_VERSION bumped to per-extension-aggregate-v3, which triggers a one-time model-loader rescan on first run after upgrade. ExtensionCatalogStore.upsert SQL changed from INSERT OR REPLACE to INSERT ... ON CONFLICT(source_path) DO UPDATE SET (legacy columns + state) — extension_name/extension_version intentionally NOT in the SET list so the migration's identity backfill survives the rescan. - markCatalogValidationFailed migrated from writing validation_failed=true to state='ValidationFailed'. All six readers migrated to read state instead of validation_failed: five loaders (model:947, driver:538, vault:539, datastore:528, report:386) plus bundle_freshness.ts:303 (the rebundle-loop guard from swamp-club#209). validation_failed column survives W1a as vestigial — no production code reads or writes it. W1b drops the column via SQLite recreate-table pattern. What does NOT land - extension_name/extension_version are NOT surfaced on ExtensionTypeRow. They are write-once-by-migration (preserved on UPDATE via the ON CONFLICT SET-list exclusion) and read by W1b's ExtensionRepository via direct SQL. Loaders' upsert payloads do not include identity columns, so loader rescans cannot clobber them. - No domain value objects (Extension, Source, RowState, SourceLocation, BundleLocation), no ExtensionRepository, no findRepoRoot helper, no cold-start guard unification, no forceCatalogRescan migration, no validation_failed column drop. All deferred to W1b per the plan. Why this PR is bigger than the issue's literal "schema migration only" scope The issue's literal W1a/W1b boundary creates a window where validation_failed is writable by W1a-vintage helpers but no longer trusted by W1b-vintage readers (or vice versa). The only safe boundary is one where the column is genuinely vestigial during the gap: writers migrate to state (this PR), readers migrate to state (this PR), and the drop happens after both have shipped (W1b). Coupling all three changes in one PR is larger; decoupling is unsafe; this carve is the smallest safe carve. Found-during-implementation corrections from the v5 plan 1. Pulled-extensions paths do NOT encode version. The on-disk layout is .swamp/pulled-extensions/<name>/<kind>/<file> with no version segment; version is owned by upstream_extensions.json (the lockfile) and consulted at read time by W1b's repository fallback (Option A). Migration backfills extension_name only for pulled rows; extension_version stays empty. Post-condition narrowed to check extension_name non-empty only. 2. Source-mounted extensions (`swamp extension source add <externalDir>`) have absolute paths OUTSIDE repoRoot. The original heuristic only matched <repoRoot>/extensions/<kind>/ and missed them — would have caused W1b's repository fallback to skip+DELETE every source- mounted row. The generalised matcher recognises any **/extensions/<known-kind>/ segment and rolls source-mounted into the same @local/<basename(repoRoot)> aggregate as repo-internal locals. Pre-work contracts pinned 1. Path canonicalization rule: lowercased + forward-slash on Windows; raw on POSIX. 2. Repo-root identification rule: nearest ancestor of the source path containing a .swamp/ directory; first match wins; lexical-only walk (no realpath); innermost wins for nested worktree-in-repo cases. (findRepoRoot helper itself lands in W1b.) 3. Unmatched-row backfill: drop the row; cold-start guard repopulates from disk. 4. Migration ordering: canonicalize source_path FIRST, then backfill identity. Backfill SQL reads source_path; canonicalizing after would match against legacy strings. Out-of-scope guardrails - extension rm row pruning → W2 - findStaleFiles algorithm changes / bundleWithCache → W3 - ReconcileFromDisk service → W3 - Loader unification (KindAdapter) → W4 - Per-fingerprint import URLs → W5 - swamp doctor extensions changes → W6 - Two pulled versions of the same extension coexisting on disk (interrupted upgrade) — would correctly trigger I-Repo-1 in W1b; repair belongs to W3's reconcile. - Windows correctness is NOT a W1 merge gate (parallel workstream). Reversibility - W1a revert: rolling back the binary triggers a layout-version mismatch on next start, model loader cold-start guard invalidates populated:model and rebuilds from disk. New columns sit unread by old code. Mild loss: rows broken during the W1a window get re-tried on revert (markCatalogValidationFailed wrote state, not validation_failed; old binary reads validation_failed=0). Not silent corruption — just re-attempted. - W1b revert (future): forward-only. Reverting the binary after W1b drops validation_failed leaves the schema without the column; old loaders that read validation_failed see undefined → false → broken rows leak through. Revert path requires deleting _extension_catalog.db (manual ops or via forceCatalogRescan-equivalent). Tests - 5270 unit + integration tests pass (deno run test). - 388 swamp-uat CLI tests pass against the recompiled binary. - 92 swamp-uat adversarial tests pass against the recompiled binary (state corruption, concurrency, resource exhaustion, process lifecycle, security tiers). - Two manual smoke tests against /tmp scratch repos with @swamp/aws/s3 + @swamp/digitalocean (43 pulled rows) and a source-mounted external dir confirm migration is idempotent, identity is correctly backfilled, layout version bumps, and the model-loader rescan does NOT clobber identity columns. Test additions - canonicalize_path_test.ts: 11 cases covering POSIX raw / Windows lowercase+forward-slash branches and the EXTENSIONS/Models/A.ts ↔ extensions/models/a.ts collapse pair on Windows. - derive_extension_identity_test.ts: 16 cases covering pulled (single-segment + multi-segment scoped names + unmatched paths + zero-length names), local-internal, source-mounted (external dir + /tmp + sibling-repo), path-prefix safety (extensions-archive/, /extensions/<unknown-kind>/), and Windows canonical form. - extension_catalog_store_test.ts: 13 new W1a tests — post-#1286 state backfill, pulled extension_name backfill (version intentionally empty), local @local/<repo>/0.0.0 backfill, unmatched- path drop with post-condition success, mixed pulled+local+unmatched in a single migration pass, ON CONFLICT preservation canary (ADV-V3-1: identity columns survive a UPDATE), cold-start rebuild empties bundle_types and clears populated:* keys. - bundle_freshness_test.ts: #209 regression canary updated to state semantics; markCatalogValidationFailed tests assert state='ValidationFailed' instead of validation_failed=true. - All five loader tests + auto_resolver_adapters fixtures updated for the state-based reader contract. Plan record divergence The @swamp/issue-lifecycle model record has plan v5; this PR ships v6 (Option A + source-mounted handling). The lifecycle model has no revise_plan method post-approve, so the canonical record is stale. Documentation chain: issue spec → v5 (approved) → discovered constraint during implementation → v6 (this PR). Local v6 working spec at /tmp/plan-issue-211-v6.yaml. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ionIdentity Windows CI on PR #1292 caught that `backfillExtensionIdentity` passed the raw `inferRepoRootFromDbPath(dbPath)` result (native Windows form with backslashes + mixed case) to `deriveExtensionIdentity`, while the row's `source_path` had already been canonicalized by sub-step 4 (lowercase + forward-slash on Windows). The prefix-match in `deriveExtensionIdentity` requires both inputs in matching canonical form, so `sourcePath.startsWith(pulledPrefix)` never matched on Windows — six legacy migration tests dropped their seed rows and failed. The helper's docstring already requires callers to pre-canonicalize both inputs; the migration just forgot for `repoRoot`. Fix wraps the inferred repoRoot in `canonicalizePath()`. No-op on POSIX (canonicalizePath is the identity function); fixes the prefix-match on Windows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second Windows CI failure on PR #1292. The W1a migration tests build seed paths via `join(repoRoot, ...)` which produces native (backslash) form on Windows, then look up rows via `byPath.get(originalPath)` or `rows.find(r => r.source_path === originalPath)`. The migration's sub-step 4 canonicalizes every row's source_path to lowercase + forward-slash on Windows, so the lookup key never matches the stored row's PRIMARY KEY value. Fix: wrap each test's lookup key in `canonicalizePath()` so the comparison matches the stored canonical form. No-op on POSIX (the canonicalizePath identity branch); fixes the lookup on Windows. Also fix the mixed-rows test's `expectedLocalName` computation: it called `repoRoot.split("/")` which doesn't separate Windows backslash paths. Wrap repoRoot in canonicalizePath first so split("/") works on both platforms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
Thorough review of all 14 files (1699 additions, 111 deletions). This PR implements W1a of the extension catalog rearchitecture — schema migration, data backfill, and reader/writer migration from validation_failed to state.
Blocking Issues
None.
Suggestions
-
statetyped as barestringonExtensionTypeRow(extension_catalog_store.ts:102) — a string literal union ('Indexed' | 'ValidationFailed' | ...) would catch typos at compile time. Acknowledged this is explicitly deferred to W1b when all 7 tags land; just flagging it as the natural follow-up. -
Identical multi-line comments across 5 loaders — the
// Skip ValidationFailed rows (swamp-club#209) — see equivalent guard in user_model_loader.ts:registerLazyFromCatalog. Migrated to read state instead of validation_failed per W1a.comment is repeated verbatim inuser_datastore_loader.ts,user_driver_loader.ts,user_vault_loader.ts,user_report_loader.ts, anduser_model_loader.ts. The CLAUDE.md convention is "default to writing no comments" — a single-line// Skip schema-broken rows (swamp-club#209)would suffice given the code is self-documenting (entry.state === "ValidationFailed").
What looks good
- SQL injection safety: All SQL uses parameterized queries with
?bindings — no string interpolation in SQL anywhere. - Transaction design: Single-transaction migration with explicit ROLLBACK + cold-start rebuild fallback is robust. The recovery path is well-tested.
- ON CONFLICT preservation (ADV-V3-1): The critical design decision — excluding
extension_name/extension_versionfrom the UPDATE SET list — is correct and backed by the canary test atextension_catalog_store_test.ts:1227. - Cross-platform handling:
canonicalizePathis properly parameterized for testing both POSIX/Windows branches without host dependency. Tests usecanonicalizePath()in lookup keys so assertions pass on both platforms. - Test coverage: 40+ new test cases covering every migration sub-step, edge cases (duplicate rows on Windows, zero-length extension names, source-mounted external dirs, corrupt paths), and the cold-start rebuild recovery path.
- DDD alignment: New helpers are correctly placed in infrastructure/persistence.
ExtensionIdentityis a proper value object (readonly, equality by value). Domain loaders only changed their filter predicate — no persistence concerns leaked into the domain layer. - Libswamp import boundary: No violations. The new helpers are only imported within the infrastructure layer and their own tests.
- License headers: Present on all 4 new files.
- Migration idempotency: Schema changes via pragma_table_info probes; data migration gated by marker key in bundle_meta. Re-opening the catalog after migration is verified as a no-op in multiple tests.
There was a problem hiding this comment.
Adversarial Review
Reviewed all 14 changed files (1699 additions, 111 deletions). Traced every code path through the migration transaction, the ON CONFLICT upsert pattern, the path canonicalization, the identity derivation heuristic, the cold-start rebuild recovery, and the six reader-site migrations from validation_failed to state.
Critical / High
None found.
Medium
extractPulledExtensionNamegreedy first-match on kind segments (src/infrastructure/persistence/derive_extension_identity.ts:169-178): The function scans left-to-right for the first path segment matching a known kind name (models,vaults, etc.). An extension whose scoped name contains a kind-like segment — e.g.,@scope/models/foowith on-disk path.swamp/pulled-extensions/@scope/models/foo/models/x.ts— would be misparsed as@scope(stopping at the firstmodels) instead of@scope/models/foo. This is inherent to the path-based approach (the on-disk layout is ambiguous for such names), but worth documenting as a known limitation. In practice the extension registry controls namespace conventions and no existing extensions use kind-like segments in their names, so this is a warning, not a blocker.
Low
-
statecolumn typed as barestring(src/infrastructure/persistence/extension_catalog_store.ts:102): Thestatefield onExtensionTypeRowisstringrather than a discriminated union. A typo like"validationfailed"(wrong case) would silently pass the=== "ValidationFailed"checks and leak a broken row into the registry. This is accepted W1a scope per the PR description (W1b introduces proper value objects), but aRowStatestring literal union would catch this class of bug at compile time today. -
FakeCatalog.upsertstale parameter type (src/domain/extensions/bundle_freshness_test.ts:51): The test fixture still declaresvalidation_failed: booleanin itsupsertsignature rather thanstate?: string. This compiles due to TypeScript's bivariant method parameter checking and works at runtime because of theas unknown as ExtensionTypeRowcast. No test is affected (theFakeCatalogis only used withfindStaleFileswhich reads, never writes viamarkCatalogValidationFailed), but it's a vestigial type that could confuse future maintainers. -
extractPulledExtensionNamewith trailing-slash-only input (src/infrastructure/persistence/derive_extension_identity.ts:169):extractPulledExtensionName("")(emptynameAndRestafter prefix strip) producesparts = [""], no kind match, returnsnull— correct.extractPulledExtensionName("/")producesparts = ["", ""], no kind match, returnsnull— also correct. Edge cases handled.
What I verified is correct
- Transaction discipline:
runDataMigrationTransactionuses BEGIN/ROLLBACK/COMMIT correctly. The ROLLBACK on error leaves the schema columns in place (they were added outside the transaction) while reverting all row changes. The subsequentrunColdStartRebuildopens a clean transaction. - ON CONFLICT preservation (ADV-V3-1):
extension_nameandextension_versionare excluded from the UPDATE SET list, so loader rescans cannot clobber migration-backfilled identity. The canary test at line 1227 pins this. - Migration ordering:
canonicalizeAllSourcePathsruns beforebackfillExtensionIdentity, so the identity derivation matches against canonical paths. TherepoRootis also canonicalized. - Duplicate handling in canonicalization: When two rows canonicalize to the same PK, the UNIQUE constraint failure is caught, the duplicate is deleted, and the survivor proceeds through identity backfill.
- Cold-start rebuild: Correctly deletes all rows, clears
populated:*keys, and marks migration as applied. The marker prevents infinite retry loops. - Reader migration consistency: All six reader sites (five loaders +
bundle_freshness.ts) now checkentry.state === "ValidationFailed"instead ofentry.validation_failed. The writer (markCatalogValidationFailed) writesstate: "ValidationFailed". No writer/reader schism during W1a → W1b. joinForwardavoids@std/path/join: Correctly uses forward slashes for cross-platform consistency with canonicalized paths.
Verdict
PASS — No blocking issues. The migration logic is well-structured with proper error handling, recovery paths, and comprehensive test coverage (including the architect-specified ADV-V3-1 canary). The medium finding about kind-segment ambiguity is a known design limitation, not a code defect.
…gate + repository abstraction (swamp-club#223) Second of two PRs for the extension catalog rearchitecture (parent issue swamp-club#211). W1a (#1292) shipped the schema migration plus the `state` column and migrated all readers/writers from `validation_failed` to `state`. W1b finishes the rearchitecture with the domain layer, the repository abstraction, and the `validation_failed` column drop. Domain layer (`src/domain/extensions/`): - `findRepoRoot` + `RepoRootNotFoundError` — lexical-only ancestor walk - `SourceLocation`, `BundleLocation`, `SourceFingerprint` value objects - `RowState` 7-tag discriminated union (Indexed, Bundled, BundleBuildFailed, ValidationFailed, EntryPointUnreadable, OrphanedBundleOnly, Tombstoned) with literal markdown state-machine table in module-level comment - `Source` entity (fully immutable per ADV-7 — every transition produces a new instance) - `Extension` aggregate root keyed `(name, version)` with invariants I1+I2, 8 transitions including `tombstoneAll(): Extension` (load-bearing for the upgrade-as-atomic-transition pattern) Infrastructure layer (`src/infrastructure/persistence/`): - `ExtensionRepository` — composition over `ExtensionCatalogStore`, with diff-based transactional saves, lockfile-backed empty-version fallback for pulled rows (info-log once per row per boot, write-back makes subsequent boots silent), explicit `legacyStore` escape hatch with do-not-alias JSDoc (W4 will grep `.legacyStore` to remove) - I-Repo-1 (cross-aggregate `(kind, typeNormalized)` uniqueness) fires on every commit — `save(ext)` is sugar for `saveAll([ext])` and runs the same check; ROLLBACK + DuplicateTypeError naming both source paths - `invalidationGuards(kind)` returning explicit reason; `invalidateAll()` with best-effort error semantics - 5 catalog support methods: `findAll`, `findByExtension`, `updateExtensionIdentity`, `runInTransaction`, `upsertWithIdentity` - `BUNDLE_LAYOUT_VERSION` hoisted to shared location; per-kind `getDatastoreBasePath`/`setDatastoreBasePath` - `dropValidationFailedColumn()` migration phase via SQLite recreate-table pattern, gated on `migration_applied:validation-failed-dropped-v1` marker AND pragma_table_info probe; all 3 indexes recreated explicitly Loaders migrated to (a-2) wiring per ADV-V2-1: - All 5 loaders (model/vault/driver/datastore/report) take `repository` as a long-lived constructor field; `buildIndex`, `loadSingleType`, and (model only) `attachPendingExtensionsForType` drop their per-call catalog/repository params - Cold-start guards collapse to `repository.invalidationGuards(kind)`, closing the audit's "model has 3 guards, siblings have 1" coverage gap - Model loader migrated from legacy global `source_dirs_fingerprint` to per-kind `"model"` key (legacy global codepath retained for one release-window of backward-compat per ADV-9) CLI wiring: - 8 loader construction sites + 2 repository constructions in `cli/mod.ts` and `auto_resolver_adapters.ts`; lockfile pre-read once per `configureExtensionLoaders` - `open.ts:109` and `doctor_extensions.ts:105` migrated from `forceCatalogRescan` to a temporary `ExtensionRepository.invalidateAll()` with hoisted lockfilePath; standalone `forceCatalogRescan` DELETED Schema cleanup: - `validation_failed` column dropped from fresh schema; recreate-table dance for old DBs (single transaction with ROLLBACK on any failure) - `ExtensionTypeRow.validation_failed?` field + `mapRow` reader removed W1b is pure plumbing. The only user-visible improvement is the silent fix to cold-start guard parity: pre-W1b, only the `model` loader detected layout-version, datastore-base-path, and source-dirs-fingerprint changes; the other 4 loaders only detected source-dirs-fingerprint changes. After W1b, all 5 kinds invalidate uniformly. `swamp extension source add` now correctly forces a rescan of vaults/drivers/datastores/reports too — not just models. The plumbing for `swamp extension rm` to actually clean up catalog rows (swamp-club#201) is in place via the diff-aware repository, but the user-facing wiring is W2's `RemoveExtensionService`. This is groundwork for #201, NOT "fixes #201." - swamp-club#201 — `RemoveExtensionService` (W2) - Cross-extension `DuplicateType` errors at lifecycle time (W2) - `ReconcileFromDisk` / freshness-as-aggregate-query (W3) - `KindAdapter` loader unification (W4) - Per-fingerprint import URLs + subprocess test harness (W5) - `swamp doctor extensions` rendering aggregate state (W6) Post-merge, downgrading to a pre-W1b binary requires deleting `<repo>/.swamp/_extension_catalog.db`. The pre-W1b binary doesn't know how to read a catalog whose schema dropped `validation_failed`. The loader bootstraps a fresh catalog on next run. Not a W1b merge gate (parallel workstream per W1a precedent). The DDD ratchet test (`integration/ddd_layer_rules_test.ts`) bumps from 26 to 30. The 4 new domain → infrastructure imports are `canonicalizePath` (in `bundle_location.ts`, `source_location.ts`) and `ExtensionKind` (in `source.ts`, `extension.ts`). These are accepted as transitional ports — the canonicalizer should move to a shared path-utility module (W3 territory) and `ExtensionKind` should hoist to the domain layer when the catalog gets fully replaced (W4). Tests: 5,348 pass / 0 fail / 28 ignored (vs 5,294 / 0 / 28 on main). Net new: +33 domain tests, +15 repository tests, +2 drop migration tests, +4 supporting tests. Load-bearing tests covered (per plan v3 step 17 — 13 distinct tests): 1. round-trip save/load 2. diff-save INSERT 3. diff-save DELETE — labelled as the swamp-club#201 reproducer at the repository layer 4. diff-save UPDATE 5. saveAll([vN.tombstoneAll(), vN+1]) upgrade pattern 6. saveAll cross-extension reject + ROLLBACK + both source paths named 7. I-Repo-1 fires on save(ext) directly (distinct from saveAll) 8. lockfile fallback happy path with write-back 9. lockfile fallback orphan path with DELETE + warn 10. cold-start guard parity over all 5 kinds × 4 triggers 11. drop verification — pragma_table_info, index survival, mid-dance ROLLBACK, atomicity on Deno's node:sqlite 12. drop idempotency 13. W3-corruption boundary (two pulled versions on disk → DuplicateType) Manual verification: - Fresh repo + 4 extensions pulled (@swamp/aws/ec2, @swamp/aws/s3, @swamp/digitalocean, @john/k8s); EC2/S3/k8s models load cleanly - Pre-W1b vs post-W1b binary comparison: identical DigitalOcean errors (pre-existing bug in @swamp/digitalocean@2026.05.02.1's `upgrades` chain — NOT a W1b regression) - Real upgrade path: pre-W1b catalog (164 rows, validation_failed column populated) opened with post-W1b binary → drop migration runs, all 164 rows survive, all 3 indexes recreated, both markers set, EC2 model creation works on the migrated catalog - All 5 kinds get per-kind `bundle_meta` keys after first boot; `swamp extension source add` triggers invalidation across all 5 (the silent fix verified empirically) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gate + repository abstraction (swamp-club#223) Second of two PRs for the extension catalog rearchitecture (parent issue swamp-club#211). W1a (#1292) shipped the schema migration plus the `state` column and migrated all readers/writers from `validation_failed` to `state`. W1b finishes the rearchitecture with the domain layer, the repository abstraction, and the `validation_failed` column drop. Domain layer (`src/domain/extensions/`): - `findRepoRoot` + `RepoRootNotFoundError` — lexical-only ancestor walk - `SourceLocation`, `BundleLocation`, `SourceFingerprint` value objects - `RowState` 7-tag discriminated union (Indexed, Bundled, BundleBuildFailed, ValidationFailed, EntryPointUnreadable, OrphanedBundleOnly, Tombstoned) with literal markdown state-machine table in module-level comment - `Source` entity (fully immutable per ADV-7 — every transition produces a new instance) - `Extension` aggregate root keyed `(name, version)` with invariants I1+I2, 8 transitions including `tombstoneAll(): Extension` (load-bearing for the upgrade-as-atomic-transition pattern) Infrastructure layer (`src/infrastructure/persistence/`): - `ExtensionRepository` — composition over `ExtensionCatalogStore`, with diff-based transactional saves, lockfile-backed empty-version fallback for pulled rows (info-log once per row per boot, write-back makes subsequent boots silent), explicit `legacyStore` escape hatch with do-not-alias JSDoc (W4 will grep `.legacyStore` to remove) - I-Repo-1 (cross-aggregate `(kind, typeNormalized)` uniqueness) fires on every commit — `save(ext)` is sugar for `saveAll([ext])` and runs the same check; ROLLBACK + DuplicateTypeError naming both source paths - `invalidationGuards(kind)` returning explicit reason; `invalidateAll()` with best-effort error semantics - 5 catalog support methods: `findAll`, `findByExtension`, `updateExtensionIdentity`, `runInTransaction`, `upsertWithIdentity` - `BUNDLE_LAYOUT_VERSION` hoisted to shared location; per-kind `getDatastoreBasePath`/`setDatastoreBasePath` - `dropValidationFailedColumn()` migration phase via SQLite recreate-table pattern, gated on `migration_applied:validation-failed-dropped-v1` marker AND pragma_table_info probe; all 3 indexes recreated explicitly Loaders migrated to (a-2) wiring per ADV-V2-1: - All 5 loaders (model/vault/driver/datastore/report) take `repository` as a long-lived constructor field; `buildIndex`, `loadSingleType`, and (model only) `attachPendingExtensionsForType` drop their per-call catalog/repository params - Cold-start guards collapse to `repository.invalidationGuards(kind)`, closing the audit's "model has 3 guards, siblings have 1" coverage gap - Model loader migrated from legacy global `source_dirs_fingerprint` to per-kind `"model"` key (legacy global codepath retained for one release-window of backward-compat per ADV-9) CLI wiring: - 8 loader construction sites + 2 repository constructions in `cli/mod.ts` and `auto_resolver_adapters.ts`; lockfile pre-read once per `configureExtensionLoaders` - `open.ts:109` and `doctor_extensions.ts:105` migrated from `forceCatalogRescan` to a temporary `ExtensionRepository.invalidateAll()` with hoisted lockfilePath; standalone `forceCatalogRescan` DELETED Schema cleanup: - `validation_failed` column dropped from fresh schema; recreate-table dance for old DBs (single transaction with ROLLBACK on any failure) - `ExtensionTypeRow.validation_failed?` field + `mapRow` reader removed W1b is pure plumbing. The only user-visible improvement is the silent fix to cold-start guard parity: pre-W1b, only the `model` loader detected layout-version, datastore-base-path, and source-dirs-fingerprint changes; the other 4 loaders only detected source-dirs-fingerprint changes. After W1b, all 5 kinds invalidate uniformly. `swamp extension source add` now correctly forces a rescan of vaults/drivers/datastores/reports too — not just models. The plumbing for `swamp extension rm` to actually clean up catalog rows (swamp-club#201) is in place via the diff-aware repository, but the user-facing wiring is W2's `RemoveExtensionService`. This is groundwork for #201, NOT "fixes #201." - swamp-club#201 — `RemoveExtensionService` (W2) - Cross-extension `DuplicateType` errors at lifecycle time (W2) - `ReconcileFromDisk` / freshness-as-aggregate-query (W3) - `KindAdapter` loader unification (W4) - Per-fingerprint import URLs + subprocess test harness (W5) - `swamp doctor extensions` rendering aggregate state (W6) Post-merge, downgrading to a pre-W1b binary requires deleting `<repo>/.swamp/_extension_catalog.db`. The pre-W1b binary doesn't know how to read a catalog whose schema dropped `validation_failed`. The loader bootstraps a fresh catalog on next run. Not a W1b merge gate (parallel workstream per W1a precedent). The DDD ratchet test (`integration/ddd_layer_rules_test.ts`) bumps from 26 to 30. The 4 new domain → infrastructure imports are `canonicalizePath` (in `bundle_location.ts`, `source_location.ts`) and `ExtensionKind` (in `source.ts`, `extension.ts`). These are accepted as transitional ports — the canonicalizer should move to a shared path-utility module (W3 territory) and `ExtensionKind` should hoist to the domain layer when the catalog gets fully replaced (W4). Tests: 5,348 pass / 0 fail / 28 ignored (vs 5,294 / 0 / 28 on main). Net new: +33 domain tests, +15 repository tests, +2 drop migration tests, +4 supporting tests. Load-bearing tests covered (per plan v3 step 17 — 13 distinct tests): 1. round-trip save/load 2. diff-save INSERT 3. diff-save DELETE — labelled as the swamp-club#201 reproducer at the repository layer 4. diff-save UPDATE 5. saveAll([vN.tombstoneAll(), vN+1]) upgrade pattern 6. saveAll cross-extension reject + ROLLBACK + both source paths named 7. I-Repo-1 fires on save(ext) directly (distinct from saveAll) 8. lockfile fallback happy path with write-back 9. lockfile fallback orphan path with DELETE + warn 10. cold-start guard parity over all 5 kinds × 4 triggers 11. drop verification — pragma_table_info, index survival, mid-dance ROLLBACK, atomicity on Deno's node:sqlite 12. drop idempotency 13. W3-corruption boundary (two pulled versions on disk → DuplicateType) Manual verification: - Fresh repo + 4 extensions pulled (@swamp/aws/ec2, @swamp/aws/s3, @swamp/digitalocean, @john/k8s); EC2/S3/k8s models load cleanly - Pre-W1b vs post-W1b binary comparison: identical DigitalOcean errors (pre-existing bug in @swamp/digitalocean@2026.05.02.1's `upgrades` chain — NOT a W1b regression) - Real upgrade path: pre-W1b catalog (164 rows, validation_failed column populated) opened with post-W1b binary → drop migration runs, all 164 rows survive, all 3 indexes recreated, both markers set, EC2 model creation works on the migrated catalog - All 5 kinds get per-kind `bundle_meta` keys after first boot; `swamp extension source add` triggers invalidation across all 5 (the silent fix verified empirically) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Implements W1a of the extension catalog rearchitecture (swamp-club#211). Pure plumbing — no user-visible behaviour change. Groundwork for #201, not a fix for it.
What lands
bundle_typescolumns:state,extension_name,extension_version. Schema-level changes idempotent via the existingpragma_table_infoprobe pattern.ROLLBACKon post-condition failure. Ordered TS-driven per-row UPDATEs: canonicalizesource_path→ backfillstatefromvalidation_failed→ backfillextension_nameviaderiveExtensionIdentity→ drop unmatched rows → verify post-condition (extension_namenon-empty). On failure: cold-start rebuild (DELETE all rows, clearpopulated:*keys, mark migration applied) so loaders repopulate from disk on next access. Amigration_applied:per-extension-aggregate-v3marker key inbundle_metamakes subsequent process restarts skip the data phase.src/infrastructure/persistence/:canonicalizePath(lowercased + forward-slash on Windows; raw on POSIX) andderiveExtensionIdentity(path →{name, version}for pulled, local, source-mounted; null for unmatched).BUNDLE_LAYOUT_VERSIONbumped toper-extension-aggregate-v3, triggering a one-time model-loader rescan on first run after upgrade.ExtensionCatalogStore.upsertSQL changed fromINSERT OR REPLACEtoINSERT ... ON CONFLICT(source_path) DO UPDATE SET (legacy columns + state)—extension_name/extension_versionintentionally NOT in the SET list so the migration's identity backfill survives the rescan.markCatalogValidationFailedmigrated from writingvalidation_failed=truetostate='ValidationFailed'. All six readers migrated: five loaders (model:947,driver:538,vault:539,datastore:528,report:386) plusbundle_freshness.ts:303(the rebundle-loop guard from swamp-club#209).validation_failedcolumn survives W1a as vestigial — no production code reads or writes it. W1b drops the column via SQLite recreate-table pattern.What does NOT land
extension_name/extension_versionare NOT surfaced onExtensionTypeRow. Write-once-by-migration (preserved on UPDATE via the ON CONFLICT SET-list exclusion); read by W1b'sExtensionRepositoryvia direct SQL. Loaders' upsert payloads do not include identity columns, so loader rescans cannot clobber them.ExtensionRepository, nofindRepoRoothelper, no cold-start guard unification, noforceCatalogRescanmigration, novalidation_failedcolumn drop. All deferred to W1b per the plan.Why this PR is bigger than the issue's literal "schema migration only" scope
The issue's literal W1a/W1b boundary creates a window where
validation_failedis writable by W1a-vintage helpers but no longer trusted by W1b-vintage readers (or vice versa). The only safe boundary is one where the column is genuinely vestigial during the gap: writers migrate tostate(this PR), readers migrate tostate(this PR), and the drop happens after both have shipped (W1b). Coupling all three changes in one PR is larger; decoupling is unsafe; this carve is the smallest safe carve.Found-during-implementation corrections from the v5 plan
.swamp/pulled-extensions/<name>/<kind>/<file>with no version segment; version is owned byupstream_extensions.json(the lockfile) and consulted at read time by W1b's repository fallback (Option A). Migration backfillsextension_nameonly for pulled rows;extension_versionstays empty. Post-condition narrowed to checkextension_namenon-empty only.swamp extension source add <externalDir>) have absolute paths OUTSIDErepoRoot. The original heuristic only matched<repoRoot>/extensions/<kind>/and missed them — would have caused W1b's repository fallback to skip+DELETE every source-mounted row. The generalised matcher recognises any**/extensions/<known-kind>/segment and rolls source-mounted into the same@local/<basename(repoRoot)>aggregate as repo-internal locals.Pre-work contracts pinned
.swamp/directory; first match wins; lexical-only walk (no realpath); innermost wins for nested worktree-in-repo cases. (findRepoRoothelper itself lands in W1b.)source_pathFIRST, then backfill identity. Backfill SQL readssource_path; canonicalizing after would match against legacy strings.Out-of-scope guardrails
extension rmrow pruning → W2findStaleFilesalgorithm changes /bundleWithCache→ W3ReconcileFromDiskservice → W3swamp doctor extensionschanges → W6Reversibility
populated:modeland rebuilds from disk. New columns sit unread by old code. Mild loss: rows broken during the W1a window get re-tried on revert (markCatalogValidationFailedwrotestate, notvalidation_failed; old binary readsvalidation_failed=0). Not silent corruption — just re-attempted.validation_failedleaves the schema without the column; old loaders that readvalidation_failedsee undefined → false → broken rows leak through. Revert path requires deleting_extension_catalog.db(manual ops or viaforceCatalogRescan-equivalent).Plan record divergence
The
@swamp/issue-lifecyclemodel record has plan v5; this PR ships v6 (Option A + source-mounted handling). The lifecycle model has norevise_planmethod post-approve, so the canonical record is stale. Documentation chain: issue spec → v5 (approved) → discovered constraint during implementation → v6 (this PR).Test plan
deno fmt --checkpassesdeno lintpassesdeno check src/passesdeno run test— 5270 passed, 0 failed, 28 ignoreddeno run compilesucceedsswamp-uat uat:cliagainst the W1a-compiled binary — 388 passed, 0 failed, 2 ignored (57s)swamp-uat uat:adversarialagainst the W1a-compiled binary — 92 passed, 0 failed, 1 ignored (3m9s) — covers state corruption, concurrency, resource exhaustion, process lifecycle, security tiersswamp extension pull @swamp/aws/s3+swamp extension pull @swamp/digitalocean(43 pulled catalog rows). W1a binary migrates cleanly:bundle_layoutbumpsper-extension-v2→per-extension-aggregate-v3, all 43 rows preserved withextension_namecorrectly grouped (10 + 33),extension_versionempty per Option A,state='Indexed'. Idempotent on second run.swamp extension source add /tmp/external-srcdir(source-mounted). W1a binary migrates correctly: source-mounted row resolves to@local/<repo>/0.0.0, no rows dropped, no infinite churn.extension_name/extension_version. Catches future regression toINSERT OR REPLACE.findStaleFiles + ValidationFailed → not staletest passes under new state semantics.🤖 Generated with Claude Code