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
Closed
Reconcile the two schema lineages and land the stranded contact-merge/embed work (#217)#220joestump wants to merge 25 commits into
joestump wants to merge 25 commits into
Conversation
* 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>
Owner
Author
|
Superseded by the canonical Gitea PR: https://gitea.stump.rocks/stump.wtf/msgbrowse/pulls/245 The head commit was pushed to msgbrowse now develops on Gitea; 🤖 Posted on behalf of |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Reconciles the two schema lineages that diverged at
acb2183and 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 = 12withembed_runsand the contact tables but nojournal_days/journal_digests, whilemaindefines v11 as the journal tables. No build ofmaincould open it:The forward-refusal at
internal/store/migrations.gois correct and caught it. The dangerous case is the quiet one:migrate()only runscurrent+1 … schemaVersion, so a naive merge that bumpedschemaVersionto 13 would leave main-lineage databases permanently withoutembed_runsand fork-lineage databases permanently without the journal tables — both reporting the same version while having different schemas.The reconciliation
journal_days,journal_digestsembed_runscontact_links,contact_merge_rulesv14 re-asserts the union of all three, composed as
schemaV11 + schemaV12 + schemaV13so it cannot drift from what it repairs. Safe to re-run because every contested migration isCREATE … IF NOT EXISTSand the one seed row usesON CONFLICT DO NOTHING. Convergence paths:Making the next one impossible
Migration ledger.
schema_migrationsrecords version + sha256 of the exact SQL, written in the same transaction as the DDL and theuser_versionbump, 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 intomake checkandci.yml, diffs everyschemaV*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.TestMigrationRegistryIsCompletecovers what needs no git history.Same root cause, two more collisions
The merge silently accepted two
ADR-0022files and twoSPEC-0015s. The contact-merge pair is renumbered to ADR-0024 / SPEC-0018 (mainshipped 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 keptinternal/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 viamake css, not hand-resolvedinternal/store/journal_test.go— duplicatesysMsghelper collapsed onto the three-arg form (signal.SystemSenderis"No-Sender", so the two were equivalent)Verification
TestLineageConvergencerebuilds 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: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
TestLoadDefaultsininternal/configfails 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
@joestumpby Claude.