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_versionMCP tools), C4 AI platform (conflict snapshots surface here; digest pages are documented there).
Capabilities
| Capability | What it does | Entry points | Modes |
|---|---|---|---|
| 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 version | lib/storage/local/writes.ts:160-174,255-277; SaaS notes_save RPC via lib/storage/supabase/writes.ts | both |
| 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 reindex | lib/index/scanner.ts:231-245 via chokidar → lib/index/incremental.ts:17-42; SKIP_DIRS at lib/index/walk.ts:5 | local |
| Local diff page | /v/diff/<path>?a&b renders a section-level diff between two versions; defaults to the two newest; needs ≥2 versions | app/(local)/v/diff/[...path]/page.tsx; Compare links in the inspector History card | local |
| SaaS diff page | Line-for-line sibling at /w/<slug>/diff/<path>?a&b; identical component tree, Postgres versions via RLS-gated reads | app/(saas)/(app)/w/[workspace]/diff/[...path]/page.tsx | saas |
| Version picker | From/To selects listing every version as date · authorKind · message; change does router.push with new ?a&b | components/diff/version-picker.tsx | both |
| Section-level diff rendering | DiffView renders SectionDiff[] cards with +/−/~/= badges, diff-match-patch hunk lines, and GitHub-style collapsing of runs of ≥2 unchanged sections | components/diff/diff-view.tsx | both |
| 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 mutated | restoreSectionAction in app/_actions/diff.ts; button in DiffView | both |
| History card with Compare links | Inspector shows the last 8 versions; each non-latest row links to <diffRouteBase>/<path>?a=<that>&b=<latest>; conflict snapshots highlightable | components/inspector/properties-panel.tsx:251-298 | both |
MCP get_diff / restore_version | Agents get the same section diff (storage.diffVersions) and append-only restore (updateNote with authorKind:'agent', message restored from <id>); neither touches lib/git | lib/mcp/tools/diff.ts (see C5) | both |
| Debounced git auto-commit-on-save | Local 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 vault | LocalAdapter.updateNote → enqueueCommit (lib/storage/local/writes.ts:330-337, lib/git/commit-loop.ts) | local |
| Agent-attributed git commits | Agent/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, clipper | local |
| Dual git runtime, 500 ms detection | getGit() 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.ts | local |
| Soft-delete to trash | deleteNoteAction(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-embed | app/_actions/notes.ts:225; Delete button in properties panel | both |
| Restore from trash | Local: 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 button | both |
| Permanent (hard) delete | deleteNoteAction(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/comments | TrashList per-row delete (confirm-gated) | both |
| Empty trash | Lists onlyTrashed (requests 1000, adapter-capped 500 local / 200 SaaS) and hard-deletes each, skipping per-row failures; optional olderThanDays uses updatedAt as trashed-at proxy | emptyTrashAction (app/_actions/notes.ts:254); TrashList header button | both |
| 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 toasts | pages + components/trash-list.tsx; command palette ‘Trash’ (local), SaaS top-nav | both |
| 30-day trash auto-prune cron | POST 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 UTC | app/api/cron/trash-prune/route.ts; vercel.json cron 0 4 * * * | both |
| Pin / unpin + pinned list | togglePinAction 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 home | app/_actions/notes.ts:298-340; properties-panel pin toggle | both (action) / saas (list UI) |
| Bulk move / tag rename / archive | Three 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 exists | app/_actions/bulk.ts:53,88,127 | both |
| Left-inbox toast signal | archiveNoteAction/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 toast | app/_actions/notes.ts:148-154,344-376 (shared with C2) | both |
| Escape hatches | ELI_DISABLE_GIT=1 kills the commit loop; flushNow (‘Sync now’), reenableCommitLoop(‘Settings → Git retry’), isCommitLoopDisabled, getCommitLoopError are exported but have zero UI callers today | lib/git/commit-loop.ts:139-199 | local |
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
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
| Component | Role | Files |
|---|---|---|
| GitAdapter contract | Storage-agnostic interface: init, add, commit, log (sha-cursor paginated), show, diff, restore, checkout, status, setRemote, push, pull; kind marker for diagnostics | lib/git/adapter.ts, lib/git/types.ts |
| Runtime detection | probeSystemGit spawns git --version with a 500 ms timeout; result memoised at globalThis.__ELI_GIT_DETECT__; getGit resolves the concrete adapter | lib/git/detect.ts, lib/git/index.ts |
| SimpleGitAdapter | Shells 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 |
| IsoGitAdapter | Pure-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 loop | Per-vault debounced queue at globalThis.__ELI_GIT_LOOPS__; dedup by relPath; one commit per flush; self-disables on any failure | lib/git/commit-loop.ts |
| Conformance suite + tests | Identical behavioural suite run against both adapters in fresh tmp repos; detect and commit-loop unit tests | lib/git/conformance.ts, lib/git/*.test.ts |
| Diff pages | Server 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 terminal | app/(local)/v/diff/[...path]/page.tsx, app/(saas)/(app)/w/[workspace]/diff/[...path]/page.tsx |
| Diff UI components | DiffView (section cards, hunks, restore transitions, toasts) and VersionPicker (From/To soft navigation) | components/diff/diff-view.tsx, components/diff/version-picker.tsx |
| Restore-section action | Zod-validated: getNote + getVersion + replaceSection + updateNote(authorKind:'user'); revalidates /v paths | app/_actions/diff.ts, lib/storage/helpers/section-replace.ts |
| Section diff algorithm | diffSections: anchor-keyed section split (ATX headings), classify added/removed/modified/unchanged, line-mode diff-match-patch hunks; pure function shared by both adapters | lib/storage/helpers/section-diff.ts |
| Version store | Local: transactional version rows + conflict snapshots, ordered createdAt DESC, id DESC; SaaS: rows appended by notes_save RPC, RLS-gated reads | lib/storage/local/versions.ts, lib/storage/local/writes.ts, lib/storage/supabase/{queries,writes}.ts |
| Note lifecycle actions | deleteNoteAction (soft/hard), restoreNoteAction (mode-forked), emptyTrashAction, togglePinAction, listPinnedNotesAction, leftInbox signal | app/_actions/notes.ts |
| TrashList + pages | Shared optimistic client list (restore / hard-delete / empty-trash with confirms and rollback) rendered by both force-dynamic trash pages | components/trash-list.tsx, app/(local)/v/trash/page.tsx, app/(saas)/(app)/w/[workspace]/trash/page.tsx |
| Trash-prune cron | CRON_SECRET-gated daily purge (default 30 d, ?days=N); local = active vault, SaaS = service-role fan-out via SupabaseAdapter.fromClient | app/api/cron/trash-prune/route.ts, vercel.json |
| Bulk actions | Headless move/tag-rename/archive over ≤500 ids with per-row error collection; no UI consumer | app/_actions/bulk.ts |
| Trash storage modeling | Local: 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 NULL | lib/storage/local/writes.ts, lib/storage/supabase/writes.ts, lib/db/schema.sqlite.ts, lib/db/migrations/pg/0000_initial.sql |
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):
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):
Recovery lifecycle (prose sequence). Soft-delete → trash → restore/purge runs entirely on C1 storage primitives:
- Properties-panel Delete (confirm
'Move this note to .eli/trash/?') →deleteNoteAction(id)with defaulttrash:true(app/_actions/notes.ts:225-231). - Local: file renamed to
.eli/trash/<path>,trashed_atstamped,note:deletedemitted, watcher echo suppressed (lib/storage/local/writes.ts:351-366). SaaS:UPDATE notes SET trashed_at=now()scopedworkspace_id+idunder RLS (lib/storage/supabase/writes.ts:235-244). Pending auto-embed cancelled best-effort (notes.ts:234-240). - The note disappears from every list/search/favorites surface via their
trashed_at IS NULLfilters and appears only on the trash pages (listNotes({onlyTrashed:true},{limit:200})). - Restore: TrashList row button →
restoreNoteAction— local reverses the file move (throws if the trashed file is gone,writes.ts:395-401) and emitsnote:created; SaaS clearstrashed_atby id under RLS only. - Purge: per-row hard delete or header Empty-trash (both confirm-gated, optimistic with rollback,
components/trash-list.tsx:37-87); nightly0 4 * * *cron hard-deletes trashed rows whoseupdatedAtis 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/trashfile — 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/updateNotein both adapters appends the version row this cluster reads (lib/storage/local/writes.ts:160-174,notes_saveRPC vialib/storage/supabase/writes.ts);updateNoteis also the sole production bridge intolib/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,diffRouteBasewired atapp/(local)/v/n/[...path]/page.tsx:52and 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-snapshotversion that surfaces (highlighted) in the History card and is diffable like any other (writes.ts:255-277);deleteNoteActioncancels 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) andlist_versionsread/write the same version store; SaveOptsauthorKind/agentNamefrom MCP calls flow all the way into git commit authorship; the standalone MCP binary shipssimple-git/isomorphic-gitas 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:15deliberately routes project mutations throughupdateNoteso they “participate in the version log + git auto-commit”. - C9 platform-ops → C6:
~/.eli/config.jsonsuppliesgit.auto/git.debounceSec(lib/config/schema.ts:26-29,80);vercel.jsonschedules the prune cron;CRON_SECRETgates it;ELI_DISABLE_GITis the global git off switch.
Load-bearing details
- The entire production consumer surface of
lib/gitis one call site:lib/storage/local/writes.ts:331(enqueueCommitfromupdateNote, import at line 22). SaaS never toucheslib/git; the diff UI never toucheslib/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 slidingsetTimeout,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 setsdisabled=true+lastError— no retry, all future enqueues for that vault silently dropped untilreenableCommitLoop()(commit-loop.ts:177-181). - Unreadable/uninitialised
~/.eli/config.jsondegrades to{auto:false}— a fresh boot never commits (commit-loop.ts:106-109). Config bounds:git.debounceSecint 5–3600 default 30,git.autodefault 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 forprefer:'auto'with no injected probe (detect.ts:71-90). - Commit messages are exactly
eli: update <relPath>(single file) oreli: <N> file(s) updated(batch) (commit-loop.ts:167-170). Batch authorship nuance (verified in source): the commit loop’slastAgentfilter matches onlyauthorKind === 'agent'(commit-loop.ts:165), so a batch containing only clipper saves commits asauthorKind:'user'— even though both adapters would treat'clipper'as an agent insideresolveAuthor(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.diffpasses-M(renames reported asrenamedwitholdPath);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
listVersionsorderscreatedAt DESC, id DESC(ULID ids give sub-ms ordering,lib/storage/local/versions.ts:29-36), but the diff pages re-sort with acreatedAtstring 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 whenCRON_SECRETis unset (route.ts:33-34— verified);GETis aliased toPOST(route.ts:113-115) so Vercel cron GETs work. SaaS fan-out buildsSupabaseAdapter.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 emitsnote:createdso downstream indexes pick the note back up. - Pin semantics: unpin deletes the
_pinnedfrontmatter key rather than writingfalse(notes.ts:310-314); underscore-prefixed system fields surviveupdateFrontmatterAction’s user-field replacement loop (notes.ts:357-359). VersionAuthorKindis'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_saveis a SECURITY-INVOKER plpgsql function (RLS still applies) that stampsauthor_idfrom the JWTsubclaim and raises errcode40001onversion_idmismatch (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 withp_expected_version_id: null(force-accept) (lib/storage/supabase/writes.ts:107-171). - Conflict surfacing contract:
saveNoteActionround-tripsexpectedVersionIdfrom the editor form and returnssnapshotVersionId; ConflictBanner’s Review button setswindow.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 writesauthorKind:'system', messagerestored from <id>(lib/storage/local/versions.ts:48-66— verified); SaaS writesauthorKind:'user', messagerestore version <id>(lib/storage/supabase.ts:172-180— verified). MCPrestore_versiondeliberately 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— yetemptyTrashAction’solderThanDays(notes.ts:267-270), the prune cron cutoff (route.ts:46— verified), and both trash pages’ “trashed” timestamp all useupdatedAtas 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 — andrms it withforce: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-29andlib/git/adapter.ts:36-38claim the commit loop “checks status() first to avoid spurious nothing-to-commit failures” — verified false:flushNowstages 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
updateNoteenqueues.createNote,deleteNote(trash move), andrestoreNoteFromTrashnever 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,getCommitLoopErrorhave 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-150references author-test files that don’t exist;storage.restoreVersionhas zero production callers in either mode (grep: only a doc-comment mention inlib/mcp/tools/diff.ts:22) — no web-UI whole-version restore exists. - Headless bulk actions:
bulkMoveAction/bulkTagRenameAction/bulkArchiveActionhave no UI consumer anywhere (grep confirms onlybulk.ts+bulk.test.ts) — P5-BULK-01 shipped the server API without a selection UI. The docstring’s promisedbulkRestoreActiondoesn’t exist (bulk.ts:20-21) even though its backing methodadapter.unarchiveNoteis implemented in both modes;archiveNotetakes no SaveOpts, so bulk archive can’t record author metadata (lib/storage/adapter.ts:113); bulk actions revalidate only/vlayout, leaving SaaS workspace caches stale (bulk.ts:84,123,144), andbulkMoveActiondoes no..rejection at the action layer (delegated to adapter path resolution,bulk.ts:61). - Bulk-action semantics (verified): moves go through
updateNote({path}), notrenameNote, so incoming[[wikilinks]]are not rewritten (local still fs-renames the file and emitsnote:renamed); no action passesexpectedVersionId, so every bulk write is always-win with no conflict snapshot; silent skips (already-at-targetbulk.ts:72, tag-absentbulk.ts:108) increment neither counter, sosucceeded+failedcan undercount the input;bulkTagRenameActionhandles only array-formtags— comma-separated string tags, whichextractTagsdoes index intonote_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,targetFolder1–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 aworkspace_idpredicate (notes.ts:211-214) — RLSnotes_update_editoris 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 pinsworkspace_id). - Silently shrunk purge batches:
emptyTrashActionrequests 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 theeli_wscookie (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_diffbuilt-in LCS line-diff fallback (lib/mcp/tools/diff.ts:167-198) fires only onNotImplementedError, which no live adapter throws — effectively unreachable. - Stale casts: both trash pages and
emptyTrashActionpass{onlyTrashed:true}castas nevereven thoughNoteFilterlegitimately declaresonlyTrashed(lib/domain/types.ts:235-238; visible atroute.ts:44). - Test race:
lib/git/simple.test.ts:8-24setsgitAvailablevia an async.then()after module load, so on a git-less machine early tests can start before the skip flag resolves.