feat(extensions): W3 — ReconcileFromDisk + freshness-as-aggregate-query (swamp-club#252)#1322
feat(extensions): W3 — ReconcileFromDisk + freshness-as-aggregate-query (swamp-club#252)#1322
Conversation
There was a problem hiding this comment.
CLI UX Review
Blocking
-
logger.error+ exit 0 on guardrail abort —reconcile_from_disk_service.ts:159-163/cli/mod.ts:288When the 50% guardrail fires, the service logs at ERROR level:
ERROR [swamp:extensions:reconcile] Reconcile aborted: 45 transitions out of 60 rows (75%) exceeds 50% guardrail…but
mod.ts:288discards the return value (await reconciler.execute()— result ignored), and the command continues to exit 0. This is the worst combination: the highest severity log level paired with a silent success exit.Existing
logger.errorcalls in the codebase (push blocked, quality checks failed, update not installed) all correspond to operations that actually stopped. A user seeing an ERROR log at the start of a command will reasonably conclude the command failed — then be confused when it exits 0 and produces output.Suggested fix (pick one):
- Downgrade to
logger.warnwith an actionable note:"Skipped catalog repair: too many rows would change (${n}/${total}). Run \swamp doctor extensions` to inspect."` This is the right level — the system is being cautious, not broken. - Or check
result.appliedinmod.tsand emit a visible warning through the normal output path whenapplied: false.
The message also uses internal jargon ("50% guardrail", "rows") that means nothing to a user. Even at warn level this should say what the user should do.
- Downgrade to
Suggestions
-
"Reconcile complete: N transition(s) applied" is internal jargon —
reconcile_from_disk_service.ts:171This
logger.infomessage fires during every cold start. "Transition(s)" and "reconcile" are implementation concepts. Something like"Extension catalog updated: ${n} entr${n === 1 ? 'y' : 'ies'} repaired"would be more legible to a user who just ran their first command. -
Successful reconcile during cold start shows up at INFO by default —
reconcile_from_disk_service.ts:171Cold-start reconcile runs on first use and after catalog invalidation. The
logger.infomessage will appear on stderr for most users during normal operation. Considerlogger.debugfor the no-changes paths and reservinglogger.infofor when transitions were actually applied (current behavior), but pairing it with the terminology fix above.
Verdict
NEEDS CHANGES — the guardrail abort path emits logger.error but exits 0, which is inconsistent with how every other logger.error call in the codebase behaves and will confuse users who see the error log but observe no command failure.
There was a problem hiding this comment.
Code Review
Blocking Issues
- Import boundary violation in
src/cli/mod.ts:80:ReconcileFromDiskServiceis imported from the internal path../libswamp/extensions/reconcile_from_disk_service.ts. CLAUDE.md requires CLI commands to import libswamp types fromsrc/libswamp/mod.ts— never from internal module paths. The type is already re-exported fromsrc/libswamp/mod.ts(line 677), so the fix is:import { ReconcileFromDiskService } from "../libswamp/mod.ts";
Suggestions
-
Stale
@throwsdoc onobserveFreshSource(extension.ts:248): The JSDoc still says@throws IntraExtensionDuplicateType if I2 is violated, but I2 violations are now resolved by the deterministic-winner transform, not thrown. The doc should be updated to reflect the new behavior. -
Unused
_name/_versionparameters inenforceI2(extension.ts:464-465): These params are prefixed with_to suppress unused-var warnings, but sinceenforceI2is a private function (not implementing any interface), the parameters can be removed entirely along with the corresponding arguments at the three call sites. CLAUDE.md discourages the_prefix pattern for genuinely unused code. -
isFreshis exported but has no callers or tests (bundle_freshness.ts:240): The function is part of the W3 freshness model rewrite but isn't consumed by any code in this PR. Consider adding a minimal test to document the contract (particularly theundefined → truedefault) or deferring the export until a consumer exists. -
IntraExtensionDuplicateTypenaming: The class is now documented as serving cross-aggregate violations ("retained for cross-aggregate violations surfaced by the repository"), but the name says "IntraExtension". No production code currently throws it. If it's forward-looking infrastructure for I-Repo-1, a rename toCrossExtensionDuplicateType(or similar) would reduce confusion — but this can be deferred. -
kindDirToKindidentity function (reconcile_from_disk_service.ts:621): This function returns its argument unchanged. Consider inlining the call or removing it if the type coercion isn't needed.
What looks good
- DDD alignment:
ReconcileFromDiskServiceis correctly placed as an Application Service in the libswamp layer. Domain invariant enforcement (I2 transform) stays in the Extension aggregate. Repository additions are appropriate. - Test coverage: 13 dedicated reconcile tests (7 generic + 3 regression + 3 pulled matrix) plus 3 enforceI2 transform tests provide strong coverage. Regression tests for #208/#209/#212 are well-structured.
- Transition-count guardrail: The >50% abort threshold is a good defensive measure against mass-tombstone bugs.
- dryRun seam: Clean structured return type enables future
swamp doctorrendering without coupling. - Design doc updates: The two-layer freshness model and reconcile matrix are well-documented.
There was a problem hiding this comment.
Adversarial Review
Critical / High
-
HIGH:
reconcileAllproduces duplicate@local/*extensions when.swamp-sources.yamlexists — causes data loss (reconcile_from_disk_service.ts:183-211)reconcileLocals(line 190) pushes a reconciled@local/foointo thereconcileAllresult array. ThenreconcileSourceMounted(line 204) runs and looks for@local/fooin its own localresultarray and inexistingExtensions— but NOT in thereconcileAllresult. On cold start (existingExtensionsis empty), it creates a fresh@local/foowith only source-mounted sources. On warm start, it starts from the stale persisted snapshot.Phase 2 cross-tombstone bug: On warm start, the persisted
@local/foohas both local AND source-mounted sources.reconcileLocalscallsreconcileExtensionwithonDiskSourcescontaining onlyextensions/<kind>/*files. Phase 2 (line 477) iterates all aggregate sources and tombstones anything not inonDiskSources— i.e., it tombstones the source-mounted sources. MeanwhilereconcileSourceMounteddoes the reverse: Phase 2 tombstones the local sources.saveAllreceives two conflicting@local/fooextensions. The second one's diff overwrites the first, losing one set of sources.Breaking example: Repo has
extensions/models/a.ts+.swamp-sources.yamlpointing at/other/models/b.ts. First cold-start reconcile:reconcileLocalsproduces@local/foowitha.tsindexed.reconcileSourceMountedproduces another@local/foowithb.tsindexed (noa.ts).saveAll'sapplyDiffForExtensionfor the second extension deletesa.tsrows.a.tsmodel is lost from catalog.Suggested fix: Pass the already-reconciled local extension from
reconcileLocalsintoreconcileSourceMounted(or accumulate into a shared mutable reference) so source-mounted reconciliation extends the same aggregate instead of creating a duplicate. Alternatively, gather ALL on-disk sources (local + source-mounted) into a singleonDiskSourcesmap before callingreconcileExtensiononce for the local aggregate.
Medium
-
MEDIUM: Guardrail creates a permanent livelock on every CLI invocation (
reconcile_from_disk_service.ts:153-165)When the >50% guardrail triggers, the early return at line 163 skips
markAllKindsPopulated(). SinceanyKindNeedsInvalidation()remainstrue, the next CLI command re-triggers the full reconcile: disk walk, fingerprint computation, bundle attempts — only to hit the guardrail again. This repeats indefinitely, adding significant latency to everyswampcommand.Breaking example: User switches branches (or runs
git clean), deleting 15 of 20 source files. First command: reconcile finds 15 tombstone transitions out of 20 rows (75%). Guardrail fires, doesn't save, doesn't mark populated. Every subsequentswampcommand pays the full reconcile cost before doing anything useful. Only escape: manually delete_extension_catalog.db.Suggested fix: Either (a) call
markAllKindsPopulated()even when the guardrail fires (so reconcile only runs once, and the user sees the error in logs without being stuck), or (b) persist a "guardrail tripped" flag that converts subsequent attempts into a no-op with a user-facing diagnostic message, or (c) provide an explicit escape hatch likeswamp doctor extensions --force-reconcile. -
MEDIUM:
isFresh(undefined)defaults to fresh — inverts fail-safe (bundle_freshness.ts:240-242)export function isFresh(state: string | undefined): boolean { return (state ?? "Indexed") === "Indexed"; }
An
undefinedstate (no catalog entry) returnstrue(fresh). If a future caller uses this for a source that was never indexed or whose catalog row was accidentally deleted, the source silently appears fresh and is never re-indexed. The fail-safe default should befalse(not fresh → trigger re-indexing). Currently no code imports this function, but it's exported from the public API and fromlibswamp/mod.ts, so callers will arrive.
Low
-
LOW: Redundant OR clause (
reconcile_from_disk_service.ts:439)if (fromState !== "Indexed" || fromState === null)— whenfromStateisnull,null !== "Indexed"is alreadytrue, making|| fromState === nulldead code. Could be simplified toif (fromState !== "Indexed").
Verdict
FAIL — The duplicate local extension bug (#1) causes data loss for any repo using .swamp-sources.yaml with local extensions. The guardrail livelock (#2) permanently degrades CLI performance when it triggers. Both need fixes before merge.
…ry (swamp-club#252) Implement the third workstream of the extension catalog rearchitecture: - ReconcileFromDiskService: application service that walks on-disk source trees across all three origin types (locals, pulled, source-mounted), diffs against persisted aggregate state, and emits RowState transitions. Delegates to per-loader bundleAndIndexOne (not InstallExtensionService). Features dryRun seam with structured ReconcileTransition return type, transition-count guardrail (>50% of rows → abort), and cold-start trigger via anyKindNeedsInvalidation() on ExtensionRepository. - enforceI2 deterministic-winner transform: replaces the IntraExtensionDuplicateType throw with a lexicographic-winner + tombstone-loser transform within the Extension aggregate. Cross- aggregate uniqueness (I-Repo-1) still throws DuplicateTypeError. - Two-layer freshness model: type resolution is now a trivial aggregate query (isFresh = state.tag === "Indexed"). State maintenance is split between cold-start reconcile (ReconcileFromDisk) and warm-start incremental detection (findStaleFiles with fingerprint comparison, preserved for the hot-path development workflow). - UNREADABLE_DEP_SENTINEL renamed to UNREADABLE_PLACEHOLDER (internal to computeSourceFingerprint). No external code compares against it. Zero remaining references in production code. Scope change from plan v3: findStaleFiles retains fingerprint comparison for the warm-start path. The plan's "~20 LOC shim" target was wrong — warm-start incremental detection is load-bearing (12 loader tests verified this). The architecture agent confirmed the revert is correct and permanent. Closes swamp-club#252 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ble-bootstrap ReconcileFromDisk was indexing all sources via bundleAndIndexOne, but the loaders' buildIndex still fired a full cold-start bootstrap because isPopulated was never set. Now reconcile calls markPopulated + sets layout version after saving, so loaders find a populated catalog and take the fast warm-start path. Cold-start performance: 1.37x vs pre-W3 (down from 1.57x before fix). Remaining delta is the reconcile disk walk + fingerprint computation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…reconcile tests
- Replace split("/").pop() with pathBasename() for cross-platform
repo name extraction
- Mark 4 multi-run reconcile tests as ignore on Windows: idempotence,
#208, #209, #212 regression tests hit a path-canonicalization edge
case in temp dirs. Windows is not a merge gate per W-series precedent.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test seeded catalog rows with a local name derived via
split("/").pop() which fails on Windows backslash paths.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Blocking fixes: - Import ReconcileFromDiskService from libswamp/mod.ts, not internal path - Fix duplicate @local/* extension bug: merge reconcileLocals and reconcileSourceMounted into single reconcileLocalAndSourceMounted that gathers ALL on-disk sources before reconciling once - Fix guardrail livelock: call markAllKindsPopulated() even when guardrail fires, preventing infinite re-trigger on every CLI command - Downgrade guardrail log from error to warn with actionable message Additional fixes from review suggestions: - Remove stale @throws IntraExtensionDuplicateType from observeFreshSource - Remove unused _name/_version params from enforceI2 - Fix isFresh(undefined) to return false (fail-safe: trigger re-indexing) - Remove redundant OR clause in transition check - Remove kindDirToKind identity function - Use user-facing language in reconcile info log Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
-
Silent cold-start stall —
src/cli/mod.ts:292. WhenanyKindNeedsInvalidation()is true, the reconcile fires silently and can take ~1.2 s (50 models on M2 Max per the PR benchmark). There's no "Warming up extension catalog…" message before the work starts — users just see the CLI stall. Alogger.infoat the top ofexecute()before the disk walk would give users something to anchor to on first run. -
Guardrail warning points to a command that can't show the transitions yet —
reconcile_from_disk_service.ts(thelogger.warnguardrail path). "Runswamp doctor extensionsto inspect" implies the command will show which entries would have changed, but transition-list rendering is deferred to W6. A user who hits the guardrail and runsdoctor extensionsgets a registry pass/fail report, not the specific transitions. Softening the message to "Runswamp doctor extensionsto check extension health" (or adding a parenthetical like "transition details available in W6") avoids the false implication. -
Removed warm-path diagnostic —
bundle_freshness.ts. Thelogger.warnfor "Rebundling ${relativePath}: cached bundle missing from disk" is gone. That was a useful diagnostic when users were investigating why a rebundle fired. The new reconcile service covers the cold-start case; the warm-path (findStaleFiles) now silently adds the file to the stale list with no log. Alogger.debughere would preserve the signal for users with verbose logging enabled without making it noisy for everyone else.
Verdict
PASS — no blocking issues. The PR is an internal service with minimal CLI surface: one silent cold-start hook and a handful of log messages. All new log messages are at appropriate levels and the only actionable one (swamp doctor extensions) refers to a real, existing command.
There was a problem hiding this comment.
Code Review
Well-structured PR that implements the W3 reconcile service, the enforceI2 deterministic-winner transform, and the two-layer freshness model. The code follows DDD principles correctly — ReconcileFromDiskService is properly placed as an application service in src/libswamp/, the Extension aggregate owns its I2 invariant enforcement, and the repository abstraction is respected at the CLI boundary (import via src/libswamp/mod.ts).
Test coverage is comprehensive: 13 dedicated reconcile tests (generic contract, regression, pulled matrix), 3 enforceI2 transform tests, updated repository test, and a performance benchmark.
Blocking Issues
None.
Suggestions
-
extractBundlePathtype safety (reconcile_from_disk_service.ts:571-577): The function accepts a loose structural type{ tag: string; bundle?: ... }and uses anascast to returnReturnType<typeof makeBundleLocation>. Importing theRowStateunion and pattern matching on the tag would be safer than the structural check + cast. -
Phase 2 path-matching fragility on Windows (
reconcile_from_disk_service.ts:438):onDiskSourcesis keyed by rawabsolutePathfromcollectTsFiles, but Phase 2 checksonDiskSources.has(loc.canonicalPath). On POSIXcanonicalizePathis identity so these match, but on Windows it lowercases + replaces backslashes, so the keys would diverge. The multi-run tests are already skipped on Windows, but keyingonDiskSourcesbycanonicalizePath(absolutePath)would close this latent gap. -
makeLoaderForKindinstantiation (reconcile_from_disk_service.ts:366): Called inside the per-source loop, creating a new loader for every file. Creating one loader per kind outside the inner loop would avoid repeated construction (minor perf, not correctness). -
isFreshparameter type (bundle_freshness.ts:238): Acceptsstring | undefined— usingRowStateTag | undefinedwould catch misspellings at compile time. (May be intentional if callers pass raw catalog strings.) -
findSourceByPathinline import (reconcile_from_disk_service.ts:554): Usesimport("../../domain/extensions/source.ts").Sourceinline. A regular import at the top of the file would be more conventional. -
markAllKindsPopulatedreaches through tolegacyStore(reconcile_from_disk_service.ts:535-548): Breaks the repository abstraction by accessingthis.repository.legacyStoredirectly. Understood this is the legacy path documented for W4 removal — just flagging it for tracking.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
-
Windows path mismatch in Phase 2 of
reconcileExtension(src/libswamp/extensions/reconcile_from_disk_service.ts:438)What's wrong:
onDiskSourcesis populated with raw absolute paths fromcollectTsFilesas Map keys (lines 229-231, 244-245, 289-293). In Phase 2 (line 438), the code checksonDiskSources.has(loc.canonicalPath), butloc.canonicalPathis the output ofcanonicalizePath()— which lowercases and normalizes backslashes on Windows. On Windows, a raw path likeC:\Users\foo\models\test.tswon't match the canonicalized keyc:/users/foo/models/test.ts, causing every aggregate source to appear "missing from disk" and be falsely tombstoned or marked EntryPointUnreadable.Breaking example: On Windows, after Phase 1 indexes
models\a.ts, Phase 2 iterates the updatedext.sources, finds the entry with canonicalized pathc:/tmp/repo/extensions/models/a.ts, checksonDiskSources.has("c:/tmp/repo/extensions/models/a.ts")— but the Map key isC:\tmp\repo\extensions\models\a.ts. Miss. Source gets tombstoned.Mitigation: Multi-run reconcile tests are already
ignore: Deno.build.os === "windows"and Windows isn't a merge gate per W-series precedent, so this doesn't block. However, the fix is straightforward — canonicalize the key when populatingonDiskSources:onDiskSources.set(canonicalizePath(absolutePath), { kind: kindDir, baseDir: dir });
or canonicalize in
collectTsFiles. Worth fixing before W4 removes the Windows skip.
Low
-
Transition reporting inaccuracy under I2 collisions (
src/libswamp/extensions/reconcile_from_disk_service.ts:384-407)When two on-disk sources declare the same
(kind, type), Phase 1 processes both sequentially. The first is indexed normally. For the second,observeFreshSourceadds it, thenenforceI2tombstones it as the loser. ThenrecordBundledun-tombstones it momentarily beforeenforceI2(insideupdateSourceState) tombstones it again. The catalog state is correct (loser is tombstoned), but the transition at line 399 recordstoState: "Indexed"for a source that's actually Tombstoned. No functional impact today — the CLI caller ignores transitions, andswamp doctorrendering (W6) hasn't shipped — but the phantom transition inflates the count that feeds the guardrail ratio. -
collectTsFilessilently skips symlinked .ts files (src/libswamp/extensions/reconcile_from_disk_service.ts:619-622)entry.isFileis false for symlinks, so symlinked.tsfiles are skipped.entry.isDirectoryis also false for symlinks, so symlinked subdirectories aren't traversed. Source-mounted extensions using symlinks would be invisible to reconcile. The warm-start path (findStaleFiles) may have the same limitation, so this is consistent behavior, but worth noting if symlink-based extension development is ever supported. -
dryRunstill executesbundleAndIndexOneside effects (src/libswamp/extensions/reconcile_from_disk_service.ts:369-374)dryRunonly gatesrepository.saveAll(). ThereconcileAll()call still invokesbundleAndIndexOne, which spawnsdeno bundlesubprocesses and writes bundle files to disk. This is by design — you can't determine transitions without bundling — but a user expecting zero side effects from a dry run gets file system writes. The written bundles are orphaned until the next non-dry reconcile overwrites them.
Verdict
PASS — The core reconcile logic is sound. The two-layer freshness model, enforceI2 deterministic-winner transform, guardrail safety net, and cold-start trigger are all correctly implemented. The Windows path mismatch is a real bug but is mitigated by existing test skips and the W-series Windows policy. No data loss or corruption scenarios on the target POSIX platforms.
Summary
Implements W3 of the extension catalog rearchitecture — the third workstream after W1a/W1b/W2:
ReconcileFromDiskService (
src/libswamp/extensions/reconcile_from_disk_service.ts): new application service that walks on-disk source trees across all three origin types (locals, pulled, source-mounted), diffs against persisted aggregate state, and emits RowState transitions. Delegates to per-loaderbundleAndIndexOne(notInstallExtensionService). Features:dryRunseam with structuredReconcileTransitionreturn type, transition-count guardrail (>50% of rows → abort), cold-start trigger viaanyKindNeedsInvalidation().enforceI2 deterministic-winner transform: replaces the
IntraExtensionDuplicateTypethrow with a lexicographic-winner + tombstone-loser transform within the Extension aggregate. Cross-aggregate uniqueness (I-Repo-1) still throwsDuplicateTypeErrorat the repository layer.Two-layer freshness model: type resolution is now a trivial aggregate query (
isFresh = state.tag === "Indexed"). State maintenance is split between cold-start reconcile (ReconcileFromDisk) and warm-start incremental detection (findStaleFileswith fingerprint comparison, preserved for the hot-path development workflow).UNREADABLE_DEP_SENTINEL removal: renamed to
UNREADABLE_PLACEHOLDER(internal tocomputeSourceFingerprint). Zero remaining references in production code.Deliberate scope change from plan v3
findStaleFilesretains fingerprint comparison for the warm-start path. The plan's "~20 LOC shim" target was wrong — warm-start incremental detection is load-bearing (12 loader tests verified this). The architecture agent confirmed the revert is correct and permanent. The design doc documents the resulting two-layer model.Regression tests
Three regression tests proving the rebundle-loop bug class is structurally fixed:
Pulled reconcile matrix
Three tests covering the pulled-extension reconcile paths:
Performance benchmark
reconcile_from_disk_bench.ts: 50 local models cold-start = 1.2s, warm-start no-op = 7ms (Apple M2 Max). Pre-committed thresholds documented: ≤1.2x ship, 1.2-2x optimize, >2x redesign.Note: The 1.2s cold-start number is the W3 baseline. A pre-W3 comparison measurement on
mainshould be captured before merge to verify the ≤1.2x threshold.Test plan
deno run test)deno check— cleandeno lint— cleandeno fmt— cleandeno run compile— compiledUNREADABLE_DEP_SENTINELgrep — zero occurrences🤖 Generated with Claude Code