Internals Reference
Working map of the live codebase: invariants, hot files, extension points, and debugging landmarks. For the conceptual system shape, see architecture.md.
Key Invariants
| Invariant | Enforced in |
|---|---|
| Archive writes are idempotent by content hash | pipeline/ids.py, pipeline/services/ingest_batch/_core.py |
| Content hash excludes user metadata (tags, summaries) | pipeline/ids.py:session_content_hash() |
| Content hash uses NFC normalization | core/hashing.py:hash_text() |
| Async SQLite is the primary runtime; sync SQLite exists for CLI, schema tooling, and batch-ingest write paths | storage/sqlite/async_sqlite.py, storage/sqlite/connection.py, pipeline/services/ingest_batch/_core.py |
| SQLite read/write tuning is profile-driven, not backend-local | storage/sqlite/connection_profile.py |
FTS tokenizer is unicode61 (no porter stemmer) |
storage/sqlite/archive_tiers/index.py |
| Schema bootstrap branching is shared across sync and async backends | storage/sqlite/schema_bootstrap.py:decide_schema_bootstrap() |
Hot Files
Entry Points
| File | Purpose |
|---|---|
polylogue/api/__init__.py |
Async library API |
polylogue/config.py |
Runtime configuration and XDG resolution |
polylogue/cli/click_app.py |
Root query-first CLI dispatch |
polylogue/cli/command_inventory.py |
CLI command inventory |
polylogue/operations/archive.py |
High-level archive operations |
Storage
| File | Purpose |
|---|---|
storage/sqlite/archive_tiers/ |
Current split archive DDL and tier versions |
storage/sqlite/schema.py |
Shared sync/async fresh-init, version guard, and extension application |
storage/sqlite/schema_bootstrap.py |
Shared schema snapshot, bootstrap branching, and extension planning |
storage/sqlite/connection_profile.py |
Canonical read/write SQLite timeouts, cache, mmap, and PRAGMA profiles |
storage/repository/__init__.py |
Repository facade (10-mixin composition: archive reads, archive writes, raw, vectors, and six insight readers — profile, run-projection, timeline, thread, summary, topology) |
storage/search_providers/fts5.py |
Lexical search |
storage/search_providers/hybrid.py |
Hybrid retrieval (RRF fusion) |
Sources and Pipeline
| File | Purpose |
|---|---|
sources/dispatch.py |
Provider detection and parser routing |
sources/parsers/*.py |
Per-provider parsing |
pipeline/ingest_support.py |
Ingest stage definitions and source selection helpers |
pipeline/ids.py |
Content hashing and ID generation |
pipeline/services/ingest_batch/_core.py |
Batch ingest (largest pipeline file) |
Extension Points
Adding a provider: Start at sources/dispatch.py:detect_provider(). Add a
looks_like() function in a new parser under sources/parsers/. Add a
Provider enum variant in core/enums.py. Add a provider schema bundle under
schemas/providers/. Dispatch is generally strict-before-loose for record-path
and Pydantic-validated checks, but sources/dispatch.py runs some structural
sequence detectors before loose code/dict probes. Insert the new check at the
tightness level it deserves or an earlier parser will claim its records first.
Adding a filter: Filter chain: archive/filter/filters.py. If the filter
needs a stats-table join, update _needs_stats_join() in
storage/sqlite/connection.py. Add the corresponding CLI flag in
cli/query.py and MCP parameter in mcp/.
Adding a CLI command: Register in cli/command_inventory.py. Implementation
goes in cli/commands/. The CLI shows fast daemon status on bare invocation
and falls back to archive summary when the daemon is not running.
Adding a session insight: Define the insight model in insights/. Add
storage in storage/insights/session/. Wire rebuild logic and register in
insights/registry.py.
Adding a devtools command: Add a CommandSpec to
devtools/command_catalog.py. Implementation goes in devtools/<name>.py.
Run devtools render all to update the generated catalog in
docs/devtools.md.
Adding any new module/file under polylogue/: regenerate the topology
projection or verify topology and render all --check will fail on the new
path. Run devtools render topology-projection && devtools render topology-status
and commit the updated docs/plans/topology-target.yaml and
docs/topology-status.md alongside the code.
Schema Versioning Model
Polylogue has two schema-evolution regimes, keyed by tier durability.
-
Tier version constants under
storage/sqlite/archive_tiers/are the authority. The canonical fresh schema is described directly by each tier DDL. -
Durable tiers (
source.db,user.db) may use explicit additive migrations. Migration SQL lives understorage/sqlite/migrations/{source,user}/NNN_name.sql, advancesPRAGMA user_versionone step at a time, and requires a verified backup manifest containing the affected tier before it runs. Verification restores the backup into scratch, checks every included SQLite tier and referenced blob, then writes a versioned receipt authenticated by a per-tier HMAC key under the Polylogue XDG state directory. The key path is derived independently from the live archive path and is never copied into the backup. This prevents public backup hashes or a transplanted receipt from impersonating successful verification. It is an artifact-integrity boundary, not protection against hostile arbitrary code running as the same Unix user, which can read the local0600key. Every accepted bundle artifact must also be a contained, single-link regular file: symlinks, hardlinks, non-regular files, linked blob ancestry, and a target tier that aliases the live database are rejected so migration cannot mutate its own recovery copy. SQLite-wal,-shm, and rollback-journal sidecars are forbidden in a verified bundle; checkpointed copies are read in immutable mode, so no unsigned sidecar can change their logical contents. The receipt also binds a closed recursive inventory of every directory and file (except the receipt itself), including auxiliary debt reports, so no undeclared artifact can appear, disappear, or change. Additive meansCREATE TABLE,CREATE INDEX,ADD COLUMN, and bounded backfills. Destructive durable-tier changes require a copy-forward design and explicit operator consent. -
Derived tiers (
index.db,embeddings.db) do not have in-place migration chains. They are rebuildable products over durable source/user evidence: schema mismatches are handled by rebuilding or blue-green replacing the affected derived tier from source, preserving durable tiers. -
Disposable tiers (
ops.db) may keep narrow bootstrap-timeALTER TABLEhelpers for daemon telemetry because the tier is disposable. -
On startup the on-disk
PRAGMA user_versionis compared against the tier constant:- Empty file (
user_version == 0): bootstrap fresh. - Version match: open as-is.
- Older durable tier: refuse ordinary open and require explicit migration with a backup manifest.
- Derived mismatch or newer durable tier: reject and require rebuild or a newer runtime as appropriate.
- Empty file (
-
During development, schema changes are triaged before reset/reingest: metadata-only, index-only, additive-derived, additive-durable, or semantic-reparse-required. Same-tier schema changes from ready Beads should be batched before a live rebuild so the active archive is not reset repeatedly.
-
devtools lab policy schema-versioningenforces the boundary: durable SQL migrations are allowed only under the numbered migration resource roots, while derived-tier upgrade helpers remain forbidden. -
User schema version 7 adds durable content-addressed
queries, mutablequery_names, promotedresult_sets/result_set_members, and planner emittedquery_edges. Existingsaved_queryassertions are repointed to their canonicalquery:<hash>target in the same verified-backup migration. Exact result members are retained only for watch/pinned/finding/cohort persistence classes; ordinary execution relations remain ops-tier telemetry. -
User schema version 6 adds immutable, fingerprinted
annotation_schemasdefinitions and independentannotation_batchesprovenance containers. Batch labels remain assertion rows linked through anannotation-batch:<id>scope_ref. Existing v5 tiers migrate additively only after an authenticated verified-backup receipt; fresh user tiers create the same canonical tables and registereddelegation.discourse@v1schema. -
User schema version 5 adds
context_deliveries, an immutable receipt ledger for the exact context image delivered to an agent, including recipient, actor, run/boundary, included refs, omissions, caveats, and image digest. Existing v4 tiers migrate additively after an authenticated verified-backup receipt; fresh user tiers create the table directly. -
User schema version 4 adds
user_settings, a durable key/value table for workspace/settings state that is not an epistemic assertion. Existing v3 user tiers migrate additively after a verified backup manifest; fresh user tiers create the table directly. -
Source schema version 3 drops
pending_blob_refs(polylogue-v7e0). The table backed a blob-GC lease mechanism (acquire_blob_leases/release_operation_leases) that a race-window audit found no production ingest caller ever populated — the table was provably empty in every real deployment (zero writers anywhere in the write path) — see "GC concurrency model" below for the current, lease-free contract. Existing v2 source tiers migrate viastorage/sqlite/migrations/source/003_drop_pending_blob_refs.sqlafter a verified backup manifest; fresh source tiers never create the table. -
Source schema version 4 adds
blob_publication_reservations, an exact filesystem-publication boundary rather than the removed late write-effects lease. Rows are keyed by per-publication receipt ID and indexed by content hash, so concurrent publishers of identical bytes remain independent. Existing v3 tiers migrate additively through004_blob_publication_reservations.sqlafter a verified backup manifest. -
Index schema version 35 adds Polish lexical search-recall folding (polylogue-9jsi). The
messages_fts,threads_fts, andsession_work_events_ftstokenizers move fromunicode61tounicode61 remove_diacritics 2, which folds ordinary combining-mark diacritics (ó->o,ż->z,ą->a, ...) symmetrically for indexed andMATCHquery text. Separately,ł/Ł(Latin L with stroke) has no Unicode decomposition, so neither NFD normalization norremove_diacriticscan fold it; every write path for the three FTS surfaces—fresh-write triggers, full rebuilds, missing-row repair, and dangling derived-surface repair—applies an inlineREPLACE-chain fold (polylogue/storage/fts/pl_fold.py:pl_fold_sql_expr) to the indexedtextcolumn, andescape_fts5_queryapplies the byte-identical Python fold (pl_fold) to query text, solatwo/zrobilemqueries find seededłatwo/zrobiłemcontent. The fold is deliberately narrow (ł/Łonly) and expressed once fromPL_FOLD_TABLE; real-route rebuild and repair tests lock the write-side calls to query-side normalization. Existing index tiers must be rebuilt from source evidence (polylogue ops reset --index && polylogued run) to pick up the new tokenizer and re-fold already-indexed rows. -
Index schema version 34 rebuilds the
delegationsview (polylogue-y964, polylogue-4c27). The prior view aliasedsession_links.src_session_id(canonically the CHILD) asparent_session_idandresolved_dst_session_id(canonically the PARENT) aschild_session_id— backwards — and joinedbranch_point_message_id(the child's last inherited parent message, for lineage composition) as if it were the Task dispatch pointer. The rebuilt view spines on parent-sideactionsrows (semantic_type='subagent') instead ofsession_links, exposes amapping_state(resolved/unresolved/ambiguous/edge_only/quarantined) instead of silently dropping unpaired or quarantined edges, and separates model identity intodispatch_turn_model(the message that authored the dispatch),requested_model(an explicit routing override from the dispatch tool_input, if the provider recorded one), andparent_session_dominant_model/child_session_dominant_model(the session-level dominant-model fallback, explicitly named and excluded from turn-level claims) — replacing the oldorchestrator_model/subagent_modelcolumns, which conflated a session-wide aggregate with per-turn dispatch authorship. Existing index tiers must be rebuilt from source evidence (polylogue ops reset --index && polylogued run); no public reader should keep consuming the old column names. -
Index schema version 33 adds
'provider_usage'to theinsight_materialization.insight_typeCHECK constraint (polylogue-f2qv.5), sosession_model_usagecan carry a materializer-version stamp and self-heal through the same session-insight rebuild path assession_profile/latency/etc instead of requiring a manualops reset --indexafter a provider-usage materializer fix. Existing index tiers must be rebuilt from source evidence (polylogue ops reset --index && polylogued run) — aCREATE TABLE IF NOT EXISTSon an already-existing table does not retroactively widen its CHECK constraint, so the version bump (not just the DDL edit) is what forces existing archives through the fresh-first rebuild path rather than hitting a CHECK-constraint violation the first time a session's insights are rebuilt. -
Index schema version 30 makes
session_eventsthe lossless generic relation for every parsed non-message event. It retains open event types and structured payloads in original positions while policy and usage tables remain typed projections. The original provider-local source-message reference is stored independently from its nullable canonicalmessages.message_idresolution, so unresolved and lineage-normalized references remain auditable. Existing index tiers must be rebuilt from source evidence (polylogue ops reset --index && polylogued run). -
Index schema version 28 adds the
delegationsVIEW, derived fromsession_links(link_type='subagent') LEFT JOIN'd to the parent's Task dispatchactionsrow and both sessions'session_profiles— no convergence stage needed, matching theactionsview's own derived-tier precedent.result_status(ok/error/unknown) is derived only fromactions.is_error/exit_code, never guessed. -
Index schema version 27 adds
session_profiles.primary_model_nameandprimary_model_family, the dominant model per session by assistant OUTPUT-token share, backing thedelegationsview above. -
Index schema version 26 rewrites the
actionsview to pairtool_use/tool_resultblocks by transcript rank within(session_id, tool_id)instead of a plaintool_idequality join, which fanned out N*M when a provider re-emitted the sametool_idon distinct messages. -
Index schema version 25 adds
blocks.content_hash, a SHA-256 over canonical block evidence (type, text, tool_name, canonical tool_input, semantic/media/language, is_error, exit_code — excluding session_id/message_id/position/tool_id) so a stored block citation anchor survives fork-position shift, re-ingest renumbering, and provider tool-id regeneration. Seepolylogue/storage/block_anchor.py. -
Index schema version 24 admits
capture_gaprows insession_events. These are narrow lifecycle evidence events emitted when ingest rejects a lower- precedence DOM browser-capture fallback because a richer source row already owns the session. Existing index tiers must be rebuilt from source evidence (polylogue ops reset --index && polylogued run). -
Index schema version 23 adds
idx_blocks_search_text_populated, a partial index over text-bearingblocksrows, and makesfts_freshness_statepart of the canonical fresh index tier. Message search readiness compares that source set withmessages_fts_docsizeand consults the durable freshness marker before running user FTS queries; without the partial index and ledger, large archives can spend the query budget scanningblocksmerely to decide whetherpolylogue find hermesis allowed to run. Existing index tiers must be rebuilt from source evidence (polylogue ops reset --index && polylogued run). -
Index schema version 22 adds
idx_blocks_tool_result_outcome, a partial index over structuredtool_resultoutcome fields. Claim-vs-evidence and action-outcome reads anchor on provider-reportedis_error/ non-zeroexit_coderather than assistant prose; without an outcome-leading index, large archives must scan the whole tool-result block set before pairing actions. Existing index tiers must be rebuilt from source evidence (polylogue ops reset --index && polylogued run). -
Index schema version 21 adds
idx_messages_embedding_prose, a partial covering index for authored prose messages eligible for paid embeddings: standardmessagerows fromuser/assistantroles, withhuman_authored/assistant_authoredmaterial origin and positive word count. Embedding preflight, status detail, and archive-session embedding reads use this index when present so cost windows do not scan unrelated tool, protocol, context-pack, or runtime rows in large archives. Existing index tiers must be rebuilt from source evidence (polylogue ops reset --index && polylogued run). -
Index schema version 20 adds
idx_blocks_type_tool, an expression index on(block_type, COALESCE(NULLIF(LOWER(tool_name), ''), 'unknown')), so tool family rollups can resolve exact tool names and MCP-server prefixes without scanning everytool_useblock in large archives. Theanalyze toolslowerers use range predicates for MCP prefixes to match the index expression. Existing index tiers must be rebuilt from source evidence (polylogue ops reset --index && polylogued run). -
Index schema version 19 adds expression indexes for materialized
session_observed_eventstool outcome payload fields. The terminal query DSL can now ask questions such asobserved-events where kind:tool_finished | group by handler | count; on large archives the v18 shape filtered bykindand then built temporary B-trees over JSON-extractedtool_name,handler_kind, orstatusvalues. The v19 indexes key(kind, COALESCE(NULLIF(json_extract(payload_json, '$.<field>'), ''), 'unknown'))fortool_name,handler_kind, andstatus, matching the SQL lowerer's group expressions. Existing index tiers must be rebuilt from source evidence (polylogue ops reset --index && polylogued run). -
Index schema version 16 captures structured tool-result outcomes (the "keystone").
blocksgainstool_result_is_error(0/1, nullable) andtool_result_exit_code(nullable INTEGER); theactionsview exposes them asis_error/exit_codealongside the paired tool_use row. Previously the source's own outcome fields (Claudeis_erroron tool_result content, Codexfunction_call_output.metadata.exit_code) were dropped at parse, forcing in-session outcomes ("tests passed", "command failed") to be regex-guessed from result text. Now they are read from structure: NULL means unknown (default), never a fabricated positive. This is additive — existing rows read NULL until rebuilt. Rebuild from source evidence (polylogue ops reset --index && polylogued run). -
Index schema version 15 makes
idx_messages_session_sortkeyan expression index —(session_id, (occurred_at_ms IS NULL), occurred_at_ms, message_id)(#2475 perf audit). The keyset/paginated message reads order by(occurred_at_ms IS NULL), occurred_at_ms, message_idso NULL-timestamp rows sort last; the v14 plain(session_id, occurred_at_ms, message_id)index could not satisfy that leadingIS NULLexpression, so the planner fell back toUSE TEMP B-TREE FOR ORDER BYand sorted the whole session per chunk (expensive on multi-thousand-message sessions). The expression index matches the ORDER BY exactly and plans as a covering-index scan with no temp sort (verified via EXPLAIN QUERY PLAN). Rebuild from source evidence (polylogue ops reset --index && polylogued run). -
Index schema version 14 hardens lineage normalization (#2467 audit).
session_links.branch_point_message_idis no longer a FK withON DELETE SET NULL: a parent's full-replace re-ingest (DELETE FROM messagesthen re-INSERT) would otherwise null the child's branch point during the DELETE step and permanently break the child's composition.message_idis deterministic, so the plain-TEXT reference survives the re-create; reads bail to the child's own tail if a branch point ever dangles. Also addsidx_messages_session_sortkeyso the keyset/paginated message reads stop doing a temp B-tree sort per chunk. Rebuild from source evidence. -
Index schema version 13 makes attachment bytes honest (#2468).
attachments.blob_hashis now nullable and holds the true SHA-256 of the stored bytes when acquired (previously a synthetic hash of the attachment id was written with no blob ever stored — 0 blobs for 8,425 rows). A newacquisition_status(acquired/unavailable/unfetched) records whether the bytes were fetched. Inline export bytes (e.g. Gemini base64) are written to the content-addressed blob store at ingest; other sources stayunfetchedwith the source ref preserved for later re-acquisition. Rebuild from source evidence. -
Index schema version 12 normalizes prefix-sharing lineage (#2467). A fork, resume, spawned subagent, or auto-compaction copy physically replays the parent's leading context; the writer now drops that inherited prefix and keeps only the child's divergent tail, recording the relationship on
session_linksvia two new columns:branch_point_message_id(the last inherited parent message) andinheritance(prefix-sharingvsspawned-fresh). Reads (get_messages,read_archive_session_envelope) compose the parent transcript up to the branch point + the child's own tail, so each real message is stored once while the full logical transcript is still served. Existing index tiers must be rebuilt from source evidence (polylogue ops reset --index && polylogued run);source.dbis untouched, so this is a derived-index rebuild with no user-data impact. (The matching parser detection of Codexforked_from_id/thread_spawnand Claudeagent-acompact-*shipped alongside.) -
Index schema version 11 materializes the run projection into three derived read-model tables —
session_runs,session_observed_events, andsession_context_snapshots— recomputed by the session-insight materializer (compile_session_digest(...).run_projection) exactly likesession_profiles. They are derived/rebuildable enrichments, not the only query substrate: terminalrun/observed-event/context-snapshotqueries also synthesize cheap local rows directly fromsessionsandblocks(main runs,session_started, tool-finished outcomes, and session-start context snapshots). The durable evidence stayssession_events+messages+session_links, and materialized rows are retained only where they encode richer non-local projections that are not cheap to lower directly. Existing index tiers must be rebuilt from source evidence (polylogue ops reset --index && polylogued run). -
Index schema version 10 adds a role-leading
idx_messages_roleindex so daemon/api/facetscan compute global role counts without scanning the session-role compound index and spilling to a temp B-tree. Existing index tiers must be rebuilt from source evidence or explicitly operator-patched by adding the index to a backed-up archive before moving that archive to version 10 with the matching deployed package. -
Index schema version 9 adds typed
sessions.session_kindfor standard vs temporary sessions. Existing index tiers must be rebuilt from source evidence or explicitly operator-patched so temporary ChatGPT/Claude/browser-capture sessions become schema state instead of only parser auto-tags. -
Index schema version 8 added
web_content_constructsfor provider-native web/export constructs such as citations, source references, canvas/artifact revisions, asset pointers, token-budget rows, and task/search records. -
Index schema version 7 added JSON-object checks for
blocks.tool_inputandsession_provider_usage_events.payload_json. Existing index tiers must be rebuilt from source evidence so the canonical read model is recreated with the stricter JSON contract. -
Provider usage accounting is audited as a source-derived read model: exact event rows (
session_provider_usage_events), text-only estimates, unsupported origins, source rows acquired but not materialized, and stalesession_model_usagerollups are distinct diagnostic states. Cache read/write token lanes remain labelled and are not merged into generic input/output. -
Index schema version 6 added
session_provider_usage_eventsfor provider-reported token usage events and single-model rollup repair from those events. Existing index tiers must be rebuilt from source evidence so Codextoken_countrecords materialize into durable provider usage rows instead of disappearing after parse. -
Index schema version 5 added
messages.material_originplus authored-user aggregate columns onsessions. Existing index tiers must be rebuilt from source evidence so Claude Coderole=userprotocol/context/generated-pack rows move out of authored-user accounting while provider-role counts remain available. -
Provider schemas (the parsing/validation surface, distinct from the storage schema) are still regenerated fresh via
devtools lab schema generateand promoted viadevtools lab schema promote.
For derived tiers (index.db, embeddings.db) this design intentionally
rejects in-place upgrade-chain complexity (no Alembic, no forward/reverse
upgrade scripts, no partially-applied upgrade states, no
_apply_version_upgrade_plan rollback windows): a derived-tier schema mismatch
rebuilds or blue-green-replaces the tier from durable source/user evidence.
Files that are not configured archive paths are not classified or handled by
the archive runtime.
For durable tiers (source.db, user.db) the boundary is different, because
user.db holds irreplaceable human assertions that cannot be rebuilt from
source. These tiers use explicit additive numbered SQL migrations under
storage/sqlite/migrations/{source,user}/NNN_*.sql, applied one PRAGMA user_version step at a time by migration_runner.py behind a verified backup
manifest for the affected tier. Additive means CREATE TABLE/CREATE INDEX/
ADD COLUMN/bounded backfill; destructive durable-tier changes require a
copy-forward design and explicit operator consent, never a routine migration.
ops.db (disposable daemon telemetry) may additionally use narrowly scoped
ALTER TABLE ... ADD COLUMN bootstrap helpers in
storage/sqlite/archive_tiers/bootstrap.py
(ingest-cursor runtime fields, cursor-lag rollups). The
devtools lab policy schema-versioning lint enforces the whole boundary:
numbered durable-tier migrations are allowed; derived-tier upgrade helpers are
forbidden.
Archive Activation
The archive file set is split by durability class:
source.dbstores raw acquisition rows and source evidence that rebuildable projections depend on.index.dbstores the parsed session/message/block tree, FTS/search indexes, graph/topology rows, and derived insight read models.embeddings.dbstores vector rows, embedding status, and embedding catch-up metadata; it is rebuildable, but expensive.user.dbstores irreplaceable human input as assertion rows: marks, annotations, corrections, user tags, metadata keys, saved views, recall packs, workspaces, blackboard notes, candidates, and judgments.ops.dbstores disposable daemon telemetry such as ingest cursors, attempts, convergence debt, stage events, embedding catch-up runs, and OTLP spans.
The operator flow is explicit:
polylogue ops maintenance archive-plan
polylogue ops maintenance archive-init --yes
polylogued run
polylogue ops maintenance archive-read --limit 20
archive-init bootstraps the archive file set. The daemon and explicit
ingest paths populate source.db and index.db directly from source
artifacts. Root query commands use the active index.db.
Source schema version 2 removes the old unique (origin, native_id) raw-row
constraint. A native session can have multiple durable source observations
(direct export, browser native capture, DOM fallback, historical ZIPs) while
still coalescing to one canonical indexed session; source evidence must not be
replaced merely because two captures describe the same provider-native id.
Topology Edges (#1258)
The session_links table (index tier) persists every parent reference asserted
by a parser as a typed row, including references whose parent has not yet been
ingested (out-of-order ingestion) or has been hard-deleted. The pre-existing
fast path (sessions.parent_session_id set when the parent is in the prepare
cache) is unchanged; session_links is an additional durable record that always
carries the original provider-native parent id. The same table also carries the
#2467 prefix-sharing lineage columns (branch_point_message_id, inheritance).
- Identity: primary key
(src_session_id, dst_origin, dst_native_id, link_type). Re-ingesting the same child is idempotent. - Closed enums:
polylogue/core/enums.pyownsLinkType(continuation / sidechain / subagent / branch / fork / resume / repaired), re-exported asTopologyEdgeTypefrompolylogue/archive/topology/edge.py, andTopologyEdgeStatus(unresolved / resolved / repaired / quarantined).quarantinedmarks a link the topology reducer dropped to break a cycle. - Resolve: every session save runs
resolve_session_links_for_session(polylogue/storage/sqlite/queries/session_links.py) so that an out-of-order child's edge flips toresolvedthe moment its parent's native id appears insessions. - Hash boundary: these links are derived per ingest and are NOT part
of
sessions.content_hash— mirrors the same boundary as user correction assertions (#1131).
Logical Session Identity (#866)
session_profiles.logical_session_id materializes the resolved root of a
session's parent chain. For a root session it equals
session_id; for continuations, forks, sidechains, and subagents it points
at the root session that represents the logical work session.
Day summaries and tag rollups retain session_count as the physical
session count and add logical_session_count plus
logical_session_ids_json so weekly and cross-provider reducers can count
logical sessions without re-walking parent pointers. The Python API exposes
get_logical_session(session_id) as the compact read-pull envelope for
agents and MCP callers; get_session_topology remains the full graph view.
Learning Corrections (Feedback Loop)
User corrections live outside the content-hash boundary by construction
(#1131). In the split archive they are AssertionKind.CORRECTION rows in the
unified user.db assertions table; a legacy user_corrections table is still
read for pre-split single-file archives (storage/insights/feedback/):
- Keyed by session and correction kind — at most one correction of each kind per session, so deterministic rebuilds always produce the same merged insight output.
- Recognized kinds (closed
CorrectionKindenum):tag_reject,tag_accept,summary_override. New kinds are an explicit code change. - Recording or removing a correction never touches
sessions.content_hash. The hash invariant is asserted bytests/unit/insights/test_feedback.py. - Insight materialization paths consult corrections after computing
heuristic suggestions. Auto-tag and summary merge helpers live in
polylogue/insights/feedback.py. - Surfaces:
- MCP:
record_correction,list_corrections,clear_corrections. - Library:
Polylogue.record_correction(...),Polylogue.list_corrections(...),Polylogue.delete_correction(...),Polylogue.clear_corrections(...).
- MCP:
- Storage backed by
polylogue/storage/sqlite/archive_tiers/archive.py(ArchiveStore.record_correction/list_corrections/delete_correction/clear_corrections) andpolylogue/storage/insights/feedback/(async SQL helpers).
Text Handling Contracts
Polylogue exposes several text-processing boundaries. Each declares one of
three contracts per edge case; the matrix in
tests/property/test_encoding_boundary_matrix.py (#1305) is the executable
record of which contract applies where.
| Boundary | Module | Contract for edge cases |
|---|---|---|
| JSON byte decoding | polylogue/sources/decoder_json.py:decode_json_bytes |
UTF-8 BOM and BOM-bearing UTF-16 are decoded and the BOM is stripped; raw UTF-16 without a BOM is unsupported by design. |
| Content hash | polylogue/core/hashing.py:hash_text, polylogue/pipeline/ids.py |
NFC normalization is applied to text fields (title, message text) before hashing, so NFC and NFD inputs produce identical content_hash. Lone surrogates raise UnicodeEncodeError (typed rejection — not silent corruption). |
| FTS5 indexing | polylogue/storage/sqlite/archive_tiers/index.py (messages_fts, unicode61) |
Block search text is stored and indexed unchanged. RTL scripts (Arabic, Hebrew) and Latin-with-diacritics are word-tokenized; CJK runs index as a single token (substring queries against CJK are not supported). Zero-width and bidi characters pass through without crashing indexing. |
| FTS5 query escaping | polylogue/storage/search/query_support.py:escape_fts5_query |
Every edge-case input produces a MATCH-safe query — bidi, zero-width, RTL, CJK, surrogate-pair emoji never raise OperationalError. |
| Terminal output | UTF-8 TextIOWrapper |
All matrix strings pass through unchanged; lone surrogates raise UnicodeEncodeError. |
Edge cases covered: UTF-8/UTF-16 BOM, NFC/NFD equivalence, combining marks, RTL (Arabic, Hebrew), bidi overrides, zero-width joiners and ZWNJ/ZWSP, non-BMP characters (CJK extension B), ZWJ emoji sequences with skin-tone modifiers, and unpaired surrogates.
WAL Management
SQLite WAL behavior for the archive database (see also
polylogue/storage/sqlite/connection_profile.py):
- Journal mode: WAL on the write profile. Set once per database the first time a writer opens it; persists in the file header.
- Connection cache profiles: high-throughput ingest writes use the
full write profile (
128 MiBSQLite cache,1 GiBmmap allowance). Long-running daemon/ops writes use the daemon write profile (16 MiBcache,64 MiBmmap allowance) so small telemetry, cursor, heartbeat, OTLP, and maintenance writes do not keep batch-sized SQLite page-cache pressure charged topolylogued.service. Read-only daemon probes use the read profile (query_only=ON) instead of opening write connections. - Autocheckpoint threshold:
WAL_AUTOCHECKPOINT_PAGES = 10000= 40 MiB. When the WAL crosses this, SQLite runs a PASSIVE checkpoint inline with the next commit. - Size cap (#1614):
WAL_JOURNAL_SIZE_LIMIT_BYTES = 160 MiB(4x the autocheckpoint threshold). Set viaPRAGMA journal_size_limiton the write profile. When a TRUNCATE checkpoint succeeds (no readers blocking), SQLite truncates the WAL file down to the cap. A reader-blocked WAL still grows past the cap during the blocked window; the first successful TRUNCATE after the reader closes shrinks it back. - Read profile (#1614): the canonical read profile sets
PRAGMA query_only = ON. Read connections opened viaopen_readonly_connectionalready use themode=roURI flag for file-level read-only enforcement; the pragma additionally rejects accidental writes at SQL parse time instead of contending for the write lock and timing out.
Relationship to #1602 (autocheckpoint revert)
A previous PR (3c5cc1a0) lowered the autocheckpoint threshold to 4 MB
to shrink the symptom of unbounded WAL growth under reader-blocked
TRUNCATE checkpoints. That tightening was reverted in #1602 once the
real fix landed: the autocheckpoint threshold is back to 40 MB
(WAL_AUTOCHECKPOINT_PAGES = 10000), and the new
journal_size_limit cap from #1614 is what bounds WAL growth
without paying the per-commit cost of a tighter autocheckpoint
threshold. The two work together — autocheckpoint is the routine
trim, journal_size_limit is the post-blocked-window shrink.
Daemon-side periodic checkpoint
The daemon runs maybe_checkpoint_wal() every 5 minutes via the
periodic loop. This explicit TRUNCATE pass is the primary mechanism
that exercises the journal_size_limit cap in production — once the
reader contention clears, the next periodic pass shrinks the WAL.
Content Hash Model
Archive writes are idempotent by content hash:
- SHA-256 over NFC-normalized (Unicode Normalization Form C) session payload
- Hashed fields: title, timestamps, messages, attachments, content blocks
- Excluded from hash: user metadata (tags, summaries, notes) — editing these does not trigger re-import
- Hash is computed in
pipeline/ids.py:session_content_hash()and stored ascontent_hashon sessions - On re-ingest, if the content hash matches, the session is skipped (idempotency). If it differs, the session is updated and dependent insights are rebuilt.
FTS5 Model
Full-text search uses SQLite FTS5:
- Tokenizer:
unicode61(no porter stemmer — this SQLite build doesn't include it). Case-insensitive for ASCII. Unicode-aware tokenization. - Contentless, not external-content:
messages_ftsis declaredcontent=''withcontentless_delete=1— it does NOT sync against amessages(or any other) source table by re-reading it. The indexed text isblocks.search_text, aVIRTUALgenerated column (trim(text || ' ' || tool_name || ' ' || tool_input.command/file_path/path)), supplied explicitly at insert time by three rowid-keyed triggers (messages_fts_ai/_ad/_au) onblocks, not read back from any content table. Practical consequence:snippet()/highlight()returnNULL(contentless tables have no stored text for FTS5 to slice), so every read path that needs the actual text joinsblocksbyrowidand usesb.search_textdirectly instead. - No trigger-suspension/rebuild loop: an earlier design (#1242) dropped
the FTS triggers during bulk writes and rebuilt the index afterward
(
INSERT INTO messages_fts(messages_fts) VALUES('rebuild')); that auto-restore loop has been removed — it guarded a mid-batch state that never actually landed as committed drift (SQLite DDL is transactional, so a SIGKILL mid-batch always rolled back to the triggers-present state anyway). The real net today is the freshness/repair machinery: thefts_freshness_statedurable marker plus theidx_blocks_search_text_populatedpartial index, which message-search readiness consults againstmessages_fts_docsizebefore running a user FTS query (index schema version 23, see the schema-version-history note above), and the per-session repair path (#1851) that reconciles a specific session's FTS rows againstblocks.search_texton demand. - Query syntax: FTS5 boolean operators (AND/OR/NOT), phrase search
(
"exact phrase"), prefix search (prefix*). Column filters are not directly exposed; use CLI/MCP filters instead.
Blob Store Model
Content-addressed blob storage for large binary data:
- Content addressing: SHA-256 hash over raw bytes. The hash IS the address. Identical content → identical hash → automatic deduplication.
- Prefix sharding: 256 subdirectories (
blob/00/throughblob/ff/), each containing blobs keyed by the remaining 62 hex characters of the hash. - Linking:
raw_artifacts.link_group_keygroups related blobs (e.g., all blobs belonging to one session). The blob store itself (polylogue/storage/blob_store.py) is a pure content-addressed store with no notion of grouping; session-to-blob association is recorded as rows inraw_artifactskeyed byraw_idwith a sharedlink_group_key. (There is no separateblob_linkstable; the name is a historical alias for this row-group view ofraw_artifacts.) - Operations: Blobs are write-once, read-many. No in-place modification. GC identifies unreferenced blobs via a snapshot reference check plus an age floor (see GC concurrency model below), not simple link counting.
GC concurrency model — publication reservation, reference, and age floor
run_blob_gc deletes orphan blob files using three safety
invariants combined:
- DB reference check —
_still_referencedqueriesraw_sessionsfor the blob'sraw_id. If a raw record points at the blob, GC skips it. This is the snapshot/mark-and-sweep view of "this blob is in active use right now." It only sees committed rows: a blob whose referencing ingest has written the bytes to disk but not yet committed the row is, by SQLite's isolation, indistinguishable from a true orphan to this check alone. - Publication receipt — archive orchestration prepares a bounded batch of private temporary files, commits every per-publication receipt in one source-tier transaction, then publishes every final content-addressed path. The exact source-reference transaction consumes its own receipt ID; an index-only attachment consumes its receipt only after the index commit. Same-hash publishers cannot consume one another. Pure parser/source APIs receive an injected writer and remain independent of archive paths/schema. GC enumerates outside its lock, then holds the source-tier write lock only across the bounded final reference/receipt recheck and unlink. Dry-run is read-only.
- Age floor — a candidate must be older than
max(MIN_AGE_S, now - prev_generation.completed_at)(polylogue/storage/blob_gc.py:run_blob_gc_report).MIN_AGE_Sis 60 seconds;gc_generationstracks the high-water mark of completed GC runs so a blob created during the same window as the previous pass is never reclaimed before its eventual reference can land. With no prior generation recorded, the staticMIN_AGE_Sfloor applies on its own.
The age floor was previously the sole defense against the race the reference
check cannot see. A prior revision carried a late lease mechanism (pending_blob_refs,
acquire_blob_leases/release_operation_leases) meant to make that window
explicit rather than relying on a timing heuristic. It was fully
implemented and unit-tested but never reachable in production: the only
call site that could have populated the lease payload keys
(commit_archive_write_effects's _blob_hashes/_operation_id) was never
given them by any real ingest caller (_commit_sync_ingest_side_effects
built its payload without them). A race-window audit
(docs/audits/2026-07-09-race-window-audit.md, rows 1a/1b) confirmed zero
production callers across the whole write path, so has_lease was always
False and the acquire/release calls never ran. The mechanism was removed
rather than left as dead code implying a protection that did not exist
(polylogue-v7e0). A deterministic provider-shaped measurement later proved
the real window structurally unbounded: acquisition prefetched 128 artifacts
and committed references in batches of 500, so a slow following artifact
could age an earlier blob past 60 seconds. Source v4 therefore reserves at
the only boundary that closes the race: before final-path visibility.
Crash reconciliation has no TTL. It classifies and retains receipts by
default, including missing-path receipts: absence is not proof that a paused
publisher died. Automatic clearing requires archive-wide writer exclusion.
Existing blob-without-reference rows remain explicit recoverable acquisition
debt until reacquisition or confirmed operator abandonment through
polylogue ops maintenance blob-publications. Age is never treated as proof
that a publisher is dead. Archive backup holds the same writer exclusion and
copies an exact hash/size inventory for the union of durable references and
publication receipts.
- Known issues: GC has bugs with orphan detection and integrity verification (#818) — independent of the lease design audited above.
Daemon Convergence Evidence
polylogue ops diagnostics workload produces a stable, JSON-serializable
snapshot of daemon-relevant state read directly from the archive SQLite
database. The probe is read-only and does not talk to the running daemon.
Raw acquisition, index materialization, and FTS readiness are treated as one
auditable convergence contract. A source.db.raw_sessions row that is not
explicitly skipped and has no matching index.db.sessions row is
raw-materialization archive debt; daemon status exposes it through
component_readiness.raw_materialization and
raw_materialization_readiness instead of reporting the archive as simply
healthy. FTS readiness is likewise a freshness invariant, not a best-effort
cache: stale or untrusted recorded counts make search readiness non-ready until
the index is demonstrably current.
Session insights are the same class of daemon-enforced derived-model invariant, alongside FTS and embeddings. Missing or stale session profiles, work events, phases, threads, and run projections remain convergence debt until the insights stage has rebuilt them; a bounded false result is retried like the other derived-model stages rather than becoming an operator-maintenance task. The rebuild is idempotent by session id and materializer version, and the daemon records the stage timing and debt evidence through the normal convergence attempt path.
Insight rebuilds deliberately stay out of the ingest write transaction even though they are automatic. They can hydrate and summarize large sessions, so the materializer commits per message-budget chunk to keep WAL and write-lock scope bounded. They also batch hot, still-changing source files until a quiet window, avoiding repeated expensive rebuilds while Codex or Claude continues to append to a session. Finally, the materializer has its own versioned rebuild contract: changing the derived insight algorithm should refresh the rebuildable read model without changing the durable source/index write boundary.
For #845-style before/after convergence evidence snapshots:
polylogue ops diagnostics workload --json > before.json
# ...run convergence work (e.g. polylogued runs, ingest, debt drain)...
polylogue ops diagnostics workload --json > after.json
polylogue ops diagnostics workload --compare before.json after.json
polylogue ops diagnostics workload --compare before.json after.json --json > diff.json
The report has a stable top-level shape carrying its report_version,
captured_at, and structured sections that compare diffs arithmetically:
attempt_counts— total/running/completed/failedlive_ingest_attemptrows plusstale_cursor_writesand overlapping running source paths.recent_attempts— most recent attempts with read amplification, parse/convergence timings, and source-path bundles.convergence_stage_timings— min/max/sum/mean parse/convergence/read- amplification stats over completed attempts.boundary_table_counts— mixed cheap evidence for the tables in_BOUNDARY_TABLES/_OPS_BOUNDARY_TABLES(devtools/daemon_workload_probe.py, non-exhaustive here): exact counts for small/core archive cardinality tables and maintained rollups such assessions,raw_sessions,messages, andsession_profiles; planner-estimated counts only where exact counting would scan large derived tables (blocks,messages_fts_docsize,message_embeddings,session_events,session_links,repos,session_repos,session_commits,live_ingest_attempt,convergence_debt). Missing tables surface as-1and expensive tables without SQLite planner statistics surface as-2rather than crashing the probe. Pass--exact-derived-countswhen a before/after evidence run needs exact arithmetic and the archive can afford the scans. The companionboundary_table_count_precisionmap labels each value asexact,estimate,unavailable, ormissing.archive_tiers— archive inventory forsource.db,index.db,embeddings.db,user.db, andops.db: file presence, durability/backup policy,PRAGMA user_version, missing backup-required tiers, and the same mixed cheap/exact table counts per tier with atable_count_precisionmap for each tier. It does not run SQLitePRAGMA quick_checkby default because that is a full-file integrity scan on large archives; pass--integrity-checkwhen the workload snapshot should include that expensive evidence. It also avoids exact generated-text reconciliation by default; pass--exact-derived-countswhen diagnosing FTS/source-row drift and the archive can afford the scan.blob_reference_debt— skipped by default so routine workload snapshots do not stat every referenced blob path on large archives. Pass--blob-reference-debtwhen diagnosing backup/integrity incidents; the section then reports the exact missing referenced-blob count, bounded hash sample, reference-source counts, and source/index DB path used. For recovery planning, runpolylogue ops maintenance blob-reference-debt --output-format json; that read-only classifier groups missing blob refs by table, ref type, origin, raw-row joinability, validation/parse state, and source-path availability. During daemon convergence, direct source files whose current bytes still hash to a missing blob address are restored automatically before raw materialization replay. Container/member paths such asexport.zip:conversations.jsonremain source re-acquisition work because the referenced blob may be an extracted record inside the member, not the member file itself.gc_state— high-watergc_generationsrow,last_completed_at, total generation count.fts_trigger_state— the three expected FTS sync triggers (messages_fts_a{i,d,u}) withpresent,missing, andall_presentfields. A missing trigger means FTS index drift risk (suspended during bulk operations and not restored, for example).daemon_resource_signal— RSS / cgroup memory / worker-progress fields pulled from the most recentlive_ingest_attemptrow (these are the only daemon-RSS signals readable without IPC).source_path_churn,convergence_debt,query_plans— source-path churn/read amplification, debt-by-stage, and hot-query EXPLAIN evidence. On archives, churn is read acrosssource.db.raw_sessionsandindex.db.sessionsso full-vs-append raw rows and unmaterialized raw payloads stay visible without requiringraw_sessionsinindex.db.
The compare mode refuses incompatible report_version inputs loudly and
requires both inputs to be ok: True. Numeric fields produce
{before, after, delta} triples; the FTS trigger section reports
regressed (newly missing) and restored separately so trigger drift is
attributable to a specific convergence cycle.
Daemon Metrics Endpoint (#1321)
GET /metrics returns the Prometheus text exposition format
(text/plain; version=0.0.4). Implementation lives in
polylogue/daemon/metrics.py; the route is wired in
polylogue/daemon/http.py alongside /healthz/* so all three scrape
surfaces share the same unauthenticated posture (Prometheus and
kubernetes/docker healthchecks cannot supply credentials, and the
daemon binds to loopback by default).
Series are derived from existing daemon state tables via
open_readonly_connection — live_ingest_attempt (totals by status,
in-flight gauge, recent-attempt duration min/mean/max), unresolved
live_convergence_debt grouped by stage, and the expected FTS sync triggers
from active_fts_triggers_sync. The same scrape also exposes embedding
backlog counts and the latest embedding_catchup_runs progress row so
semantic-search catch-up is visible in normal daemon dashboards without
running an operator CLI command. Missing tables degrade to zero samples
rather than 5xx-ing, so a fresh archive still emits the discovery
skeleton.
Archive layout observability is emitted on the same scrape. The
polylogue_archive_storage_layout gauge mirrors polylogue config paths
with bounded labels for archive_missing, archive_partial, and
archive_complete; polylogue_archive_tier_count and
polylogue_archive_blocker_count provide compact alerting
totals; the per-tier, per-blocker, and active_tier_role gauges give
the drilldown needed to tell whether the daemon is anchored on the
normal index.db path and which split-file tier is missing or stale.
Polylogue does not depend on prometheus_client; the exposition format
is hand-rolled. The OTLP HTTP receiver from #1224's ambitious-move
section is intentionally deferred and tracked under the same issue.
Debugging Landmarks
Cross-check adjacent surfaces after changes:
- query:
cli/query*.py↔archive/filter/filters.py↔storage/search*.py - pipeline:
daemon/↔pipeline/↔storage/↔insights/ - maintenance:
cli/commands/check.py↔storage/repair.py↔health.py - publication:
rendering/↔site/↔devtools/ - schema:
schemas/↔sources/providers/↔pipeline/services/validation_*
Drift check:
devtools render all --check
devtools test tests/unit/cli/test_demo_command.py tests/unit/demo/test_demo_seed_verify.py tests/visual
Local State
.cache/: disposable caches (hypothesis, pytest, mypy, ruff).local/: untracked outputs (campaigns, demo artifacts, build artifacts).local/result: out-link fordevtools release build-package