> 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.nvim`** — Lua Neovim plugin: the primary user surface ("org-mode"-style); a thin client of the local `hephd`.
## 2. Development approach
**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.
| **server** | `LocalStore` | yes (authenticated) | yes | fronts the file for remote clients; **the sync hub** |
| **client** | `RemoteStore` → a server | n/a | **no** | thin; online-only convenience |
**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.
**Where offline + sync live:** offline capability comes from a **local-backed** instance (`local` or `server`) — a full replica with its own op-log. Two *different machines* each run a local-backed instance and **sync** op-logs through a hub (a `server`-mode instance), which is where automatic merge + the **conflict queue** (§12) happen; sync never blocks local work, and the hub need not always be online. A `client`-mode instance is **online-only** (no replica, no offline) — the convenience option (e.g. on the hub host, or an always-connected client). Same-file local↔server is single-host graceful-degradation; device↔device sync uses *separate replicas* that reconcile.
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**. Optional **late-on** marks when lateness becomes a problem.
- **Commitment axis:** `committed = 1` tasks participate in scheduling/"what is next"; `committed = 0` are **ephemeral context items** scoped to a container (`container_id`), with only `outstanding`/done states, never surfaced globally. Context items may be **promoted** to committed tasks.
- **States:** `outstanding`, `done`, `dropped` (done and dropped are both "not outstanding"; the distinction is retained).
- **No hard deletes:** everything uses `tombstoned`; physical deletion only in an explicit cleanup mode.
### 4.4 Recurrence (§3.3 of [[design]])
A `task` with a non-null `recurrence` (RFC-5545 RRULE) is a **recurring definition**. Sub-items flagged `is_template = 1` form its **checklist template**. **Each occurrence produces a fresh checklist instance** (new `outstanding` context items copied from the template); **completion never carries forward across occurrences** — this is a hard requirement.
Two candidate implementations (pick at kickoff; (a) is the lean):
- **(a) Occurrence instances:** definition spawns a `task_occurrences` row per occurrence, each with its own do-date and fresh checklist items. Full history.
- **(b) Roll-forward in place:** single node; on completion, log the occurrence, reset the checklist to `outstanding`, advance the do-date.
### 4.5 SQLite schema (starting point)
```
nodes(
id TEXT PRIMARY KEY, -- ULID
owner_id TEXT NOT NULL REFERENCES users(id), -- per-user isolation
kind TEXT NOT NULL, -- doc|task|project|tag|journal
title TEXT NOT NULL,
body TEXT, -- markdown (nullable); materialized view of body_crdt
body_crdt BLOB, -- text-CRDT state for the body (merge), nullable
created_at INTEGER NOT NULL, -- epoch ms
modified_at INTEGER NOT NULL,
hlc TEXT NOT NULL, -- hybrid logical clock of last write (sync ordering)
is_template INTEGER NOT NULL DEFAULT 0 -- checklist-template item
)
-- recurrence model (a) only:
task_occurrences(
id TEXT PRIMARY KEY, -- ULID
def_id TEXT NOT NULL REFERENCES nodes(id),
occurrence_date INTEGER NOT NULL,
state TEXT NOT NULL, -- outstanding|done|dropped|skipped
created_at INTEGER NOT NULL,
tombstoned INTEGER NOT NULL DEFAULT 0
)
links(
id TEXT PRIMARY KEY, -- ULID
src_id TEXT NOT NULL REFERENCES nodes(id),
dst_id TEXT NOT NULL REFERENCES nodes(id),
type TEXT NOT NULL,
created_at INTEGER NOT NULL,
tombstoned INTEGER NOT NULL DEFAULT 0
)
aliases(node_id TEXT REFERENCES nodes(id), alias TEXT) -- wiki-link name resolution
nodes_fts -- FTS5 over title, body
-- identity & sync --
users(
id TEXT PRIMARY KEY, -- ULID
oidc_sub TEXT UNIQUE NOT NULL, -- OIDC subject (Authentik)
name TEXT,
created_at INTEGER NOT NULL
)
oplog( -- append-only operation log (the sync unit)
id TEXT PRIMARY KEY, -- ULID
owner_id TEXT NOT NULL REFERENCES users(id),
hlc TEXT NOT NULL, -- hybrid logical clock (causal order)
origin TEXT NOT NULL, -- originating device id
op_type TEXT NOT NULL, -- node.create|node.body_delta|task.set_field|link.add|link.remove|...
target_id TEXT NOT NULL,
payload TEXT NOT NULL, -- JSON (e.g. CRDT delta, field+value, OR-Set add/remove)
applied INTEGER NOT NULL DEFAULT 0
)
sync_state( -- per-peer cursor (device ↔ hub)
peer TEXT PRIMARY KEY, -- 'hub' on a client; device id on the hub
last_pushed_hlc TEXT,
last_pulled_hlc TEXT,
updated_at INTEGER NOT NULL
)
conflicts( -- ambiguous merges surfaced to the user
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id),
node_id TEXT NOT NULL REFERENCES nodes(id),
field TEXT NOT NULL, -- which field / 'body-region'
local_val TEXT, remote_val TEXT,
local_hlc TEXT, remote_hlc TEXT,
status TEXT NOT NULL, -- open|resolved
created_at INTEGER NOT NULL
)
```
Projects/tags are `nodes`; membership is `links` (`in-project`, `tagged`). All `tasks`/`task_occurrences`/`links` rows inherit ownership via their node(s).
## 5. Markdown handling
- Bodies are stored verbatim. On write (node create/update), `heph-core` extracts:
-`[[wiki-links]]` → `wiki` links (resolved via `aliases`/title; unresolved links are allowed and recorded).
- GFM task-list items (`- [ ]` / `- [x]`) → context-item state under the node (Option A editing model — see [[design]] §6.3; the alternative is command-driven items, decided in-prototype).
- Extraction is idempotent and diff-based: re-writing an unchanged body is a no-op; reworded checklist lines tombstone-old + add-new (context items are cheap).
-`export` materializes all non-tombstoned nodes to a directory tree of `.md` files (frontmatter + body), reproducing the corpus portably.
## 6. Daemon RPC API (JSON-RPC over unix socket)
Methods (request → response; errors are JSON-RPC errors). Signatures are indicative, not final:
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.
### 6.1 Hub (server-mode) sync endpoint
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
Given an optional `scope` (project/context) and `limit` (default 5):
1.**Candidates:** committed tasks where `state = outstanding`, not tombstoned, `attention ≠ blue`, and actionable now: `do_date IS NULL OR do_date ≤ now`. For recurring definitions, evaluate the current active occurrence's do-date. Apply `scope` if given.
2.**Order:**
1. attention: `red` → `orange` → `white`;
2. urgency: tasks past `late_on` first, then most-overdue (smallest `do_date`) first;
3. tie-break: earlier `do_date`, then `created_at`.
3.**Output:** concise rows — title, project, attention, do/late, link to canonical context. `red` items always appear regardless of `limit`.
`blue` (on-deck) is hidden from `next` by design; surfaced only by an explicit on-deck view. `health()` exposes the working-set tensions (orange ≤ 6, active ≤ ~30, on-deck count) honestly — never masking overload nor manufacturing calm.
- Task capture; show "what is next" (`:Heph next`); set attention; mark done/dropped.
- Open a task's canonical context doc; edit context-item checkboxes (Option A) in the buffer (extracted on `:w`).
- Per-task log quick-append without leaving the current buffer.
## 9. Testing strategy (TDD, layered)
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`).
- **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) — *leaning*; alternative `automerge`. Used for `doc`/`journal`/log bodies. Structured fields use a bespoke op-log + HLC (no library needed).
- **HLC:** small bespoke hybrid-logical-clock (or a crate) — deterministic, clock-injected.
- **Hub network transport:** `axum` (HTTP/JSON) for the sync endpoint — *leaning* (reuses the eventual web-UI server); `reqwest` on the client side.
- **OIDC:** `openidconnect` crate for the Authentik device-code flow; tokens cached in the OS keychain (`keyring`) / 1Password.
## 11. v1 scope
**In:**
- The full data model, markdown handling, "what is next?" ranking, and **recurrence + recurring checklists** (§4–§8).
- **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.
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.
## 13. Authentication
- **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.
- **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]]).
## Related
- [[design]] — full design document with rationale and decision history