Skip to content

Target architecture

Status: Adopted 2026-04-26 after a three-way review (FC/IS purist agent, pragmatic-Rust agent, and codex). Supersedes the “pure-logic crate” framing in crates/core/src/lib.rs.

This is the architecture this codebase is trying to land at. Treat it as the target every refactor should bring us closer to. Code that violates the rules here is a known debt, not a precedent.

Core is an application core with a pure event/reducer sublayer. Shell owns transport, runtime, and concrete adapters.

Two halves:

  • The pure sublayer is what we’d call “functional core” in Bernhardt’s sense — events, reducers, projection queries, SRS calc, Shape semantics. No traits with side effects. No clock. No I/O. Unit-testable with values only.
  • The application core wraps that sublayer with use-case services that do take I/O ports as &dyn Trait arguments. This is hexagonal architecture — pure decisions where it counts, trait-injected I/O where the cost of going strict-pure outweighs the benefit.

We deliberately did not adopt strict FC/IS. Three independent reviewers (including the agent arguing for it) concluded the cost in Rust — continuation trampolines, intent enums, ~3x orchestration lines — outweighs the testability gain. See “Why not strict FC/IS” below.

crates/core/src/lib.rs opens with “the pure-logic crate.” That’s been false since Device and LlmClient traits landed in core, and the mismatch keeps causing exactly this confusion. Going forward:

  • template-core is the application core. It contains the pure sublayer + the use-case services that compose pure decisions with I/O ports.
  • The actual pure layer lives inside core in specific modules (events, projection, domain, srs, future decide). Those are the ones a strict FC/IS reader would recognize as “functional core.”

Rename happens during the projection refactor — until then, just stop saying “pure” out loud when you mean “core.”

┌─────────────────────────────────────────────────────────────┐
│ src/ (Elm) │
│ All UI. No durable state. Talks to Tauri host via one port. │
└────────────────────────┬────────────────────────────────────┘
│ JSON envelope
┌─────────────────────────────────────────────────────────────┐
│ crates/api (transport adapter) │
│ Parse Command JSON → call core service → build Response. │
│ Returns DispatchSideEffect for host-spawned background work.│
│ No business logic; no I/O of its own. │
└────────────────────────┬────────────────────────────────────┘
│ services::*(ports, command_data)
┌─────────────────────────────────────────────────────────────┐
│ crates/core (application core) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Use-case services (hexagonal) │ │
│ │ capture_entry, classify_entry, reschedule_notifs. │ │
│ │ Take narrow ports (Classifier, EventStore, Clock, ...). │ │
│ │ Compose pure decisions with port calls. │ │
│ └────────────────┬────────────────────────────────────────┘ │
│ │ pure fn calls │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Pure sublayer (functional core) │ │
│ │ events.rs, projection.rs, domain.rs, srs.rs, decide/. │ │
│ │ No traits. No I/O. No clock. Pure (data) → (data). │ │
│ └─────────────────────────────────────────────────────────┘ │
└────────────────────────┬────────────────────────────────────┘
│ Port trait calls
┌─────────────────────────────────────────────────────────────┐
│ src-tauri/src/* + crates/shell/* (imperative shell) │
│ Concrete adapters: FsEventStore, CloudEventStore, │
│ WorkerClassifier, KeychainSessionStore, TauriClock, │
│ TauriNotifications. tauri::State, spawn_blocking, plugins. │
└─────────────────────────────────────────────────────────────┘
LayerOwnsDoes NOT own
Elm (src/)All UI, page state, ephemeral session state, Cmd orchestration for UI-only side effectsAnything durable, anything that survives WebView death
Transport (crates/api)Command parsing, Response envelope construction, DispatchSideEffect declarations, routing one variant to one serviceBusiness logic, I/O, time, the LLM, the log
Use-case services (crates/core/src/services/*.rs)Multi-step orchestrations (classify_entry, capture_entry), composing pure decisions with port callsTauri-isms, tauri::State, spawn_blocking, plugin specifics
Pure sublayer (crates/core/src/{events,projection,domain,srs,decide}/*.rs)Event types, reducers (apply(state, event) -> state), projection queries, SRS math, Shape semantics, pure decision functionsAnything with side effects. No traits. No clock. No I/O. No I/O dependencies in Cargo.toml (no tokio, no ureq, no keyring, no tauri-*).
Imperative shell (src-tauri/src/lib.rs, future crates/shell/)Concrete port impls (Fs/Cloud event store, Worker classifier, keychain session, OS notifications, chrono::Utc::now, tauri::State, tauri::async_runtime::spawn, spawn_blocking, deep-link/plugin wiring)Business logic — every decision delegates to a service or a pure fn

Today’s Device: AppendLog + SessionStore + NotificationScheduler + LlmClient is convenient but architecturally dishonest — every core function takes “the device” and could quietly grow new dependencies under that umbrella. Replace with use-case-shaped ports:

// crates/core/src/ports.rs (new module)
pub trait EventStore {
fn append(&self, event: &Event) -> Result<(), EventStoreError>;
fn read_all(&self) -> Result<Vec<Event>, EventStoreError>;
}
pub trait Classifier {
fn classify(
&self, bearer: Option<&str>, judgment: &str,
) -> Result<ClassifierReply, ClassifierError>;
}
pub trait SessionReader {
fn get(&self) -> Result<Option<Session>, SessionError>;
}
pub trait SessionWriter: SessionReader {
fn set(&self, session: &Session) -> Result<(), SessionError>;
fn clear(&self) -> Result<(), SessionError>;
}
pub trait Clock {
fn now_millis(&self) -> i64;
fn utc_offset_seconds(&self) -> i32;
}
pub trait Notifications {
fn reschedule(&self, specs: &[NotificationSpec]) -> Result<u32, String>;
fn request_permission(&self) -> Result<PermissionStatus, String>;
fn pending(&self) -> Result<Vec<PendingNotificationSummary>, String>;
}

A use-case service depends on exactly the ports it needs:

crates/core/src/services/classify.rs
pub fn classify_entry(
classifier: &dyn Classifier,
store: &dyn EventStore,
session: &dyn SessionReader,
clock: &dyn Clock,
projection: &Projection,
entry_id: &str,
judgment: &str,
) -> Result<(), ClassifyError> { ... }

Reading the signature tells you what this function can possibly do. &dyn Device told you nothing.

Device survives only as a shell-side aggregate that bundles concrete impls into one struct (TauriDevice, ServerDevice) for the host’s convenience. It’s not exported from crates/core.

No new function in crates/core may take &dyn Device. Core code takes pure data and named ports only.

Three layers of enforcement, in order of strength:

  1. Cargo.toml discipline. crates/core keeps no I/O deps it doesn’t need (tokio, keyring, tauri-*, ureq move to the shell crate when we extract one). A reviewer notices new I/O deps in a diff. CI step (future): cargo tree -p template-core | grep -E '(tokio|ureq|keyring|tauri)' must be empty.
  2. Clippy disallowed_types / disallowed_methods in crates/core/clippy.toml:
    disallowed-methods = [
    "std::time::SystemTime::now",
    "chrono::Local::now",
    "chrono::Utc::now",
    ]
    Clock comes through the Clock port. No exceptions.
  3. Code review heuristic. Any PR that adds &dyn Device to a crates/core function is rejected on this rule alone. New ports are fine; the umbrella isn’t.

For the record, here’s what we considered and rejected.

Strict FC/IS would mean core contains no traits whose methods do I/O. Functions return WriteIntent { log, line } records; the shell executes every I/O hop. Multi-step flows (classify_and_supersede) become continuation trampolines: decide_step1 -> shell calls LLM -> decide_step2 -> shell appends events -> done.

The cost in this codebase, as reviewed:

  • 2-3x lines of orchestration code. classify_and_supersede’s 80-line linear flow becomes ~250 lines split across decide_* functions, a Plan/Continuation enum, and an interpreter loop in the shell.
  • No Rust syntactic support for continuations. Haskell/F# have do-notation; Rust has async/await, which is the wrong abstraction for “core decides, shell does, core decides again.”
  • Two-phase write atomicity gets harder, not easier. Today Projection::record_* owns “append, then apply” in one place. Strict FC/IS splits that across the boundary; the shell can forget to apply.
  • Continuation passing replicates what async/await already does for free — but uglier and without the type system’s help.
  • The TauriDevice::append session-aware backend selection (fs vs. cloud, decided per call from live session state) doesn’t fit intent records cleanly. It’s a runtime decision a port makes cheaply.

The win — pure unit tests that don’t need a tempdir — we capture 85% of by:

  • Extracting obviously-pure helpers (dedup_entries(lines) -> Vec<Entry>, join_views(entries, interactions, offset) -> Vec<EntryView>).
  • Making the projection’s reducer + queries actually pure (no &dyn AppendLog, just &ProjectionData + values).
  • Building a shared test_support crate so TestDevice boilerplate (~60 lines per file today) is written once.

The projection refactor (docs/architecture/in-memory-projection.md) is the single biggest motion toward this target. It naturally:

  • Carves out the pure sublayer: Projection, apply, list_entry_views(state, offset). These have no &dyn AppendLog.
  • Forces use-case services to be named and explicit: Projection::record_entry_captured is a service composing pure apply with an EventStore::append port call.

What the projection plan doesn’t do on its own — and what we should layer in as it lands:

  • Replace &dyn AppendLog in the service signatures with &dyn EventStore. Same data, narrower contract.
  • Decompose Device into the narrow ports above. Device retreats to the shell.
  • Tie classification to a captured-event version (see “Known correctness debt” below).

Stale-classification protection (resolved)

Section titled “Stale-classification protection (resolved)”

services::classify::classify_entry (Stage 3c) protects against the “stale AI overwrites user’s manual pick” race by checking the projection’s current shape before the supersede write: if anything other than Shape::Unsure, the supersede is skipped. The user’s intent always wins. Provenance (EntryClassified) is still logged, so the audit trail records what the classifier picked even when it wasn’t applied.

Earlier we considered a versioned-event approach (pin EntryClassified to a specific captured-event id, refuse if a later write landed). The shape check is a simpler invariant that gives the same protection at this scale: classify only ever picks Train or Recognize, and only acts on Unsure entries. If we later add an LLM-driven write that can change a non-Unsure shape (e.g. an “AI suggested anchor” feature), we’d revisit and likely need the versioning. For today’s surface the shape check is sufficient.

crates/core/
src/
lib.rs Re-exports; no Device, no AppendLog
events.rs Event enum (the unified log facts)
domain.rs Entry, Shape, Interaction, Firing, Classification
projection/
mod.rs Projection wrapper (RwLock holder)
data.rs ProjectionData (pure)
apply.rs apply(&mut ProjectionData, Event) (pure)
query.rs list_entry_views, etc. (pure)
ports.rs EventStore, Classifier, SessionReader/Writer, Clock, Notifications
services/
capture.rs capture_entry use-case
classify.rs classify_entry use-case
notifications.rs reschedule_notifications use-case
auth.rs sign_in, sign_out use-cases
srs.rs Pure SRS calc (already)
decide/ (optional) Pure decision fns shared across services
crates/shell/ (extract from src-tauri/ when convenient)
src/
fs_event_store.rs FsEventStore (port impl)
cloud_event_store.rs CloudEventStore (port impl)
worker_classifier.rs WorkerClassifier (port impl)
keychain_session.rs KeychainSessionStore (port impl)
tauri_clock.rs TauriClock (chrono::Utc + chrono::Local)
tauri_notifications.rs TauriNotifications (port impl)
test_support.rs Shared TestStore, TestClock, TestClassifier
crates/api/ (unchanged shape; smaller surface)
src/
lib.rs dispatch_with_device replaced by dispatch(&Ports, req)
DispatchSideEffect stays as the host-spawn intent
src-tauri/
src/
lib.rs Wires concrete adapters into a Ports struct
tauri::Builder, plugins, deep-link, spawn wiring

This is the target. We don’t refactor everything to match it tomorrow. Order of operations:

  1. Land the projection + unified event log (per in-memory-projection.md). Build it on a new EventStore port (not &dyn AppendLog). Pure sublayer gets carved out as a side-effect.
  2. Replace &dyn Device in core function signatures with narrow ports. Mechanical refactor; one PR per service.
  3. Fix the stale-classification race as part of step 1’s classify_entry rewrite.
  4. Extract the shell crate (optional, do when src-tauri/src/ gets uncomfortable). Until then, concrete adapters live in src-tauri/src/ and that’s fine — they’re already in the right architectural slot.

Existing code that takes &dyn Device is grandfathered. The rule applies to new code and to refactors-in-progress.

When reading a PR that touches crates/core:

  • Does any new function take &dyn Device? Reject. Decompose to narrow ports.
  • Does any new function in events, projection, domain, or srs take a trait at all? Reject. Pure sublayer.
  • Does the PR add chrono::*::now() anywhere in core? Reject. Use the Clock port.
  • Does the PR add tokio, ureq, keyring, or tauri-* to crates/core/Cargo.toml? Reject. Move to the shell crate.
  • If the PR adds a multi-step flow that does I/O between steps, is the orchestration in a use-case service (not in dispatch_with_device)? Yes / refactor before merge.
  • 2026-04-26 three-way agent + codex review on FC/IS vs. hexagonal. All three (including the strict-FC/IS-purist agent) recommended hexagonal-with-discipline. Memory: feedback_application_core_target.md.
  • 2026-04-25 adoption of event-sourced state and the side-effect orchestration rule. See event-sourced-state.md, side-effect-orchestration.md.