Maintenance
This guide is for operators choosing between
polylogue ops maintenance preview, polylogue ops maintenance plan,
polylogue ops maintenance run, polylogue ops reset, and "do nothing — the
daemon will catch up." It also collects runbook recipes for the most
common operational incidents.
For the conceptual model behind derived insights and the FTS / blob substrate, see architecture.md and internals.md. For daemon ownership of the inline maintenance loop, see daemon.md.
What a maintenance operation is
A maintenance operation is an explicit, resumable, idempotent pass over already-ingested archive state. It does not acquire new source data. It does not rewrite or delete imported sessions beyond targeted cleanup. It rebuilds, repairs, or prunes the things the archive depends on but does not own as primary data:
- derived read models (session insights, actions, work threads, day/week summaries, message-type classifications);
- search indexes (the FTS5 projections over messages and action events);
- backfilled columns (e.g.
message_typefor rows ingested before the classifier existed); - archive-cleanup scopes (orphaned messages, orphaned content blocks, empty sessions, orphaned attachments, orphaned blobs); A WAL checkpoint is not a maintenance operation. Ingest runs bounded passive checkpoints after commits, the daemon runs periodic truncate checkpoints, and status/metrics report WAL pressure. If WAL stays large after those automatic paths have had a chance to run, treat that as a daemon/storage bug rather than a user-facing repair verb.
A maintenance operation is distinguished from three adjacent things:
| Surface | What it does | When you reach for it |
|---|---|---|
Import (polylogued, polylogue import PATH) |
Daemon acquires source payloads, parses provider records, writes archive rows, and advances derived models for the new rows. polylogue import PATH asks the running daemon to schedule an explicit file or directory. |
You have new exports/sessions to import. |
Daemon convergence (polylogued inline loops) |
Performs the same operations as ingest plus automatic WAL checkpointing, FTS convergence, heartbeat, health checks, and embedding/profile catch-up. | The daemon is running. You do nothing. |
Maintenance (polylogue ops maintenance ...) |
Rebuilds derived state and prunes archive debt over already-ingested rows. Read-only by default; mutations are explicit. | A derived model is stale or missing for old rows that the daemon's small inline windows will not pick up. |
Reset (polylogue ops reset) |
Deletes data: the SQLite database, the blob store, attachments, cache, OAuth tokens, or named sessions (soft-delete via tombstones). | The data itself is wrong or unwanted, not just a derived projection of it. |
The order of preference is: do nothing → daemon → maintenance → reset. Reset is the only one that destroys primary data.
Typed scopes
Maintenance targets are grouped into four scopes:
| Scope | Mode | Destructive | Targets |
|---|---|---|---|
derived (derived_repair) |
repair | no | session_insights, message_type_backfill |
archive_cleanup |
cleanup | yes | orphaned_messages, orphaned_content_blocks, empty_sessions, orphaned_attachments, orphaned_blobs |
backfill |
repair | no | column/row backfills surfaced by the planner (currently subsumed by derived). Re-acquiring raw artifacts from source, WAL checkpointing, and repairing FTS coherence are daemon/ingest convergence responsibilities, not maintenance targets. |
The canonical target list is enforced by
polylogue/maintenance/targets.py. The CLI --target option's
click.Choice is built from
MAINTENANCE_TARGET_NAMES, so the source is the type system: an
unknown target is rejected at the CLI boundary.
When to use which surface
something looks off
|
+--------------------+--------------------+
| |
one or a few sessions wide swath of the archive
look wrong / outdated looks stale (FTS misses
| hits, session profiles
| missing for old data, ...)
| |
polylogue ops maintenance preview polylogue ops maintenance preview
(scoped to that session) (no scope — full inventory)
| |
nothing stale? nothing stale?
- the data really is that way. - the daemon already converged.
stop. open an issue with a stop. nothing to do.
concrete acceptance criterion.
| |
stale rows reported? stale rows reported?
polylogue ops maintenance run polylogue ops maintenance run
--session <id> (no scope) or
[--target ...] --target <subset>
| |
still wrong? the data itself failures reported?
is wrong, not the projection. inspect failure_samples,
polylogue ops reset --session re-run with --operation-id
<id> (tombstones it; preserves to resume from cursor.
identity ledger for re-import)
Heuristics:
- Preview before plan, plan before run.
previewis read-only;planis a dry-run summary;runis the only mutating verb. - Prefer the narrowest target.
--target session_insightsis cheaper and safer than rebuilding everything. - Do not reach for
resetto "fix a stale projection." That is whatmaintenanceis for. Reset destroys the primary data the projection is built from. - If the daemon is running and the issue is recent, wait one convergence cycle (~10 minutes for FTS, ~5 minutes for WAL) and re-check before reaching for maintenance.
- If maintenance reports zero stale rows but the archive still looks wrong, the bug is upstream (ingest, parser, schema) — open an issue, do not loop on maintenance.
Subcommands
polylogue ops maintenance preview — staleness inventory
Read-only. Produces a per-model inventory of stale, missing, orphan,
or version-mismatched rows tagged with a typed InvalidationReason
(missing, stale, orphan, missing_provenance,
version_mismatch, orphan_archive_row). Models with nothing stale
produce explicit zero rows rather than being absent from the output.
polylogue ops maintenance preview
polylogue ops maintenance preview --scope derived
polylogue ops maintenance preview --scope archive_cleanup --output-format json
polylogue ops maintenance preview --shallow # skip expensive full-verification path
Use this before triggering run so you know what would be touched
and why. A write-watching SQLite hook in the test suite confirms zero
writes during a preview pass.
polylogue ops maintenance plan — dry-run summary
Read-only. Resolves targets, evaluates affected rows, and produces a
BackfillOperation envelope without executing any repair. Use it to
sanity-check what the next run will do.
polylogue ops maintenance plan
polylogue ops maintenance plan --target session_insights --target message_type_backfill
polylogue ops maintenance plan --output-format json | jq .
--output-format json emits the shared
MaintenanceOperationEnvelope so the CLI output is byte-for-byte
identical to the daemon HTTP and MCP responses.
polylogue ops maintenance run — execute
Runs the resolved targets. Per-target failures are isolated as
FailureSample entries; one failing target does not abort the rest.
Use --dry-run to combine the safety of plan with the full
execution-path code, or pass --operation-id <uuid> together with
--resume to pick up an interrupted operation.
polylogue ops maintenance run --dry-run
polylogue ops maintenance run --target session_insights --output-format json
--operation-id and --resume: worked example
Replay execution writes a small JSON state file under
<archive_root>/.maintenance-state/<operation_id>.json after each
target completes. The state file is removed when the operation
terminates successfully. The cursor is an opaque string
(target:N) encoding the index of the next target to run.
# Start an operation, capture its id.
op=$(polylogue ops maintenance run --output-format json \
--target session_insights \
--target message_type_backfill \
| jq -r .operation_id)
# ... operation is killed mid-run (Ctrl-C, OOM, oncall reboot) ...
# Resume from the persisted cursor — same id, same target set, no flag needed.
polylogue ops maintenance run --operation-id "$op" \
--target session_insights \
--target message_type_backfill
# Explicit cursor override (rare — for surgical replays).
polylogue ops maintenance run --operation-id "$op" --resume target:2 \
--target session_insights \
--target message_type_backfill
Two correctness guarantees the executor provides:
- Convergence. Running the same operation twice in a row produces no additional changes after the first pass converges. The underlying repair functions are idempotent by construction; the replay loop adds the multi-target convergence guarantee.
- Resume integrity. Targets already marked done in the state file are skipped on resume, and no target is run twice.
If the state file is missing and --operation-id is supplied without
--resume, the executor treats the id as a fresh start.
Scope filters
The current shipping surface accepts repeatable --session-id, --origin,
--source-family, --source-root, --since/--until, --failure-kind,
and --parser-version filters. Each target decides which dimensions it can
honestly narrow; unsupported dimensions are preserved in the envelope but do
not pretend to reduce the affected-row count.
polylogue ops maintenance run --session-id abc123 --target session_insights
polylogue ops maintenance run --origin claude --target session_insights
polylogue ops maintenance run --since 2026-04-01 --until 2026-05-01 \
--target session_insights
polylogue ops maintenance run --failure-kind parse_error --target message_type_backfill
Until #1196 lands, the only way to narrow a run is through --target
and --scope. Do not script against flag names that are not yet on
polylogue ops maintenance run --help.
Status surface
A long-running operation exposes its current cursor and in-flight failure samples through three coherent surfaces:
| Surface | How to read |
|---|---|
CLI (polylogue ops maintenance run) |
Progress lines printed to stderr each checkpoint: [processed/total] target cursor=target:N failures=K. The final stdout block reports operation_id, target results, elapsed time, and Failures: listing. |
| Daemon HTTP | POST /api/maintenance/plan and POST /api/maintenance/run return the same MaintenanceOperationEnvelope as the CLI. A dedicated GET /api/maintenance/status/<op_id> endpoint is tracked in #1197. |
| MCP | maintenance_preview and maintenance_execute return the same envelope as the CLI/HTTP. A maintenance_status tool is tracked in #1197. |
All three surfaces share the same MaintenanceOperationEnvelope
contract from polylogue/maintenance/envelope.py, so a jq script
that parses the CLI JSON also parses HTTP and MCP responses byte for
byte. The envelope carries operation_id, status, targets,
resume_cursor, affected_rows, started_at, completed_at,
per-target results, and a bounded failure_samples envelope.
Failure surface
Replay failures are bounded by BoundedFailureSamples (a small
fixed cap per operation) so a runaway target cannot fill the
operation envelope with samples. Failures appear in three places:
polylogue ops maintenance runstderr — the finalFailures:block lists<kind> @ <locator>: <message>for each captured sample. A truncation marker is printed if the cap was hit.polylogue ops doctor/polylogue ops doctor— readiness reports include maintenance-target readiness rows (seeMaintenanceTargetSpec.doctor_readiness_operationanddoctor_repair_operation).- Daemon raw-failure surface — once
#1198 lands,
maintenance failures will route into the same raw-failure surface
that ingest uses, so they show up in
polyloguedstatus, the health checks added in #844, and any notification backend configured under[notifications].
If a replay fails repeatedly with the same FailureSample.kind and
locator, that is the signal to escalate from "re-run with
--operation-id" to "open an issue against the underlying repair
function."
Idempotency contract
Re-running the same operation against unchanged input is a no-op. Concretely:
previewis read-only; running it twice produces the same inventory minus timing jitter.planis read-only; running it twice produces the same envelope modulo timestamps.runconverges: the secondrunfor the same target set against unchanged source rows reports zero affected rows and zero failure samples. This is enforced by repair functions being idempotent by construction (seepolylogue/storage/repair.py) plus the replay loop's per-target convergence guarantee.
The convergence guarantee is what makes resume safe: an interrupted operation that already advanced past target N will not redo target N on resume, and the redo would have been a no-op anyway.
Runbooks
The runbooks below assume:
- You have a recent local backup (
polylogue ops backup— see daemon.md § Operator-Owned Tasks). - You can stop the daemon if a runbook requires exclusive write
access (
systemctl --user stop polylogued.service). - You ran
polylogue ops maintenance previewfirst to confirm the symptom matches the runbook.
Recovering from a stale FTS index
Symptoms. Search returns fewer hits than expected for known
strings. polylogue ops doctor reports a messages_fts discrepancy.
polylogue ops diagnostics workload
shows non-empty fts_trigger_state.missing or regressed triggers.
Root cause. messages_fts is a contentless FTS5 table
(content='', contentless_delete=1) indexing blocks.search_text,
kept in sync by three rowid-keyed triggers on blocks
(messages_fts_ai/_ad/_au) — there is no bulk-suspend/rebuild step to
interrupt (an earlier design that suspended these triggers during bulk
writes was removed; SQLite DDL is transactional, so that suspension
window never actually produced committed drift). A missing or regressed
trigger today means schema corruption or an incomplete/partial schema
application, not an interrupted suspension window. The daemon startup
check and FTS convergence loop restore a missing trigger by re-running
the canonical DDL, which is idempotent (CREATE TRIGGER IF NOT EXISTS).
Recovery.
# 1. Confirm trigger state.
polylogue ops diagnostics workload --json | jq .fts_trigger_state
# Expect all_present=true. If `missing` is non-empty, continue.
# 2. Start daemon convergence. Startup/read paths restore the FTS invariant.
polylogued run
# 3. Verify.
polylogue ops diagnostics workload --json | jq .fts_trigger_state.all_present
# Expect: true.
If FTS remains non-ready after daemon convergence, the underlying issue is structural (missing columns, corrupted index file, or a broken write path). Stop the daemon, restore from backup or rebuild the affected index tier, and open an issue with the probe output attached.
Draining the convergence-debt queue
Symptoms. polylogue ops diagnostics workload reports a non-trivial
convergence_debt section. polylogue analyze shows derived
materialization counts (session_profile, actions,
work_threads) lagging behind sessions.
Root cause. The daemon's inline convergence loops process a bounded slice each cycle. If ingest outpaced the loop (initial backfill of a large archive, bulk re-import, schema bump) the remaining backlog will not drain inside one cycle.
Recovery.
# 1. Snapshot the workload before.
polylogue ops diagnostics workload --json > /tmp/before.json
# 2. Run daemon convergence. It drains raw materialization, FTS, embeddings,
# and ordinary derived read models in bounded batches.
polylogued run
# 3. Snapshot after and diff.
polylogue ops diagnostics workload --json > /tmp/after.json
polylogue ops diagnostics workload --compare /tmp/before.json /tmp/after.json
Expect convergence_debt.delta to be negative across each stage.
If a stage's delta is zero or positive, that target's repair function
is not draining the backlog — capture the FailureSample block and
escalate.
Rolling back a bad schema upgrade
Symptoms. Polylogue refuses to start after a schema bump:
SchemaVersionError: database is version N, code expects version M.
Polylogue uses durability-keyed schema versioning (see
internals.md § Schema Versioning Model):
derived tiers rebuild, while durable source.db and user.db may advance only
through explicit additive numbered migrations. There is no auto-downgrade.
Root cause. A new release advanced one tier's schema version and the database is on the previous version. There is no reverse in-place migration.
Recovery.
# 1. Confirm the version mismatch.
polylogue --version
sqlite3 ~/.local/share/polylogue/index.db "PRAGMA user_version;"
# 2. STOP the daemon to release exclusive locks.
systemctl --user stop polylogued.service
# 3. Classify the tier before acting.
# 3a. Code rollback (preferred when a release just went out and you
# have not yet relied on any new feature):
# install the previous polylogue version, leave the database
# alone, restart the daemon.
# 3b. Derived-tier forward rebuild: keep the source/user/embedding tiers safe,
# move the mismatched index database aside, and re-ingest/rederive
# the rebuildable index with the new polylogue binary.
cp ~/.local/share/polylogue/index.db /tmp/index-before-rebuild.db
# ...run the documented re-ingest/rederive flow for the release, verify
# it opens cleanly with the new polylogue binary, then restart production.
# 3c. Durable-tier additive migration: keep the daemon stopped, create and
# scratch-verify a minimal backup, then use its authenticated receipt.
polylogue ops backup --output-dir /path/to/staging \
--profile user_overlays --verify
polylogue ops maintenance migrate-tier user \
--backup-manifest /path/to/staging/polylogue-archive-*/manifest.json \
--output-format json
# 4. Restart and verify.
systemctl --user start polylogued.service
polylogue ops doctor
Never hand-edit a tier or use a plain manifest as migration authority. A durable migration requires a successful scratch-restore receipt authenticated by the exact live tier's local key; public hashes and an in-memory "Verification: OK" are insufficient. Keep the backup as an independent copied file set: linked or symlinked tiers are rejected because they do not survive mutation of the live database. If release notes provide neither an additive durable migration nor a derived-tier rebuild plan, keep the daemon stopped and roll back the binary.
Investigating a stuck source
Symptoms. A source family stops producing new sessions even
though source files are present. polylogue sources shows a source
with stale last_seen. Daemon logs show repeated parse errors for
the same artifact id.
Recovery.
# 1. Identify the stuck source.
polylogue sources --output-format json | jq '.[] | select(.healthy==false)'
# 2. Inspect raw-artifact failures from that source.
polylogue ops diagnostics workload --json \
| jq '.recent_attempts[] | select(.source_paths[]? | contains("PATH"))'
# 3. Pull the raw artifact directly to inspect it.
curl -sf "http://127.0.0.1:8765/api/raw_artifacts/<artifact_id>" | jq .
# 4. If the artifact is malformed at the source layer (truncated
# JSONL, missing required field), the fix is upstream — fix the
# source file, then ask the running daemon to import it:
polylogue import <path-to-source>
# 5. If the artifact is fine but the parser rejects it, the fix is
# in the parser. File an issue with the provider and artifact details.
# 6. While the upstream fix is in flight, you can tombstone the
# bad session so it stops blocking convergence:
polylogue ops reset --session <conv_id>
Do not reach for polylogue ops maintenance run to "fix" a stuck
source. Maintenance operates over already-ingested rows; if the rows
are not in the archive yet, maintenance has nothing to do.
Recovering a corrupt blob store
Symptoms. polylogue ops doctor reports unreadable blobs. Session
exports fail with "blob not found". polylogue ops diagnostics workload
shows divergence between blob_links count and the count of files
under blob/.
Root cause. A blob file under <archive_root>/blob/ab/cdef...
was deleted, partially overwritten, or its prefix shard directory
permissions changed. Or: a GC pass with a known orphan-detection bug
(#818) deleted a
blob that was still referenced.
Recovery.
# 1. Stop the daemon to halt new writes.
systemctl --user stop polylogued.service
# 2. Snapshot the GC generation state to capture the age-floor gate's
# high-water mark at the time (GC has no lease state — see
# docs/internals.md "GC concurrency model").
polylogue ops diagnostics workload --json | jq '{gc: .gc_state}'
# 3. Identify the affected sessions.
polylogue ops doctor --schemas --blob-integrity --format json \
| jq '.unreadable_blobs[]'
# 4. If you have a recent backup, restore just the blob store.
# The blob store is content-addressed, so per-blob restore is
# safe — the hash is the address.
restic restore latest --target / --include /path/to/archive_root/blob
# 5. If the blob is gone for good, the session referencing it
# cannot be exported. Tombstone it so it stops blocking exports
# and import from the original source if available:
polylogue ops reset --session <conv_id>
polylogue import <path-to-source>
# 6. After recovery, GC the orphan references that point at the
# now-missing blobs.
polylogue ops maintenance run --target orphaned_blobs
# 7. Restart the daemon.
systemctl --user start polylogued.service
If the corruption is the result of a known GC race (PR #1002 closed the primary one, but #818 tracks remaining classes), attach the lease/GC probe snapshot from step 2 to that issue so the GC pass that mis-classified the blob can be reproduced.