Skip to content
This repository was archived by the owner on Jul 25, 2026. It is now read-only.

Reconcile the two schema lineages and land the stranded contact-merge/embed work (#217)#220

Closed
joestump wants to merge 25 commits into
mainfrom
fix-217-schema-lineage
Closed

Reconcile the two schema lineages and land the stranded contact-merge/embed work (#217)#220
joestump wants to merge 25 commits into
mainfrom
fix-217-schema-lineage

Conversation

@joestump

Copy link
Copy Markdown
Owner

Reconciles the two schema lineages that diverged at acb2183 and independently numbered a migration v11, and lands the 23 commits of contact-merge / embed / media work that were stranded on the other side of that divergence.

Why this is urgent

Both lineages reached real databases. A 241 MB archive on macOS was stamped user_version = 12 with embed_runs and the contact tables but no journal_days / journal_digests, while main defines v11 as the journal tables. No build of main could open it:

msgbrowse: database is at schema version 12, newer than this binary supports (11)

The forward-refusal at internal/store/migrations.go is correct and caught it. The dangerous case is the quiet one: migrate() only runs current+1 … schemaVersion, so a naive merge that bumped schemaVersion to 13 would leave main-lineage databases permanently without embed_runs and fork-lineage databases permanently without the journal tables — both reporting the same version while having different schemas.

The reconciliation

Version Migration Previously
v11 journal_days, journal_digests main's v11 — unchanged, it already shipped
v12 embed_runs this lineage's v11
v13 contact_links, contact_merge_rules this lineage's v12
v14 lineage repair new

v14 re-asserts the union of all three, composed as schemaV11 + schemaV12 + schemaV13 so it cannot drift from what it repairs. Safe to re-run because every contested migration is CREATE … IF NOT EXISTS and the one seed row uses ON CONFLICT DO NOTHING. Convergence paths:

v10 (common ancestor) → v11, v12, v13 applied normally, v14 a no-op
main lineage @ v11    → v12, v13 create this lineage's tables, v14 a no-op
fork lineage @ v11    → v12 no-op, v13 contacts, v14 creates the journal
fork lineage @ v12    → v13 no-op, v14 creates the journal

Making the next one impossible

Migration ledger. schema_migrations records version + sha256 of the exact SQL, written in the same transaction as the DDL and the user_version bump, so the ledger can never disagree with the schema it describes. Every Open re-verifies. Pre-ledger databases are backfilled with empty checksums rather than guessed ones — we cannot know which v11 a legacy archive ran, and a fabricated checksum would assert knowledge we do not have.

Append-only guard. scripts/check-migrations.sh, wired into make check and ci.yml, diffs every schemaV* constant against the base branch and fails if one that already shipped was edited or deleted. Negative-tested: changing a single column in v11 produces a labelled diff and exit 1. TestMigrationRegistryIsComplete covers what needs no git history.

Same root cause, two more collisions

The merge silently accepted two ADR-0022 files and two SPEC-0015s. The contact-merge pair is renumbered to ADR-0024 / SPEC-0018 (main shipped 0022 Telegram, 0023 journal, SPEC-0017 contact-profile), with all 11 referencing files updated.

Other conflict resolutions

  • internal/web/server.go — both sides' struct fields kept
  • internal/web/templates/index.html — this lineage's provider/index status cards, main's four-across quick-links grid (there are now four links)
  • internal/web/static/app.css — regenerated from the merged templates via make css, not hand-resolved
  • internal/store/journal_test.go — duplicate sysMsg helper collapsed onto the three-arg form (signal.SystemSender is "No-Sender", so the two were equivalent)

Verification

TestLineageConvergence rebuilds all five historical states — fresh, v10 ancestor, main-lineage v11, fork-lineage v11, fork-lineage v12 — and asserts each reaches the same five tables at the same version with the seed row present exactly once.

Against the real 241 MB archive, migrated by a locally built .app:

BEFORE  user_version = 12   journal_days MISSING, journal_digests MISSING
AFTER   user_version = 14   all five tables present
        integrity_check ok, foreign_key_check clean
        316,116 messages / 2,435 conversations — identical before and after

Then served over HTTP from that build: /journal (needs main's tables) 200, /settings/contacts (needs this lineage's) 200, overview rendering 303,917 messages. Both lineages' features work against one converged database.

Note on CI

TestLoadDefaults in internal/config fails on a machine that has a real ~/Library/Application Support/msgbrowse/config.yaml, on this branch and on the unmerged one alike — it reads host config. That is what #216 fixes; it passes on a hermetic runner.

Part of #217

🤖 Posted on behalf of @joestump by Claude.

joestump-agent and others added 25 commits July 11, 2026 09:00
* Open transcript images in the shared :target lightbox (#3)

Clicking an inline image in the chat/transcript opened the raw media file
in a new tab, while the Media tab opened an in-app lightbox. Wire transcript
images to the same pure-CSS :target lightbox the gallery uses.

The renderable-image thumbnail now anchors at #lb-m{msgID}-{idx} instead of
target=_blank to the raw file, and a matching hidden .lightbox overlay
(reusing the gallery's .lightbox/.lightbox-close/.lightbox-caption classes,
close affordance included) is emitted as a SIBLING of the .msg-row. Siblings,
not children: the row carries content-visibility:auto, which establishes a
containing block for fixed-positioned descendants, so a nested overlay's
inset:0 would cover only the row instead of the viewport. Out at
.transcript level it pins to the viewport exactly like the gallery's
grid-child overlays.

No JS (CSP-safe under script-src 'self') and no new CSS — every class is
already in the built stylesheet. Non-renderable formats (HEIC placeholders,
files) keep their download-link behavior. Works for boosted and
infinite-scroll continuation loads since the overlays ride along in the
message_list fragment.

* Lightbox close returns to the image's row, not the top (#3)

The transcript lightbox close anchor used href="#", which clears the
:target but also scrolls the document to the top. On a long transcript a
reader who scrolled deep, opened an image, and closed it was bounced to the
newest message. Point the close anchor at the originating row (#m{msgID})
instead: it still clears the lightbox :target and returns the viewport to
the message the image belongs to. The row's content-visibility:auto is kept
visible by its :target/.target rules, so the anchor resolves.

---------

Co-authored-by: Claude <noreply@anthropic.com>
On the Media → Files tab, clicking a file card did nothing. The file
card's anchor points straight at the /media/{id}/{path...} attachment
route, whose response is a raw binary with Content-Disposition:
attachment. The app boosts navigation by container (the header nav and
the sidebar aside), and htmx boost is inherited: the moment such an
anchor sits under a boosted region, a click becomes an htmx AJAX GET
that tries to swap the binary/error response into #main-content. htmx
can't swap that, so the click silently does nothing — the reported bug.
The download attribute (issue #161) does not stop boosting.

Mark the download anchors hx-boost="false" so they always stay native
browser navigations and the server's attachment disposition drives a
real download. Applied to the Files-tab card link and the Images-tab
"no preview" placeholder, which share the same direct-to-/media
download pattern. Both live in template defines reused by the
infinite-scroll continuation, so continuation pages are covered too.

Verified the server side already returns Content-Disposition:
attachment; filename="..." for non-images, so no handler change is
needed. Added regression tests: the file anchor opts out of hx-boost
(and keeps download) on the initial page and the /gallery/items
continuation fragment, and /media serves a PDF as an attachment.


Claude-Session: https://claude.ai/code/session_01PruXCwhbcG7C6U88x7wykZ

Co-authored-by: Claude <noreply@anthropic.com>
* Add newest/oldest sort control to the Media gallery

The Media page walked attachments in a single fixed keyset order with no
way to flip newest/oldest. Add a Sort control to the filter bar (Newest
first default, mirroring the transcript's ?sort= convention) and thread
the choice through the whole filter plumbing so it survives tab switches
and infinite-scroll pagination.

- store.GalleryFilter gains SortAsc; listAttachmentsSQL flips ORDER BY and
  the keyset cursor predicate for the oldest-first walk. The same pinned
  index serves both directions (SQLite walks it forward for ASC, backward
  for DESC), so no whole-table sort is introduced.
- galleryFilterForm carries Sort; parseGalleryFilter reuses parseSort;
  filterValues emits sort only when non-default, so tab links, GalleryQuery,
  and the attachmentsNextURL/linksNextURL cursor URLs all preserve it.
- Links stay domain-grouped (chronological order is not meaningful there);
  the sort param is carried but only steers Images/Files.
- Reuses the existing filter-control/label/input styles, so no CSS or
  Tailwind rebuild is required.

Part of #5

* Hide the Media sort control on the Links tab

The Sort control is inert on Links (they stay domain-grouped), so
rendering it there let a stale selection read as a broken Apply. Gate it
behind the tab so it only shows where it does something; the sort param
still rides through the URL unchanged.

Part of #5

---------

Co-authored-by: Claude <noreply@anthropic.com>
#19)

* Add click-to-copy to the URL/link tile (transcript pill + Media→Links)

The link tile — the accent pill under a message (which shows only the
DOMAIN) and the Media→Links row — could only be clicked through to open;
there was no way to grab the underlying URL. Add a small icon-only copy
button beside each tile that copies the FULL URL.

- copy.js: support data-copy-value for buttons whose visible text isn't
  the value to copy (the pill shows the domain, not the URL). Avoids a
  hidden per-item element with a unique id for every repeated link.
- The copy button is a SIBLING of the link anchor, not a child, so
  copying never triggers the tile's click-through-to-open.
- Move the #copy-announce aria-live region into the persistent page
  shell (page_end) so transcript / Media pages announce into it and it
  survives boosted swaps; Settings now reuses this shared region instead
  of carrying its own.
- .link-tile / .media-link-row / .copy-btn-inline styling. Tailwind CLI
  is proxy-blocked, so the built app.css is hand-mirrored (appended).

Reuses the existing .copy-btn icon-swap + focus-visible + aria-live
plumbing — no inline JS, script-src 'self' unchanged.

Closes #14

* Rebuild app.css with the real Tailwind CLI (replaces hand-mirrored rules)

The css-fresh CI check diffs a scratch 'make css' run against the committed
stylesheet, so the hand-appended rules failed it. Rebuilt with the pinned
Tailwind v4.3.1 + daisyUI 5.6.3 toolchain; .link-tile / .media-link-row /
.copy-btn-inline now come from the normal build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWWaS4f9mr6Yp43S4GXHE7

---------

Co-authored-by: Claude <noreply@anthropic.com>
…e binary (#20)

* Providers redesign, LLM API key, and gate device sync out of the binary

Reworked onto current main (Providers is now a Settings tab; the LLM tab
and live-apply provider swap already exist upstream).

LLM API key (Settings → LLM tab):
- Add an API-key field to the tab. It's a write-only secret: the value is
  never rendered back (the form shows only whether a key is set), a blank
  submission keeps the current key, and it applies live like the other LLM
  settings (swaps the running client, no restart).
- Persist it to the 0600 config file via config.SaveLLM (llm.api_key), and
  thread it through llm.Settings + the Applier. MSGBROWSE_LLM_API_KEY still
  overrides at startup.

Providers page (now the /providers Settings tab):
- 2×2 grid of source cards plus a Telegram "coming soon" placeholder (not
  wired into internal/source).
- Remove the one-shot "Refresh all sources" button/route and replace it with
  a background auto-refresh scheduler (providers.refresh_interval, default 6h;
  0 disables), reusing the per-source job guard.
- "Last synced" per card from a new store.LastSyncTimes over ingest_runs.

Device sync gated out of the binary (ADR-0021 / SPEC-0014):
- Behind the `devicesync` build tag. The default `msgbrowse` binary links with
  zero internal/syncthing symbols and no engine constructors; build with
  `-tags devicesync` to include it.
- serve + desktop wiring, `msgbrowse devices`, and doctor's device checks split
  into tagged/stub files behind a wireDeviceSync seam.
- The Device sync UI on /settings and /status is hidden via a compile-time
  feature flag (web.SetDeviceSyncFeature).

Both build variants pass `go build`/`go test` for the main and desktop modules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PruXCwhbcG7C6U88x7wykZ

* Rebuild app.css with the real Tailwind CLI (replaces hand-mirrored rules)

The css-fresh CI check diffs a scratch 'make css' run against the committed
stylesheet, so the hand-appended setup-card rules failed it. Rebuilt with the
pinned Tailwind v4.3.1 + daisyUI 5.6.3 toolchain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWWaS4f9mr6Yp43S4GXHE7

* Docs: align SECURITY.md/README/config docs with the Settings-tab API key

Review follow-up: the threat-model docs still claimed the LLM API key is
env-only and never expected in a file, which this PR's Settings → LLM
persistence made false. State the real posture: env always wins at startup,
the Settings tab persists to the 0600 config file, and that file must never
be committed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWWaS4f9mr6Yp43S4GXHE7

---------

Co-authored-by: Claude <noreply@anthropic.com>
#21)

* CI: branch-protection ruleset, govulncheck workflow, Dependabot config

- .github/rulesets/main.json: importable ruleset for main — PRs only
  (squash), required checks 'gofmt + vet + tests' + 'docker image builds',
  review-thread resolution, linear history, no force pushes/deletions.
  README documents rationale and the one-command apply (gh api / UI import).
- .github/workflows/security.yml: govulncheck over both Go modules on
  module-graph PRs, main pushes, weekly schedule, and manual dispatch.
- .github/dependabot.yml: weekly grouped updates for both gomod modules,
  Actions pins, and the docs-site npm tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCk6XWnqks9gjbJ4Zi5uVT

* CI: check-latest Go patch releases (fixes GO-2026-5856 in builds + scan)

govulncheck's first run flagged GO-2026-5856 (crypto/tls ECH privacy leak,
fixed in Go 1.25.12) because setup-go resolved '1.25' to the runner's cached
1.25.11 — for the scan AND the shipped builds. check-latest: true makes
every Go job track the newest 1.25.x patch, where stdlib security fixes land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JCk6XWnqks9gjbJ4Zi5uVT

* Pin toolchain go1.25.12 in both modules (GO-2026-5856)

setup-go's version manifest can lag the module proxy, so check-latest alone
still resolved go1.25.11 on the runner. The toolchain directive makes every
build — CI and local — use the crypto/tls-patched 1.25.12 regardless of the
installed Go.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWWaS4f9mr6Yp43S4GXHE7

* Docker: pin golang:1.25.12-bookworm so the image honors the toolchain fix

Review follow-up: official golang images set GOTOOLCHAIN=local, so the build
stage ignored the go.mod 'toolchain go1.25.12' directive and could still ship
the GO-2026-5856-vulnerable crypto/tls while the Security workflow passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWWaS4f9mr6Yp43S4GXHE7

---------

Co-authored-by: Claude <noreply@anthropic.com>
…tion (#22)

* docs: ADR-0022 + SPEC-0015 for contact merging & address-book abstraction

Add ADR-0022 (MADR) deciding the ContactResolver seam (mirroring the
SetDetector/SetEnabler/SetPairingSource injection contract), the platform
split (macOS Contacts provider behind a darwin+macontacts build tag with a
no-op default, per the devicesync gating precedent from #20), the
suggest-by-default matching posture (ADR-0003's manual-confirmation rule),
and identifier-keyed merge/split persistence (contact_links +
contact_merge_rules, migration v11) that survives re-ingest via an
idempotent reconcile pass -- the same stable-identity keying embeddings,
facts, and reactions use against rowid churn.

Add the paired SPEC-0015 (spec.md REQ-0015-001..010 with scenarios, plus
design.md rationale/schema/testing) under docs/openspec/specs/contact-merge/,
cross-linked to ADR-0022, epic #8, and children #9-#12, with ADR-0011 as
prior art. Bump ARCHITECTURE.md's ADR range to 0022.

Part of #8; closes #13.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: address cross-review findings on ADR-0022 / SPEC-0015

- ARCHITECTURE.md: list ADR-0022 in the curated ADR bullets (consistency
  with 0020/0021).
- SPEC-0015 spec.md: pin the resolver's Go identifier as contacts.Resolver
  (wired via SetContactResolver) to remove a downstream contract-mismatch
  hazard between #9/#11/#12.
- ADR-0022 / spec.md / design.md: restate the deterministic merge-winner
  rule as an explicit ordered rule (user-meaningful display_name wins;
  both/neither user-meaningful falls through to lower id).
- ADR-0022 / design.md: drop the misleading /contacts/{id} URL example
  (no contact-by-id route exists; routes are /c/{id}), reframe as a
  hypothetical future reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
* contacts: pluggable ContactResolver seam + identifier normalization (#9)

New dependency-light (stdlib-only) internal/contacts package, the
address-book abstraction behind cross-provider contact merging (epic #8):

- Resolver interface, minimal and merge-engine-driven (#11): Available /
  Resolve(one canonical identifier) / People(enumerate entries), with a
  Person model of exactly what matching needs — stable per-address-book
  Key, DisplayName, canonical Identifiers.
- Unavailable{}, the Linux/default no-op provider: answers "no address
  book" with empty results and nil errors, so the merge path degrades to
  a no-op instead of failing on platforms without native Contacts.
- Shared normalization helpers both the macOS provider (#10) and the
  merge engine (#11) canonicalize through: NormalizePhone (E.164-ish —
  separators stripped, 00→+, no country-code guessing), NormalizeEmail
  (trimmed, fully lowercased, shape-validated), and Normalize (classify
  an unknown handle into phone/email/opaque handle).

Table-driven unit tests pin the canonical forms and the never-errors
no-op contract.

Part of #8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: SetContactResolver injection seam for the address-book provider (#9)

Wire internal/contacts.Resolver through the established Set… pattern
(mirroring SetPairingSource / SetEnabler): the macOS desktop shell will
inject its Contacts-backed provider after NewServer; handlers read the
field without locking. Unlike the Enabler seam, unwired is not a
disabled feature — contactResolver() substitutes contacts.Unavailable{}
for a nil field so consumers (merge engine #11, merge settings UI #12)
never nil-check and never see an error for "no address book" (Linux,
browser mode).

Seam tests in the existing internal/web style: a fakeContactResolver
proves the accessor defaults to Unavailable with the empty-and-nil
contract, and that a wired provider is returned verbatim and reached by
queries. ARCHITECTURE.md layering gains the internal/contacts line.

Part of #8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* contacts: address cross-review findings on the resolver seam

Tri-state Availability (major): replace the boolean Available(ctx) with
Availability(ctx) returning Absent / NeedsPermission / Available, mirroring
internal/setup's PermissionState. A boolean collapsed "no provider" and
"provider present but OS grant missing", which the macOS provider (#10) and
merge settings UI (#12) must distinguish. Updated Unavailable, the web fake,
and seam tests.

Phone byte-for-byte contract (major): reconcile the contradictory package
docs. resolver.go promised byte-for-byte equality while normalize.go said the
merge engine "decides how boldly to match" national-vs-international phone
shapes. Both docs now ratify one rule: KindEmail/KindHandle match byte-for-byte;
KindPhone is the single documented cross-shape exception the merge engine (#11)
resolves via trailing-subscriber-digit comparison.

Email normalization (minor): reject domains with empty labels (consecutive
dots, e.g. a@b..com) and reject interior Unicode whitespace including NBSP via
unicode.IsSpace, for parity with NormalizePhone's NBSP handling.

nil-vs-empty docs (minor): soften Resolve/People docs to state len-0 with nil
error (callers must test len(), not nil), since the shipped Unavailable returns
nil slices.

Skipped (minor, known follow-up): the review asked to rename Resolve ->
LookupIdentifier and Person.DisplayName -> Name to match ADR-0022/SPEC-0015.
Those documents do not exist in the repo (ADRs stop at 0021; specs are named,
not numbered), so there is no authoritative contract to reconcile against; the
current names are internally consistent and idiomatic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
… Overview (#24)

* store: record embedding runs and expose semantic-index coverage

Schema v11 adds embed_runs — the embeddings analogue of ingest_runs — so a
semantic-search indexing run leaves a durable trail: a row at start
(finished_at='' marks in-flight), a per-batch updated_at heartbeat, and a
terminal write with the totals or the abort error. The embed CLI and
'msgbrowse serve' are separate processes sharing one SQLite file, so this
table is how the web Overview observes a live run (fresh heartbeat), a
crashed one (stale heartbeat), or the last completed one (issue #1).

EmbeddingCoverage is the called-out store-level coverage query: one LEFT JOIN
pass over messages x the embeddings PK counting the embeddable corpus
(non-system, non-blank — the exact CountMissingEmbeddings predicate, so
Embeddable-Embedded always equals the pending count) and the subset already
carrying a vector for the model.

Part of #1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* embed: record each run in embed_runs (begin/heartbeat/finish)

Run now wraps the embedding loop with best-effort run recording: BeginEmbedRun
before any work, UpdateEmbedRunProgress after every batch (the liveness
heartbeat the Overview reads), and FinishEmbedRun on the way out — under
context.WithoutCancel so a Ctrl-C'd run still lands its terminal write instead
of reading as crashed forever. Recording failures log a warning and never
abort the embedding work itself.

Part of #1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: consolidate freshness, index status, and MCP config onto the Overview

The Messages landing screen (/) is now the single Overview (issue #1):

- Archive freshness at the top: the existing global stat strip, expanded
  with a per-provider table (Signal/iMessage/WhatsApp) wiring
  store.SourceCounts + store.LastSyncTimes — each provider's own
  conversation/message counts and last-synced stamp, in canonical source
  order, rows only for providers with data or a recorded run.
- Semantic search index card: embeddings coverage for the currently
  configured embed model (live via the LLM-tab configurator when wired),
  the last recorded index run, a live 'Indexing…' state driven by the
  embed_runs heartbeat, and honest 'Interrupted'/'Failed' states. An unset
  embed model renders a pointer to Settings → LLM, never fake zeros.
- MCP connection card below the stats: the endpoint URL, client JSON, and
  claude mcp add blocks extracted from settings_content into a shared
  mcp_connect_card define — Settings keeps rendering the identical card, so
  the two surfaces can never drift; copy.js affordances unchanged.

Deliberate decisions (the ticket's open questions): settings_subnav stays
as-is and the Overview stays outside the Settings shell; GET /status and
GET /settings remain canonical with nothing redirecting (the #163 contract);
device pairing stays on Settings; snapshots stay on /status for the Backups
tab to claim later. The boosted-partial contract is untouched: index_content
still owns <title> + #main-content, and the HX-Request path still uses the
cheap ArchiveStats aggregate — the new queries are all small aggregates run
on both paths.

app.css: regenerated (the new mt-4 utility); no hand edits.

Part of #1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: address Overview cross-review — model-mismatch label, MCP card gate, stale window

Cross-review follow-ups on the Overview consolidation (all minor):

- Semantic-index card could silently disagree with itself after a user
  switched llm.embed_model: coverage is scoped to the configured model but
  LatestEmbedRun is model-agnostic. Surface the last run's model in the
  "Last index run" line when it differs from the configured one so the two
  halves explain rather than contradict each other.
- Move the MCP connection card outside the {{if .HasArchive}} gate on the
  Overview so a fresh, un-ingested user can wire up their client before
  importing — restoring the "same card on / and /settings" invariant.
- Raise embedRunStaleAfter 10m -> 30m: a large batch (up to 512) against a
  slow local embedding endpoint can exceed 10m per heartbeat, and misreading
  a live run as "Interrupted" invites a second concurrent embed against the
  same SQLite file — the costlier error.
- Note the accepted cost of the per-render EmbeddingCoverage full scan on the
  "/" hot path (revisit with a cached aggregate at the millions-of-messages
  target).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rebuild app.css from the Tailwind toolchain (fix css-fresh CI check)

The committed stylesheet didn't match a clean 'make css' build; regenerated
with the pinned Tailwind v4.3.1 + daisyUI 5.6.3 toolchain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
* store: GalleryFilter takes a multi-conversation ID set (issue #6)

Replace GalleryFilter.ConversationID (int64) with ConversationIDs
([]int64): attachment/link/count clauses bind the set as an IN(...)
parameter list (never string-interpolated), and listAttachmentsSQL keeps
pinning idx_attachments_conv_kind whenever the set is non-empty — SQLite
runs the IN over the index's leading column as one seek per id, so the
walk stays index-driven. MCP's list_media/list_links wrap their single
resolved conversation in a one-element set.

TestGalleryQueryPlans gains a multi-conversation case proving no table
scan crept in, and TestGalleryMultiConversationFilter covers union
semantics (lists + counts, exclusion of unselected conversations, empty
set = all).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWWaS4f9mr6Yp43S4GXHE7

* web: Media multi-select conversation filter + one-line filter bar (issues #6, #7)

The Media conversation filter becomes a CSS-only multi-select: a
<details> dropdown of checkboxes (no inline JS, CSP script-src 'self'
clean; Enter/Space toggles the summary, Tab walks the checkboxes). Every
checked conversation submits a repeated ?conversation= param;
parseGalleryFilter reads the whole set (deduped, garbage dropped) and
filterValues re-emits it, so the selection rides tab links and the
infinite-scroll load-more URLs. Empty selection = all conversations.
The collapsed control labels itself with the one selected name, a count,
or "All conversations".

Filter bar layout (issue #7): the summary is capped at 13rem and
ellipsizes, so long conversation names never widen the bar; at >=1024px
the Media bar (scoped .filter-row-media — Search keeps wrapping) stays
on one line with the conversation control shrinking first, and narrower
viewports fall back to the default wrap. The dropdown panel is
absolutely positioned so opening it never reflows the bar.

Also fixes the PR #16 review nit: the Links tab hides the inert Sort
select, so a previously chosen sort=asc now travels as a hidden input —
Apply from Links no longer silently resets the order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWWaS4f9mr6Yp43S4GXHE7

* web: cap conversation filter ids, announce selection to screen readers

Review fixes for the media filter bar (issues #6, #7):

- parseGalleryFilter clamps the distinct ?conversation= id set to 200
  (maxConversationFilterIDs). Each id becomes one bound IN(...) parameter,
  so an unbounded crafted URL could exceed SQLite's 32766-parameter limit
  and 500 every gallery query at prepare. The UI can never produce more
  than one id per conversation, so the cap only bites hand-crafted URLs.
- The multi-select summary now uses
  aria-labelledby="conv-filter-label conv-filter-value" with an id on the
  value span, so screen readers announce both the label and the current
  selection ("All conversations" / a name / "N conversations") instead of
  just "Conversation".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWWaS4f9mr6Yp43S4GXHE7

---------

Co-authored-by: Claude <noreply@anthropic.com>
…ing files (#26)

* Media: stat-gated image renderability + labeled placeholders for missing files (#15, #4)

Diagnosis of issue #15 (browser broken-image glyphs on Media -> Images):

- Hypothesis 1 CONFIRMED (root cause): imgRenderable returned true for any
  web-native extension without checking that the file exists on disk (or
  that the source's archive root even resolves), so an attachment row whose
  file is absent rendered an <img> whose /media src answers 404 (400 on an
  unconfigured root) -- exactly the browser's broken-image glyph.
- Hypothesis 2 (encoding round-trip) disproved: mediaURL PathEscapes per
  segment and the Go 1.22 mux decodes {path...}; spaces, unicode, '#', '%',
  '&', '+', ';' and subfolders all round-trip. Locked in as regression by
  TestMediaURLEncodingRoundTrip and the extended TestMediaURLEscaping.
- Hypothesis 3 (transcode gap) disproved as stated: the HEIC/TIFF gate
  already stats the derived JPEG -- it never claims a derivative that does
  not exist. Full state matrix covered by TestImgTileState.
- Hypothesis 4 ruled out by inspection: tiles are static HTML swapped in by
  htmx; no JS touches img src, and img-src 'self' admits /media.

Fix: a three-state imgTileState (img / nopreview / missing) classifies each
image with the same existence checks handleMedia applies at serve time. The
gallery grid renders msgbrowse's own inert labeled "missing" placeholder for
absent files (an <img> would 404; a download link would save an error page),
and the transcript -- imgRenderable now delegates to the classifier -- falls
back to its labeled attachment chip instead of a broken thumbnail.

Files tab (issue #4 remnant): decorateFiles already stat'ed every row, so
missing files now flag Missing and render an inert labeled card instead of a
download anchor -- a native click on <a download> answering 404 surfaces as
a silently failed download, i.e. "clicking does nothing" in a plain browser.

Cost: one stat per rendered tile (plus one for the derivative on convertible
formats) -- the same per-item price the Files tab already paid in
decorateFiles, microseconds against the SPEC-0008 budgets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Media: commit transcript missing-image placeholder + reconcile test/CSS (#15, #4)

The transcript fix for a DB-only image (file absent from the archive on this
machine) was left as uncommitted working-tree edits, so the tree was in an
inconsistent, un-mergeable state: partials.html rendered an inert
attach-chip-missing <span>, but media_issue15_test.go still asserted a live
<a class="attach-chip"> download anchor to ghost.jpg, and app.css was never
regenerated for the new .attach-chip-missing rules — CI failed on the tree.

Reconcile the deliverable into one self-consistent commit:

- Finalize partials.html: a missing image/attachment renders the inert,
  labeled attach-chip-missing placeholder instead of a download anchor whose
  click fetches a 404 (the issue #4 "clicking does nothing" silently failed
  download). This matches the gallery's inert missing tile/card treatment,
  so graceful degradation is applied consistently across the transcript and
  the Media Images/Files tabs.
- Update TestTranscriptMissingImageNoBrokenImg to codify the fix: assert the
  missing image is NOT a link (no <a ...ghost.jpg) and renders the labeled
  attach-chip-missing placeholder, mirroring the gallery's inert-element
  assertions — rather than green-lighting the dead download link.
- Regenerate internal/web/static/app.css so the .attach-chip-missing rules
  ship; the embedded server serves static/app.css, so without this the chip
  would render unstyled.
- gallery.html/input.css three-state imgTileState changes folded in.

CGO_ENABLED=0 make check passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
A dangling node_modules symlink pointing into a build-time scratch dir was
accidentally committed via #20's CSS rebuild and inherited by every branch
since. It's harmless to the pure-Go build but resolves to nothing on any
other clone. Remove it and gitignore /node_modules and /.tools/ so the
Tailwind CLI shim can't be committed again.

Co-authored-by: Claude <noreply@anthropic.com>
* feat(web): move encrypted DB snapshots into a dedicated Backups tab

The encrypted-DB-snapshot inventory graduated out of /status into its own
Settings-shell section at /backups (issue #2). The new tab renders ONLY the
snapshot story — total footprint, snapshot count, and the per-snapshot table
(name, taken-at, size, retention tier) — reusing store.ListSnapshots and the
footprint sum that used to live in handleStatus.

- New GET /backups route + handleBackups (internal/web/backups.go), following
  the SPEC-0008 *_content boosted-partial pattern (backups_content owns the
  <title> + #main-content).
- Snapshot card markup moved from status.html into backups.html; statusData
  drops Snapshots/SnapshotFootprint/HasSnapshotPipeline and handleStatus no
  longer computes them. Status is once again just ingest + sync health.
- HasSnapshotPipeline behavior preserved verbatim (#164): a machine with no
  snapshot pipeline shows the single neutral "No snapshot pipeline on this
  machine" line, not a "0 B across 0 snapshots" card.
- settings_subnav gains a Backups tab after Status (Status renamed from
  "Status & backups"); the Overview's stale Status quick-link copy updated.
- Tests: TestBackupsPage (with pipeline), TestBackupsPageNoPipeline (moved from
  the old /status conditional test), TestBackupsBoostedPartial; /backups added
  to the shared pageRoutes so the REQ-0008-006 partial/full contract covers it;
  TestStatusPage asserts the snapshot surface is gone from /status.

No new CSS classes (backups reuses status-* classes; the tab reuses
settings-tab), so app.css is unchanged and CI-fresh.

Part of #2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(web-ui): update route map for Backups tab

Status no longer renders snapshots (moved to /backups in #2); document
the new GET /backups route in the design.md route map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
* contacts: pure candidate matcher for cross-provider merge (#11)

Add internal/contacts/match.go: the storage-free core of the merge engine
(ADR-0022 / SPEC-0015 REQ-0015-004). Given the archive's stored identifiers
and an optional address book, Candidates() groups the ones belonging to the
same real person and explains why (phone / email / address-book), gated by a
MatchRules struct the settings UI (#12) will drive. It only ever suggests:
no storage, no I/O, no merging.

Phone matching reuses the package's canonical forms and implements the one
documented cross-shape rule (resolver.go / normalize.go) — a national number
matches its international form by trailing subscriber digits with a 1-3 digit
country code, never guessing and never suffix-matching two national numbers.

Part of #11.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* store: contact-merge decision journal + reconcile engine (#11)

Add schema v12 (contact_links + contact_merge_rules) and the store-layer
merge engine (ADR-0022 / SPEC-0015): MergeRules get/set, MergeCandidates,
transactional MergeContacts / SplitContact, and the idempotent
ReconcileContacts pass.

Decisions are journaled against canonical-ordered stable (source, identifier)
pairs — never contact rowids, which churn under DeleteSourceData + re-enable —
so a manual merge or split survives re-ingest: reconcile folds a re-created
identity straight back onto its person. One current decision per pair (merge
replaces split and vice versa on manual action) plus a split-block check give
the precedence manual split > manual merge > auto rules. MergeContacts records
the full bipartite pairing so any surviving pair re-links a partially-deleted
group; the winner is the deterministic ordered rule (user-meaningful
display_name, else lower id). Facts ride the existing UNIQUE(contact_id,
fact_hash) dedup via UPDATE OR IGNORE. Auto-merge is off by default and
address-book hints never auto-merge.

Note: ADR-0022 wrote "migration v11", but embed_runs (#1) claimed v11 first,
so the merge tables land at v12 — tables and behavior exactly as pinned, only
the migration number moved (a detail the ADR delegates to the implementing
issue). Source-delete correctness: DeleteSourceData already prunes orphaned
contacts but must KEEP contact_links (an absent identifier makes a link inert,
not invalid; it re-activates on re-import) — verified by test, no change
needed there.

Tests cover rules round-trip/defaults, merge union + fact dedup + loser
deletion, meaningful-name winner determinism, split separation + record,
reconcile re-apply across a simulated disable/re-import, idempotency,
opt-in auto-merge, split precedence over auto, and split-pair exclusion from
candidates.

Part of #11.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ingest: run contact reconcile after every import (#11)

Hook ReconcileContacts into the Signal, iMessage, and WhatsApp import Run
paths, after the ingest run is recorded. A re-import's get-or-create in
UpsertConversation may resurrect an unmerged contact; the reconcile pass
immediately folds it back onto its merged person so manual merges and
opt-in auto-merges survive re-ingest (ADR-0022 / SPEC-0015 REQ-0015-007).

Reconcile is idempotent and local-only; the nil resolver means "no address
book", which is all reconcile needs — stored decisions re-apply without it and
address-book hints never auto-merge, so no importer takes on a cgo/Contacts
dependency.

Part of #11.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* merge-engine: honor auto-merge-off on re-ingest, best-effort reconcile, doc retention

Address cross-review findings on the contact-merge engine:

- ReconcileContacts step 1 no longer re-folds origin='auto' merge links when
  auto-merge is disabled, so turning auto-merge OFF is honored across re-ingest
  (manual merges still always re-apply; loadMergeLinks gains an includeAuto
  filter driven by rules.AutoMerge).
- Import paths (signal/ingest, imessage, whatsapp) now treat a post-commit
  reconcile failure as best-effort: log + run.Errors++ instead of failing an
  already-committed, hash-idempotent import that would be needlessly retried.
- SECURITY.md documents that disabling a source keeps its raw identifiers in the
  contact_links merge journal (ADR-0022 durability trade-off); deleting data_dir
  is the permanent-erase step.

Skipped (verified, no code change warranted):
- schema slot-12 coordination note: informational, cross-branch merge concern.
- match.go national-vs-international suffix rule: explicitly documented residual
  risk of the opt-in trusted-phone path, not a defect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
* Add macOS Contacts provider (ContactResolver) behind macoscontacts tag

Implements issue #10: the platform-specific address-book provider behind the
contacts.Resolver seam (#9), read by the merge engine (#11) and merge settings
UI (#12).

internal/macoscontacts splits pure-Go logic from a thin cgo edge so the whole
pipeline is testable in the CGO_ENABLED=0 CI where Contacts.framework cannot be
linked:

- provider.go (always compiled, no cgo): Provider satisfying contacts.Resolver,
  the CNAuthorizationStatus -> Availability tri-state mapping (Absent vs
  NeedsPermission vs Available, matching internal/setup's permission model),
  raw-record -> contacts.Person normalization through the shared
  contacts.Normalize* helpers (phone canonical, email lowercase), and the
  enumeration-dump parser. Resolve matches by exact (Kind,Value) equality; the
  KindPhone national/international cross-shape widening stays the engine's job.
- backend_darwin.go (darwin && macoscontacts && cgo): the sole ~40-line
  Contacts.framework binding — detect-only authorization (never prompts) plus a
  raw contact dump the pure-Go parser consumes.
- backend_stub.go (everything else): a no-provider backend, so go build ./...
  with no tags — and every release binary — links zero Contacts symbols and the
  provider behaves exactly like contacts.Unavailable.

Gated by a dedicated `macoscontacts` build tag layered on the desktop build
(the devicesync precedent) rather than the always-on `desktop && darwin` glue,
since Contacts needs a TCC entitlement + usage string and must not be
force-linked; plus a runtime GOOS==darwin guard in New.

Desktop wiring mirrors the syncthing tagged/untagged split: embedded's
wireContacts injects macoscontacts.New via SetContactResolver under the tag and
is a no-op otherwise, leaving the web layer's contacts.Unavailable default.

Tests (pure Go, fake backend): authorization mapping, availability gating,
normalization/dedupe/filtering, dump parsing, Resolve/People behavior, the New
runtime-guard fallback, a #20-style build-constraint proof (backendCompiledIn),
and a `go tool nm` proof that the default msgbrowse binary carries no Contacts
symbols. The cgo binding's C side is the only part not compile-verifiable in
this Linux CI.

Part of #8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(macoscontacts): note Resolve re-enumerates, prefer People() for bulk

Cross-review minor: document that Provider.Resolve is a single-lookup
affordance that re-enumerates the whole address book per call, steering
future bulk callers to enumerate once via People() and index locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
* store: add MergedContacts for the split-review surface

The merge engine (#11) can split identifiers off a contact but exposes no way
to enumerate the multi-identifier contacts a split would act on. MergedContacts
returns every contact holding at least two identifiers, each with its
identifiers ordered by source then value and the set ordered by display name —
exactly the review set the Settings → Contacts tab (#12) lists so a mistaken
merge can be pulled apart. Single-identifier contacts (nothing to split) are
excluded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* web: add Settings → Contacts tab for merge rules and manual overrides

The user-facing surface over the cross-provider contact merge engine (#11,
ADR-0022 / SPEC-0015), the seventh Settings sub-nav tab (#12):

- Matching RULES form: which identifier kinds to trust (phone/email), whether
  to auto-merge or only suggest, and whether the native address book
  contributes hint suggestions — persisted via SetMergeRules / loaded via
  GetMergeRules.
- CANDIDATE review: MergeCandidates lists the suggested cross-source merges
  under the current rules, each with a one-click Merge (MergeContacts).
- SPLIT overrides: the merged (multi-identifier) contacts, each with a
  per-identifier checklist to pull a mistakenly-merged person apart
  (SplitContact). Both overrides record durable decisions in the engine, so
  they survive re-ingest.

The address-book toggle mirrors the resolver's tri-state (#9 seam): live only
when contacts.Available; disabled with the matching absent / needs-permission
affordance otherwise, and a save from the disabled control never flips the
stored preference. Works today against the Unavailable no-op on main; the macOS
provider (#10) lands in parallel.

Every mutating POST (save-rules / merge / split) is gated by the same
checkSetupPOST contract as the Setup POSTs (same-origin + per-session token +
body cap, 403 before any work) and re-renders the boosted partial with a
fixed-enum result banner, mirroring the PairResult/UnpairResult pattern. No
inline JS (CSP script-src 'self'); the toggle-chip disabled styling is added to
input.css and app.css regenerated.

Re-bumps the settings-tab count assertion (6 → 7) after #2's Backups tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Treat merge-rules read error as save failure, not silent clear

When the address-book resolver is unavailable, handleSettingsMergeRules
preserves the stored UseAddressBook preference by reading it back before
persisting. A read error was swallowed, leaving UseAddressBook at its zero
value (false) and then persisting it — silently clearing the user's stored
preference. Treat the read error as a save failure: log it and re-render
the error banner instead of writing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
)

* Fix #4: route desktop-webview file downloads through the OS browser

The Files-tab card link, the no-preview image placeholder, and the
transcript attachment chip all point at same-origin /media URLs served with
Content-Disposition: attachment. In the desktop shell the Wails v2 webview
wires no download delegate (WKWebView/WebView2/WebKitGTK alike), so that
navigation is dropped and the click reads as 'nothing happens' — the actual
root cause behind #4, distinct from the htmx-boost angle #18 hardened.

desktop.js already hands cross-origin links to the OS browser via
POST /desktop/open-url (issue #179). Extend that same interceptor to also
catch same-origin anchors carrying the download attribute and route them the
same way: a loopback http URL is a valid http(s) URL, so it flows through the
existing validation + shell opener unchanged, and the OS browser downloads it
via the server's attachment disposition. Ordinary same-origin links still
navigate inside the webview untouched. No new privileged seam; the sameOrigin
POST gate and validExternalURL allowlist remain the security boundary.

Adds TestOpenURLAcceptsLoopbackMediaDownload pinning that path-encoded
loopback /media URLs pass the bridge to the opener byte-for-byte.

Closes #4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address review nits: clarify test scope + disposition-coupling comment

- Reword TestOpenURLAcceptsLoopbackMediaDownload's docstring: it guards the
  server half (bridge acceptance); the desktop.js selection logic is
  review-verified, matching #179's server-only test posture.
- Note near the interceptor that only href is sent, so the OS browser obeys
  the server's Content-Disposition — fine for /media (always attachment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
* Add home glyph to the toolbar contextual title

The unified header's contextual title always links home, but nothing
signalled that affordance. Prepend the existing icon-home glyph (kept
decorative/aria-hidden so aria-label="msgbrowse home" stays the sole
accessible name) and wrap the label in its own span so only the text
ellipsis-truncates while the icon holds a fixed size. .toolbar-title
becomes an inline-flex row (gap + centered) and app.css is regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add "Test connection" probe to the Settings LLM tab

A new POST /settings/llm/test handler (handleSettingsLLMTest), gated by
checkSetupPOST exactly like the save, probes the CURRENTLY-ENTERED
(unsaved) form values so a user can verify a LiteLLM/Ollama endpoint
before saving. It reads the same fields (blank api_key keeps the current
key), validates, then probes without persisting or swapping the live
client.

The LLMConfigurator seam gains TestLLM(ctx, llm.Settings) error,
implemented on *llm.Applier: it builds a transient client from the
settings (shared build func with ApplyLLM) and makes one cheap real call
under a 5s timeout — an embed of "ping" when EmbedModel is set, else a
1-token chat when only the facts model is set. serve.go and embedded.go
need no change; the concrete Applier already flows through SetLLMConfig.

Results render as a fixed-enum TestResult banner (ok / unreachable /
unavailable); the raw probe error is logged server-side but never echoed
into the page (it can carry the endpoint URL). The button is a second
submit with hx-post (boosted) + formaction (no-JS) to the test route —
CSP-clean, no inline JS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* TestLLM: probe both embed and facts models when both are configured

The "Test connection" probe took the embed branch and returned before
ever exercising the facts/chat model, so a valid embed model masked a
typo'd facts model — the user saw "Connection succeeded. ...the model is
valid." and only discovered the broken facts pipeline after saving. Probe
each configured model (embed AND chat) and surface the first failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rebuild app.css from the Tailwind toolchain (css-fresh CI)

Regenerate the committed stylesheet so it matches a clean 'make css' build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Addresses verified issues from the settings redesign review:

- LLM API key env-key leak: a key supplied via MSGBROWSE_LLM_API_KEY is now
  used live but never written to the config file (llm.Settings.APIKeyFromEnv;
  newLLMApplier suppresses the on-disk copy). Keeps Option A config storage
  for keys the user actually types.
- Add an explicit "Clear saved key" control and autocomplete="new-password"
  to the API-key field; the key is still never echoed back.
- "Last synced" no longer vanishes on Enable/Recheck/Disable fragment swaps:
  the timestamp is folded into setupCardFor so every render path carries it.
- Restore the three-way Providers footer (#162): a machine with nothing
  detected no longer claims its enabled sources auto-refresh.
- Last-sync stamp no longer advances on an all-failed ingest run (imported
  nothing AND recorded errors); a clean no-op still counts.
- device_sync.enabled: true on a non-devicesync build now WARNs at startup
  and in doctor instead of silently doing nothing.
- Correct the overclaiming build-tag comments: the tag gates the runtime and
  UI, not the dependency graph (internal/web imports internal/devsync
  unconditionally, so both packages stay in the binary).

Docs: amend ADR-0009/ADR-0010 for config-file key storage (env still wins and
is never persisted), document the devicesync build-tag boundary in ADR-0021,
and note the env-not-persisted guarantee in SECURITY.md.

Tests: env-key-not-persisted, clear-key, typed-key-overrides-env,
last-sync-excludes-failed-runs, recheck-fragment-keeps-last-synced,
footer three-way, and doctor WARN-on-enabled.


Claude-Session: https://claude.ai/code/session_01PruXCwhbcG7C6U88x7wykZ

Co-authored-by: Claude <noreply@anthropic.com>
Add store.ResetEmbeddings(ctx), which clears every stored vector AND the
embed_runs log in one transaction, so after a from-scratch rebuild the UI
immediately reads as un-indexed (coverage 0, LatestEmbedRun nil / "never")
rather than showing a stale run for wiped-out vectors.

Add embed.Indexer, the concrete on-demand embedding runner the serve/desktop
shells wire behind the Status page's Build / Reset-&-rebuild controls. It
holds the process's shared *llm.Holder so a job started after a Settings → LLM
save picks up the new endpoint + embed model; RunEmbed optionally resets first,
then runs the same embed.Run pass the CLI does (Prune on).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add the Indexer seam on *web.Server (SetIndexer, mirroring SetLLMConfig): the
web layer owns a single-flight guard + detached goroutine so at most one global
index job runs at a time and a second Build coalesces to a no-op instead of a
duplicate SQLite writer. The job runs under context.Background() so it outlives
the request; embed.Run records its own begin/finish, so progress is observable
on the next Status render.

Add privileged POST /status/index (build the missing delta) and
POST /status/index/reset (clear + rebuild), both checkSetupPOST-gated and
re-rendering Status with a fixed-enum banner (started / reset / inprogress /
nomodel / unavailable / error). No Indexer wired → "unavailable"; empty embed
model → "nomodel" and nothing started.

Add the "Semantic search index" status-card: coverage, last run + model, and
the embed_runs-heartbeat in-progress marker, with Build / Reset-&-rebuild
buttons disabled while a run is in progress and hidden when no model is
configured (pointing to Settings → LLM). role="status" aria-live="polite".
The card reflects state per render — a manual refresh watches coverage climb
(no hx-poll, keeping the page CSP-clean). The Overview card now links here
("Manage index →") rather than duplicating the buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire embed.NewIndexer(store, llmHolder) into both serve.go and the desktop
embedded server, using the SAME shared llm.Holder that already backs the
Settings → LLM tab and the MCP semantic search — so a Build kicked off after an
LLM save uses the new endpoint with no relaunch. Browser / no-op mode wires no
Indexer and the Status controls degrade to their "unavailable" state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Close a reset-then-empty-model race: startReindex checks EmbedModel() on
the request goroutine, but a concurrent Settings -> LLM save can clear the
model before the detached goroutine runs. Without a re-check RunEmbed(reset)
would ResetEmbeddings (wiping vectors and embed_runs) and only then have
embed.Run no-op on "model not configured", leaving an empty index with no
run row. Re-read and validate the model before touching the store.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reconciles the two lineages that diverged at acb2183 and independently
numbered a migration v11.

Schema (the collision itself):
  v11 stays main's journal_days + journal_digests — it already shipped
  v12 was the fork's v11 (embed_runs)
  v13 was the fork's v12 (contact_links + contact_merge_rules)
  v14 is a new lineage-repair migration re-asserting the union of all
      three, so every database converges regardless of the path it took

Renumbering alone is insufficient: migrate() only runs current+1…latest,
so a fork-lineage DB stamped 11 or 12 would never receive the journal
tables and a main-lineage DB stamped 11 would never receive embed_runs.
v14 closes that hole and is safe to re-run — every contested migration
is CREATE … IF NOT EXISTS and the one seed row is ON CONFLICT DO NOTHING.

Same root cause, two more collisions:
  ADR-0022 → ADR-0024 for contact merging (main shipped 0022 Telegram)
  SPEC-0015 → SPEC-0018 for contact merging (main shipped 0015 Telegram)

Other conflicts: server.go took both sides' struct fields; index.html
kept the fork's provider/index status cards and main's four-across
quick-links grid; app.css regenerated from the merged templates; the
duplicated sysMsg test helper collapsed onto the three-arg form
(signal.SystemSender is "No-Sender", so the two were equivalent).

Part of #217

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renumbering fixed the collision that already happened; these two changes
make the next one detectable and then impossible.

Ledger (internal/store/migrations.go). PRAGMA user_version is one
integer, and #217 proved an integer is not a schema identity — two
databases both reporting 11 had different tables and nothing could tell
them apart. schema_migrations now records one row per applied version
with a sha256 of the exact SQL that ran, written in the same transaction
as the DDL and the version bump so the ledger can never disagree with
the schema it describes. Every Open re-verifies recorded checksums and
fails loudly if a shipped migration's text has since changed.

Pre-ledger databases are backfilled with EMPTY checksums rather than
guessed ones. We cannot know which v11 a legacy archive ran, and a
fabricated checksum would assert knowledge we do not have; empty reads
as "applied, unverifiable" and verification skips it. The v14 repair
makes the distinction moot going forward.

Guard (scripts/check-migrations.sh, wired into `make check` and ci.yml).
Diffs every schemaV* constant against the base branch and fails the
build if one that already shipped was edited or deleted. Verified it
fires: mutating a single column in v11 produces a labelled diff and a
non-zero exit. TestMigrationRegistryIsComplete covers the mistakes that
need no git history (unregistered version, schemaVersion out of step).

Tests: TestLineageConvergence rebuilds all five historical states —
fresh, v10 ancestor, main-lineage v11, fork-lineage v11, fork-lineage
v12 — and asserts each converges on the same five tables at the same
version, with the seed row present exactly once.

Part of #217

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@joestump joestump added the bug Something isn't working label Jul 24, 2026
@joestump

Copy link
Copy Markdown
Owner Author

Superseded by the canonical Gitea PR: https://gitea.stump.rocks/stump.wtf/msgbrowse/pulls/245

The head commit was pushed to gitea.stump.rocks/stump.wtf/msgbrowse unchanged and the PR reopened there against main. Nothing from this branch is lost — the commit SHA is identical.

msgbrowse now develops on Gitea; stump.wtf/msgbrowse is the source of truth, push-mirrored to https://github.com/stump-wtf/msgbrowse. This personal repo is being archived.

🤖 Posted on behalf of @joestump by Claude.

@joestump joestump closed this Jul 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants