> Clean, implementation-facing spec for the v1 prototype. For the *why* behind every choice (history, prior art, decision trail), see [[design]]. Where this spec and the design doc disagree, the design doc's latest decision wins — file an update here.
Hephaestus (heph) is a self-hosted personal context-management system that unifies a markdown knowledge base with task management in one database. **v1 supports the full spectrum from local-only to distributed**, selected by configuration via a **targetable storage backend** (§3.1): run purely local against a SQLite file, or run a server that fronts that file for remote clients and acts as the sync hub. Multi-device use is **offline-first** — local-backed replicas reconcile through the hub with **automatic merge + conflict resolution** — and access is authenticated via **OIDC (Authentik)** with per-user isolation. The web UI and the actual k3s deployment are later phases; the hub ships in v1 as a runnable/deployable binary. Components:
- **`heph-core`** — Rust library: data model, the `Store` abstraction + local SQLite store, query engine, markdown parsing/extraction, recurrence, and the sync engine (op-log, HLC, CRDT merge, conflict detection).
- **`hephd`** — Rust daemon; one binary, three runtime modes (`local` / `server` / `client`, §3.1). Always serves a JSON-RPC API over a local unix socket to local surfaces; in `server` mode it additionally exposes an authenticated network endpoint and runs as the sync hub.
- **`heph`** — Rust CLI: **task capture/scripting + the complete daemon API** (every RPC method has a command), plus admin/export/`heph conflicts`. Structured task fields are flags (`-a red --do tomorrow --recur weekly`).
- **`heph.nvim`** — Lua Neovim plugin: the primary **context / knowledge-base** surface ("org-mode"-style — docs, wiki-links, journals, the canonical-context doc, checklists); a thin client of the local `hephd`. Surfaces tasks for navigation/context, not structured-field editing.
- **`heph-tui`** *(planned, §8.1)* — Rust terminal UI: the primary **task agenda / triage** surface (the dominant task activity per [[design]] §6.2.1); launches into `heph.nvim` for a task's context and back.
> **Surface model (revised 2026-06, [[design]] §4 / §6.2.1).** Tasks and knowledge pull in different interaction directions, so v1 uses **three surfaces**, each to its strength: **CLI** = capture/scripting + complete API; **TUI** = interactive task agenda/triage; **nvim** = context/knowledge base. This supersedes the earlier "heph.nvim is *the* primary surface" framing.
**Development is test-driven (TDD).** Write the failing test first; implement to green; refactor. No feature is "done" without tests at the appropriate layer(s) in §9. Core logic must be **deterministic and clock-injected** (no ambient wall-clock reads in `heph-core`; the current time is always passed in) so ranking and recurrence are testable.
- Surfaces (`heph.nvim`, `heph` CLI) never touch SQLite directly. They connect to the local `hephd` over a unix socket (default `$XDG_RUNTIME_DIR/heph/hephd.sock`) regardless of mode.
-`heph-core` is synchronous and side-effect-light (incl. deterministic merge logic); `hephd` wraps it with async I/O, transport, and auth (`tokio`). DB calls run on a blocking pool.
`heph-core` exposes a **`Store` trait** with two implementations, so what a runtime points at is configuration:
- **`LocalStore`** — a SQLite file opened **directly**, acquiring an **exclusive lock** on open (advisory `flock` on the DB file / a sidecar `.lock`). Refuses to start if the file is already locked.
- **`RemoteStore`** — no local file; proxies every operation to a `server` over the network.
1.**Backend** — `LocalStore` (own replica + op-log) vs. `RemoteStore` (proxy to a server).
2.**Inbound listener** — does it expose an authenticated network endpoint others connect to (i.e. is it a hub)?
3.**Outbound sync***(optional, `LocalStore` only)* — an optional **`hub_url`**; when set, the instance is a **spoke** that background-syncs its op-log to that hub. Independent of whether it also listens.
| **local** | `LocalStore` | none | optional | yes | everyday device — standalone if `hub_url` unset, a **syncing spoke** if set |
| **server** | `LocalStore` | yes (authenticated) | n/a (it *is* the hub) | yes | the sync **hub**; fronts the file and serves remote clients |
| **client** | `RemoteStore` → a server | n/a | n/a | **no** | thin online-only convenience (no replica) |
"Inbound listener: none" on `local` means *no inbound endpoint* — it does **not** preclude **outbound** sync. The everyday device (Gilbert, ringtail) runs **`local` + `hub_url`**: a full offline-capable replica that syncs to the blumeops hub. A `local` instance with no `hub_url` is the first-class, fully-offline standalone config. `client` is the escape hatch for "don't keep a replica here" (a borrowed box, CI, the future web-UI backend).
**Lock handoff (the key flexibility):** `local` and `server` both take the file's exclusive lock, so **only one can own a given DB file at a time**. Kill the server → lock releases → a `local` process can open *the exact same file* and just work. Stop it → relaunch in `server` mode → remote clients reconnect. A `client` process never opens the file, so it never contends. Each machine takes the lock on its *own* file; **device↔device sync uses separate replicas that reconcile through the hub** (§12), never a shared file.
All first-class entities are **nodes**; relationships are **links**. Markdown bodies are stored in SQLite; files are an export artifact, not the source of truth.
| `task` | thin task or ephemeral context item (see §4.3) | none (context via links) |
| `project` | grouping/context for tasks | optional |
| `tag` | label | optional |
| `journal` | daily note, titled by ISO date | markdown |
### 4.2 Link types
`wiki` (materialized from `[[links]]` in a body), `canonical-context` (task → its auto-created context doc), `context-of`, `log-of` (task → its append-only log), `blocks`, `parent`, `tagged`, `in-project`.
### 4.3 Task semantics
- **Attention-state** (required on committed tasks): `white` (do once do-date arrives), `orange` (top of mind), `red` (top of mind + a consequence exists if late — *consequence, not severity*), `blue` (on-deck/backlog).
- **do-date** = *earliest actionable date* ("do date"), **not a deadline** and **not an urgency signal** — a boolean candidacy gate only (§7). Optional **late-on** marks when lateness becomes a problem; it is the *sole* urgency signal.
- **Commitment axis (Fork A — [[design]] §6.3):** a **committed task** is a `task` node (a row in `tasks`) and participates in "what is next". A **context item** is *not* a synced node — it is a `- [ ]` line in a container `doc` body (its `[ ]`/`[x]` is its only state), a **locally-derived index** over the converged body (§5) that converges for free under sync. Identity is pinned only at **promotion**, which mints a committed `task` node and rewrites the source line into a link to it.
A `task` with a non-null `recurrence` (RFC-5545 RRULE) is a **recurring definition**. Its checklist is the set of `- [ ]` lines in its canonical context `doc` body — under Fork A the body *is* the template (no separate template flag). **Each occurrence presents a fresh, all-unchecked checklist**, and **completion never carries forward across occurrences** — a hard requirement.
**Model: roll-forward in place** (decided; the occurrence-instances alternative was rejected because, under Fork A, each occurrence would need its own body and thus its own node — the explosion §6.6 forbids). A recurring task is a **single node**. On `task.set_state(done)`:
1. append the completed occurrence to the task's log (`log-of`);
2. reset the context doc's checkboxes to unchecked (a body-CRDT edit);
3. advance `do_date` to **the next RRULE instance strictly after `now`** — *skipping* missed occurrences rather than enqueuing them.
So missing a daily routine for three days leaves **one** gently-overdue item, never a pile; the gap is implied by the hole in the log, not by overdue rows. RRULE expansion is **lazy** — only "the next instance after now" is ever computed, never the series (§6.6). A `skipped` state advances the do-date the same way without logging a completion. History is narrative (the log), per "narrative > list".
Projects/tags are `nodes`; membership is `links` (`in-project`, `tagged`). All `tasks`/`links` rows inherit ownership via their node(s). **Context items are not in this schema** — they are `- [ ]` lines in `doc` bodies (§4.3, §5); a daemon may keep a *local, non-synced* derived index of them for the plugin, rebuilt from bodies and never written to the op-log. **Deterministic ids** for `journal`/`tag` let two offline replicas that independently create the same day's journal or the same-named tag converge automatically; the tag-name normalization function is a fixed, versioned constant so every device derives byte-identical ids (tag rename = retag, not in-place key change).
- **The body is the source of truth** and merges as a **text CRDT** (`body_crdt`); the `body` TEXT column is its materialized view. A full-body write (`node.update({body})`) is **diffed into the CRDT** (yrs computes the delta), so surfaces can send whole-buffer text without knowing about deltas.
- GFM task-list items (`- [ ]` / `- [x]`) → the **local context-item index** (Fork A — see [[design]] §6.3). This index is **derived, not synced**: each replica rebuilds it from its converged CRDT body, so context items converge for free and never get divergent ULIDs. Context-item identity is pinned only at **promotion** (§6).
- Derivation is idempotent and diff-based: re-writing an unchanged body is a no-op.
- **Tombstoned nodes are excluded** from the derived index, `nodes_fts`, `next`/`list`, and `export`.
-`export` materializes all non-tombstoned nodes to a directory tree of `.md` files (**frontmatter — id, kind, task scalars, aliases, links — plus body**), a faithful one-way portable snapshot (no import in v1).
-`task.promote({container_id, item_ref, attention?, project?}) → Task` (mints a committed task from a context-item line and rewrites that line into a link to it, §4.3)
-`next({scope?, limit?}) → [RankedTask]` (the Tactical blank-slate "what is next?" ranking, §7)
-`list(ListFilter) → [RankedTask]` (the §8.2 predicate-as-data: `{attention_in?, attention_not?, scope?, exclude_projects?, actionable?}`; an empty filter is the whole outstanding set — the Organizational survey)
-`view({name}) → [RankedTask]` (run a built-in filter view `tom|ondeck|chores|work|tasks`, §8.2 — resolves project names→ids+subtree and lists)
The daemon is **mode-agnostic** — Tactical/Strategic/Organizational are plugin-side compositions of these primitives (§8), not daemon concepts. Every local mutation method records an op in the **op-log** for sync. The local daemon supports **server-push** notifications (e.g. a node changed by an incoming sync) so an open buffer can reconcile in real time.
Separate from the local unix-socket RPC, the hub exposes an **authenticated network endpoint** (HTTP/JSON or gRPC — pick at kickoff) for op exchange: clients present an OIDC bearer token (§13); the hub validates, scopes by `owner_id`, accepts pushed ops, and returns ops the client hasn't seen (by HLC cursor). The hub applies the same `heph-core` merge logic.
## 7. "What is next?" ranking (Tactical blank-slate)
This is the **Tactical** ranking only; Strategic/Organizational are other plugin-side views over different primitives (§6, §8). Given an optional `scope` and `limit` (default 5), the engine is **two stages — a filter, then an order expressed as data** so the sort is trivially reorderable later.
1.**Filter (candidacy)** — a pure predicate: `committed` ∧ `state = outstanding` ∧ ¬`tombstoned` ∧ `attention ≠ blue` ∧ **actionable** (`do_date IS NULL OR do_date ≤ now`) ∧ in `scope`. **`do_date` is used *only here*** — a boolean "can this be done now?" gate, never an urgency input. For a recurring task, the single rolled-forward node's `do_date` is the gate (§4.4).
2.**Order** — an **ordered list of named sort dimensions**, applied lexicographically; reordering this list (one place) reshuffles the ranking:
- **`late_on` is the sole urgency signal** and forms a global top tier: items past `late_on` float above everything (incl. `red`), most-past first. This is the only legitimate "overdue" — `late_on` is a deliberately-set problem marker, not an actionability date. *(Global-tier vs. within-band placement is expected to be revisited; it's just a reordering of `RANKING`.)*
- then **attention band**`red → orange → white`;
- tie-break **`created_at` ascending (FIFO)**. **Age is never urgency** — an item is not hotter for having been actionable longer; raise its attention or set a `late_on` to escalate it deliberately.
`blue` (on-deck) is hidden from `next` by design; surfaced only by `list` (§6). `health()` exposes the working-set tensions (orange vs. 6, active vs. ~30, on-deck count) **with over-threshold deltas**, honestly — never masking overload nor manufacturing calm.
Replaces obsidian.nvim. Telescope-backed. **Primarily the context / knowledge-base surface** ([[design]] §4): docs, wiki-links, journals, the canonical-context doc, checklists, per-task log. Task **agenda/triage** is the TUI's job (§8.1) and structured-field *editing* is the CLI's (flags); nvim surfaces tasks for **navigation and context**. **Tactical / Strategic / Organizational are named plugin-side views** composed from the mode-agnostic daemon primitives (§6) — the daemon never infers a mode. The session **goal stack** is plugin state (no persistent stack in v1; the durable re-seed is the per-task breadcrumb, `log.tail`). Core commands/gestures:
- Show "what is next" (`:Heph next`, Tactical) and `list` views (Organizational) for **navigation** — `<CR>` jumps to a task's canonical context. *(Showing do/late in rows and a clean jump-to-context gesture is a small future polish item.)*
- Open a task's canonical context doc; edit context-item checkboxes (Fork A) in the buffer (derived on `:w`); context-item **promotion**.
- Lightweight task mutation (capture, attention, done/drop/skip) remains available, but the **primary** task-mutation surface is the CLI/TUI. The eventual richer nvim task-edit story is **frontmatter-as-edit-surface** (the task buffer presents scalars as editable YAML frontmatter parsed back on `:w` — `export` already serializes this), *not* bespoke form widgets — a later slice.
**Deferred (fast-follow, scaffolded):** guided working-set rituals — the Blue keep/drop review and Orange daily reconfirm — are pure compositions of `list` + `set_attention` + `set_state(dropped)`; v1 ships `health()` reporting and manual review. (These become first-class **filters in the TUI**, §8.1.)
**Known-hard:** reconciling an incoming CRDT body delta into a *dirty* buffer (unsaved local edits, cursor position) — the §9 "update arrives while a buffer is open" case — is genuinely fiddly under Fork A; expect to iterate.
> **Status: daily-driver core built** (slices T1–T2c; NL quick-add + search are the remaining T3 polish). The §6.2.1 Todoist study shows the dominant task activity is *interactive triage of a large set* (387 active tasks; daily orange reconfirm, blue keep/drop review, browse-by-project) — work that is awkward as either CLI flags or nvim buffers. A terminal UI owns it; the CLI (capture/scripting) and nvim (context) flank it.
- **Crate `crates/heph-tui`** ✅ — `ratatui` (which re-exports `crossterm`), a **thin client of the daemon unix socket** (reuse `hephd::Client`); never touches SQLite, same as nvim. `App` is generic over a `Backend` seam so navigation/triage logic is unit-testable without a terminal or daemon; `ui::render` is pure.
- **Layout** ✅ — three panes: **sidebar** (the five §8.2 filter views + projects) · **task list** (each row: a leading attention **flag** + a **project-colored bullet**, the title, recurrence `↻`, and a compact human do/late chip; a scrollbar appears when the list overflows) · **preview** (canonical-context doc body + `log.tail`).
- **TUI ↔ nvim handoff** ✅ — `o` suspends the alternate screen and launches `nvim +"lua require('heph.node').open('<ctx-id>')"` (heph.nvim's live buffer surface), passing `$HEPH_SOCKET` so the child points at the same daemon, then restores and reloads. *(A nvim command shelling back to the TUI is later polish.)*
- **Testing** ✅ — TDD against a real daemon; headless render assertions via `ratatui`'s `TestBackend`, plus in-memory navigation/input-flow units against a fake backend.
- **Prereqs** (landed): **§8.2 filter views**; the CLI-complete task surface and `task.set_schedule`.
- **flag column + project-colored bullets** ✅ — a leading **flag glyph** (`⚑`) colored by attention (red/orange/blue; **blank for white/none**, freeing the bullet for project identity); the **bullet `●` colored by its project** from a stable `hash(project_id) → hue` (FNV-1a → HSL → `Color::Rgb` truecolor; overlap acceptable; the glyph *shape* is reserved for future semantics). A stored, editable per-project color override is a later refinement (derived client-side for now).
- **scrollbar** ✅ — a `ratatui``Scrollbar` on the task list once it overflows (a `ListState` selection drives scroll-to-visible so a task below the fold stays reachable).
- **sort toggle `s`** ✅ — **default**: attention (red→orange→white→blue) → days-overdue (desc; no-date = 0) → project name → `created_at` (FIFO); **project mode**: project primary with dimmed **`──── Name ────` group separators** that ride atop each group's first task (so the cursor only ever lands on real tasks). The view filter always runs before the sort. (`skip` moved to `S`.)
> **Status: built.** [[design]] §6.2 / §6.2.1 establish that the owner navigates work through a fixed set of **saved filters**, not one flat list — so `next` alone is too coarse. This slice made those filters first-class, shared by the CLI now and the TUI (§8.1) next. (The reference-context noise those filters excluded — `##Culture`, `#Camano Info` — has already been reclassified out of tasks into wiki docs, [[design]] §6.2.1.)
>
> Implemented as a `ListFilter` **predicate-as-data** (`heph-core::filter`): `list` takes a `ListFilter` (attention include/exclude sets, project-id `scope`, `exclude_projects`, an `actionable` do-date gate); `Store::view(name)` resolves a built-in [`ViewSpec`] — looking project **names** up to ids and **subtree-expanding** them through `parent` links — then runs `list`. Surfaced as `heph view <name>` (no name lists the five), the `view` RPC, and `:Heph view <name>` in nvim.
**The five built-in views** (the owner's sixth Todoist filter, **Schedule**, is intentionally dropped — see below), each derived from the verbatim Todoist query ([[design]] §6.2.1) and realized in heph terms (attention: p1→red, p2→orange, p4→white, p3→blue):
-`attention`: an **include set** (`attention_in`, e.g. `{red,orange}`) *and* an **exclude set** (`attention_not`, e.g. `{blue}` for "≠ blue"). The split matters: a whitelist drops attention-less tasks, but "≠ blue" must keep them — so Work/Tasks use `attention_not`, ToM/On Deck use `attention_in`.
-`scope`: a project **including its descendant projects** (subtree, for `##Culture` / Work-tree), and/or **multiple** projects (Chores + Camano Chores).
-`exclude_projects`: a list subtracted from the result (the "Tasks" leftover view).
-`actionable`: a bool toggle applying the §7 do-date candidacy gate inside `list` (today the gate is `next`-only).
- (`recurring`: optional filter for the `Schedule` approximation.)
Project-subtree resolution needs the **parent-project links** ([[design]] §6.2.1) — a small `links`/query addition. `!subtask` is largely implicit (heph `list`/`next` return committed tasks, not context items).
**Surface:**
- **CLI:** `heph view <name>` (`tom|tasks|work|chores|ondeck`) prints the slice using the same row format as `next`/`list`. A `heph view` with no name lists the available views.
- **TUI (§8.1):** the same views power the left filter pane.
- **nvim:** may later expose them (`:Heph view <name>`) — navigation, not required for this slice.
**Defaults vs. custom:** the five ship as **built-in named views** seeded from the queries above. **User-defined filters** (a small config-driven view list, eventually a query DSL) are a later extension — deliberately deferred to avoid building a Todoist-query-language parser now.
**Schedule view dropped (decided 2026-06).** `Schedule`'s `!no time` selects items with a *time-of-day*, which heph's **date-grained**`do_date` can't represent; rather than ship a misleading approximation it is **omitted for now**. It is entangled with the chores rework below (timed routines), so revisit both together if/when time-of-day lands on tasks.
> **Future — chores as a first-class feature (noted, not scheduled).** The `Chores` view here is an interim project-scoped filter. The intent is to make **chores a first-class concept** with their own **do-date / recurrence semantics** (distinct from regular tasks), retiring the `#Chores` / `#Camano Chores` *projects* (and the Camano split) entirely — chores would be a task flag/kind, not a project you scope to. When that lands, the `Chores` view becomes "tasks where `is_chore`," and `Schedule` (timed routines) is reconsidered alongside it. See [[design]] §6.2.1.
> **Status: built.** When a node is opened in `heph.nvim`, its structured metadata (id, title, project, do-date, tags, …) is visible and editable as a YAML frontmatter block atop the body — without that metadata ever becoming a second, drifting source of truth in the body.
- ✅ **The store is dumb and safe.** Frontmatter is a **projection generated on read** and **stripped + silently ignored on write**. `heph-core::frontmatter::strip(body)` runs inside `update_node`**before** the `yrs` CRDT diff (conservative — it only removes a leading `---` block whose first line is a YAML key, so a leading `---` thematic break in prose survives; idempotent). The **render** side lives in `hephd` (`hephd::frontmatter::render`, since formatting `do_date`/`late_on` for humans needs the local timezone via `datespec::fmt_iso`); `node.get {frontmatter: true}` prepends it. Invariants: the at-rest body and the CRDT doc **never** contain frontmatter; an unchanged read→write round-trips to a no-op. Because inbound frontmatter is always discarded, a naive editor (or the future web UI) **cannot corrupt metadata** — at worst it sends stale frontmatter and the store drops it; the canonical block regenerates on the next read.
- ✅ **`heph.nvim` is the smart client.** `node.lua` reads with `frontmatter: true` (the buffer opens with the block on top) and caches the rendered block; on `BufWriteCmd`, `frontmatter.lua` parses the buffer's block, **diffs it against the cached one**, and translates each changed field into the **correct structured RPC**: `title` → rename, `attention` → `set_attention`, `do_date`/`late_on`/`recurrence` → `set_schedule` (dates parsed `YYYY-MM-DD` → local-midnight ms; a removed line clears via `null`), `project` → **`set_project`** (resolved by name, §8.1), `tags` → `tag.add`/`tag.remove` (§14 tags); a mistyped `state` surfaces the daemon's validation error. Then it sends the (frontmatter-less) body. A buffer with **no** block touches no metadata — only deleting individual fields edits them — so removing the whole block can't accidentally wipe tags. Inline **`#hashtags`** in the body are unioned into the tag set on save (a `# heading` has a space after `#`, so it doesn't match). (Body-position features — `[[link]]` follow, promote — are content-relative, so the prepended block doesn't disturb them.)
**The schema** (a curated, *editable* subset — not the full export snapshot). `id`/`kind` are read-only; `title` and `tags` edit the opened node; when the node **is** a task or backs one (its canonical-context doc), the owning task's scalars are rendered and a read-only `task:` id says where those edits route:
Field rules: `id`/`kind` are **read-only** (display only); `title`, `attention`, `do_date`, `late_on`, `recurrence`, `project`, `tags`, and `state` are editable. **`state` is editable but has no picker or hint** (to keep the UI simple) — a mistyped status value returns a **validation error** rather than guessing. Frontmatter is rendered for any editable-body node.
**Inline `#hashtags`** are a **`heph.nvim` feature**, not core extraction (for now): the plugin detects them on save and routes them through the same `tag.add`/`tag.remove` path. (Core-side hashtag extraction can come later, e.g. for the zk import.)
## 8.4 Wiki-links by node id (planned)
> **Status: planned** (§14 roadmap, 2026-06-03). Today bodies store the human text `[[Title]]` and links are materialized by resolving name→id at write time, which is ambiguous (a task and its canonical-context doc share a title — hence the resolution hack in §6/`links::resolve_id`). The fix: **the body stores the canonical node id**, and **no name-addressed link ever enters the DB**.
- **At rest:** `[[NODEID]]`, or `[[NODEID|custom text]]` when the author wrote explicit display text. Extraction reads the id directly — no resolution, no ambiguity — and the canonical-context exclusion hack in `resolve_id` is **removed**.
- **Projection (same philosophy as §8.3):** on **read**, `heph-core` expands a bare `[[NODEID]]` → `[[NODEID|Current Name]]`, so buffers, `heph export`, and any dumb reader show readable, always-fresh link text. On **write**, a `|text` that equals the target's current name **collapses back** to bare `[[NODEID]]`; a `|text` that differs is preserved as a real override. Needs an **id→name batch resolve** RPC for the expansion.
- **`heph.nvim` authoring:** typing `[[` triggers a picker (reuse `picker.lua` / Telescope, **no new dependency**) that searches titles via the `search` RPC and inserts `[[NODEID]]`; a **"Create new: «typed»"** entry mints a `doc` and inserts its id.
- **`heph.nvim` display:** a completed link is **concealed** to its name (or `|text`), rendered as a styled hyperlink (extmark `conceal` + inline virtual text), and revealed in raw form when the cursor is on it.
- **Migration:** a **one-time fixup script** rewrites existing `[[Title]]` bodies to `[[NODEID]]` (resolve→id; flag the unresolvable). No special care is warranted — there is no critical data in the store yet — and a first-class migrations feature stays **deferred**.
All layers are required; CI runs them on every push/PR (extend `.forgejo/scripts/build` to run `cargo test` and the nvim e2e suite; `prek` already runs in `build.yaml`). **The Forgejo runner image must provide `neovim` + `plenary.nvim`** for the headless e2e suite.
- **Unit (`heph-core`):** model invariants; markdown extraction (wiki-links, checkboxes); **RRULE expansion and the fresh-checklist-per-occurrence rule** (assert completion never carries forward); the "what is next?" ranking (table-driven cases); migration up/down.
- **Property tests (`proptest`):** ranking yields a total order; extraction is idempotent; recurrence never leaks completion state across occurrences; tombstones are never resurrected.
- **Integration (`hephd`, real sockets):** start a daemon against a **temp SQLite file**, connect over a **real unix socket**, and exercise the RPC API end-to-end, asserting resulting DB state. Include **multi-client concurrency** tests on the socket and clock-injection for deterministic time.
- **Sync & offline (multi-replica):** spin up **two client `hephd` replicas + a hub `hephd`**, all over **real network sockets** against temp DBs, and assert convergence:
- online round-trip: edit on A → appears on B via the hub;
- **offline → reconcile:** partition A and B from the hub, make divergent edits on each, reconnect, assert both converge;
- **conflict path:** concurrent conflicting scalar edits (e.g. both set a different do-date) land in the **conflict queue** and `conflicts.resolve` settles them deterministically;
- **body CRDT merge:** concurrent edits to the same `doc` body auto-merge without a hard conflict;
- HLC ordering and op-log idempotency (replaying ops is a no-op).
- **Auth:** OIDC token validation on the hub endpoint (reject missing/invalid/expired); **per-user isolation** (user A cannot read/sync user B's nodes); device-code flow happy path against a mock IdP.
- **End-to-end (headless nvim):** drive `heph.nvim` in `nvim --headless` against a real `hephd` + temp DB, running **scripted example workflows** and asserting outcomes (via RPC/DB state and buffer contents). Minimum workflows:
- capture a task → appears in `:Heph next` → open canonical context → add a checklist item → check it → mark task done;
- create today's journal via the picker;
- follow a `[[link]]` on `<Enter>` to the target doc;
- **a recurring task with a checklist: complete it, then assert the next occurrence presents a fresh, all-unchecked checklist;**
- **a sync-driven update arrives while a buffer is open and the buffer reconciles.**
- Harness: `plenary.nvim`/busted, or drive nvim via its msgpack-RPC from the test runner. Keep example workflows as reusable fixtures.
- **CLI tests:** invoke `heph` subcommands against a temp DB; snapshot output; assert `export` round-trips the corpus; `heph conflicts` lists/resolves.
## 10. Technology stack (ratified)
`rusqlite` (bundled) + migration runner · `tokio` + line-delimited JSON-RPC over unix socket · `ulid` · `rrule` · `pulldown-cmark` · `clap` · `anyhow`/`thiserror` · `tracing`. Neovim plugin in Lua, depending on `telescope.nvim`. Cargo workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/`.
**Added for v1 client/server + auth (some to confirm at kickoff):**
- **Text CRDT (body merge):** ✅ **`yrs` (Rust Yjs)** (ratified at the Phase 1 sync kickoff, 2026-05-31; `automerge` was the alternative). Used for `doc`/`journal`/log bodies. Structured fields use a bespoke op-log + HLC (no library needed).
- **Targetable storage backend + all three runtime modes** — `local`, `server`, `client` — with the **exclusive-lock handoff** over a shared SQLite file (§3.1). Local-only is a first-class, fully-supported configuration.
- **Offline-first** operation for local-backed instances, with **op-log + CRDT sync** and **automatic merge + a conflict queue** (§12).
- **OIDC/Authentik authentication** with **per-user data isolation** (§13), enforced on the `server` network endpoint.
**Out (later phases, scaffolded so as not to block):**
- **Web UI** (the hub serves sync only in v1; reserve `axum` for it later).
- **Actual k3s deployment** to blumeops (Dagger→Zot image, ArgoCD app + Kustomize manifests, external-secrets) — fast-follow once the architecture is proven; the hub binary is built to be deployable.
- **Dependency-refresh pass:** before declaring v1 done, sweep every external dependency (Cargo crates, the Neovim plugin's deps, CI/tooling) up to its latest stable release, re-running the full suite + `clippy`/`fmt` to catch breakage. Slices add deps at the version current when written; this reconciles them once at the end rather than churning mid-build.
See [[design]] §5–§7 for the constraints later phases impose on present choices (keep tasks vs. calendar events separate; expand RRULEs lazily).
## 12. Sync & conflict resolution
**Topology:** hub-and-spoke. Each device holds a full local replica + op-log; the hub is the rendezvous. Devices **push/pull ops by HLC cursor**; the hub never needs to be online for local work.
**Merge semantics (the unit of sync is the op):**
- **`doc`/`journal`/log bodies:** **text CRDT** (`yrs`) → concurrent edits always merge, no hard conflict.
- **Scalar task fields** (attention, do_date, late_on, state, …): **last-writer-wins by HLC**. The losing value, if meaningful, is recorded in `conflicts` (surfaced, not silently dropped).
- **Links / set membership** (tags, project, parent): **OR-Set** add/remove semantics → no conflicts.
- **Tombstones**, never hard deletes → deletion/merge is monotonic and CRDT-friendly.
**Conflict queue:** the unresolvable/ambiguous remainder (a discarded LWW value on a meaningful field; flagged overlapping body regions) becomes an `open` row in `conflicts`. Surfaced via `health().conflict_count`, `conflicts.list`, `heph conflicts`, and a heph.nvim view: *"you have N conflicts."*`conflicts.resolve({id, choice})` settles each. **Sync never blocks** on conflicts.
**Determinism:** HLCs are clock-injected; op application is idempotent and order-independent given HLC. These are the core invariants the sync tests assert.
**Open at kickoff:** CRDT lib confirmation (`yrs` vs `automerge`); hub transport (`axum` HTTP/JSON vs gRPC); propagation cadence (push vs. periodic pull); exactly which fields are "meaningful" enough to enqueue vs. silently LWW; **device-id provisioning** (how a spoke mints its `origin` id and registers with the hub).
- **OIDC against Authentik.** Clients authenticate via the **OAuth 2.0 device-code flow** (`auth.login`); the resulting tokens are cached in the OS keychain (`keyring`) / 1Password and refreshed automatically. Offline devices operate on cached credentials.
- **Auth is a network-boundary concern; ops are authenticated at *exchange*, not creation.** Creating ops needs only local trust, so losing the IdP/hub never blocks local work — ops queue in the op-log and the bearer token gates only the push/pull session. The one degraded case: the refresh token expiring during a long offline stretch → re-run the device-code flow once on reconnect, then the queued op-log drains.
- **Local-only needs no auth.** A `local` instance with no `hub_url` runs against a single **local user** (`users` row, generated `id`, `oidc_sub = NULL`) — friction-free, no IdP. `oidc_sub` is therefore nullable.
- **The hub is authoritative for user identity.** On first authentication it maps `sub` → a canonical `users.id` (creating it if new) and hands the *same* id to every device that authenticates as that human — no device-vs-device id reconciliation.
- **Adoption (local-only → authed)** is a **one-time, pre-first-sync** rewrite: on a previously-local replica's first authenticated sync, fetch the canonical `users.id`, then in one transaction rewrite `owner_id` (and the owner-embedded deterministic ids of §4.5 + the links referencing them) from the local placeholder to the canonical id. Safe precisely because it precedes any sync (no remote refs, no concurrent writers); afterward `owner_id` is immutable. Born-authed replicas stamp the canonical id from the start and never rewrite.
- **Hub enforcement:** the sync endpoint requires a valid **OIDC bearer token**; the hub maps the token's `sub` to a `users` row and **scopes every op by `owner_id`**. No cross-user reads/writes.
- **Per-user isolation:** all nodes (and their dependent rows) carry `owner_id`; queries and sync are always user-scoped. In practice a single user (`eblume`), but the isolation is real from day one.
- **Local trust:** the local unix-socket RPC trusts the OS user (file-permission-scoped socket); app-level auth is for the **network** boundary (device ↔ hub).
- **At-rest:** plain SQLite in v1 (no encryption) — security boundary is auth + (eventually) network restriction. May revisit (see [[design]]).
> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-03 — **186 Rust tests** (`cargo test --all`) + **18 heph.nvim headless e2e specs** (`mise run test-nvim`; also runs in CI via `dagger call test-nvim`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, **`crates/heph-tui`**, plus `heph.nvim/` (slices 11a–11c **+ a UX iteration + filter views + the heph-tui agenda**, below). **The plugin is installed and running on the dev machine** (built from the forge; see [[install-heph]]).
- ✅ **Body text CRDT (§5, §12, slice 8d):** node bodies merge through the **`yrs`** text CRDT (`body_crdt` BLOB) instead of LWW. A device authors under a stable `client_id` derived from its `origin`; whole-buffer writes are diffed (common prefix/suffix, char-boundary safe) into the doc; the yrs delta rides the `node.create`/`node.set` op (`body_crdt` field) and `apply` merges it — concurrent disjoint edits both survive and never enqueue a conflict.
- ✅ **Network sync (§6.1, §12, slice 9a):****transport ratified = `axum` HTTP/JSON.** The hub (`server` mode) exposes `POST /sync/push` + `GET /sync/pull?after=<hlc>` over the same store; a spoke (`local` + `hub_url`) runs `sync::sync_once` (pull→merge, then push) and background-syncs on a 30s interval. Incremental by HLC cursor (`sync_state`/`SyncCursors`); idempotent re-push is a no-op. Two spokes converge through a real-HTTP hub (incl. scalar conflict) in `tests/sync_http.rs`. **Unauthenticated for now, single-owner** (auth + per-user scoping is slice 10).
- ✅ **`client` mode + `RemoteStore` (§3.1, slice 9b):** a no-replica backend that proxies every `Store` call to a `server`'s `POST /rpc` (the full `dispatch`, over HTTP) via a **blocking** reqwest client — the online-only escape hatch. `Daemon` is now generic over `dyn Store + Send`, so the same unix-socket surface fronts either a `LocalStore` or a `RemoteStore`. Sync primitives are stubbed (a client has no op-log). Proven in `tests/client_mode.rs`. `dispatch` gained `task.get` + `links.add`.
- ✅ **Hub auth — verification side (§13, slice 10a):** the hub validates an **OIDC bearer token** (`jsonwebtoken`, RS256-pinned, exact iss+aud, exp/nbf, required `sub`; JWKS discovered + cached, refetched on unknown `kid`) on `/sync/*` + `/rpc`. A [`TokenVerifier`] trait seam keeps it mockable; **single-tenant** owner gate (`authorize_owner_sub`: claim-on-first, then require-match → 403 for any other identity). `--oidc-issuer`/`--oidc-audience` enable it (open when unset, for local dev). Tested fully offline: stub-verifier middleware tests + an adversarial battery against an in-process mock IdP (expired/wrong-iss/wrong-aud/unknown-kid/tampered/alg-confusion/missing-sub all rejected).
- ✅ **CLI (§1) — the complete daemon API + task driver:**`heph` covers every RPC (next/list/task/done/drop/skip/attention/**edit**/promote/show/log/health/doc/node/get/resolve/search/journal/links/backlinks/link/project[+list]/sync/conflicts/export/auth) with **human dates** (`--do-date tomorrow|+3d|fri|ISO`) and **recurrence** (presets + NL + raw `--rrule`; `datespec`). Reschedule is the new **`task.set_schedule`** RPC. Process-tested over a real socket.
- ✅ **Daemon as an OS service (§3, [[design]] §4):****`heph daemon start/stop/restart/status/uninstall`** idempotently manages a launchd agent (macOS) / systemd user service (Linux) running `hephd` on the default store; render fns unit-tested, verified live on macOS. The default socket is now a **stable**`<data-dir>/heph/hephd.sock` (was `$TMPDIR`-based) so a persistent service and its clients always agree. Surfaces are **connect-only** (no auto-spawn).
- ✅ **Todoist importer (tooling):**`mise run import-todoist` seeds a store from Todoist (dry-run default, `-- --commit` to write) — see [[import-todoist]].
- ✅ **CI (§9):** Forgejo `build.yaml` runs **entirely through Dagger** (the k8s job image is a thin Alpine + Dagger orchestrator with a DinD sidecar — no native Rust/nvim toolchain): `dagger call check` (cargo fmt/clippy/test on `rust:1-bookworm`) + `dagger call test-nvim` (build hephd + headless e2e). Cargo parallelism capped (`CARGO_BUILD_JOBS`) to avoid OOMing the build engine; cargo caches shared across runs. `prek` runs locally via git hooks, not in CI.
- ✅ **`heph.nvim` slice 11a (§8) — the primary surface begins:** the Lua plugin (`heph.nvim/`) as a thin client of the `hephd` unix socket. **RPC client** over a `vim.uv` pipe (blocking `call` via `vim.wait`; id-demuxed; partial-line buffered; `luanil` so JSON `null`→`nil`; isolated `Session`s for tests). **Buffer-backed nodes** — `heph://node/<id>` buffers (`buftype=acwrite`), `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update` (whole-buffer body, CRDT-diffed; exact round-trip). **`[[wiki-link]]` follow** on `<CR>` via a new **`node.resolve {title}`** RPC (exact alias-then-title match, the same mapping that materializes `wiki` links — never fuzzy `search`; unresolved links allowed). **Daily journal** (`:Heph today`/`journal <date>`, idempotent). `:Heph` command surface + completion. **Headless e2e (§9):** drives the plugin in `nvim --headless` against a real daemon over a temp socket via a **self-contained busted-style runner** (`tests/e2e/runner.lua` — no external plugins/network, deterministic exit codes); specs cover journal round-trip, follow-link (+ unresolved no-op), and link-two-docs/backlink. `mise run test-nvim` builds the daemon and runs the suite (dev: system nvim/rustc; CI: a Dagger container provides them — slice 11c).
- ✅ **`heph.nvim` slice 11b (§8) — task views:** **`list` enriched** to titled [`RankedTask`] rows (title + `canonical_context_id`, shared `ranked_from_row` with `next`) so the Organizational view needs no N+1 `node.get`. Plugin: **Tactical `next`** + **Organizational `list`** views (rendered scratch buffers, `<CR>` opens a row's canonical-context doc — the node autocmd narrowed to `heph://node/*` so view buffers don't trip it); **task capture**, **set-attention**, **done/drop**, **skip**, **per-task `log` append** — all resolving "the current task" from the buffer (a `task` node, or a context doc via its `canonical-context` backlink); **`vim.ui.select` picker** (`picker.lua`) with Telescope auto-upgrade; `:Heph next/list/capture/attention/done/drop/skip/log/search` subcommands. e2e specs: **capture→next→open context→add/check checklist→done**, and **recurring fresh-checklist** (complete rolls forward in place; the next occurrence is all-unchecked — the §4.4 hard requirement).
- ✅ **`heph.nvim` slice 11c (§8) — promotion + Dagger CI:** backend **`task.promote {container_id, item_ref, attention?, project?}`** — mints a committed task from the `item_ref`-th `- [ ]` context item (1-based, document order via a new `extract::context_item_lines`) and rewrites that source line into a `[[link]]` to it. **Wiki-link resolution now excludes canonical-context docs** (`resolve_id`), so `[[Task Title]]` deterministically resolves to the task, not its identically-titled context doc — a general fix surfaced by promotion. Plugin: `:Heph promote`, `promote_under_cursor` (save-if-dirty → `util.context_item_index_at_cursor` mirrors extract's fence rules → `task.promote` → reload). e2e spec (f). **CI via Dagger:** a `test_nvim` function in `.dagger/` bakes a **pinned, arch-detected Neovim** (`v0.11.2`; Debian's is too old for `vim.uv`) onto `rust:1-bookworm`, builds `hephd`, and runs the shim suite (cargo + cargo-target cache volumes); `build.yaml` calls `dagger call test-nvim`. `run.lua` fails on zero-specs (no false-green) — validated end-to-end (failing spec → Dagger exit 1).
- **Managed daemon (~~plug-and-play autostart~~ — SUPERSEDED 2026-06 by `heph daemon`, below):** the plugin used to spawn + supervise its own `hephd` and kill it on exit. Removed once the CLI became a first-class surface — a surface-owned daemon can't be shared. Lifecycle is now an explicit OS service ([[design]] §4); all surfaces are connect-only.
- **Knowledge-base UX:** **follow-or-create** (`<CR>` on an unresolved `[[link]]` mints the doc + materializes the source backlink), **`:Heph doc`**, **`:Heph home`** (an open-or-create index/landing page), **`:Heph journals`** (recent-days dailies picker with Telescope preview + `@create`).
- **Interactive task views:** `:Heph next`/`list` buffers gained `a` add / `d` done / `r` refresh (+ `<CR>` open), with a dimmed key-hint **header line** (virt-lines-above the first row render off-screen — use a real header).
- **Dev/installed isolation:** installed `heph`/`hephd` own the default paths; `mise run dev` runs the working-tree daemon on `.dev/` paths; `$HEPH_SOCKET`/`$HEPH_DB` point a dev Neovim at it.
- **CI is now fully Dagger** (`build.yaml`: `dagger call check` + `test-nvim`; **prek dropped from CI** — the Alpine job image has no Rust/nvim/prek, only Dagger + DinD). First-ever green CI.
- ✅ **Filter views (§8.2) — saved agenda slices:** the owner's saved filters are first-class so the agenda isn't one flat list. New **`heph-core::filter`**: a `ListFilter`**predicate-as-data** (`attention_in`/`attention_not` sets, project-id `scope`, `exclude_projects`, an `actionable` do-date gate) + the five built-in [`ViewSpec`]s (Top of Mind / On Deck / Chores / Work Tasks / Tasks — **Schedule dropped**, §8.2). `Store::list` now takes a `ListFilter`; new `Store::view(name)` resolves a spec's project **names** → ids and **subtree-expands** them through `parent` links (`links::project_subtree`/`resolve_project_id`), tolerating absent projects (a scoped view whose projects are all missing returns empty, never widens). Surfaces: `heph view <name>` (no name lists the five), the **`view` RPC** + `RemoteStore` forward, and `:Heph view <name>` in nvim (`heph://view/<name>` buffers). The `list` RPC/`RemoteStore`/CLI/`heph.nvim` migrated to the filter wire (legacy `--scope`/`--attention`/`--no-blue` map onto it). Tested: `filter` unit predicate, a `views` integration suite (subtree scope+exclude, actionable gate, unknown-view error, absent-project empties), a socket `list`/`view` dispatch test, and two nvim e2e specs.
- ✅ **`heph-tui` (§8.1) — the task agenda/triage surface, daily-driver core (slices T1–T2c):** a `ratatui` terminal UI, thin client of the daemon socket (never touches SQLite). **3-pane layout** — sidebar (the five §8.2 views + projects) · attention-colored task list with compact human do/late · preview (canonical-context body + `log.tail`). `App` is generic over a `Backend` seam (unit-testable without a terminal/daemon); `ui::render` is pure. **Gestures:**`j/k`/`Tab`/`h`/`l` navigate; `a` guided add (title→attention→do-date, filed under the selected project); `x` done, `s` skip, `d` drop, `A` cycle attention, `b` push-to-blue, `e` reschedule do-date; `o` suspends and opens the task's context doc in nvim (`+lua require('heph.node').open`, `$HEPH_SOCKET` shared) then reloads. Shared client date/recurrence parsing (`datespec`) moved from the CLI into `hephd`'s lib so both surfaces use one parser (heph-core stays clock-pure). `a` is a **single-line NL quick-add** (`quickadd::parse`, pure + exhaustively unit-tested): `Buy milk tomorrow p2 #Camano Chores every 3 days` → title + attention + do-date + recurrence + (greedy multi-word) project, reusing `datespec`. Tested: `TestBackend` render assertions against a real spawned daemon + in-memory navigation/input-flow/quick-add/search units. **`/` runs FTS `search`** as an overlay (Enter opens a hit in nvim — task hits at their context doc). *(Remaining: move-to-project, which needs a `task.set_project` RPC.)*
> The Rust backend is feature-complete; the **CLI is the complete API + task driver**, the **daemon runs as an OS service** (`heph daemon`; all surfaces connect-only), the live store has been seeded from Todoist ([[design]] §6.2.1), **filter views (§8.2) are built** (`heph view`), and the **`heph-tui` daily-driver core is built** (§8.1, T1–T2c). **Surface strategy = three-surface model** ([[design]] §4): **CLI = capture/scripting + complete API** (done), **TUI = primary task agenda/triage** (core done, the UX wave below remains), **nvim = context/KB**.
>
> The remaining work is the **UX roadmap agreed 2026-06-03** (design conversation with the owner). It is documented docs-first — the bigger items have design sections above (`heph-tui` UX in §8.1, frontmatter in §8.3, wiki-links-by-id in §8.4) — and built in this order:
1. ✅ **`heph-tui` — move-to-project (§8.1) — DONE:** the **`task.set_project` RPC** (tombstone the old `in-project` link + add the new — OR-set semantics, no task-scalar op; given project must be a live project-kind node) plus the TUI `m` list-pick overlay and `heph edit --project <name>|none` (which also fixed a duplicate-link bug in the old re-file path). Unblocks the project-edit path of the frontmatter surface (§8.3).
- ✅ **(a) flag column + project-colored bullets — DONE:** a leading `⚑` colored by attention (red/orange/blue; blank for white/none), and the bullet `●` colored by its project via a stable `hash(project_id) → hue` (FNV-1a → HSL → `Color::Rgb`). Hashing is chosen for stability-under-insertion over golden-angle spread; overlap is acceptable. The bullet **glyph shape** is reserved for future semantics. A per-project **color override** (stored on the project node, editable) is a later refinement — colors are derived client-side for now (no schema change).
- ✅ **(e) scrollbar — DONE:** the task list grows a `ratatui``Scrollbar` (tracking the selection) when content overflows; a `ListState` selection keeps the highlighted row scrolled into view.
- ✅ **(b) sort toggle `s` — DONE:** **default**: attention (red→orange→white→blue) → days-overdue (descending; no-date = 0) → project (name) → `created_at` (FIFO). **project mode**: project is primary, with dimmed **`──── Name ────` separators** riding atop each group's first task (the cursor only lands on real tasks). View filtering always runs **before** the sort. (`skip` moved to `S` to free `s`.)
- ✅ **nvim task-navigation polish (§8) — DONE:**`:Heph next`/`list` rows now carry a compact **do/late date chip** (and a recurrence `↻`); `<CR>` already jumps to a row's canonical-context doc (read/navigate, not field-edit).
3. ✅ **Tags (§4, §8.3) — DONE:** a tag is a `tag`-kind node whose id is **deterministic in `(owner, name)`** (`tag:<owner>:<name>`, like the journal), so a name is one canonical tag and replicas converge — no duplicate tag nodes. Tagging is an **OR-set `tagged` link** (mirroring `in-project`): `Store::add_tag` (get-or-create the tag node, idempotent link), `remove_tag` (tombstone the link), `tags_of` (sorted names); enumerate all tags via `list_nodes(Tag)`. RPCs `tag.add`/`tag.remove`/`tag.list` (+ RemoteStore forward); CLI `heph tag add|rm|list`. Names are trimmed, case preserved (canonical normalization deferred to the zk import). Unblocks the `tags:` line of the frontmatter surface (§8.3) and the eventual zk import; inline `#hashtags` remain a heph.nvim concern (§8.3).
4. ✅ **YAML frontmatter as an edit surface (§8.3) — DONE:** the projection — `heph-core::frontmatter::strip` (conservative, runs in `update_node` before the CRDT diff) + `hephd::frontmatter::render` (local-tz dates via `datespec::fmt_iso`) behind `node.get {frontmatter: true}`; a task's context-doc surfaces the owning task's scalars + a `task:` ref; round-trip is a no-op and inbound frontmatter is always stripped (safe vs any client). And the `heph.nvim` smart client (`frontmatter.lua`): the buffer opens with the editable block, and `BufWriteCmd` diffs it → `title`→rename / `attention`→set_attention / dates→set_schedule / `project`→set_project / `tags`→tag.add·remove (a no-block buffer touches no metadata), and inline `#hashtags` in the body are unioned into the tag set on save.
5. ⏳ **Wiki-links by node id (§8.4) — docs-first C1 (maybe C2):** canonical `[[NODEID]]` at rest, expanded/concealed for display; a `[[` picker; no name-links in the DB. Includes a one-time body fixup. See §8.4.
6. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9).
7. ⏳ **Split `heph.nvim` to its own forge repo (§8) — UX polish:** generated from this monorepo (subtree-split in CI) so the lazy spec becomes `{ "eblume/heph.nvim" }` instead of a local-clone `dir` (see [[install-heph]]).
8. ⏳ **Adoption refinement + multi-tenant (§13) — before v1 done, low priority:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension.
9. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite.