Rust hexagonal core + Tauri 2 / React desktop. Mobile (Expo + UniFFI) and
Obsidian (axum HTTP API) are downstream shells. Full rationale in
2026-04-09-multiplatform-rust-perima.md and
2026-04-09-documentation-research.md — read those before decisions.
Developed without human confirmation. Never stop to ask; human speech is steering, not interruption. Chip one task at a time.
superpowers:brainstorming— intent, requirements, design.- Spec: write in working tree → reviewer subagent → revise → repeat
until clean → one
docs(specs):commit. superpowers:writing-plans— same working-tree loop → onedocs(plans):commit.superpowers:executing-plans+superpowers:subagent-driven-development.- Per task: reviewer subagent (
superpowers:requesting-code-review) checks code vs spec + plan. superpowers:verification-before-completionbefore claiming done.superpowers:test-driven-developmentthroughout.
If you have just been compacted, load the skills appropriate for the current task. The most common and important ones: mid-execution - subagent-dr-dev. Pre: brainstorm.
Correctness rides on tests (no human e2e feedback): unit + integration always, e2e where feasible. Spec/plan drafts stay uncommitted during review — collapses N revision commits into one clean commit and keeps main from snapshotting half-reviewed intent as "current state".
Flat crates/ layout, virtual workspace manifest at root. crates/core
= domain + trait ports, zero framework deps. crates/{db,fs,hash} =
adapters. crates/{desktop,api,cli,ffi} wire adapters to core.
apps/desktop = React frontend (Vite + Tailwind). Build automation in
justfile; reintroduce crates/xtask only when shell scripts stop scaling.
- JS/TS: always
bununless incompatible. - Rust:
cargo,cargo clippy,cargo test,cargo doc.rusqlite(bundled), not sqlx.thiserrorin libs,anyhowin apps. justfileat root (Rustxtaskdeferred).codebase-memory-mcp— perima is indexed (project namemedia-vboxuser-G-samsung1-0utoffiles-code-perima). Tools:mcp__codebase-memory-mcp__{query_graph, trace_call_path, search_code, search_graph, get_architecture, index_status}. Reach for it on "who calls X?", "what breaks if I rename Y?", "crates depending on Z?", "find all Tauri command handlers". Skip for raw text in docs/comments or known file paths. Dispatch subagents with the MCP note — they inherit the tools; include "mcp__codebase-memory-mcp__*available; prefer over grep for structural queries" in their prompt.- LSP rust-analyzer —
ToolSearch select:LSP. Rust-only semantic symbol queries; prefer over grep for Rust names. Grep last resort - In doubt of api and implementations, use context7, deepwiki, and web search, to find latest docs. Things include: crates, ts/react libraries, best practices, test libraries, e2e, DBs, react practices and anti-patterns.
- Rust:
cargo nextest run+insta(snapshots) +proptest(hash determinism, path round-trip). Integration tests hit real SQLite. Doctests still go throughcargo test --doc(nextest doesn't run them). - NEVER use
cargo testfor non-doc tests (banned repo-wide viascripts/no-cargo-test.sh, enforced injust ci+ lefthook pre-push). WHY: SQLite has a lock-order inversion inunixClose(GLOBAL→PER-INODE) vsunixLockfromsqlite3WalClose(PER-INODE→GLOBAL) — concurrent Connection drops on the same DB file deadlock.cargo testreproduces it ~20% of runs (intermittent silent hang);cargo nextest run's process-per-test isolation eliminates it. SeeRESEARCH-sqlite-deadlock.md- GH #121. Reproduced 2026-04-23 with two gdb backtraces.
- TS:
bun testunits;vitest+ jsdom for React; Playwright only when a phase ships UI worth e2e-ing.
Set subagent timeout NNN wrappers at ~2× expected, not blanket 600s.
A too-loose timeout wastes 5-10 min per failed attempt.
cargo nextest run -p perima-db --test-threads 2: ~15s (slowest single testfts_consistent_under_tag_churn~14s — proptest)cargo nextest run -p perima-app --test-threads 2: ~5-10s (23 tests)cargo nextest run -p perima-cli --test-threads 2: <10scargo nextest run --workspace --exclude perima-desktop -j 2: <60s cleancargo nextest run -p perima-desktop: 120-300s (Tauri compile dominates)cargo clippy --workspace --exclude perima-desktop -j 2 -- -D warnings: 30-90s
Nextest 0.9.133 (latest as of 2026-04-22) lacks a --slow-timeout CLI
flag, but honors the equivalent config key. .config/nextest.toml
committed at repo root with [profile.default] slow-timeout = { period = "60s", terminate-after = 2 } — any test past 120s self-aborts so
outer timeout NNN wrappers don't kill the whole suite on one hang.
- Wrap workspace-wide
cargo {nextest,build,run}intimeout 600. Trip = deadlock signal, NOT retry trigger. cargo nextest runis mandatory for non-doc tests (see Test stack above +scripts/no-cargo-test.sh). Hung test self-terminates via.config/nextest.tomlslow-timeout; suite continues. Doctests still needcargo test --doc.- Bash output silent >3min +
psshows 0% CPU with all threads onfutex_do_wait/ep_poll= deadlock. Kill,gdb -p <pid> -batch -ex "thread apply all bt 20"for backtrace, fix root cause. - Include this rule verbatim in every subagent dispatch that runs tests — they read CLAUDE.md but forget rules under load.
- Rust:
#![deny(rustdoc::broken_intra_doc_links)]+#![warn(missing_docs)]workspace-wide. - Clippy:
-D warnings+clippy::cognitive_complexity,clippy::too_many_lines,clippy::excessive_nesting. Cyclomatic <10, cognitive <15. kanion curated#[kani::proof]invariants (hashing, path norm);just verify+ nightly CI only, never per-commit.- TS:
eslint-plugin-tsdoc, TypeDoc--validation.notDocumented. - Every non-obvious decision gets a
// WHY:comment. - Architectural decisions: CLAUDE.md (rules) + phase specs (intent) + commit WHY blocks (ground truth). No separate DECISIONS.md.
- Unified doc site: Astro Starlight (Tauri-aligned, MDX polyglot).
Rust doctests via
mdbook test.cargo doc+ TypeDoc feed Starlight in a later phase.
UUIDv7 PKs, updated_at + device_id on every mutable row, soft
deletes, no UNIQUE on mutable columns, no FK cascades. Every FTS /
search aggregation MUST filter deleted_at IS NULL on every joined
table (link + entity, e.g. both file_tags AND tags); every
trigger touching a soft-deletable table MUST handle both transition
directions (soft-delete AND restore). V007→V008 bug class.
- All work on
main. No branches, no worktrees. - Releases = semver tags (
v0.N.x). No fixed v1.0.0. "Phase" is internal vocabulary only — never in tags, commits, or CHANGELOG. - Conventional Commits with component scopes (
core,db,fs,hash,cli,desktop,ci,deps,docs,release).release-plzhandles bumps + CHANGELOG from v0.4.0 on. - Commit order: execute → tests green → reviewer green → commit.
**/*.mdgitignored exceptCHANGELOG.md+docs/superpowers/**/*.md(tracked so cloud agents can continue across sessions).- New commits only; no amend, no
--no-verify.
Opus 4.6 for planning/review & complex coding. Sonnet for bulk/simple execution; Haiku 4.5 for trivial lookups. Never include Co-Authored-By: in commits.
These sections are incrementally populated as Batches B-J land. Cross-ref: AGENTS.md for library/framework pins (different scope).
crates/core— domain types + trait ports, zero framework deps.crates/app— UseCase structs withArc<dyn Port>fields (Batch B).crates/{db,fs,hash,media}— adapters.crates/{cli,desktop}+ futureapi+ffi— shell binaries, wire-up only.
- Repositories take
&self, not&mut self(A2, landed081c694+333c4b6). - Interior mutability lives in the adapter.
- No generic parameters on service structs; use
Arc<dyn Port>fields.
- Concrete
struct XxUseCasewithArc<dyn Port>fields; zero generics. - Single
pub async fn execute(&self, cmd: XxCommand) -> Result<XxOutput, CoreError>. - One UseCase per user-visible workflow (not per entity). Current set:
Scan,Search,Tag,Volume,Metadata. AppContaineris#[derive(Clone)]; fields areArc<XxUseCase>+Arc<dyn EventBus>(namedevents).AppContainer::new(deps: AppDeps, handlers: Vec<Arc<dyn EventBus>>)is the ONLYCompositeEventBus::newconstruction site in production code. Adding a second is a regression.- Shells build one container per process: desktop via Tauri
.setup(...), CLI viabuild_container(db_path, extra_handlers). Shell-specific event handlers (Tauri emitter, db writeback) enter asextra_handlers. LogEventHandlerlives inperima_app::telemetry.DbEventHandlerstays shell-local in desktop (coupled toSqliteFileRepository).CoreError::Internal(String)wraps orchestration-layer errors until Batch D's typedCoreErrordiscriminated union lands.- Watch (filesystem watcher) is shell-local by design (
crates/cli/src/cmd/watch.rs+crates/desktop/src/commands.rs::{start,stop,is}_watch); both consumestate.container.eventsfor emission. Tracked in #120. - New write paths do NOT populate
hlcin Batch B — deferred to Batch C's writer actor where the single SQL entry point can generate the HLC once per command. - Post-Batch-B narrowing of shell-side residuals (volume filter arg,
TagOutput::Attachedwidening,detach by id,VolumeCommand::FindOrCreate,write_manifesthome) tracked on #119.
- One
SqliteWriteractor per process, on astd::thread::spawnOS thread. Owns the sole writablerusqlite::Connectionfor its lifetime. Writer is an OS thread, NOT a second tokio runtime — one-runtime-per-binary rule preserved. (Shells currently hold auxiliary writer handles for pre-container construction ordering — CLI 3 sites, desktop 2 sites; tracked in #125 for consolidation, not a blocker.) - Writes: adapter methods build
WriteCmd::*variants withflume::bounded(1)reply channels,writer.send(cmd)(sync),reply_rx.recv()(sync). Port traits stay sync; UseCaseexecutemethods are async for forward-compat but hold no.awaiton the repo call. - One HLC value per
WriteCmd(== one user-visible logical event), stamped on every HLC-bearing row the command writes. Two WriteCmds in the same millisecond get distinct HLCs via the process-global counter inHLC_STATE. - Reads:
r2d2::Pool<r2d2_sqlite::SqliteConnectionManager>, size 4,SQLITE_OPEN_READ_ONLY | SQLITE_OPEN_NO_MUTEX,with_initpragmas (busy_timeout=5000,temp_store=MEMORY,mmap_size=268435456,query_only=1). - Migrations: Refinery, run ONCE at startup inside
SqliteWriter::start, synchronously, on the connection that becomes the writer. No per-command migration sniff. - Domain events publish AFTER
COMMITfrom inside the writer. Handlers returnResult<(Out, Vec<FileEvent>), CoreError>; writer fans out viaArc<dyn EventBus>passed fromAppContainer::new. Rollback → zero events. Failedemit()logs viatracing::warn!, does not abort other handlers. Batch C handlers currently return empty event vecs — scaffolding in place for Batch E. - Writer receives
Arc<dyn EventBus>fromAppContainer::new.crates/dbdoes NOT constructCompositeEventBus. Preserves Batch B's single-construction-site invariant. - No
open_and_migratecallsite in production code undercrates/{cli,desktop,app}/src/**(non-#[cfg(test)]) outsideSqliteWriter::start. The function itself stays incrates/db/src/connection.rsas the writer's migration primitive. SqliteFileRepositoryandSqliteSearchRepository(and the other three adapters) are{ writer: Sender<WriteCmd>, reads: ReadPool }only — noInner::Legacy/new_legacy. Re-introducing that enum arm or constructor is a regression.- Port traits in
crates/core/src/ports/*.rsunchanged — sync&selfmethods,Send + Syncadapter bounds. - Why
r2d2_sqliteoverdeadpool-sqlite: port traits are sync;deadpool-sqlite's asyncinteractAPI would force async ports (out-of-batch refactor). Library-audit §Q1 deferred the pool pick; Batch C resolved tor2d2_sqlite 0.32. - Why
flumefor reply channels:tokio::sync::oneshot::Receiver::blocking_recvpanics inside a tokio runtime context;flume::Receiver::recvis runtime-agnostic. Single dep covers command channel + reply channels. - FTS5 proptests (
fts_consistent_under_tag_churn,fts_matches_ground_truth_under_soft_delete_churn) are capped at 64 / 32 cases respectively. Raising the cap without an FTS-oracle rewrite is a regression (#124).
- Tauri commands return
Result<T, CoreError>. NO handler returnsResult<T, String>. NO handler calls.map_err(|e| e.to_string()). CoreErrorlives incrates/core::errors. It derivesthiserror::Error + Serialize + Clonealways, andspecta::Typebehind thespectafeature.#[serde(tag = "kind", content = "data")]produces a TypeScript discriminated union.CoreError::Io { kind, message }lowersstd::io::Error::kind()(e.g."NotFound","PermissionDenied") to a string + the display message. Lossy by design —std::io::Erroris!Serialize. Capture both fields, never just the message. Construct viaCoreError::from(io_err)(singleFrom<io::Error>impl incrates/core/src/errors.rs);?-propagation works through it.crates/desktopenablesperima-core/spectaandperima-app/specta.crates/clidoes NOT —crates/coreandcrates/appstay framework-dep-free in CLI builds (verified:cargo tree -i specta -p perimais empty).bindings.tsis committed to git atapps/desktop/src/bindings.ts. It is generated bytauri-spectawhencrates/desktopis built with--features specta-export. CI runsjust bindings(=cargo build -p perima-desktop --features specta-export && git diff --exit-code apps/desktop/src/bindings.ts) — drift fails the job. Local pre-push verification:just bindings.- Production release builds do NOT enable
specta-export(no surprise fs writes into the frontend source tree). apps/desktop/src/types.tsdoes NOT exist. Re-introducing it is a regression. All TS types come from./bindings.apps/desktop/src/api.ts::fromInvokereturnsResultAsync<T, CoreError>viaResultAsync.fromPromise(invoke<T>(...), parseCoreError).parseCoreErrorfalls back to{ kind: "Internal", data: <stringified> }for non-CoreError rejections.- Re-introducing per-shell wire-type structs that mirror core types 1:1 is a regression — derive
specta::Typeon the core type instead. The exception is shell-private composite payloads that have no clean core analogue:crates/desktop/src/payloads.rsretainsFileWithMetadataPayload(deliberate 17-field flat shape for grid binding) andFileWithTagsPayload(#[serde(flatten)]composition). Adding more shell-side composites is allowed only when the same 1:1-vs-composite decision is documented inline. - The 10 specta-derived core types:
BlakeHash,FileSize,MediaPath,VolumeId,FileLocationRecord,VolumeRecord(incrates/core/src/types.rs);MediaMetadata,Tag,SearchHit,FileEvent(one per file, same crate).ScanReport+ScanReportEntryfromcrates/app/src/scan.rsare also bindings-emitted (with#[serde(skip)]on shell-internal routing fields). Adding a new domain type that crosses the IPC boundary requires#[cfg_attr(feature = "specta", derive(specta::Type))]+Serializeon the type itself, NOT a wire-type mirror. FileEventuses#[serde(tag = "type")]INLINE (no content key) to stay byte-compatible with the pre-Batch-DFileEventPayloadchannel contract; CoreError uses#[serde(tag = "kind", content = "data")]. Different shapes by design — CoreError is a Result error type, FileEvent is a v1-frozen channel payload.- Adding a new
CoreErrorvariant: append to the enum with#[error("…")]+ verifybindings.tsregenerates + extend frontendparseCoreError'sKNOWN_KINDSset + extend theswitch (err.kind)block inapps/desktop/src/components/StatusBar.tsx(TypeScript exhaustivenessneverdefault makes this a compile-error if missed). - At least one frontend component (
StatusBar.tsx) pattern-matches onerr.kindwith a TypeScript-exhaustiveneverdefault, proving the typed error flows end-to-end. Other components stringify viaapps/desktop/src/lib/coreError.ts::coreErrorMessageuntil Batch H rewrites the toast/banner UX.
EventBustrait lives incrates/core::eventsas the publisher port. Single method:fn emit(&self, event: &AppEvent) -> Result<(), CoreError>. Sync — writer thread (Batch C) is not on a tokio runtime.AppEventenum (incrates/core::events) has 3 variants:File(FileEvent)— wraps the existing filesystem-watcher event.ScanCompleted { volume, files_seen, files_new, duration_ms }— published byScanUseCase::executeafter a successful scan.IndexInvalidated { reason: InvalidationReason }— published by the writer actor after everyWriteCmdthat changes a query-relevant index. Reasons:TagsChanged,FilesChanged,MetadataChanged,SearchIndexRebuilt.
AppEventderivesSerialize + Clonealways;specta::Typebehind thespectafeature.#[serde(tag = "kind", content = "data")]produces the TypeScript discriminated union the frontend pattern-matches on.- The concrete
Buslives incrates/app::busand is the SOLE production impl ofEventBus. Built once inAppContainer::new. Usesasync_broadcast::Sender<AppEvent>(capacity 256, backpressure mode = default —set_overflow(false)). Re-introducingCompositeEventBusis a regression. - Handlers implement
EventHandler(incrates/app::events) —async fn handle(&mut self, event: AppEvent). Three production impls:LogEventHandler(crates/app::telemetry),DbEventHandler(shell-local: CLI'scrates/cli/src/cmd/watch.rs, desktop'scrates/desktop/src/commands.rs),TauriEventHandler(crates/desktop/src/events.rs). - Handler tasks are spawned by
AppContainer::newfrom aVec<Box<dyn EventHandler>>parameter. Each task owns its ownReceiverand runs arecv_loopthat handlesOverflowed(lag → log warn + continue) andClosed(shutdown → exit). Panics insidehandleare caught + logged; the loop continues. Bus::emitis synctry_broadcast. OnFull(slow subscriber):tracing::warn!+ return Ok — the writer is best-effort. Capacity 256 + fast-handler invariant means Full should not fire under normal load.tokio::sync::broadcastis BANNED. It silently drops on lag. Useasync_broadcast::broadcast(default = backpressure mode).- Tauri channel: ONE channel
"app-event"carriesAppEvent. The previous"file-event"channel is gone. Frontendapps/desktop/src/api.ts::subscribeToAppEventsis the single subscription point. - The writer's per-
WriteCmdhandler returnsResult<(Out, Vec<AppEvent>), CoreError>. Empty Vec = no event. EachIndexInvalidated::*reason maps to a category of writes per the spec — adding a new write type that affects a query requires populating the Vec. NoopBus(crates/db::test_utils::noop_bus) is the test stub. Used by writer tests that don't care about events.MockEventBus(incrates/fs/src/watcher.rs::tests) collects events for assertion. Both impl theEventBustrait.- Adding a new
AppEventvariant: append to enum + add#[serde(...)]tag if needed + verifybindings.tsregenerates + extend frontendApp.tsxswitch onevent.kind(no exhaustiveness check today — a future Batch H refactor may add one). - Adding a new
EventHandler: impl the trait + add to the shell's handlerVecpassed intoAppContainer::new.
Stub: three-layer split. TanStack Query v5 for server-state via bindings.ts commands. Zustand 5 for global UI state. useState for component-local only. TanStack Router with createHashHistory. Cache invalidation driven by AppEvent listener, never by Rust-side invalidate_query!-style macros. React Compiler 1.0 is on (L2 landed); do not write manual useMemo/useCallback. Details in Batch H spec.
Stub: tracing + tracing-subscriber JSON in release; #[tracing::instrument] on every UseCase::execute; perima --debug-report dumps last N traced events. Details in Batch I spec.
- One tokio runtime per binary. Never spawn a second.
- Desktop: Tauri owns the runtime.
- CLI:
#[tokio::main]. - Tests:
#[tokio::test]or single-threaded runtime per test. - Writer actor (Batch C) is a dedicated OS thread, NOT a second runtime.
thiserrorin lib crates (crates/core,db,fs,hash,media, futureapp).miettewithfancyfeature in binary crates ONLY (crates/cli,crates/desktop) — landed L7 (938eccc).CoreErrorlives incrates/core::errors. It does NOT derivemiette::Diagnostic— binary-UX-only concern that stays at the binary boundary. Batch D will add#[derive(Serialize, specta::Type)]+#[serde(tag = "kind", content = "data")]behind thespectafeature.anyhowis rejected incrates/cli+crates/desktopdeclared deps (L7 removed it); transitive via deps is acceptable.color-eyreis rejected (archived upstream).
- Every mutable user-editable + sync-eligible row carries an
hlc INTEGERcolumn alongsideupdated_at(v1 posture: nullable, populated in Batch B+). - Device-local rows (
volume_mounts,scan_progress,thumbnail_queue,device_config) do NOT carryhlc— their identity is machine-scoped and they never sync. shared_doctable is reserved empty for post-v1 Loro integration. Do NOT add rows in v1. Do NOT addlorocrate.- HLC packing: 48 low-bits ms + 16 high-bits counter → non-negative i64.
crates/core::Hlcprovidesnow()+pack()+unpack(). - Existing rules (unchanged): UUIDv7 PKs,
updated_at+device_idon every mutable row, soft deletes, no UNIQUE on mutable columns, no FK cascades, FTS filters + triggers respect soft-delete + restore (V007→V008 bug class).
Note: for now, dont commit plans, specs, claude.md updates until the human says otherwise.