Skip to documentation

Capability clusters

C6 · Versioning, history & recovery

This cluster is eli’s safety net: it guarantees that no note write is ever destructive and that every destructive-looking operation is recoverable.

It owns two deliberately independent layers. Layer 1 is the append-only versions table — written by the storage adapters (C1) on every note save, plus system rows from the local index scanner for out-of-band edits — which is the sole data source for all history, diff, and restore UI in both modes. Layer 2 is git cold storage: a Local-mode-only, debounced auto-commit loop that snapshots the vault into a real .git repository as pure bookkeeping — the diff/restore UI never reads git, and git never feeds the versions table. On top of both sits the recovery lifecycle: soft-delete to trash (.eli/trash/ file move locally, trashed_at stamp in SaaS), per-row restore, manual empty-trash, a 30-day auto-prune cron, plus pin/unpin and a set of headless bulk actions.

  • Modes: versions/diff/trash/pin = local + saas; git layer = local only.
  • Key dirs: lib/git/, components/diff/, app/_actions/diff.ts, app/_actions/notes.ts, app/_actions/bulk.ts, components/trash-list.tsx, app/api/cron/trash-prune/, lib/storage/helpers/section-{diff,replace}.ts.
  • Depends on: C1 storage adapters (version rows, trash storage), C9 platform-ops (~/.eli/config.json, CRON_SECRET, vercel crons).
  • Feeds: C2 authoring UX (History card, Delete/Pin controls), C5 agent surface (get_diff/restore_version MCP tools), C4 AI platform (conflict snapshots surface here; digest pages are documented there).

Capabilities

CapabilityWhat it doesEntry pointsModes
Version production (append-only)Every createNote/updateNote appends a versions row (bodyMd, frontmatter, contentHash, authorKind, agentName, message, parentVersionId) in the same transaction as the note write; optimistic-concurrency mismatches additionally snapshot the loser as an auto-conflict-snapshot system versionlib/storage/local/writes.ts:160-174,255-277; SaaS notes_save RPC via lib/storage/supabase/writes.tsboth
Out-of-band edit capture (scanner versions)Local full/incremental scans insert a versions row per changed file — authorKind:'system', message initial scan/rescan, parentVersionId:null; content-hash match skips unchanged files — so edits made outside eli still land in history; the walker skips .eli/ so trashed files never reindexlib/index/scanner.ts:231-245 via chokidar → lib/index/incremental.ts:17-42; SKIP_DIRS at lib/index/walk.ts:5local
Local diff page/v/diff/<path>?a&b renders a section-level diff between two versions; defaults to the two newest; needs ≥2 versionsapp/(local)/v/diff/[...path]/page.tsx; Compare links in the inspector History cardlocal
SaaS diff pageLine-for-line sibling at /w/<slug>/diff/<path>?a&b; identical component tree, Postgres versions via RLS-gated readsapp/(saas)/(app)/w/[workspace]/diff/[...path]/page.tsxsaas
Version pickerFrom/To selects listing every version as date · authorKind · message; change does router.push with new ?a&bcomponents/diff/version-picker.tsxboth
Section-level diff renderingDiffView renders SectionDiff[] cards with +/−/~/= badges, diff-match-patch hunk lines, and GitHub-style collapsing of runs of ≥2 unchanged sectionscomponents/diff/diff-view.tsxboth
Per-section restore‘Restore this section’ splices one section from an old version into the current body via replaceSection and writes it as a new version (authorKind:'user', message restore section <anchor> from <id8>) — history is never mutatedrestoreSectionAction in app/_actions/diff.ts; button in DiffViewboth
History card with Compare linksInspector shows the last 8 versions; each non-latest row links to <diffRouteBase>/<path>?a=<that>&b=<latest>; conflict snapshots highlightablecomponents/inspector/properties-panel.tsx:251-298both
MCP get_diff / restore_versionAgents get the same section diff (storage.diffVersions) and append-only restore (updateNote with authorKind:'agent', message restored from <id>); neither touches lib/gitlib/mcp/tools/diff.ts (see C5)both
Debounced git auto-commit-on-saveLocal saves enqueue their path; after git.debounceSec (default 30 s, clamp 5–3600) of quiet, all queued paths land as ONE commit. Gated on .git/ existing, git.auto:true, and ELI_DISABLE_GIT != '1'. Any failure self-disables the loop for that vaultLocalAdapter.updateNote → enqueueCommit (lib/storage/local/writes.ts:330-337, lib/git/commit-loop.ts)local
Agent-attributed git commitsAgent/clipper saves author commits as ${agentName} <${agentName}@eli.local>; user commits use git config user.name/email (throws if unset); any agent save in a batch makes the whole commit agent-authored (latest agent wins)SaveOpts authorKind/agentName flowing from MCP tools, actions, clipperlocal
Dual git runtime, 500 ms detectiongetGit() probes git --version with a hard 500 ms budget; success → SimpleGitAdapter (shells out, rename detection, working push/pull); failure/timeout → IsoGitAdapter (pure-JS isomorphic-git, no renames, push/pull stubbed). Memoised at globalThis.__ELI_GIT_DETECT__getGit({vaultPath, prefer?, probe?}), lib/git/detect.tslocal
Soft-delete to trashdeleteNoteAction(id) (default trash:true): local moves the file to .eli/trash/<path> + stamps trashed_at (with watcher echo-suppression); SaaS stamps trashed_at via RLS-scoped UPDATE. Best-effort cancels pending auto-embedapp/_actions/notes.ts:225; Delete button in properties panelboth
Restore from trashLocal: file moved back from .eli/trash/, trashedAt cleared, throws if the file vanished; SaaS: trashed_at=NULL by id under RLS (no workspace_id predicate)restoreNoteAction (app/_actions/notes.ts:199); TrashList Restore buttonboth
Permanent (hard) deletedeleteNoteAction(id, {hard:true}): local rm of the live path (force:true) + SQLite row delete — an already-trashed note’s .eli/trash copy stays on disk (see Gaps); SaaS DELETE with FK cascade of versions/links/commentsTrashList per-row delete (confirm-gated)both
Empty trashLists onlyTrashed (requests 1000, adapter-capped 500 local / 200 SaaS) and hard-deletes each, skipping per-row failures; optional olderThanDays uses updatedAt as trashed-at proxyemptyTrashAction (app/_actions/notes.ts:254); TrashList header buttonboth
Trash recovery pages/v/trash and /w/<slug>/trash render the shared TrashList over listNotes({onlyTrashed:true},{limit:200}) with optimistic restore/delete and rollback toastspages + components/trash-list.tsx; command palette ‘Trash’ (local), SaaS top-navboth
30-day trash auto-prune cronPOST or GET /api/cron/trash-prune, Bearer-checked against CRON_SECRET (fails closed if unset), ?days=N override; local prunes the active vault, SaaS fans out over every workspace via the service-role client (RLS bypassed by design). Daily 04:00 UTCapp/api/cron/trash-prune/route.ts; vercel.json cron 0 4 * * *both
Pin / unpin + pinned listtogglePinAction flips the _pinned system frontmatter key (deletes the key on unpin, never writes false); listPinnedNotesAction post-filters _pinned===true in memory — UI surface exists only on the SaaS workspace homeapp/_actions/notes.ts:298-340; properties-panel pin toggleboth (action) / saas (list UI)
Bulk move / tag rename / archiveThree zod-validated headless server actions over 1–500 note ids: rewrite path to <folder>/<basename> via updateNote (not renameNote — wikilinks not rewritten), swap a tag in frontmatter.tags, or archiveNote each — with per-row error collection {succeeded, failed, errors[]}. No UI consumer existsapp/_actions/bulk.ts:53,88,127both
Left-inbox toast signalarchiveNoteAction/updateFrontmatterAction return leftInbox:true when the mutation moves the note out of the Inbox bucket (noteBucket() dual-signal rule); properties panel shows a one-shot toastapp/_actions/notes.ts:148-154,344-376 (shared with C2)both
Escape hatchesELI_DISABLE_GIT=1 kills the commit loop; flushNow (‘Sync now’), reenableCommitLoop(‘Settings → Git retry’), isCommitLoopDisabled, getCommitLoopError are exported but have zero UI callers todaylib/git/commit-loop.ts:139-199local

Digest pages, the Generate-now button, and the digest builder appear in the trash-cluster findings but belong to the AI platform — see C4. Likewise lib/agents-md/restore.ts (managed AGENTS.md/CLAUDE.md recovery) is agent-rules recovery, not note recovery — documented, including its unwired status, in C5.

System view — simplified

System view — simplified

C6 is eli’s memory and undo layer. Standalone, it needs nothing but a storage adapter: every save appends a row to the versions table, and the diff/restore UI is pure text processing (diff-match-patch) over pairs of those rows — no git binary, no AI, no network beyond the adapter itself. The git layer is fully separable in the other direction too: lib/git is a self-contained toolkit (init/add/commit/log/show/diff/status with two interchangeable runtimes) whose only production consumer is a single enqueueCommit call after local saves; disable it (ELI_DISABLE_GIT=1, or just no .git/ dir) and nothing else in the platform notices. The trash pipeline likewise works with everything else off: soft-delete is a file move plus a column stamp, restore reverses it, and the prune cron is a plain HTTP route.

System view — detailed

System view — detailed
ComponentRoleFiles
GitAdapter contractStorage-agnostic interface: init, add, commit, log (sha-cursor paginated), show, diff, restore, checkout, status, setRemote, push, pull; kind marker for diagnosticslib/git/adapter.ts, lib/git/types.ts
Runtime detectionprobeSystemGit spawns git --version with a 500 ms timeout; result memoised at globalThis.__ELI_GIT_DETECT__; getGit resolves the concrete adapterlib/git/detect.ts, lib/git/index.ts
SimpleGitAdapterShells to system git via simple-git; two-phase log; --name-status -M diff with rename detection; porcelain-v1 status; author resolution from git config (throws if unset)lib/git/simple.ts
IsoGitAdapterPure-JS isomorphic-git fallback (~5–10× slower log); tree-walk diff with no rename detection; push/pull throw ‘stubbed until P6-SYNC’lib/git/iso.ts
Commit loopPer-vault debounced queue at globalThis.__ELI_GIT_LOOPS__; dedup by relPath; one commit per flush; self-disables on any failurelib/git/commit-loop.ts
Conformance suite + testsIdentical behavioural suite run against both adapters in fresh tmp repos; detect and commit-loop unit testslib/git/conformance.ts, lib/git/*.test.ts
Diff pagesServer components: getNote → listVersions → diffVersions; near-identical twins differing only in route base/BackLink; both force-dynamic; siblings of the note route (not nested under /v/n) because Next 16 Turbopack requires catch-all segments to be terminalapp/(local)/v/diff/[...path]/page.tsx, app/(saas)/(app)/w/[workspace]/diff/[...path]/page.tsx
Diff UI componentsDiffView (section cards, hunks, restore transitions, toasts) and VersionPicker (From/To soft navigation)components/diff/diff-view.tsx, components/diff/version-picker.tsx
Restore-section actionZod-validated: getNote + getVersion + replaceSection + updateNote(authorKind:'user'); revalidates /v pathsapp/_actions/diff.ts, lib/storage/helpers/section-replace.ts
Section diff algorithmdiffSections: anchor-keyed section split (ATX headings), classify added/removed/modified/unchanged, line-mode diff-match-patch hunks; pure function shared by both adapterslib/storage/helpers/section-diff.ts
Version storeLocal: transactional version rows + conflict snapshots, ordered createdAt DESC, id DESC; SaaS: rows appended by notes_save RPC, RLS-gated readslib/storage/local/versions.ts, lib/storage/local/writes.ts, lib/storage/supabase/{queries,writes}.ts
Note lifecycle actionsdeleteNoteAction (soft/hard), restoreNoteAction (mode-forked), emptyTrashAction, togglePinAction, listPinnedNotesAction, leftInbox signalapp/_actions/notes.ts
TrashList + pagesShared optimistic client list (restore / hard-delete / empty-trash with confirms and rollback) rendered by both force-dynamic trash pagescomponents/trash-list.tsx, app/(local)/v/trash/page.tsx, app/(saas)/(app)/w/[workspace]/trash/page.tsx
Trash-prune cronCRON_SECRET-gated daily purge (default 30 d, ?days=N); local = active vault, SaaS = service-role fan-out via SupabaseAdapter.fromClientapp/api/cron/trash-prune/route.ts, vercel.json
Bulk actionsHeadless move/tag-rename/archive over ≤500 ids with per-row error collection; no UI consumerapp/_actions/bulk.ts
Trash storage modelingLocal: rename into .eli/trash/<path> + indexed trashed_at TEXT column, watcher echo-suppression; SaaS: trashed_at timestamptz + FK cascade on hard delete; every non-trash read filters trashed_at IS NULLlib/storage/local/writes.ts, lib/storage/supabase/writes.ts, lib/db/schema.sqlite.ts, lib/db/migrations/pg/0000_initial.sql

Data flow — simplified

Data flow — simplified

The single most important flow: a save first lands durably (file on disk + note row + version row in one transaction), then best-effort enqueues a git commit. The debounce window (default 30 s, git.debounceSec) coalesces bursts of saves into one commit; the versions table — not git — is what every history and diff surface reads. In SaaS mode the left half is the notes_save Postgres RPC and the git half simply does not exist.

Data flow — detailed

Sequence A — save → version row → debounced git commit (lib/storage/local/writes.ts, lib/git/commit-loop.ts, lib/git/detect.ts):

Data flow — detailed (Sequence A: save → version row → debounced git commit)

Sequence B — compare versions → section diff → restore one section (app/(local)/v/diff/[...path]/page.tsx, lib/storage/helpers/section-diff.ts, app/_actions/diff.ts):

Data flow — detailed (Sequence B: compare versions → section diff → restore one section)

Recovery lifecycle (prose sequence). Soft-delete → trash → restore/purge runs entirely on C1 storage primitives:

  1. Properties-panel Delete (confirm 'Move this note to .eli/trash/?') → deleteNoteAction(id) with default trash:true (app/_actions/notes.ts:225-231).
  2. Local: file renamed to .eli/trash/<path>, trashed_at stamped, note:deleted emitted, watcher echo suppressed (lib/storage/local/writes.ts:351-366). SaaS: UPDATE notes SET trashed_at=now() scoped workspace_id+id under RLS (lib/storage/supabase/writes.ts:235-244). Pending auto-embed cancelled best-effort (notes.ts:234-240).
  3. The note disappears from every list/search/favorites surface via their trashed_at IS NULL filters and appears only on the trash pages (listNotes({onlyTrashed:true},{limit:200})).
  4. Restore: TrashList row button → restoreNoteAction — local reverses the file move (throws if the trashed file is gone, writes.ts:395-401) and emits note:created; SaaS clears trashed_at by id under RLS only.
  5. Purge: per-row hard delete or header Empty-trash (both confirm-gated, optimistic with rollback, components/trash-list.tsx:37-87); nightly 0 4 * * * cron hard-deletes trashed rows whose updatedAt is older than 30 days (app/api/cron/trash-prune/route.ts:38-55). Local purge removes the DB row (versions cascade) but not the .eli/trash file — see Gaps.

Works separately / works together

Standalone

With every other cluster turned off, C6 still delivers: (1) a complete, append-only edit history for every note — each save is a version row with author attribution, so “who changed what, when” is answerable from the adapter alone; (2) a working compare-and-restore UI — the diff pages need only listVersions/diffVersions, which are pure reads plus a pure text function (diffSections), no git, no AI, no network; (3) a full trash lifecycle — soft-delete, browse, restore, purge — that is just file moves and a nullable column; and (4) an independent git snapshot of the vault that any external git tooling (git log, GitHub, git checkout) can consume, since the commit loop writes a perfectly ordinary repository. The two layers are so decoupled that each is the other’s backup: lose the SQLite index and git still has file history; delete .git/ and the versions table still has everything.

Composed

  • C1 core storage → C6 versions: every createNote/updateNote in both adapters appends the version row this cluster reads (lib/storage/local/writes.ts:160-174, notes_save RPC via lib/storage/supabase/writes.ts); updateNote is also the sole production bridge into lib/git (writes.ts:331 → enqueueCommit).
  • C2 authoring UX → C6: the inspector History card is the primary navigation entry into the diff pages (components/inspector/properties-panel.tsx:281-288, diffRouteBase wired at app/(local)/v/n/[...path]/page.tsx:52 and the SaaS twin at line 101); the Delete and Pin controls live in the same panel; the command palette links /v/trash (components/command-palette.tsx:233-236).
  • C4 AI platform ↔ C6: optimistic-concurrency conflicts snapshot the loser as an auto-conflict-snapshot version that surfaces (highlighted) in the History card and is diffable like any other (writes.ts:255-277); deleteNoteAction cancels pending auto-embeds (notes.ts:234-240); digest pages/cron documented in C4.
  • C5 agent surface ↔ C6: MCP get_diff/restore_version (lib/mcp/tools/diff.ts) and list_versions read/write the same version store; SaveOpts authorKind/agentName from MCP calls flow all the way into git commit authorship; the standalone MCP binary ships simple-git/isomorphic-git as externals (scripts/build-mcp-bin.mjs:29-37).
  • C7 SaaS collaboration → C6: all SaaS permissioning for trash/restore/diff is Postgres RLS (lib/db/migrations/pg/0001_rls.sql:140-180) — the actions add no explicit role checks; hard delete cascades versions/links/comments by FK.
  • C8 capture/templates → C6: clipper saves carry authorKind:'clipper' into the version log; app/_actions/projects.ts:15 deliberately routes project mutations through updateNoteso they “participate in the version log + git auto-commit”.
  • C9 platform-ops → C6: ~/.eli/config.json supplies git.auto/git.debounceSec (lib/config/schema.ts:26-29,80); vercel.json schedules the prune cron; CRON_SECRET gates it; ELI_DISABLE_GIT is the global git off switch.

Load-bearing details

  • The entire production consumer surface of lib/git is one call site: lib/storage/local/writes.ts:331 (enqueueCommit from updateNote, import at line 22). SaaS never touches lib/git; the diff UI never touches lib/git; the whole GitAdapter read surface (log/show/diff/restore/checkout/status/push/pull) is exercised only by the conformance test suite. Verified by grep.
  • Debounce mechanics: per-vault state pinned at globalThis.__ELI_GIT_LOOPS__so Turbopack’s per-bundle module evaluation doesn’t fragment the queue (commit-loop.ts:39-48); the timer is a single sliding setTimeout, unref()’d so a pending commit never keeps Node alive (commit-loop.ts:127-133).
  • Failure posture is deliberately best-effort: enqueue errors are swallowed in updateNote(“the file on disk is the source of truth; commit is bookkeeping”, writes.ts:335-337), and any flush failure sets disabled=true + lastError — no retry, all future enqueues for that vault silently dropped until reenableCommitLoop() (commit-loop.ts:177-181).
  • Unreadable/uninitialised ~/.eli/config.json degrades to {auto:false} — a fresh boot never commits (commit-loop.ts:106-109). Config bounds: git.debounceSec int 5–3600 default 30, git.auto default true (lib/config/schema.ts:28,80 — verified).
  • The 500 ms probe cap exists so a hung git --version (AV scanner, broken alias) degrades to iso-git instead of blocking first render; error, timeout, and failure-to-spawn all resolve {ok:false} (lib/git/detect.ts:19-39). Memoisation applies only for prefer:'auto' with no injected probe (detect.ts:71-90).
  • Commit messages are exactly eli: update <relPath> (single file) or eli: <N> file(s) updated (batch) (commit-loop.ts:167-170). Batch authorship nuance (verified in source): the commit loop’s lastAgent filter matches only authorKind === 'agent' (commit-loop.ts:165), so a batch containing only clipper saves commits as authorKind:'user' — even though both adapters would treat 'clipper' as an agent inside resolveAuthor (lib/git/simple.ts:159, lib/git/iso.ts). Clipper attribution reaches the versions table but not git commit authorship.
  • Adapter divergence hidden below the shared contract: SimpleGitAdapter.diff passes -M (renames reported as renamed with oldPath); IsoGitAdapter’s tree diff cannot detect renames (delete+add) and its path-filtered log is “correct-but-slow” per-commit tree-diffing (lib/git/iso.ts:296-313). The conformance suite never asserts rename behaviour.
  • Version ordering tiebreak: local listVersions orders createdAt DESC, id DESC (ULID ids give sub-ms ordering, lib/storage/local/versions.ts:29-36), but the diff pages re-sort with a createdAt string compare only (page.tsx:58-60— verified), so equal-timestamp default picks can differ from the inspector’s ordering.
  • Per-section restore semantics (lib/storage/helpers/section-replace.ts:21-49): section in both versions → replace; only in current → delete (“restore the deletion”); only in source → append at end; in neither → error. The result is always a new version — nothing in history is ever rewritten, in either layer.
  • DiffViewcollapses runs of ≥2 consecutive unchanged sections behind a “Show N unchanged” toggle; runs of 1 render inline; no virtualisation — it renders up to ~200 sections inline per its own comment (components/diff/diff-view.tsx:23-26,260-281).
  • Trash-prune auth fails closed: badAuth() returns true when CRON_SECRET is unset (route.ts:33-34 — verified); GET is aliased to POST (route.ts:113-115) so Vercel cron GETs work. SaaS fan-out builds SupabaseAdapter.fromClient(serviceRole, ws.id, ws.owner_id) per workspace — RLS bypassed by design (route.ts:96-102).
  • Local soft-delete/restore call markEchobefore the file move so the vault watcher doesn’t re-index its own operation (writes.ts:355,394 — verified); restore emits note:created so downstream indexes pick the note back up.
  • Pin semantics: unpin deletes the _pinned frontmatter key rather than writing false (notes.ts:310-314); underscore-prefixed system fields survive updateFrontmatterAction’s user-field replacement loop (notes.ts:357-359).
  • VersionAuthorKind is 'user'|'agent'|'clipper'|'system' (lib/domain/types.ts:41); 'system' is used for auto-conflict snapshots, scanner rows, and local whole-version restores.
  • Version rows are full-content snapshots (bodyMd + frontmatter), not deltas — ULID ids local, UUID SaaS — and cascade-delete with their note in both schemata (lib/db/schema.sqlite.ts:85-109, lib/db/schema.pg.ts:167-195).
  • SaaS OCC mechanics (verified): notes_save is a SECURITY-INVOKER plpgsql function (RLS still applies) that stamps author_id from the JWT sub claim and raises errcode 40001 on version_id mismatch (lib/db/migrations/pg/0006_notes_save.sql:7,38,49); the adapter pre-snapshots the loser on read mismatch, and a 40001 race triggers exactly one re-read + re-snapshot + retry with p_expected_version_id: null (force-accept) (lib/storage/supabase/writes.ts:107-171).
  • Conflict surfacing contract: saveNoteAction round-trips expectedVersionId from the editor form and returns snapshotVersionId; ConflictBanner’s Review button sets window.location.hash = 'version-<id>' and the properties panel highlights the matching #version-<id> History row via a hash listener (components/editor/blocknote-editor.tsx:157, components/inspector/properties-panel.tsx:67-76,271).
  • storage.restoreVersion (whole-version restore) is implemented by both adapters but diverges: local writes authorKind:'system', message restored from <id> (lib/storage/local/versions.ts:48-66 — verified); SaaS writes authorKind:'user', message restore version <id> (lib/storage/supabase.ts:172-180 — verified). MCP restore_version deliberately bypasses it so restores are agent-attributed; per-section restore is the only user-facing restore surface.

Gaps and partially wired

  • Retention-clock defect (both modes): neither soft-delete path updates updated_at — yet emptyTrashAction’s olderThanDays (notes.ts:267-270), the prune cron cutoff (route.ts:46— verified), and both trash pages’ “trashed” timestamp all use updatedAt as a trashed-at proxy. A note last edited > 30 days ago is hard-purged by the next 04:00 UTC cron after being trashed, and the trash UI shows last-edit time as trash time.
  • Orphaned local trash files on purge: hard delete resolves cur.path — the live path — and rms it with force:true (lib/storage/local/writes.ts:349,370— verified); a trashed note’s file lives at .eli/trash/<path>, so per-row permanent delete, Empty-trash, and the nightly cron remove the DB row (versions cascade) but never the on-disk trash copy. Local trash files accumulate indefinitely, invisible only because the walker skips .eli/ (lib/index/walk.ts:5).
  • Comment/code divergence, latent loop-killer: lib/git/simple.ts:27-29 and lib/git/adapter.ts:36-38claim the commit loop “checks status() first to avoid spurious nothing-to-commit failures” — verified false: flushNow stages then commits unconditionally (commit-loop.ts:153-176), so a flush where nothing actually changed throws and permanently self-disables the loop for that vault.
  • Commit-loop coverage gaps: only updateNote enqueues. createNote, deleteNote (trash move), and restoreNoteFromTrashnever do — a brand-new note is first committed only after its first edit; a rename enqueues only the new path, leaving the old path’s deletion unstaged.
  • Unwired affordances: flushNow(“Sync now”), reenableCommitLoop (“Settings → Git retry”), isCommitLoopDisabled, getCommitLoopError have zero UI/route callers (commit-loop.ts:139-199); GitRuntimeInfo’s promised “Settings → About” surfacing (lib/git/types.ts:85-87) is unbuilt; IsoGitAdapter.push/pullthrow “stubbed until P6-SYNC” (iso.ts:169-179); conformance.ts:144-150 references author-test files that don’t exist; storage.restoreVersion has zero production callers in either mode (grep: only a doc-comment mention in lib/mcp/tools/diff.ts:22) — no web-UI whole-version restore exists.
  • Headless bulk actions: bulkMoveAction/bulkTagRenameAction/bulkArchiveAction have no UI consumer anywhere (grep confirms only bulk.ts + bulk.test.ts) — P5-BULK-01 shipped the server API without a selection UI. The docstring’s promised bulkRestoreActiondoesn’t exist (bulk.ts:20-21) even though its backing method adapter.unarchiveNote is implemented in both modes; archiveNotetakes no SaveOpts, so bulk archive can’t record author metadata (lib/storage/adapter.ts:113); bulk actions revalidate only /v layout, leaving SaaS workspace caches stale (bulk.ts:84,123,144), and bulkMoveAction does no .. rejection at the action layer (delegated to adapter path resolution, bulk.ts:61).
  • Bulk-action semantics (verified): moves go through updateNote({path}), not renameNote, so incoming [[wikilinks]] are not rewritten (local still fs-renames the file and emits note:renamed); no action passes expectedVersionId, so every bulk write is always-win with no conflict snapshot; silent skips (already-at-target bulk.ts:72, tag-absent bulk.ts:108) increment neither counter, so succeeded+failed can undercount the input; bulkTagRenameAction handles only array-form tags — comma-separated string tags, which extractTags does index into note_tags, are silently skipped (lib/storage/helpers/tags.ts:8-15 — verified); iteration is serial with no batch transaction, so a mid-batch failure leaves earlier rows committed; zod bounds are ids 1–500, targetFolder 1–256 chars, tags 1–80 chars, and a validation failure rejects the whole batch ({ok:false}, no chunking). Version messages: bulk move → <folder> / bulk tag rename <old> → <new>.
  • SaaS restore scoping: restoreNoteAction’s SaaS branch updates by id without a workspace_id predicate (notes.ts:211-214) — RLS notes_update_editor is the only gate, so an editor of any workspace can restore a trashed note in a non-active workspace by id (delete, by contrast, always pins workspace_id).
  • Silently shrunk purge batches: emptyTrashAction requests limit 1000 (notes.ts:259 — verified) and the cron requests 5000 (route.ts:44 — verified), but adapters clamp to 500 local / 200 SaaS and neither caller paginates — a single run purges at most that many rows per workspace.
  • Cookie/slug divergence: the SaaS trash page resolves the workspace card from the URL slug but fetches notes via getStorage(), which pins the workspace from the eli_ws cookie (lib/storage/supabase.ts:83-96) — divergence shows one workspace’s chrome with another’s notes.
  • restoreSectionAction revalidation gap: it revalidates hardcoded local paths (/v, /v/n/<path>) even when invoked from the SaaS diff page (app/_actions/diff.ts:60-61 — verified); SaaS /w/<slug>/... routes are never revalidated (both diff pages being force-dynamic masks this for the diff view itself).
  • Dead fallback: the MCP get_diff built-in LCS line-diff fallback (lib/mcp/tools/diff.ts:167-198) fires only on NotImplementedError, which no live adapter throws — effectively unreachable.
  • Stale casts: both trash pages and emptyTrashAction pass {onlyTrashed:true} cast as never even though NoteFilter legitimately declares onlyTrashed (lib/domain/types.ts:235-238; visible at route.ts:44).
  • Test race: lib/git/simple.test.ts:8-24 sets gitAvailable via an async .then() after module load, so on a git-less machine early tests can start before the skip flag resolves.