Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b616a52
feat(settings): temperature + models.dev context detection in AI & Ch…
ddutchie Jul 23, 2026
14f0dcb
feat(notes): Live Preview editor mode with inline markdown rendering
ddutchie Jul 23, 2026
75e94aa
feat(notes): render callouts inline in Live Preview (Tier 2 POC)
ddutchie Jul 23, 2026
9fc34f3
Linking a note to a task no longer risks deleting the note.
ddutchie Jul 24, 2026
9d78d09
Add ConnectorLogo component for community registry connectors
ddutchie Jul 24, 2026
16402a8
Registry 1: community manifest fetch + cache
ddutchie Jul 24, 2026
6462d77
Registry 2: Browse Community tools + 1-click install
ddutchie Jul 24, 2026
d9fb6f8
Handle OAuth sign-in cancellation + timeout clearly
ddutchie Jul 24, 2026
6913947
ConnectorLogo: add Zapier glyph for the gateway connector
ddutchie Jul 24, 2026
7af9d0d
Lint: remove explicit any in registry/link tests; tidy BrowseCommunit…
ddutchie Jul 24, 2026
c3b0241
Address CodeRabbit review (PR #92)
ddutchie Jul 24, 2026
d546bed
fix(lint): silence intentional set-state-in-effect warnings
ddutchie Jul 24, 2026
2bcd125
fix(notes): disable inline callout widgets (block-widget layout bug)
ddutchie Jul 24, 2026
f40988f
fix(sync): desktop soft-deletes notes (tombstone) instead of hard-delete
ddutchie Jul 24, 2026
89143a0
ConnectorLogo: render registry-supplied iconSvg; drop bundled brand g…
ddutchie Jul 24, 2026
88c58fe
fix: address review findings (OAuth teardown, delete idempotency, SVG…
ddutchie Jul 24, 2026
9dbc701
Show registry brand logos in the configured-servers list
ddutchie Jul 24, 2026
b36e7de
Browse Community: category chips instead of a tag wall
ddutchie Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions changelogs/v2.5.6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# v2.5.6

## Added

- **Temperature is now in AI & Chat settings.** The sampling temperature for the chat panel and inline AI — previously only reachable from the in-chat gear popover — now has a dedicated control in Settings → AI & Chat, matching the Coding Agent settings. Presets (0.1 / 0.3 / 0.5 / 0.7) plus a custom value.
- **Automatic context-window detection via models.dev.** The **Context window** control in both AI & Chat settings (Cloud / Local API provider) and Coding Agent settings now detects your model's real context size from the [models.dev](https://models.dev) catalog. With **Auto** enabled (the default), it applies the detected value automatically whenever you change models — no clicking required. Setting a value manually (custom input or preset) switches to a manual override; tapping **Auto** re-enables automatic detection. Detection is fuzzy, so proxy or gateway model ids (e.g. `playground-claude-opus-4-8`, `us.anthropic.claude-opus-4-7`) still resolve to the underlying model's context size. The catalog is cached locally so lookups are instant and work offline after the first fetch. (This brings desktop in line with the mobile app, which already used models.dev.)
- **Live Preview in the note editor.** Write mode now hides markdown syntax markers (`**`, `#`, `` ` ``, `-`, and `[…](…)` link URLs) on every line except the one your cursor is on — so a note looks rendered while you type, but the markers reappear the moment you click into a line to edit them. Bullet markers render as a `•`, and links show just their display text. Headings now render at their true size and bold/italic/code are properly styled in the editor itself (previously the editor theme referenced highlight classes that were never actually emitted, so formatting only appeared in Read mode), and the editor's heading sizes, body font, line-height and inline-code styling are now matched to Read mode so switching between Write and Read no longer shifts the layout. Nothing else changes: the raw markdown is still what's saved to disk, and Read mode remains the full render (with images, diagrams, and math). This makes the editor far friendlier for anyone who doesn't want to think in markdown.
- **Live Preview now styles highlights, wikilinks and blockquotes too.** `==highlighted==` text, `[[wikilinks]]` (shown as accent chips) and `> blockquotes` (left border) now render inline in Write mode to match Read mode, with their markers hidden until you edit the line.
- **Groundwork for a community tool registry.** Cairn can now fetch a curated catalog of MCP servers and HTTP services from the [cairn-community](https://github.com/ddutchie/cairn-community) registry — an ETag-cached, offline-resilient `manifest.json` validated against the same shape Cairn stores tools in. This is the fetch/cache plumbing; a one-click "Browse Community" installer lands in a follow-up.
- **Browse Community tools.** Settings → Tools now has a **Browse** button that opens the community catalog: search + tag-filtered MCP servers (Jira, Linear, Notion, GitHub, monday, Sentry, Asana, Stripe, HubSpot, Canva, Cloudflare, Vercel…) and HTTP services (weather, search…), each with its brand logo and the exact endpoint shown before you install. One click installs a tool (disabled, for review); connectors that need an API key prompt for it (stored in the OS keychain, never in the database or sent to the model), and OAuth connectors install ready to Connect. Installed items show an **Update** button when the registry ships a newer version. Connector logos come from the registry itself (compiled and sanitized upstream), so new connectors — and their brand marks — appear here without updating the app.

## Changed

- **Settings now reflow gracefully in a narrow window.** Settings rows previously kept their label and control side-by-side even when the window got narrow, squashing long descriptions into a thin column. Rows now stack the control below the label once the settings pane is narrow (via container queries), and the AI Provider toggle wraps instead of overflowing.
- **Corrected the AI Provider description.** It referenced "Apple Intelligence," which is a mobile-only option — on desktop the choice is between an offline On-Device Llama model and a standard cloud / local (OpenAI-compatible) API connection. The description now says so.
- **Desktop now soft-deletes notes (tombstones) instead of hard-deleting them.** Deleting a note on desktop previously physically removed the database row and relied on the sync engine reconstructing a throwaway "tombstone shell" so peers wouldn't resurrect it — a fragile mechanism that, combined with a stale device's oplog, could let a deleted note reappear or a freshly-created note vanish. The row is now tombstoned (`deleted_at` set) exactly like the mobile app already does, so both platforms represent "gone" identically and the deletion carries a durable, comparable timestamp across sync. The note still disappears from every list and search (all live reads already filter tombstones) and its `.md` file is still removed. This unifies the delete lifecycle and is the groundwork for the upcoming delete-wins reconciliation.

## Fixed

- **Outbound HTTPS requests are no longer blocked in development.** The dev Content-Security-Policy only allowed connections to `self` and localhost, so features that call external services from the renderer (like the new models.dev context-window lookup) were refused with a CSP error while running the dev build. Dev now permits `https:` connections, matching the production policy.
- **Linking a note to a task no longer risks deleting the note.** `link_note_to_task` / `unlink_note_from_task` released the MCP write-lock (`mcp_active_writes`) *before* writing the note's `.md` file. Because the file-watcher runs in a separate process, an unlink of the note's old path during that unguarded write could be misread as a deletion and the note's row purged. The file write now happens inside the lock (matching every other note tool), so the watcher correctly treats it as a relocation. Added a regression test asserting the lock is held across the write.
- **Cancelling an MCP sign-in now behaves.** If you deny consent on a connector's authorization page, the browser tab now says "Sign-in cancelled" and Cairn shows a clear cancelled message (previously it reported a confusing "response was missing or invalid"). While Cairn is waiting for the browser step, the button now offers a **Cancel** action instead of being stuck on "Waiting for browser…", and a sign-in that's never completed times out on its own after 10 minutes with a clear message.
173 changes: 173 additions & 0 deletions docs/plans/sync-lifecycle-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Sync Lifecycle Hardening — Delete/Resurrection & Stale-Peer Safety

Status: Draft / plan
Owner: —
Date: 2026-07-24
Related: `shared/sync/engine.ts`, `shared/sync/hlc.ts`, `mobile/src/db/queries.ts`, `electron/db/queries.ts`, `electron/sync/desktop-sync.ts`, `docs/plans/mobile-app-viability.md` (§4–§5)

---

## 1. Reported symptoms

Two data-loss / data-integrity bugs observed when one device (mobile) had a **stale
oplog / stale local rows** (days behind), then synced:

1. **Note vanished.** Created a note on desktop, linked it to a task via MCP; after
mobile (stale) synced, the note disappeared.
2. **Note resurrected.** Deleted a note on desktop (manual); after mobile (stale)
synced, the deleted note came back.

Both point at the reconciliation lifecycle — specifically how deletes, creates, and
last-writer-wins (LWW) interact when one peer carries stale state.

---

## 2. Root cause analysis

### 2.1 "Stale oplog" is a symptom; **stale HLC vs delete ordering** is the cause

`Hlc.send()` does `physical = max(prevPhysical, wall)` (`shared/sync/hlc.ts:94`). A device
that hasn't *synced* for days still advances its wall clock, so its new stamps are **not
literally old** — they can be numerically *higher* than a legitimate delete that happened
on the other device. Reconcile is pure LWW by `compareHlc` with **no domain-aware
tie-break** (`shared/sync/engine.ts:494`): a `put` beats a `delete` iff its HLC is higher,
regardless of user intent or which action was "more recent" in real terms.

### 2.2 Symptom 2 (resurrection) — the direct mechanism

- Desktop `deleteNote` **physically removes** the row (`DELETE FROM notes`,
`electron/db/queries.ts:280`). The AFTER-DELETE trigger stages a `delete` op.
- On drain, `UPDATE ... SET deleted_at` matches **zero rows** (row already gone), so the
engine inserts a **tombstone shell** to arm the staleness guard
(`shared/sync/engine.ts:205-220`).
- The shell only protects against a peer `put` **older** than the delete. If the stale
mobile peer holds a `put` for that note with a **higher HLC** than the delete (its wall
clock is current; or `backfill()` re-stamped the row with a fresh HLC — see 2.4), then in
`reconcileOne` the delete is treated as **stale** (`compareHlc(deleteHlc, putHlc) <= 0`)
and **skipped**, and mobile's `put` revives the row. **Note resurrects.**

### 2.3 Symptom 1 (disappearance) — the direct mechanism

- The link write updates **two** entities: `notes.linked_card_ids` and
`task_cards.linked_note_ids` (mobile `applyLinkChange`, `mobile/src/db/queries.ts:610`;
desktop equivalent). These are separate oplog rows.
- `mergeForPut` bases the merged row on the **remote** payload (it won LWW) and only
set-unions the array columns (`shared/sync/engine.ts:602-621`). If the stale peer's row
wins LWW on the `notes` entity, the note's **scalar** state (including a stale/absent
representation of "alive") overwrites desktop's fresh create.
- Compounded by the **hard-delete vs soft-delete asymmetry** (2.4): a freshly-created note
on desktop that was never seen by the stale peer can be reconciled against a
tombstone-shell or an absent row and lose.

### 2.4 Structural weaknesses (the real backlog)

1. **No delete-wins / grace semantics.** `delete` vs `put` is pure HLC LWW. There is no
"an explicit, recent delete beats a resurrecting put."
2. **Hard-delete (desktop) vs soft-delete (mobile) asymmetry.** Two representations of
"gone," reconciled through a fragile tombstone-shell mechanism that only guards the
older-put case.
3. **`backfill()` re-stamps rows with fresh HLCs** (`shared/sync/engine.ts:283-284`). A
stale device's first backfill can mint **newer-than-reality** stamps for old rows,
inverting causality against a legitimate remote delete.
4. **No durable tombstone HLC comparison.** The shell records `deleted_at` + `hlc`, but the
guard is one-directional; there is no persisted "delete happened at HLC D, reject any put
whose causal ancestor precedes D."
5. **No user-facing visibility / recovery.** When a note vanishes or resurrects, there is no
activity log explaining why and no one-tap restore.

---

## 3. Chosen policy: delete-wins within a grace window

(Decided with owner.) An explicit `delete` beats a `put` **unless the put is causally after
the delete**. Concretely:

- Persist the delete as a **durable tombstone** carrying the delete's HLC (`deleted_at` +
the HLC at delete time), never garbage-collected within a retention window.
- In `reconcileOne`, when an incoming `put` targets a row that is **tombstoned locally**:
- Apply the put (un-delete) **only if** the put's HLC is strictly greater than the
tombstone's delete-HLC **AND** the put's origin has demonstrably observed the delete
(causal-after), i.e. it is a genuine edit-after-delete-elsewhere.
- Otherwise the delete wins: keep the row tombstoned, do **not** resurrect.
- Symmetrically, a `delete` arriving against a live local row wins unless the local row was
edited causally-after the delete.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

This blocks the stale-peer resurrection (2.2) while still allowing the legitimate
"someone edited it on device B after I deleted it on device A, and device B had seen my
delete" case. Where causality can't be established (no observed delete), **delete wins** —
the safe default — and the resurrecting content is preserved as a conflict copy so nothing
is silently lost.

---

## 4. Work plan (phased)

### Phase 1 — Delete-wins reconciliation (fixes Symptom 2)
- **1a.** Add a durable tombstone record keyed by (entity, entity_id) storing `delete_hlc`
(persisted independently of the row, so a compacted/absent row still carries the delete
fact). Engine-owned table like `sync_row_base`.
- **1b.** In `reconcileOne`, gate `put`-over-tombstone on causal-after semantics (§3). Add
the mirror gate for `delete`-over-live.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
- **1c.** Preserve losing content as a conflict copy when delete wins over a divergent put
(no silent loss).
- **Tests:** stale-peer put with higher wall-clock HLC must NOT resurrect a deleted note;
legitimate edit-after-observed-delete MUST revive.

### Phase 2 — Unify delete representation (fixes Symptom 1 root) ✅ DONE
- **2a.** ✅ Desktop `deleteNote` now soft-deletes (`UPDATE notes SET deleted_at = COALESCE(deleted_at, now)`,
idempotent) instead of `DELETE FROM notes`. The AFTER-UPDATE trigger already stages a `delete`
op (keys off `NEW.deleted_at`, schema v26), so peers tombstone via the normal `drainPending`
delete branch. `.md` removal is unchanged (delete handlers look up the row before deleting).
- **2b.** ✅ Audit result — **much smaller than expected**: desktop live list/search reads
(`getNotes`, `findLiveNoteByTitle`, `searchNotes`, `getCards`) **already** filter
`deleted_at IS NULL`; the old `deleteNote` comment claiming otherwise was stale. The only gap
was by-id reads: `getNoteById` now filters tombstones (added `getNoteByIdIncludingTombstoned`
for the disk projector, which must still see the tombstone to remove the orphan `.md`).
`toNote` now exposes `deletedAt`.
- **2c.** Tombstone-shell in `drainPending` is **retained** (still needed for the
created-then-deleted-then-compacted edge and the mobile paths) — but desktop soft-delete no
longer depends on it.
- **Tests:** ✅ all 1298 pass; `engine.test.ts` + `queries.test.ts` updated to soft-delete
semantics; `type-check:all` + `compile` clean. (create-then-link stale-peer survival test is a
Phase 1 concern — the durable tombstone this phase provides is the prerequisite.)

### Phase 3 — Backfill causality safety
- **3a.** `backfill()` must **not** mint fresh HLCs that could leapfrog a peer's delete.
Either preserve each row's existing `hlc` in the seeded op, or stamp backfill ops with a
clock derived from the row's `updated_at` (never `Date.now()` for pre-existing rows).
- **Tests:** a device backfilling old rows cannot resurrect a note another device already
deleted.

### Phase 4 — Visibility & recovery (defensive, ships alongside)
- **4a.** Lightweight sync activity log: record applied ops (entity, op, hlc, origin,
outcome: applied/skipped-stale/conflict-copy/delete-won) with a bounded ring buffer.
- **4b.** Surface "recently changed by sync" + "restore" affordance for notes that were
tombstoned by a peer, so a surprise deletion is recoverable in one tap.

---

## 5. Files to touch

| Area | File |
|---|---|
| Reconcile / LWW / tombstones | `shared/sync/engine.ts` |
| HLC (causal-after helper) | `shared/sync/hlc.ts` |
| Desktop delete → soft-delete | `electron/db/queries.ts`, `electron/sync/desktop-sync.ts`, `electron/notes-files.ts`, `electron/file-watcher.ts` |
| Mobile parity (already soft-deletes) | `mobile/src/db/queries.ts` |
| Tests | `shared/sync/engine.test.ts` (+ new cases) |

---

## 6. Risks & notes

- ~~Changing desktop to soft-delete means **every desktop live query must exclude
tombstones**~~ — **resolved in Phase 2**: the list/search reads already filtered
`deleted_at IS NULL`; only the by-id reads needed the guard. No ghost rows.
- The tombstone table (Phase 1) needs a **retention/GC policy** so it doesn't grow unbounded;
GC must never run inside the grace window that protects against stale peers. Use a
**per-peer acknowledged-HLC watermark + a wall-clock grace floor** rather than "is the delete
op still in the peer's oplog file" — compaction (`compactOplog`) can drop that op, so the
file is not a stable signal. GC only when every known peer's watermark is past the delete HLC
**and** the grace period has elapsed (guards unknown / reinstalled peers).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
- Two-device single-user scope (per `mobile-app-viability.md` §4) keeps this tractable — no
CRDT needed; delete-wins + conflict-copy is sufficient.
22 changes: 19 additions & 3 deletions electron/db/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
findTombstonedNotes,
findNestedConflictCopies,
getNoteById,
getNoteByIdIncludingTombstoned,
moveNoteToProject,
createColumn,
createCard,
Expand Down Expand Up @@ -672,7 +673,7 @@ describe("tool attachment queries", () => {
});
});

describe("deleteNote (hard delete)", () => {
describe("deleteNote (soft delete / tombstone)", () => {
let db: Database.Database;

beforeEach(() => {
Expand All @@ -681,14 +682,29 @@ describe("deleteNote (hard delete)", () => {
seedProject(db);
});

it("physically removes the row (desktop lists don't filter tombstones)", () => {
it("tombstones the row (kept, but hidden from live reads/search)", () => {
createNote(db, { id: "n1", projectId: "proj1", workspaceId: "ws1", title: "Doomed", content: "x" });
expect(getNoteById(db, "n1")).not.toBeNull();
deleteNote(db, "n1");
// getNoteById filters tombstones → treated as absent.
expect(getNoteById(db, "n1")).toBeNull();
// …but the row survives so the sync staleness guard has something to compare.
expect(getNoteByIdIncludingTombstoned(db, "n1")).not.toBeNull();
expect(
(db.prepare("SELECT deleted_at FROM notes WHERE id='n1'").get() as { deleted_at: string | null }).deleted_at,
).not.toBeNull();
expect(searchNotes(db, { query: "Doomed" }).find((n) => n.id === "n1")).toBeUndefined();
});

it("is idempotent — re-deleting keeps the original tombstone timestamp", () => {
createNote(db, { id: "n1", projectId: "proj1", workspaceId: "ws1", title: "Doomed", content: "x" });
deleteNote(db, "n1");
const first = (db.prepare("SELECT deleted_at FROM notes WHERE id='n1'").get() as { deleted_at: string }).deleted_at;
deleteNote(db, "n1");
const second = (db.prepare("SELECT deleted_at FROM notes WHERE id='n1'").get() as { deleted_at: string }).deleted_at;
expect(second).toBe(first);
});

it("is a no-op for an unknown id", () => {
expect(() => deleteNote(db, "does-not-exist")).not.toThrow();
});
Expand All @@ -714,7 +730,7 @@ describe("findTombstonedNotes + getNotes tombstone filtering", () => {
expect(found[0].projectId).toBe("proj1");

// The tombstone row is KEPT (so the sync staleness guard trips) …
expect(getNoteById(db, "dead")).not.toBeNull();
expect(getNoteByIdIncludingTombstoned(db, "dead")).not.toBeNull();
// … but getNotes filters it out, so it doesn't show in the UI.
const listed = getNotes(db, "proj1").map((n: { id: string }) => n.id);
expect(listed).toContain("live");
Expand Down
Loading