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.
The one-line target
Section titled “The one-line target”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 Traitarguments. 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.
Why the relabel matters
Section titled “Why the relabel matters”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-coreis 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, futuredecide). 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.”
The four layers
Section titled “The four layers”┌─────────────────────────────────────────────────────────────┐│ 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. │└─────────────────────────────────────────────────────────────┘What lives where (the contract)
Section titled “What lives where (the contract)”| Layer | Owns | Does NOT own |
|---|---|---|
Elm (src/) | All UI, page state, ephemeral session state, Cmd orchestration for UI-only side effects | Anything durable, anything that survives WebView death |
Transport (crates/api) | Command parsing, Response envelope construction, DispatchSideEffect declarations, routing one variant to one service | Business 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 calls | Tauri-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 functions | Anything 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 |
Narrow ports, not the Device god-trait
Section titled “Narrow ports, not the Device god-trait”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:
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.
The one hard rule
Section titled “The one hard rule”No new function in
crates/coremay take&dyn Device. Core code takes pure data and named ports only.
Three layers of enforcement, in order of strength:
Cargo.tomldiscipline.crates/corekeeps no I/O deps it doesn’t need (tokio,keyring,tauri-*,ureqmove 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.- Clippy
disallowed_types/disallowed_methodsincrates/core/clippy.toml:Clock comes through thedisallowed-methods = ["std::time::SystemTime::now","chrono::Local::now","chrono::Utc::now",]Clockport. No exceptions. - Code review heuristic. Any PR that adds
&dyn Deviceto acrates/corefunction is rejected on this rule alone. New ports are fine; the umbrella isn’t.
Why not strict FC/IS
Section titled “Why not strict FC/IS”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 acrossdecide_*functions, aPlan/Continuationenum, and an interpreter loop in the shell. - No Rust syntactic support for continuations. Haskell/F# have
do-notation; Rust hasasync/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/awaitalready does for free — but uglier and without the type system’s help. - The
TauriDevice::appendsession-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_supportcrate soTestDeviceboilerplate (~60 lines per file today) is written once.
How this maps onto the planned refactors
Section titled “How this maps onto the planned refactors”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_capturedis a service composing pure apply with anEventStore::appendport call.
What the projection plan doesn’t do on its own — and what we should layer in as it lands:
- Replace
&dyn AppendLogin the service signatures with&dyn EventStore. Same data, narrower contract. - Decompose
Deviceinto the narrow ports above.Deviceretreats 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.
File-tree mapping (target)
Section titled “File-tree mapping (target)”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 wiringMigration policy
Section titled “Migration policy”This is the target. We don’t refactor everything to match it tomorrow. Order of operations:
- Land the projection + unified event log (per
in-memory-projection.md). Build it on a newEventStoreport (not&dyn AppendLog). Pure sublayer gets carved out as a side-effect. - Replace
&dyn Devicein core function signatures with narrow ports. Mechanical refactor; one PR per service. - Fix the stale-classification race as part of step 1’s classify_entry rewrite.
- Extract the shell crate (optional, do when
src-tauri/src/gets uncomfortable). Until then, concrete adapters live insrc-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.
Reviewer checklist
Section titled “Reviewer checklist”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, orsrstake a trait at all? Reject. Pure sublayer. - Does the PR add
chrono::*::now()anywhere in core? Reject. Use theClockport. - Does the PR add
tokio,ureq,keyring, ortauri-*tocrates/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.
Prior reviews informing this doc
Section titled “Prior reviews informing this doc”- 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.