#341 Surface Tombstoned transitions in doctor extensions output
Opened by stack72 · 5/12/2026· Shipped 5/13/2026
Summary
Tombstoned is a documented RowState representing "source file no longer
present and bundle was either evicted or never existed." In practice, the
state is invisible to any observer of swamp doctor extensions --json:
applyDiffForExtension in extension_repository.ts (around line 523)
DELETEs Tombstoned rows in the same SQLite transaction that records
the transition. By the time doctor extensions queries the catalog, the
rows are gone.
The transition itself is captured in ReconcileResult.transitions[], but
that result isn't exposed through any CLI surface today. So the documented
state is genuinely unobservable.
Why this matters
- Documented contract not honored. The 7-RowStates surface promises
Tombstonedas a queryable signal. Users investigating "where did my extension go" can't see "it was tombstoned three commands ago" — they see absence and have to infer. - UAT tests test absence, not presence. The swamp-uat
tombstoned_test.tsworks around this by asserting "row no longer appears insourceDetails[]" rather than "row hasstateTag === 'Tombstoned'." That's a weaker contract — passing tests don't distinguish "tombstoned" from "never existed" from "catalog corrupted." - Diagnostic value lost. A user who deletes a local extension source file should be able to run doctor and see exactly when/why each source transitioned to Tombstoned, including the last-known fingerprint and bundle path before eviction.
Two implementation options
Option A — Expose ReconcileResult.transitions[] in doctor JSON
Add a new top-level field to the doctor extensions --json --verbose
output, populated from the reconcile call that #334 introduced:
{
"overallStatus": "...",
"registries": { ... },
"aggregateState": { ... },
"recentTransitions": [
{
"sourcePath": ".../entry.ts",
"fromState": "Indexed",
"toState": "Tombstoned",
"reason": "source file deleted from disk",
"timestamp": "2026-05-13T..."
}
]
}Pros: rich diagnostic surface. Captures every transition, not just
Tombstoned. Aligns with ReconcileResult.transitions[]'s existing shape.
Cons:
- Transitions are scoped to a single reconcile call. Persisting them across CLI invocations requires storage (a new catalog table, or appending to bundle_meta). Not necessarily blocking — the doctor's reconcile-on-every-invocation behavior (post-#334) means each doctor run captures the latest set of transitions.
- Adds a new field to a public CLI contract.
Option B — Persist Tombstoned rows for one reconcile cycle
Change applyDiffForExtension to NOT delete Tombstoned rows in the
same transaction. Keep them with stateTag === "Tombstoned" for at
least one reconcile cycle. Subsequent reconcile prunes them if still
absent from disk.
Pros: Tombstoned becomes a normal observable state in sourceDetails[],
no new top-level fields, simpler mental model.
Cons:
- Slight catalog growth (rows survive one extra cycle). Bounded but real.
- Requires pruning logic (when does a Tombstoned row get DELETEd? — needs a TTL or "two reconcile passes without source" rule).
- Behavior change: existing consumers of
sourceDetails[]start seeing Tombstoned entries; need to verify downstream presentation doesn't break (e.g.,doctor extensionssummary counts).
Option C — Document Tombstoned as transient-at-persistence, accept the gap
Update row_state.ts:30 documentation to clarify that Tombstoned exists
only in the in-memory Extension aggregate during a reconcile pass — it
is never observable in the persisted catalog. Update the swamp-uat tests
to formally assert absence (not state). No code change.
Pros: zero risk. Aligns docs with reality.
Cons: punts on the diagnostic value. Users still can't see "where did my extension go."
Recommendation: Option A
Most flexible, most informative, doesn't require behavior changes to the persistence layer. The "transitions across invocations" concern is mitigated by the fact that post-#334, every doctor invocation triggers reconcile, so each call captures the latest meaningful transitions for the current process. Persistence beyond that is a follow-up if anyone needs it.
Worst case Option A turns out too noisy / too expensive — fall back to B or C.
Acceptance criteria
- Running
swamp doctor extensions --json --verboseafter deleting a local extension source file produces arecentTransitions[]entry withtoState === "Tombstoned"and the source path of the deleted file. - Running it after deleting a pulled extension source produces a
transition with
toState === "Tombstoned"and reason"orphan: no lockfile entry"(matching the existing reconcile transition reason). - Running doctor without any source-deletion produces an empty
recentTransitions[](or omits the field; design choice — empty array is more uniform for consumers). - The Zod schema in swamp-uat
(
src/cli/helpers/schemas.ts:DoctorExtensionsSchema) is updated to include the new field, with.passthrough()semantics preserved. - No regression in
doctor_extensions_failure_states_test.ts(from #334).
Files an implementing agent should read first
src/infrastructure/persistence/extension_repository.tsaroundapplyDiffForExtension(line ~523) — current Tombstoned DELETEsrc/libswamp/extensions/reconcile_from_disk_service.ts— theReconcileResult.transitions[]shape and where it's populated (around line 510-540)src/cli/commands/doctor_extensions.ts— where the reconcile call was added in #334; the transitions return value needs threading into the JSON outputsrc/cli/commands/doctor_extensions.ts:doctorExtensions(or the underlying libswamp helper) — where the JSON shape is assembledsrc/domain/extensions/row_state.ts:30— Tombstoned contract doc to update if Option C is chosen as fallback
UAT coupling
When this lands, swamp-uat tombstoned_test.ts can flip from
absence-tests to presence-tests on recentTransitions[].toState.
Coordinate the assertion update as a follow-up PR per #334's pattern.
Related context — out of scope
This issue does NOT:
- Persist transitions across CLI invocations beyond what reconcile naturally surfaces. If long-term history is wanted, that's a separate feature.
- Change
Tombstoned's semantics in the domain layer (the DDD model is fine; this is purely a persistence/surface concern). - Touch the dual-path failure-recording (
registries.failures[]vs.sourceDetails[]) — that's W7.
Related issues
- #334 — sequencing fix that introduced reconcile-on-doctor; this
issue threads its
ReconcileResult.transitions[]into the output - Original investigation:
swamp-uat/ROWSTATE_INVESTIGATION.md(the Tombstoned-DELETE finding lives there with code citations)
Shipped
Click a lifecycle step above to view its details.
Sign in to post a ripple.