Resolve the open tensions surfaced in the pre-Phase-1 second pass over design.md and tech-spec.md: - Context items: Fork A index model — body markdown is the source of truth; context items are a locally-derived, non-synced index; identity is pinned at promotion. Dissolves the body-CRDT vs. extraction convergence problem. - Recurrence: roll-forward in place; drop task_occurrences and is_template; advance to next RRULE instance after now (skip misses). - Identity: deterministic ids for journal/tag (offline-convergent); ULID for content nodes and project. - Mode/sync: orthogonal hub_url spoke capability; everyday device is local + hub_url, not server. - Auth/owner: nullable oidc_sub, friction-free local user, hub- authoritative identity, one-time pre-first-sync adoption rewrite. - Ranking: do_date is a boolean candidacy filter only; late_on is the sole urgency signal (global tier); FIFO tiebreak; order expressed as a reorderable named-dimension list. - Modes are plugin-side compositions; add list() and log.tail(). - Frame v1 as a single deliberate C1; misc cleanups (export, health, CI nvim runner, README license). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
29 KiB
| title | modified | tags | ||
|---|---|---|---|---|
| Technical Specification | 2026-05-31 |
|
Hephaestus — Technical Specification
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.
1. Overview
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, theStoreabstraction + 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; inservermode it additionally exposes an authenticated network endpoint and runs as the sync hub.heph— Rust CLI: utility/admin surface (export, scripting, smoke tests,heph conflicts).heph.nvim— Lua Neovim plugin: the primary user surface ("org-mode"-style); a thin client of the localhephd.
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.
3. Architecture
- Surfaces (
heph.nvim,hephCLI) never touch SQLite directly. They connect to the localhephdover a unix socket (default$XDG_RUNTIME_DIR/heph/hephd.sock) regardless of mode. heph-coreis synchronous and side-effect-light (incl. deterministic merge logic);hephdwraps it with async I/O, transport, and auth (tokio). DB calls run on a blocking pool.- See §3.1 (Storage backends & runtime modes), §12 (Sync & Conflict Resolution), §13 (Authentication).
3.1 Storage backends & runtime modes
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 (advisoryflockon the DB file / a sidecar.lock). Refuses to start if the file is already locked.RemoteStore— no local file; proxies every operation to aserverover the network.
Two orthogonal axes, not one knob. A runtime is configured along two independent axes plus one optional capability:
- Backend —
LocalStore(own replica + op-log) vs.RemoteStore(proxy to a server). - Inbound listener — does it expose an authenticated network endpoint others connect to (i.e. is it a hub)?
- Outbound sync (optional,
LocalStoreonly) — an optionalhub_url; when set, the instance is a spoke that background-syncs its op-log to that hub. Independent of whether it also listens.
The named modes are presets over those axes:
| Mode | Backend | Inbound listener | hub_url |
Offline | Role |
|---|---|---|---|---|---|
| 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.
4. Data model
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.
4.1 Node kinds
| kind | meaning | body |
|---|---|---|
doc |
rich context document (knowledge base, work-logs, journals) | markdown |
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
tasknode (a row intasks) and participates in "what is next". A context item is not a synced node — it is a- [ ]line in a containerdocbody (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 committedtasknode and rewrites the source line into a link to it. - 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. 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):
- append the completed occurrence to the task's log (
log-of); - reset the context doc's checkboxes to unchecked (a body-CRDT edit);
- advance
do_dateto the next RRULE instance strictly afternow— 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".
4.5 SQLite schema (starting point)
nodes(
id TEXT PRIMARY KEY, -- ULID (content nodes); deterministic fn(owner,key) for journal/tag (§3.1 of [[design]])
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)
tombstoned INTEGER NOT NULL DEFAULT 0
)
tasks( -- committed tasks only; context items live in doc bodies (§4.3)
node_id TEXT PRIMARY KEY REFERENCES nodes(id),
attention TEXT, -- white|orange|red|blue
do_date INTEGER, -- epoch ms, nullable; boolean candidacy gate only (§7)
late_on INTEGER, -- epoch ms, nullable; the sole urgency signal (§7)
state TEXT NOT NULL, -- outstanding|done|dropped
recurrence TEXT -- RRULE; present = recurring definition (roll-forward, §4.4)
)
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, -- OIDC subject (Authentik); NULL = local/unlinked user (§13)
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 (provisioning TBD at sync slice, §12)
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 spoke; 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/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).
5. Markdown handling
- The body is the source of truth and merges as a text CRDT (
body_crdt); thebodyTEXT 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. - On write,
heph-corederives from the body:[[wiki-links]]→wikilinks (resolved viaaliases/title; unresolved links are allowed and recorded).- 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, andexport. exportmaterializes all non-tombstoned nodes to a directory tree of.mdfiles (frontmatter — id, kind, task scalars, aliases, links — plus body), a faithful one-way portable snapshot (no import in v1).
6. Daemon RPC API (JSON-RPC over unix socket)
Methods (request → response; errors are JSON-RPC errors). Signatures are indicative, not final:
node.get(id) → Nodenode.create({kind, title, body?}) → Nodenode.update({id, title?, body?}) → Node(body update re-runs extraction)node.tombstone(id) → oktask.create({title, project?, attention?, do_date?, late_on?, recurrence?, committed?}) → Task(auto-creates the canonical contextdoc+canonical-contextlink)task.set_state({id, state}) → Task(recurring: advances per §4.4)task.set_attention({id, attention}) → Tasktask.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({scope?, attention?, include_blue?, include_future?, group_by?}) → [Task](enumeration for the Organizational view — the whole set incl. backlog)search({query, filters?}) → [Node](FTS)links.outgoing(id) → [Link]/links.backlinks(id) → [Link]journal.open_or_create(date) → Nodelog.append({task_id, text}) → ok(append to the task'slog-ofnode)log.tail({task_id, n?}) → [Entry](read the task's latest log entries — the resumption breadcrumb, design §6.1)export({path}) → {count}health() → {orange_count, active_count, on_deck_count, conflict_count, sync_status, ...}(working-set + sync indicators)- auth:
auth.login() → {device_code_flow...}/auth.status() → {user, logged_in}/auth.logout()(§13) - sync:
sync.now() → {pushed, pulled, conflicts}(force a sync cycle; background sync runs automatically) /sync.status() → {last_pushed, last_pulled, pending_ops, online} - conflicts:
conflicts.list() → [Conflict]/conflicts.resolve({id, choice}) → ok
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.
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 (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.
-
Filter (candidacy) — a pure predicate:
committed∧state = outstanding∧ ¬tombstoned∧attention ≠ blue∧ actionable (do_date IS NULL OR do_date ≤ now) ∧ inscope.do_dateis used only here — a boolean "can this be done now?" gate, never an urgency input. For a recurring task, the single rolled-forward node'sdo_dateis the gate (§4.4). -
Order — an ordered list of named sort dimensions, applied lexicographically; reordering this list (one place) reshuffles the ranking:
RANKING = [ PastLateOn(desc), LateOverdueAmount(desc), Attention(red>orange>white), CreatedAt(asc) ]late_onis the sole urgency signal and forms a global top tier: items pastlate_onfloat above everything (incl.red), most-past first. This is the only legitimate "overdue" —late_onis 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 ofRANKING.)- then attention band
red → orange → white; - tie-break
created_atascending (FIFO). Age is never urgency — an item is not hotter for having been actionable longer; raise its attention or set alate_onto escalate it deliberately.
-
Output: concise rows — title, project, attention, do/late, link to canonical context.
reditems always appear regardless oflimit.
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.
8. heph.nvim surface (v1)
Replaces obsidian.nvim. Telescope-backed. 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:
- Follow
[[wiki-link]]under cursor on<Enter>. - Search / quick-switch / tags / backlinks / outgoing links (pickers).
- Daily journal picker (create/open dated
journalnodes). - Task capture; show "what is next" (
:Heph next, Tactical);listviews (Organizational); set attention; mark done/dropped. - Open a task's canonical context doc; edit context-item checkboxes (Fork A) in the buffer (derived on
:w). - Per-task log quick-append without leaving the current buffer.
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.
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.
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). 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
hephdreplicas + a hubhephd, 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.resolvesettles them deterministically; - body CRDT merge: concurrent edits to the same
docbody 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.nviminnvim --headlessagainst a realhephd+ 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.
- capture a task → appears in
- CLI tests: invoke
hephsubcommands against a temp DB; snapshot output; assertexportround-trips the corpus;heph conflictslists/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; alternativeautomerge. Used fordoc/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);reqweston the client side. - OIDC:
openidconnectcrate 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
servernetwork endpoint. heph.nvim+hephCLI surfaces (incl.heph conflicts).
Out (later phases, scaffolded so as not to block):
- Web UI (the hub serves sync only in v1; reserve
axumfor 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.
- Calendar integration (read-mostly CalDAV; never explode recurrence into stored events), iOS/Watch capture, inferred/semantic context, P2P-over-tailnet sync fallback.
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).
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. - 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
localinstance with nohub_urlruns against a single local user (usersrow, generatedid,oidc_sub = NULL) — friction-free, no IdP.oidc_subis therefore nullable. - The hub is authoritative for user identity. On first authentication it maps
sub→ a canonicalusers.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 rewriteowner_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); afterwardowner_idis 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
subto ausersrow and scopes every op byowner_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