Service (Daemon) Architecture
The service module (crate::service) enables running Eidetica as a local daemon over a Unix domain socket. The RPC boundary sits at the storage-operation level: a RemoteConnection forwards every Database-level storage operation to the daemon. Higher-level code (Database, stores, Transaction, Instance) drives I/O through a single Backend trait whose remote implementation (RemoteBackend) wraps the connection, so the call sites are identical for local and connected instances.
Architecture Overview
graph LR
C1[Client Process 1] -->|Unix Socket| S[Service Server]
C2[Client Process 2] -->|Unix Socket| S
S --> I[Instance]
I --> B[Backend: SQLite/InMemory]
The server wraps a full Instance (not just a backend) so it can handle both storage operations and write notifications. A client calls Instance::connect("unix://..."), which establishes a RemoteConnection, wraps it as a RemoteBackend, fetches InstanceMetadata over the wire, and constructs an Instance with no local secrets (secrets: None) — signing keys are derived client-side after login, never held by the constructed Instance until a user logs in.
Module Structure
| Module | Role |
|---|---|
service::protocol | Wire types: Handshake, ServiceRequest, ServiceResponse, DatabaseOp, frame I/O |
service::error | ServiceError wire format and error reconstruction |
service::server | ServiceServer — accepts connections, runs the auth state machine, gates and dispatches requests |
service::client | RemoteConnection — the RemoteBackend transport + trusted_login |
The daemon-side, per-user CRDT-state cache lives on the backend engine itself (backend::BackendImpl, keyed by CacheScope) rather than in a separate service-layer cache module.
Wire Protocol
The protocol uses length-prefixed JSON frames over a Unix domain socket.
Frame Format
┌──────────────────┬──────────────────────┐
│ Length (4 bytes) │ JSON payload │
│ big-endian u32 │ (up to 64 MiB) │
└──────────────────┴──────────────────────┘
Each frame is a 4-byte big-endian length prefix followed by a JSON-serialized payload. Maximum frame size is 64 MiB (MAX_FRAME_SIZE); frames exceeding this are rejected on both read and write. write_frame/read_frame handle serialization and framing; read_frame returns None on clean EOF.
PROTOCOL_VERSION is currently 0, indicating an unstable protocol that may change without notice.
Connection Lifecycle
sequenceDiagram
participant C as Client
participant S as Server
C->>S: Handshake { protocol_version }
S->>C: HandshakeAck { protocol_version }
Note over C,S: Version mismatch → server closes connection
Note over C,S: Connection state: PreAuth
C->>S: TrustedLoginUser { username }
S->>C: TrustedLoginChallenge { challenge, user_uuid, user_info }
Note over S: state → AwaitingProof
C->>S: TrustedLoginProve { signature over challenge }
S->>C: TrustedLoginOk
Note over S: state → Authenticated { login_pubkey, session_keyset, user_uuid }
opt Register additional per-DB keys
C->>S: SessionKeyChallenge { pubkey }
S->>C: SessionKeyChallenge { challenge }
C->>S: SessionKeyRegister { pubkey, signature }
S->>C: Ok
Note over S: keyset insert pubkey
end
loop Authenticated request/response
C->>S: ServiceRequest::AuthenticatedDb(AuthenticatedDbRequest)
S->>C: ServiceResponse
end
Note over C: Client closes connection (EOF)
- Handshake: client sends
Handshake { protocol_version }; server validates and acks. On mismatch the server acks with its own version and closes the connection. - Trusted login (see Security Model below): a challenge-response over the user’s root key.
GetInstanceMetadatais the only other request permitted before login. - Optional session-key registration: once authenticated, the client may prove possession of additional pubkeys via
SessionKeyChallenge/SessionKeyRegister. Each successfully proven pubkey joins the connection’ssession_keysetand can then act as the identity on subsequent ops. See Session Keyset below. - Authenticated request loop: every storage operation travels inside
ServiceRequest::AuthenticatedDb. One response per request, strictly sequential per connection (RemoteConnectionserializes all I/O through a mutex). - Termination: client closes its write half (EOF); the server detects EOF and cleans up.
Security Model
Client-side signing. The daemon stores and serves encrypted key material and signed entries but never holds plaintext user signing keys or passwords.
- User keys stay client-side:
TrustedLoginUserreturns the user’s full record (user_info, including the encryptedUserCredentials) in the same round-trip as the challenge. The client derives the key-encryption-key locally (Argon2id over the password), decrypts the root signing key in-process, signs the challenge, and builds itsUsersession from the already-returned record — no second wire read of_users. The signing key never crosses the socket. - Authentication via challenge-response: the daemon issues fresh random challenge bytes per login attempt. Successful decryption of the user’s signing key on the client is password verification; the daemon verifies the returned signature against the user’s stored public key. No password is sent over the wire.
TrustedLoginnaming is load-bearing: the flow assumes the caller is already trusted by the socket’s filesystem permissions. Over a network transport this would need a PAKE instead — the name flags that gap deliberately.- Encrypted stores remain opaque to the daemon: per-database encrypted CRDTs merge as
Vec<EncryptedBlob>; the daemon participates in storage and sync without ever holding a content encryption key. - Filesystem permissions: the socket’s parent directory is created mode
0700and the socket itself is set to0600as an additional access-control layer.
See the crate::service module rustdoc for the full design rationale, including why daemon-side signing (the earlier draft) was rejected.
Request / Response Types
ServiceRequest
The top-level request enum is intentionally flat, keeping the pre-auth surface visible at a glance:
| Variant | When |
|---|---|
TrustedLoginUser { username } | Pre-auth step 1 — request a login challenge |
TrustedLoginProve { signature } | Pre-auth step 2 — return the signed login challenge |
GetInstanceMetadata | Pre-auth — fetch server identity (used by Instance::connect) |
SessionKeyChallenge { pubkey } | Post-auth — request a challenge bound to pubkey so the client can prove possession |
SessionKeyRegister { pubkey, signature } | Post-auth — return the signed challenge; on success pubkey joins the connection’s session keyset |
AuthenticatedDb(Box<AuthenticatedDbRequest>) | A DatabaseOp — the single envelope for every storage operation |
AuthenticatedDbRequest carries an identity claim that the server validates against the connection’s session keyset before dispatch:
pub struct AuthenticatedDbRequest {
pub root_id: ID, // database whose auth_settings gate this op
pub identity: SigKey, // claim; hint pubkey must be in session_keyset
pub op: DatabaseOp, // tree-scoped by construction
}
DatabaseOp — the wire surface
Every storage operation rides a single DatabaseOp enum carried in AuthenticatedDbRequest. The server runs its own Database on its local Instance, so verification-on-read, the Verified frontier, and CRDT-state materialization happen server-side by construction. Each variant is tree-scoped through the containing request’s root_id.
| Variant | Purpose |
|---|---|
BeginTransaction { stores, scope } | Acquire parent tips, settings tips, and merged settings needed to build+sign a transaction locally for stores, parents drawn from scope’s projection. Read. |
SubmitSignedEntry { entry } | Submit a finished, client-signed entry. Server stores it Unverified and runs its own verification pass. Verification-gated, not session-gated (see below). |
GetVerifiedTips | Database’s Verified-frontier tips. Read. |
GetStoreState { store } | Server-materialized merged state of an unencrypted store, against the server’s Verified frontier. Returns a WireCrdtValue. Read. |
GetStoreEntries { store, tips, scope } | Ordered (by subtree height), verified, opaque store entries reachable from tips in scope — the universal primitive, including encrypted stores. Read. |
GetStoreTipsUpToEntries { store, .. } | Store tips reachable from given main-tree entry IDs. Read. |
ComputeMergeState { store, .. } | Lowest common ancestor + path to tip entries in a store DAG, fused into one RPC. Read. |
GetEntry { id } | Fetch a single entry by id (gating tree resolved server-side post-fetch). Read. |
GetCachedCrdtState { store, key } | Look up a previously cached materialized CRDT state for (session user, root_id, key, store). Read. |
CacheCrdtState { store, key, blob } | Stash a client-computed materialized CRDT state. The blob is opaque to the daemon and scoped to the authenticated user. Read. |
SetInstanceMetadata { metadata } | Rewrite daemon-level pointers to its own system DBs. Special-cased server-side to gate Admin on _databases (a daemon-global system tree) instead of the request’s root_id. |
Operations deliberately not exposed over the wire — update_verification_status, get_instance_secrets, all_roots, and the verification/enumeration queries — live on the concrete local BackendImpl engine (reached via Backend::local_engine), so a RemoteBackend simply does not provide them.
update_verification_status is off-wire by design, not just for tidiness: verification is a local trust decision. If a client could set it over the socket, a client (or anything that reached the socket) could assert “this entry is Verified” without the server having validated it — exactly the caller-asserted-status hole the storage API was hardened to close. So the daemon’s Instance owns verification: it stores everything Unverified and runs Database::verify() itself. The frontier/allow_unverified distinction is applied by the daemon-side Database, and every client connected to the same daemon therefore observes the same verified view.
DatabaseOp::required_permission() -> Permission returns Write(0) for SubmitSignedEntry (advisory only — the per-tree gate is skipped for submit), Admin(0) for SetInstanceMetadata (advisory — gated against _databases instead of root_id), and Read for every other variant.
ServiceResponse
| Variant | Payload |
|---|---|
Entry(Entry) / Entries(Vec<Entry>) | One or many entries |
Ids(Vec<ID>) | One or many IDs |
Ok | Success with no data |
TransactionContext(TransactionContext) | Parent tips + settings, response to DatabaseOp::BeginTransaction |
CrdtValue(WireCrdtValue) | Materialized merged state, response to DatabaseOp::GetStoreState |
MergeState(MergeState) | LCA + path, response to DatabaseOp::ComputeMergeState |
InstanceMetadata(Option<InstanceMetadata>) | Optional instance metadata |
TrustedLoginChallenge { challenge, user_uuid, user_info } | Challenge bytes + the user’s full record (login) |
TrustedLoginOk | Login succeeded; connection now authenticated |
SessionKeyChallenge { challenge } | Challenge bytes the client signs to register an additional key |
Error(ServiceError) | Error response |
Session Keyset
A connection’s cryptographic identity is a set of pubkeys, not a single pubkey. The set is seeded at login with the pubkey that proved TrustedLoginProve (the user’s root pubkey, called login_pubkey) and grows as the client proves possession of additional keys via the registration handshake.
// In service::server
enum ConnectionState {
PreAuth,
AwaitingProof { challenge: Vec<u8>, expected_pubkey: PublicKey, .. },
Authenticated {
login_pubkey: PublicKey,
session_keyset: HashSet<PublicKey>, // includes login_pubkey
pending_key_challenges: HashMap<PublicKey, Vec<u8>>,
user_uuid: String,
..
},
}
No ConnectionState variant holds a PrivateKey (enforced by a structural test).
Why a keyset, not a single pubkey?
A User has many pubkeys — a root key, a device key, per-DB keys created via User::add_private_key, etc. Writes signed by a per-DB key produce entries whose tree auth grants that key, not the login key. With a single-pubkey session, reads of those trees would be denied (the login key isn’t a member of the tree the per-DB key authored). The keyset model lets the same connection drive ops on every tree the user actually has a key for, without forcing one connection per key.
Registration handshake
Adding a pubkey to the keyset is a two-step PoP:
- Client → server:
SessionKeyChallenge { pubkey }. Server generates a single-use, pubkey-bound random challenge, stores it inpending_key_challenges[pubkey], returns it. - Client signs the challenge with
pubkey’s private key, sendsSessionKeyRegister { pubkey, signature }. Server verifies, removes the challenge (consumed on success or failure), insertspubkeyintosession_keyseton verify, returnsOk.
Each RemoteConnection caches successful registrations in a Mutex<HashSet<PublicKey>> so register_session_key(signing_key) is idempotent — a repeat call for the same key short-circuits without touching the wire. The login pubkey is pre-cached on trusted_login success.
The handle constructors that need per-DB identities register on the caller’s behalf, so most client code never touches the registration API directly:
Database::create(instance, signing_key, settings)registerssigning_keybefore constructing the returnedRemoteBackend-backed handle.Database::open_remote(.., identity)is paired byInstance::open_system_db_for_session(root, signing_key), which registerssigning_keybefore opening.User::open_database_with_key(root, key_id)registers the user-held signing key forkey_idbefore routing throughDatabase::open_remoteon a connected instance.
Access Control: the three gates
Authenticated requests pass three gates before dispatch:
- Gate 1 — connection state: the connection must be
Authenticated. The identity hint, if any, must have itspubkeyfield present in the connection’ssession_keyset(proof of possession registered earlier). The resolved value becomes the acting pubkey for this op; an absent hint defaults tologin_pubkey. The cross-check rejects identity claims for keys the client never proved — an attacker that hijacks an authenticated session still can’t act as a key whose private material isn’t on the client. Submit (DatabaseOp::SubmitSignedEntry) is exempt from this check: an admin transporting a user-signed entry legitimately carries a non-keyset identity, and the verification gate downstream is the real boundary (see Verification-gated submit). - Gate 2 — per-tree permission: the server loads
auth_settingsfor the request’sroot_id, resolves the acting pubkey’s permission, and rejects the op unless it coversrequired_permission(). Resolution tries direct membership first; on miss it falls back to the wildcard (*) slot, so a tree’s global grant actually applies to any keyset member that isn’t otherwise listed.GetEntryis gated post-fetch (gate_entry_read): the fetched entry’s owning tree is resolved andReadis required before content is returned, in case the entry’s owning tree differs from the request’sroot_id. - Gate 3 — cross-tree admin:
SetInstanceMetadatarewrites daemon-global pointers, so its gate ignores the request’sroot_idand is applied against_databases.auth_settingsrequiringAdmin. The dispatcher special-cases this variant; it fails closed if_databasesis unreadable (D8).
Authorization for entry-id-keyed writes is being hardened separately (tracked as D1); see V1 Limitations.
Verification-gated submit
SubmitSignedEntry is verification-gated, not session-gated. The submit handler stores the incoming entry Unverified and runs the server’s own verification pass against the tree’s pinned auth lineage. An attacker without a key the tree’s auth grants cannot produce a Verified entry, and unverified junk is excluded from every default read by the frontier cut — so the per-tree session gate would add no correctness or isolation property here, and would only block legitimate transporters (e.g. an admin session carrying a user-signed genesis).
The core rule for the wire surface: reads are session-gated (confidentiality boundary); submits are verification-gated (integrity boundary).
Error Handling Across the Wire
Errors serialize as ServiceError { module, kind, message }.
Server side: dispatch catches any crate::Error, extracting the error’s module name, discriminant name, and display message.
Client side: service_error_to_eidetica_error() reconstructs the appropriate crate::Error from the (module, kind) pair:
| Module | Kind | Reconstructed Error |
|---|---|---|
backend | EntryNotFound | BackendError::EntryNotFound |
backend | VerificationStatusNotFound | BackendError::VerificationStatusNotFound |
backend | EntryNotInTree | BackendError::EntryNotInTree |
backend | NoCommonAncestor | BackendError::NoCommonAncestor |
backend | EmptyEntryList | BackendError::EmptyEntryList |
instance | DatabaseNotFound | InstanceError::DatabaseNotFound |
instance | EntryNotFound | InstanceError::EntryNotFound |
instance | InstanceAlreadyExists | InstanceError::InstanceAlreadyExists |
instance | DeviceKeyNotFound | InstanceError::DeviceKeyNotFound |
instance | AuthenticationRequired | InstanceError::AuthenticationRequired |
| (other) | (other) | Error::Io with the original message |
Unrecognized combinations fall back to an Io error carrying the original message, so callers use the same error-handling patterns (e.g. err.is_not_found()) regardless of local vs. remote. A compile-time exhaustive match over crate::Error forces a wire-mapping decision whenever a new variant is added, and a round-trip test asserts every mapped pair survives without hitting the fallback.
Write Coordination
Instance::put_entry() stores an entry through the backend and fires client-side write callbacks. On a local backend it also captures pre-write tips so callbacks see what changed; on a connected instance (RemoteBackend) the daemon owns the canonical DAG, so the pre-write snapshot call is skipped, and client-side callbacks get an empty previous_tips. The actual write travels as DatabaseOp::SubmitSignedEntry (verification-gated, see above).
Server-side write notifications drive sync triggers in the daemon’s own callback path; the legacy NotifyEntryWritten RPC is gone — the submit handler does the notification itself in-process. The client-side dispatch_write_callbacks retains its previous_tips approximation for the local-instance case and a documented empty-set for remote callbacks.
CRDT Cache
CRDT-state caching is unified on the local backend engine (BackendImpl) under a single LRU keyed by CacheScope:
CacheScope::Shared— daemon-computed state for unencrypted stores (visible to every connected user; the bytes were produced by the trusted server-side merge).CacheScope::User(uuid)— client-uploaded state for encrypted stores or any other client-merged result (scoped to the submitting user’s uuid; only that user can poison their own future reads on this slot).
When the daemon serves DatabaseOp::GetCachedCrdtState / CacheCrdtState, it routes through the same BackendImpl LRU as a local instance, supplying CacheScope::User(session_user) so user-supplied blobs cannot leak across connections. Local (non-service) flows still use CacheScope::Shared for daemon-computed merges. The single cache is the source of truth on either path; the two scopes give cross-user isolation without a separate ServiceCache data structure.
Clients additionally keep a per-connection in-memory LRU on RemoteBackend so repeated subtree-state materialization within one transaction does not round-trip to the daemon.
Database-Level Wire API
The server runs its own Database on a local Instance, so verification-on-read, the Verified frontier, and CRDT-state materialization happen server-side by construction. Every op is intrinsically tree-scoped through the containing AuthenticatedDbRequest.
Unified Backend seam
Transaction, Store, Database, and Instance all drive I/O through a single Backend trait (crate::instance::backend::Backend) with no local-vs-remote branching at the call sites:
| Method | Purpose |
|---|---|
get(id) | Fetch a single entry |
snapshot(tree) / store_snapshot(tree, store) | Raw DAG tips (Verified-frontier filtering stays in Database) |
store_snapshot_at(tree, store, up_to) | Store tips reachable from given main-tree entries |
store_at(tree, store, snapshot) | Every store entry reachable from snapshot |
find_merge_base(tree, store, ids) / get_path_from_to(tree, store, ..) | LCA + path within a store |
get_cached_crdt_state / cache_crdt_state | CRDT merge cache (see CRDT Cache) |
put(entry) | Persist an entry |
write_entry(verification, entry, source) | Persist a signed entry, applying verification and dispatching local callbacks |
get_instance_metadata / set_instance_metadata | Daemon-global system-DB pointers |
The trait is the intersection of what both implementations can honor with the same meaning. Off-seam local-only operations (instance secrets, verification-status mutation, raw all_roots/get_tree listings, scope-keyed cache access) are deliberately not on the trait and live on the concrete in-process engine. They are reached only where one exists, via Backend::local_engine() -> Option<Arc<dyn BackendImpl>> (returns None on a remote backend).
Two implementations exist:
LocalBackend— wraps anArc<dyn BackendImpl>(the concrete in-process storage engine).local_engine()returns it.RemoteBackend— wraps aRemoteConnectionand translates each method to a wire RPC. Carries only an optional acting identity (None= the connection’s session identity); tree-scoped methods take the tree from the caller, andgetderives its gating tree server-side from the fetched entry, so no per-handle root is bound and one connection multiplexes every per-database identity via the session keyset.local_engine()returnsNone.
Database selects the implementation at construction time:
// Local: share the Instance's backend.
ops: instance.backend().clone(),
// Remote (service): a per-handle RemoteBackend bound to the calling identity.
ops: Arc::new(RemoteBackend::new(conn, Some(identity))),
Encrypted-store split
Encrypted stores (wrapped in PasswordStore) are opaque to the daemon — the daemon stores and syncs EncryptedBlob entries without ever holding a content encryption key. The DatabaseOp surface handles the encryption boundary with two complementary primitives:
-
GetStoreState { store }— the unencrypted fast path (Doc/Table stores). The server loads the store’s entries, CRDT-merges them locally, materializes the merged state, and returns aWireCrdtValue(serde_json::Value). No decryption; the server works with plaintext because the underlying data is unencrypted. -
GetStoreEntries { store, tips, scope }— the universal primitive for encrypted stores. Returns opaqueEntryobjects reachable fromtips, ordered by subtree height, already verified against the server’s Verified frontier. The client receives encrypted entries it can decrypt and CRDT-merge locally. This works identically for encrypted and unencrypted stores — the server never touches content. -
GetCachedCrdtState/CacheCrdtStateprovide a CRDT-merge fast-path that avoids re-fetching entries whose merged state is already cached. The daemon serves these through itsBackendImpl’sCacheScope::User(uuid)slot (cross-session, per-user);RemoteBackendalso keeps its own per-connection in-memory LRU for intra-transaction repeats without a round-trip. See CRDT Cache.
The encrypted-store-over-service test (test_database_encrypted_store_roundtrip) exercises the full path: writes encrypted data server-side via a local Database, then reads entries via RemoteConnection::get_store_entries and confirms the opaque entries carry the correct subtree markers. Decryption and local merge are client-only.
Read scope
ReadScope controls the DAG projection exposed over the wire:
Verified(default) — only the maximal all-Verifiedancestor-closed prefix of the DAG (the “Verified frontier”). Tips that are stillUnverifiedare replaced by their nearestVerifiedancestors;Failedentries are always dropped.AllowUnverified— open against the raw DAG (onlyFaileddropped). The caller must explicitly opt in viaDatabase::allow_unverified().
BeginTransaction carries the caller’s ReadScope: write parents are drawn from the same projection the caller reads, so a write built under AllowUnverified uses unverified tips as parents and a write built under the Verified default uses only verified ancestors. This ensures the write’s parent projection is self-consistent with the caller’s read posture.
Gate scope for AuthenticatedDb
Every DatabaseOp variant is intrinsically tree-scoped via root_id — there are no tree-less operations to gate. Permission follows the required_permission() pattern: Read for queries (GetVerifiedTips, GetStoreState, GetStoreEntries, GetEntry, BeginTransaction, GetStoreTipsUpToEntries, ComputeMergeState), Write for mutation. The per-tree permission gate (Gate 2) always fires for non-submit AuthenticatedDb requests because the tree identity is known before dispatch.
SubmitSignedEntry is the exception — verification-gated, not session-gated, as covered in Access Control above.
One envelope, one op enum
The current wire is the result of a series of consolidations:
- An earlier draft mirrored every storage-trait method onto its own
BackendOpvariant, carried in a separateServiceRequest::Authenticated(AuthenticatedRequest)envelope. That surface placed auth/verification/merge above the wire boundary, treating the daemon as a passive key-value store. - The Database-level
DatabaseOp/AuthenticatedDbpath was added alongside it so the daemon could own verification-on-read, the Verified frontier, and CRDT-state materialization. - The collapse landed
SetInstanceMetadataintoDatabaseOp(dispatcher-special-cased to gate against_databases), foldedGetintoDatabaseOp::GetEntry, and removed the parallelBackendOpenum,ServiceRequest::Authenticated, andAuthenticatedRequestoutright. - The earlier
NotifyEntryWrittenRPC was removed: the server’sSubmitSignedEntryhandler fires its own write callbacks in-process.
The shape today is a single envelope (AuthenticatedDb(Box<AuthenticatedDbRequest>)) carrying a single op enum (DatabaseOp) over every storage operation.
Feature Gate
#[cfg(all(unix, feature = "service"))]
pub mod service;
The service feature is in the default full set; the unix gate restricts it to platforms with Unix domain sockets.
Testing
Two complementary layers:
tests/it/service.rs— dedicated integration tests for the service layer: handshake, the TrustedLogin challenge-response, the three gates (unauthenticated rejection, per-tree allow/deny, cross-tree denial), per-user cache isolation, concurrent clients, and the user lifecycle.TEST_BACKEND=service— runs the full integration suite through the socket, exercising the RPC layer, serialization, and error reconstruction:
TEST_BACKEND=service cargo nextest run --workspace --all-features
just test service
nix build .#test.service
The full suite passes 1:1 against the service backend. Tests that exercise process-local subsystems — Sync::new (needs the local device key), backend.all_roots / backend.get_verification_status (raw-backend listings that have no wire equivalent), client-side delegation validation that reads delegated trees via backend.get_tree, the CRDT merge cache, and multi-User-on-one-Instance patterns that would all wedge against a single wire-mode session pubkey — go through always-local helpers regardless of TEST_BACKEND:
test_local_instance()— liketest_instance()but always builds an in-memory localInstance.test_local_instance_with_user(username)andtest_local_instance_with_user_and_key(username, key_name)— counterparts to the wire-aware versions.setup_tree_with_user_key_local()andTestContext::with_local_database()— for tests that poke at local-only backend state.
The architectural seam — wire-path tests vs. subsystem tests — is explicit at the helper level. The test harness bootstraps the server-side Instance with a passwordless admin user via Instance::create_backend(..., NewUser::passwordless("admin")), then logs in over the wire as that user. (The old admin/admin auto-bootstrap is gone; production deployments now run eidetica daemon init --username <NAME> with explicit credentials, and the test harness mirrors that shape.)
V1 Limitations
This is a single trusted local client v1. The following are deferred with tracked follow-ups:
- Lock-poisoning posture: the per-connection session
RwLockand theRemoteBackendper-connection cache mutex are poison-tolerant (handlers recover the guard from a poisoned lock rather than panicking), but otherstd::sync::Mutexsites in the service path still uselock().unwrap(). Documented at the lock sites; the remaining ones must be audited before serving multiple or untrusted clients. - Verification status is never asserted over the wire: the service protocol carries no way for a client to assert a verification status; entries arriving over the wire are stored
Unverifiedand earnVerifiedonly via the daemon’s own validation pass. The status model, the pinned-settings validation that makes it staleness-free, the disclosure posture, and the unbuilt authority-reduction (revocation) gap are documented in the Verification Model design doc. snapshot()on a connected Database delegates toself.ops(): handles built viaDatabase::createorDatabase::open_remotecarry aRemoteBackendbound to a per-DB identity; reads use that identity (not the connection’s login pubkey). Handles built via plainDatabase::openon a connected instance share theInstance’sRemoteBackend(which uses the connection’s default session identity) — appropriate for code that wants the connection’s login pubkey to drive reads. The previous heuristic remote-detection inDatabase::snapshotwas replaced with an explicit ops delegation.- No server-push notifications: clients see the latest state on each request but are not notified of entries arriving from sync peers. A future bidirectional protocol would add a
Notificationframe type and a client-side background reader. - No sync delegation:
enable_sync()on a remote Instance is a silent no-op (sync runs daemon-side; the client can’t enable it from over the wire). A future admin-gatedEnableSyncRPC would let a client ask the daemon to enable its sync subsystem, parallel to howInstanceAdmin::create_userreaches_userstoday.
Future Work
- PAKE for network transport:
TrustedLoginis safe only because the socket is filesystem-gated. A network transport must replace it with a password-authenticated key exchange. - Server-push + sync delegation: as above.
- Derived-key caching: cache the Argon2id-derived encryption key in an OS secret store with a TTL, evolving the daemon into an ssh-agent-like key agent to eliminate repeated password prompts for CLI tools.