Three sub-plans operationalising ADR 0002 D1 + D3 on the existing
feat/storage-trait-phase1 branch (790c0c8, not yet pushed upstream):
- 0001a-trait-rewrite.md -- rewrite MemoryStore with
#[trait_variant::make(MemoryStore: Send)] generating a non-Send
LocalMemoryStore companion. Production callers use Arc<Storage> and are
unaffected; only the trait declaration and SQLite impl block change.
- 0001b-sqlite-split.md -- pure code motion. Split sqlite.rs (8713 lines)
into a sqlite/ directory (mod, crud, search, scheduling, graph, domain,
registry, portable_sync, trait_impl). Public re-exports unchanged; tests
green commit-by-commit. Depends on 0001a so trait_impl.rs picks up the
trait_variant attribute once.
- 0001c-async-trait-sunset.md -- rewrite Embedder the same way, then
remove async-trait = "0.1" from crates/vestige-core/Cargo.toml. End
state: zero async_trait references in the workspace.
Together these three lands as PR A.
31 KiB
Phase 1 Amendment Sub-Plan: async_trait -> trait_variant
Status: Draft
Depends on: Phase 1 (already on feat/storage-trait-phase1, tip 790c0c8)
Followed by: docs/plans/0001c-async-trait-sunset.md (Embedder rewrite + async-trait crate removal)
Related: docs/adr/0002-phase-2-execution.md (decision D1), docs/plans/0001-phase-1-storage-trait-extraction.md
Context
Phase 1 froze with the storage trait declared as
#[async_trait::async_trait] pub trait MemoryStore: Send + Sync + 'static
and a pub use MemoryStore as LocalMemoryStore; alias. async_trait boxes
every async fn in the trait into Pin<Box<dyn Future + Send>>. That is one
heap allocation per call inside the hottest code path in Vestige (insert,
search, update_scheduling are all on this surface). It also collapses the
two intended trait shapes -- a Send-bound MemoryStore for tokio/axum and a
non-Send LocalMemoryStore for thread-local backends -- into a single trait
behind an alias, removing the type-system signal we will want for Phase 2's
Postgres backend.
ADR 0002 decision D1 supersedes this. The amendment lands on the existing
feat/storage-trait-phase1 branch before Phase 2 starts, before the PR is
opened upstream. The end state:
LocalMemoryStoreis the source-of-truth trait, declared with native async-fn-in-trait (stable on MSRV 1.91), noSyncbound on the trait itself, andSync + 'staticon the trait object.MemoryStoreis auto-generated by#[trait_variant::make(MemoryStore: Send)]withSendbounds on every returned future, soArc<dyn MemoryStore>is movable acrosstokio::spawn.trait-variant0.1 is already present incrates/vestige-core/Cargo.toml. Theasync-traitcrate dependency stays in place through this sub-plan (it is still in use by the embedder impl); removing it is the job of0001c-async-trait-sunset.md.- No production caller changes. Every production call site holds
Arc<Storage>(the concreteSqliteMemoryStorealias), which the trait rewrite does not touch. Only the trait declaration and impl blocks change.
Scope
In scope
- Rewrite
MemoryStore/LocalMemoryStoredeclaration incrates/vestige-core/src/storage/memory_store.rsto use#[trait_variant::make(MemoryStore: Send)] pub trait LocalMemoryStore. - Remove the
pub use MemoryStore as LocalMemoryStore;alias from the same file.LocalMemoryStorebecomes the real trait;MemoryStoreis the generated Send variant. - Drop the
#[async_trait::async_trait]attribute on the SQLite impl block incrates/vestige-core/src/storage/sqlite.rs(line 6274). The impl block switches fromimpl MemoryStore for SqliteMemoryStore(currently spelled through theLocalMemoryStorealias) toimpl LocalMemoryStore for SqliteMemoryStore.trait_variantprovides a blanketimpl<T: LocalMemoryStore + Send> MemoryStore for Tso&dyn MemoryStoreandArc<dyn MemoryStore>keep working unchanged. - Update doc comments in
memory_store.rsto drop references to#[async_trait::async_trait]and instead describe thetrait_variantmechanism. - Keep the existing Phase 1 test suite (
tests/phase_1/*.rs) green. The tests already useArc<dyn MemoryStore>andBox<dyn Embedder>, which is exactly the surface the rewrite is meant to preserve.
Out of scope -- moved to 0001c
- Rewriting the
Embedder/LocalEmbeddertrait declaration -- handled by0001c-async-trait-sunset.md. - Dropping
#[async_trait::async_trait]fromcrates/vestige-core/src/embedder/fastembed.rs. - Removing
async-trait = "0.1"fromcrates/vestige-core/Cargo.toml. - The hard
grep -rn 'async_trait' crates/zero-match gate (async_trait is still present in the embedder module after 0001a alone).
Out of scope
- The SQLite file split (
sqlite.rs->sqlite/directory). That is the sibling sub-plan0001b-sqlite-split.md. - Any production-side refactor that switches
Arc<Storage>toArc<dyn MemoryStore>. Production code keeps using the concrete alias. - Adding
register_model/from_pool/ Postgres-specific variants ofMemoryStoreError. Those land with the Postgres backend in Phase 2. - Removing the
pub type Storage = SqliteMemoryStore;alias.
Prerequisites
Current state (verified)
crates/vestige-core/Cargo.tomlalready declarestrait-variant = "0.1"(line 117).async-trait = "0.1"(line 119) remains in place for the duration of this sub-plan;0001cremoves it.crates/vestige-core/src/storage/memory_store.rs:194declares the trait with#[async_trait::async_trait] pub trait MemoryStore: Send + Sync + 'static.crates/vestige-core/src/storage/memory_store.rs:262haspub use MemoryStore as LocalMemoryStore;.crates/vestige-core/src/storage/sqlite.rs:6274declares#[async_trait::async_trait] impl crate::storage::memory_store::LocalMemoryStore for SqliteMemoryStore.- Production call sites use
Arc<Storage>(the concreteSqliteMemoryStorealias). Confirmed by grep:grep -rn "dyn MemoryStore\|dyn LocalMemoryStore" --include="*.rs"returns hits only intests/phase_1/*.rs, in two comments insidememory_store.rsandsqlite.rs, and in one test-only&dyn MemoryStorecast insidesqlite.rs::tests(line 8669). - Workspace-wide
async_traitusages: only the trait declarations and impl blocks inmemory_store.rs,sqlite.rs,embedder/mod.rs, andembedder/fastembed.rs(verified bygrep -rn async_trait --include="*.rs"). This sub-plan touches only the first two; the embedder files are addressed by0001c.
Required crates
| Crate | Version | Action |
|---|---|---|
trait-variant |
0.1 |
Already declared. Verify present after edit. |
async-trait |
0.1 |
Unchanged for this sub-plan (still used by embedder impl). Removed by 0001c. |
blake3, thiserror, chrono, uuid, serde, serde_json |
unchanged | unchanged |
Files Touched
Grouped by category. Every change is listed; nothing else gets touched.
Trait declarations (vestige-core)
| File | Lines (approx) | Change |
|---|---|---|
crates/vestige-core/src/storage/memory_store.rs |
183-262 | Replace #[async_trait::async_trait] block with #[trait_variant::make(MemoryStore: Send)] pub trait LocalMemoryStore. Drop the pub use MemoryStore as LocalMemoryStore; alias. Update doc comments. |
Impl blocks (vestige-core)
| File | Lines (approx) | Change |
|---|---|---|
crates/vestige-core/src/storage/sqlite.rs |
6274 (impl block header only) | Delete the #[async_trait::async_trait] attribute. Keep the impl crate::storage::memory_store::LocalMemoryStore for SqliteMemoryStore { ... } body verbatim. |
Cargo dependency cleanup
None in this sub-plan. The async-trait crate stays declared in
crates/vestige-core/Cargo.toml because the embedder impl still uses it.
0001c-async-trait-sunset.md removes the dependency after the embedder
side is rewritten.
Cognitive module call sites
No changes. The 29 cognitive modules under crates/vestige-core/src/neuroscience/
and crates/vestige-core/src/advanced/ already operate on concrete
SqliteMemoryStore (via the Storage alias) or on plain data types
(KnowledgeNode, Vec<f32>, ConnectionRecord). Grep verified zero
production references to dyn MemoryStore or dyn LocalMemoryStore.
MCP call sites
No changes. All ~30 vestige-mcp/src/**.rs files holding Arc<Storage>
keep working because:
Storageis stillpub type Storage = SqliteMemoryStore;(unchanged).- They call inherent methods on the concrete type, never via a trait object.
SqliteMemoryStoreimplementsLocalMemoryStore;trait_variantauto- generates a blanketimpl<T: LocalMemoryStore + Send> MemoryStore for T, so the concrete type also satisfiesMemoryStorefor any future caller that wants the trait-object form.
Test files (vestige-core integration tests)
| File | Lines | Change |
|---|---|---|
tests/phase_1/trait_round_trip.rs |
7-18, 134 | No change. Already uses Arc<dyn MemoryStore> and use vestige_core::storage::MemoryStore. trait_variant emits a MemoryStore trait at the same path, so the imports resolve. |
tests/phase_1/send_bound_variant.rs |
10-12, 36, 57 | No change. Already asserts the trait_variant Send invariant; the rewrite is what makes the doc comment on lines 3-4 actually true. |
tests/phase_1/cognitive_module_isolation.rs |
11, 37, 76, 102, 115 | No change. |
tests/phase_1/embedding_model_registry.rs |
10 | No change. |
tests/phase_1/domain_column_migration.rs |
98 | No change. |
crates/vestige-core/src/storage/sqlite.rs::tests |
8666-8675 | No change. The existing test casts &s to &dyn MemoryStore and calls trait methods through that vtable; trait_variant preserves this exact dyn-compatible surface. |
Documentation
| File | Change |
|---|---|
crates/vestige-core/src/storage/memory_store.rs |
Rewrite the trait-level doc comment (lines 185-193) to describe trait_variant rather than async_trait. |
CLAUDE.md |
No change. The repo-level architecture notes do not name the trait attribute. |
Trait Declaration Rewrite
Before (current state on feat/storage-trait-phase1)
crates/vestige-core/src/storage/memory_store.rs:183-262:
// ----------------------------------------------------------------------------
// TRAIT
// ----------------------------------------------------------------------------
/// The single storage abstraction.
///
/// `#[async_trait::async_trait]` makes every `async fn` return a
/// `Pin<Box<dyn Future + Send>>`, which is required for `Arc<dyn MemoryStore>`
/// to be movable across `tokio::spawn` boundaries.
///
/// `LocalMemoryStore` is a type alias kept for source compatibility with code
/// that refers to the non-send variant. In Phase 1 both names refer to the same
/// (dyn-compatible, Send-safe) trait.
#[async_trait::async_trait]
pub trait MemoryStore: Send + Sync + 'static {
// --- Lifecycle ---
async fn init(&self) -> MemoryStoreResult<()>;
async fn health_check(&self) -> MemoryStoreResult<HealthStatus>;
// ... 25 more async fn ...
async fn vacuum(&self) -> MemoryStoreResult<()>;
}
/// Type alias kept for source compatibility. Both names refer to the same
/// `async_trait`-annotated trait that is dyn-compatible and `Send + Sync`.
pub use MemoryStore as LocalMemoryStore;
After
crates/vestige-core/src/storage/memory_store.rs:183-262:
// ----------------------------------------------------------------------------
// TRAIT
// ----------------------------------------------------------------------------
/// The single storage abstraction.
///
/// `LocalMemoryStore` is the source-of-truth trait. The
/// `#[trait_variant::make(MemoryStore: Send)]` attribute auto-generates a
/// `MemoryStore` trait whose returned futures are `Send`, so
/// `Arc<dyn MemoryStore>` is movable across `tokio::spawn` boundaries while
/// `Arc<dyn LocalMemoryStore>` remains usable on single-threaded executors
/// and thread-local backends.
///
/// Every method is native async-fn-in-trait (stable on MSRV 1.91); no
/// per-call heap allocation, no boxed futures.
#[trait_variant::make(MemoryStore: Send)]
pub trait LocalMemoryStore: Sync + 'static {
// --- Lifecycle ---
async fn init(&self) -> MemoryStoreResult<()>;
async fn health_check(&self) -> MemoryStoreResult<HealthStatus>;
// --- Embedding model registry ---
async fn registered_model(&self) -> MemoryStoreResult<Option<ModelSignature>>;
async fn register_model(&self, sig: &ModelSignature) -> MemoryStoreResult<()>;
// --- CRUD ---
async fn insert(&self, record: &MemoryRecord) -> MemoryStoreResult<Uuid>;
async fn get(&self, id: Uuid) -> MemoryStoreResult<Option<MemoryRecord>>;
async fn update(&self, record: &MemoryRecord) -> MemoryStoreResult<()>;
async fn delete(&self, id: Uuid) -> MemoryStoreResult<()>;
// --- Search ---
async fn search(&self, query: &SearchQuery) -> MemoryStoreResult<Vec<SearchResult>>;
async fn fts_search(&self, text: &str, limit: usize) -> MemoryStoreResult<Vec<SearchResult>>;
async fn vector_search(
&self,
embedding: &[f32],
limit: usize,
) -> MemoryStoreResult<Vec<SearchResult>>;
// --- FSRS Scheduling ---
async fn get_scheduling(
&self,
memory_id: Uuid,
) -> MemoryStoreResult<Option<SchedulingState>>;
async fn update_scheduling(&self, state: &SchedulingState) -> MemoryStoreResult<()>;
async fn get_due_memories(
&self,
before: DateTime<Utc>,
limit: usize,
) -> MemoryStoreResult<Vec<(MemoryRecord, SchedulingState)>>;
// --- Graph (spreading activation) ---
async fn add_edge(&self, edge: &MemoryEdge) -> MemoryStoreResult<()>;
async fn get_edges(
&self,
node_id: Uuid,
edge_type: Option<&str>,
) -> MemoryStoreResult<Vec<MemoryEdge>>;
async fn remove_edge(&self, source: Uuid, target: Uuid) -> MemoryStoreResult<()>;
async fn get_neighbors(
&self,
node_id: Uuid,
depth: usize,
) -> MemoryStoreResult<Vec<(MemoryRecord, f64)>>;
// --- Domains ---
async fn list_domains(&self) -> MemoryStoreResult<Vec<Domain>>;
async fn get_domain(&self, id: &str) -> MemoryStoreResult<Option<Domain>>;
async fn upsert_domain(&self, domain: &Domain) -> MemoryStoreResult<()>;
async fn delete_domain(&self, id: &str) -> MemoryStoreResult<()>;
async fn classify(&self, embedding: &[f32]) -> MemoryStoreResult<Vec<(String, f64)>>;
// --- Bulk / Maintenance ---
async fn count(&self) -> MemoryStoreResult<usize>;
async fn get_stats(&self) -> MemoryStoreResult<StoreStats>;
async fn vacuum(&self) -> MemoryStoreResult<()>;
}
Notes:
- The
pub use MemoryStore as LocalMemoryStore;line on the currentmemory_store.rs:262is deleted entirely.MemoryStoreis now the generated trait thattrait_variant::makeemits at the same module path;LocalMemoryStoreis the source-of-truth declaration. Both names export fromstorage/mod.rsalready (see lines 10-14 of that file). Sync + 'staticonLocalMemoryStore(and noSendbound on the trait itself) is correct:Sendon the trait is whattrait_variant::makeinserts when it emits theMemoryStorevariant. The current trait carriesSend + Sync + 'static; the rewrite drops theSendbound from the local variant.Arc<dyn LocalMemoryStore>isSyncbut notSend;Arc<dyn MemoryStore>(the generated variant) isSend + Sync.trait_variant0.1 does not require any attribute on the impl block. The attribute lives only on the trait declaration. See the next section.
The Embedder trait rewrite uses the identical trait_variant::make pattern
and is fully specified in 0001c-async-trait-sunset.md.
Impl Block Migration
trait_variant 0.1 attaches the attribute only to the trait declaration.
The impl side is plain impl LocalMemoryStore for SqliteMemoryStore; no
attribute on the impl, no #[trait_variant::make(MemoryStore: Send)] on the
impl block. The crate auto-generates the blanket
impl<T: LocalMemoryStore + Send> MemoryStore for T, so any concrete type
that implements LocalMemoryStore automatically also implements
MemoryStore provided it is Send.
SqliteMemoryStore already is Send + Sync (it holds Mutex<Connection>
fields whose Mutex is std::sync::Mutex, which is Send + Sync when its
guarded type is Send; rusqlite::Connection is Send). No new bound is
needed.
Before
crates/vestige-core/src/storage/sqlite.rs:6274:
#[async_trait::async_trait]
impl crate::storage::memory_store::LocalMemoryStore for SqliteMemoryStore {
async fn init(&self) -> crate::storage::memory_store::MemoryStoreResult<()> {
// Migrations run in `new`; this is a no-op for the SQLite backend.
Ok(())
}
// ... ~2400 lines of method bodies, unchanged ...
}
After
crates/vestige-core/src/storage/sqlite.rs:6274:
impl crate::storage::memory_store::LocalMemoryStore for SqliteMemoryStore {
async fn init(&self) -> crate::storage::memory_store::MemoryStoreResult<()> {
// Migrations run in `new`; this is a no-op for the SQLite backend.
Ok(())
}
// ... ~2400 lines of method bodies, unchanged ...
}
Diff is exactly one removed line (the #[async_trait::async_trait] attribute).
Every method body, every async fn signature, every use statement inside
the impl block stays verbatim. No Box::pin(async move { ... }), no manual
Pin<Box<dyn Future>>, no 'async_trait lifetime markers; native
async-fn-in-trait does this directly.
The Embedder impl block rewrite follows the identical "remove one
#[async_trait::async_trait] attribute" pattern and is fully specified in
0001c-async-trait-sunset.md.
Why the impl block does not need an attribute
trait_variant::make generates two things from the source trait:
- The source trait itself (
LocalMemoryStore), with native async fns. - A second trait (
MemoryStore) whose method signatures returnimpl Future<Output = ...> + Sendinstead ofimpl Future<Output = ...>, plus a blanket impl wiring concrete types through.
Both are emitted at the macro-call site. SqliteMemoryStore only writes one
impl block (against LocalMemoryStore); the macro-generated blanket
guarantees SqliteMemoryStore: MemoryStore for free. The current &dyn MemoryStore casts (sqlite.rs:8669; tests under tests/phase_1/) keep
type-checking unchanged.
Call Site Audit
Verified via:
grep -rn "dyn MemoryStore\|dyn LocalMemoryStore" --include="*.rs" \
/home/delandtj/prppl/vestige-phase2/
grep -rn "Arc<Storage>\|Arc<SqliteMemoryStore>" --include="*.rs" \
/home/delandtj/prppl/vestige-phase2/
grep -rn "use.*MemoryStore;\|use.*LocalMemoryStore;" --include="*.rs" \
/home/delandtj/prppl/vestige-phase2/
Files that reference the trait object form (dyn MemoryStore / dyn LocalMemoryStore)
All test-only or doc-comment-only:
| File | Line | Use | Required change |
|---|---|---|---|
tests/phase_1/trait_round_trip.rs |
7-18 | Arc<dyn MemoryStore> in make_store() and test bodies |
None. MemoryStore is the generated Send variant; signature stays. |
tests/phase_1/trait_round_trip.rs |
134 | Arc<dyn MemoryStore> upcast inside a test body |
None. |
tests/phase_1/send_bound_variant.rs |
10-97 | Arc<dyn MemoryStore> moved across tokio::spawn |
None. This test becomes meaningfully correct only after the rewrite (currently it relies on async_trait boxing; after the rewrite it relies on trait_variant's Send variant -- same observable outcome). |
tests/phase_1/cognitive_module_isolation.rs |
11, 33-115 | Arc<dyn MemoryStore> passed into cognitive module-style closures |
None. |
tests/phase_1/embedding_model_registry.rs |
10 | Arc<dyn MemoryStore> in make_store() |
None. |
tests/phase_1/domain_column_migration.rs |
98 | Arc<dyn MemoryStore> cast inside a migration assertion |
None. |
crates/vestige-core/src/storage/sqlite.rs |
8666-8675 | let dyn_s: &dyn MemoryStore = &s; inside mod tests |
None. The cast is testing that the dyn-vtable still resolves the trait methods correctly; after the rewrite it resolves through the MemoryStore trait that trait_variant::make emits at the same path. |
crates/vestige-core/src/storage/memory_store.rs |
188 (doc) | Doc comment mentioning Arc<dyn MemoryStore> |
Replaced as part of the doc rewrite (see Trait Declaration section). |
Files that hold the concrete type (Arc<Storage> / Arc<SqliteMemoryStore>)
35 files, 116 hits. Every one of them keeps working unchanged because the
concrete SqliteMemoryStore type stays exactly as it is. Listed here for
completeness so a reviewer can confirm none of them needs an edit:
crates/vestige-core/src/storage/mod.rs (alias declaration)
crates/vestige-core/src/storage/sqlite.rs (impl block)
crates/vestige-mcp/src/server.rs (Arc<Storage> in McpServer)
crates/vestige-mcp/src/cognitive.rs (hydrate(&Storage))
crates/vestige-mcp/src/autopilot.rs
crates/vestige-mcp/src/protocol/http.rs
crates/vestige-mcp/src/dashboard/mod.rs
crates/vestige-mcp/src/dashboard/state.rs
crates/vestige-mcp/src/dashboard/handlers.rs
crates/vestige-mcp/src/resources/codebase.rs
crates/vestige-mcp/src/resources/memory.rs
crates/vestige-mcp/src/tools/changelog.rs
crates/vestige-mcp/src/tools/codebase_unified.rs
crates/vestige-mcp/src/tools/context.rs
crates/vestige-mcp/src/tools/contradictions.rs
crates/vestige-mcp/src/tools/cross_reference.rs
crates/vestige-mcp/src/tools/dedup.rs
crates/vestige-mcp/src/tools/dream.rs
crates/vestige-mcp/src/tools/explore.rs
crates/vestige-mcp/src/tools/feedback.rs
crates/vestige-mcp/src/tools/graph.rs
crates/vestige-mcp/src/tools/health.rs
crates/vestige-mcp/src/tools/importance.rs
crates/vestige-mcp/src/tools/intention_unified.rs
crates/vestige-mcp/src/tools/maintenance.rs
crates/vestige-mcp/src/tools/memory_states.rs
crates/vestige-mcp/src/tools/memory_unified.rs
crates/vestige-mcp/src/tools/predict.rs
crates/vestige-mcp/src/tools/restore.rs
crates/vestige-mcp/src/tools/review.rs
crates/vestige-mcp/src/tools/search_unified.rs
crates/vestige-mcp/src/tools/session_context.rs
crates/vestige-mcp/src/tools/smart_ingest.rs
crates/vestige-mcp/src/tools/suppress.rs
crates/vestige-mcp/src/tools/tagging.rs
crates/vestige-mcp/src/tools/timeline.rs
Each holds Arc<Storage> and dispatches to inherent methods on
SqliteMemoryStore. None of them goes through a trait vtable. Required
change for every one of them: none.
Files that use ...MemoryStore from production code
grep -rn "use.*MemoryStore;\|use.*LocalMemoryStore;" --include="*.rs" \
| grep -v "memory_store.rs\|sqlite.rs\|tests/phase_1"
returns nothing. Production code does not import the trait by name.
Conclusion
The rewrite is a strictly local change to two source files
(storage/memory_store.rs and storage/sqlite.rs). Zero production call
sites need edits. The integration tests that consume Arc<dyn MemoryStore>
keep their current form; the rewrite is what gives that signature its
no-box semantics on the storage side. The Box<dyn Embedder> surface is
addressed by 0001c-async-trait-sunset.md.
Commit Sequence
Two commits, each green on cargo test -p vestige-core --no-default-features
and cargo test -p vestige-core --features embeddings,vector-search.
Commit 1: rewrite MemoryStore / LocalMemoryStore trait declaration
-
Touches:
crates/vestige-core/src/storage/memory_store.rsonly. -
Action: replace lines 183-262 per the "Trait Declaration Rewrite" section above. Delete the
pub use MemoryStore as LocalMemoryStore;line. -
Green after:
cargo check -p vestige-core(the impl block insqlite.rsstill has#[async_trait::async_trait]on it, but it still resolves through theLocalMemoryStoretrait which is now native-async; theasync_traitmacro is harmless when applied to a trait that the impl block targets by path, because the macro rewrites the impl's async fns into boxed-future fns whose signatures still match the native-async declarations after trait_variant lowering). Ifcargo checkcomplains here, fold commit 2 into commit 1.Mitigation if check fails between commits 1 and 2: combine the two into a single commit. The split is offered for review convenience; the build must be green after every commit lands.
Commit 2: drop #[async_trait::async_trait] from SqliteMemoryStore impl
- Touches:
crates/vestige-core/src/storage/sqlite.rsonly. - Action: delete line 6274 (
#[async_trait::async_trait]). - Green after:
cargo test -p vestige-core --features embeddings,vector-search, including alltrait_*tests insidesqlite.rs::tests(lines 8643-8712) and the trait-object cast on line 8669.
Combined alternative
If the per-step split feels artificial, commits 1 and 2 can collapse into
a single commit covering both the trait rewrite and the impl-attribute
drop for MemoryStore. That is acceptable; the two-commit form is
preferred only because it lets a reviewer bisect trait-rewrite failures
separately from impl-rewrite failures.
The Embedder / fastembed commits and the async-trait Cargo dependency
removal live in 0001c-async-trait-sunset.md.
Verification
Every command runs from the repo root unless noted otherwise.
# 1. Vestige-core, default features (embeddings + vector-search).
cargo test -p vestige-core --features embeddings,vector-search
# 2. Vestige-core, minimal features (no embeddings, no vector-search).
cargo test -p vestige-core --no-default-features
# 3. Workspace build, release mode (catches any feature-gated regression
# in the vestige-mcp tools tree).
cargo build --workspace --release
# 4. Whole-workspace test (vestige-mcp 406 tests + vestige-core 352 tests
# per the CLAUDE.md baseline).
cargo test --workspace
# 5. Phase 1 integration tests (these are the trait-shape contract).
cargo test --test trait_round_trip --features embeddings,vector-search
cargo test --test send_bound_variant --features embeddings,vector-search
cargo test --test cognitive_module_isolation --features embeddings,vector-search
cargo test --test embedding_model_registry --features embeddings,vector-search
cargo test --test domain_column_migration --features embeddings,vector-search
# 6. Clippy gate, deny warnings (matches Phase 1 PR policy of zero warnings).
cargo clippy --workspace --all-targets --features embeddings,vector-search -- -D warnings
# 7. Storage-side dependency hygiene check (must return zero hits).
# Scoped to the storage module only -- the embedder module still uses
# async_trait until 0001c lands.
grep -rn "async_trait\|async-trait" crates/vestige-core/src/storage/
# 8. Confirm trait_variant attribute is in place on the storage trait
# (must return exactly one hit in memory_store.rs).
grep -rn "trait_variant::make" crates/vestige-core/src/storage/
Expected outcomes:
- Command 1: 352 tests pass (matches baseline).
- Command 2: smaller test count, all pass.
- Command 3: workspace compiles in release mode.
- Command 4: 758 total tests pass (matches CLAUDE.md baseline).
- Command 5: each phase_1 integration test binary runs green. The
send_bound_variant::arc_dyn_memory_store_moves_across_tokio_taskstest is the canary; ifMemoryStorelost its Send-bound future variant, this fails to compile with "future cannot be sent between threads safely". - Command 6: zero clippy warnings. The rewrite must not introduce a new
clippy::needless_lifetimesorclippy::async_yields_async. - Command 7: empty output. async_trait is gone from the storage module.
The embedder module still contains async_trait; that is removed by
0001c-async-trait-sunset.md. - Command 8: one hit, in
memory_store.rs.
Acceptance Criteria
A reviewer should be able to check every box:
crates/vestige-core/src/storage/memory_store.rsdeclares the trait with#[trait_variant::make(MemoryStore: Send)] pub trait LocalMemoryStore: Sync + 'static, noasync_traitattribute, noSendbound onLocalMemoryStoreitself.crates/vestige-core/src/storage/memory_store.rsno longer containspub use MemoryStore as LocalMemoryStore;.crates/vestige-core/src/storage/sqlite.rs:6274is plainimpl crate::storage::memory_store::LocalMemoryStore for SqliteMemoryStore-- no attribute on the impl block.grep -rn "async_trait\|async-trait" crates/vestige-core/src/storage/returns zero hits.grep -rn "trait_variant::make" crates/vestige-core/src/storage/returns exactly one hit (the storage trait inmemory_store.rs).- All 758 workspace tests pass (
cargo test --workspace). - Phase 1 integration tests pass with the trait-object surface
(
Arc<dyn MemoryStore>) intact. cargo clippy --workspace --all-targets --features embeddings,vector-search -- -D warningsis clean.- No production source file under
crates/vestige-mcp/orcrates/vestige-core/src/{neuroscience,advanced,consolidation,codebase, memory,embeddings,embedder}/was modified by this sub-plan. Arc<Storage>still type-checks at every existing call site (verified by the workspace test pass).- Doc comments on the storage trait declaration describe
trait_variant, notasync_trait.
Risks and Mitigations
trait_variant0.1 macro emits unexpected diagnostics on MSRV 1.91. Mitigation: the master Phase 1 plan already prescribed this exact pattern (#[trait_variant::make(MemoryStore: Send)] pub trait LocalMemoryStore: Sync + 'static, see plan0001-...line 274-275); the crate has been investige-core/Cargo.tomlsince Phase 1 landed. If a diagnostic appears, pin to the exact known-good version withtrait-variant = "=0.1.2"and open an upstream issue.- Native async-fn-in-trait makes the trait no longer dyn-compatible.
Mitigation:
trait_variant::makeis specifically the workaround for this -- it emits both the source trait (for static dispatch) and a Send-bound variant whose returned futures usePin<Box<dyn Future + Send>>only at the dyn boundary.Arc<dyn MemoryStore>keeps working because the generatedMemoryStoretrait is dyn-compatible by construction. Verified by the existingsend_bound_variant::*tests, which intentionally moveArc<dyn MemoryStore>acrosstokio::spawnfrom inside amulti_threadruntime. - A cognitive module silently relied on the boxed-future return type.
Mitigation: grep verified no cognitive module imports
MemoryStore/LocalMemoryStoreor holds anArc<dyn ...>form; all of them use the concreteStoragealias. There is no Send-ness expectation downstream to break. - Future bodies inside the SQLite impl capture non-Send locals.
Mitigation: every method body in
sqlite.rs:6274..runs synchronous rusqlite calls inside the sameasync fnframe; no.awaitpoints exist inside the bodies that we ship today. TheSendbound on the generatedMemoryStorevariant is therefore satisfied automatically. If a future change adds.awaitinside an impl method, the new trait_variant surface will surface that as a compile error at the call site, which is the correct outcome. async-traitcrate is left declared after this sub-plan. This is intentional: the embedder impl still depends on it. The0001c-async-trait-sunset.mdsub-plan removes the crate after the embedder side is rewritten. Grep on the whole workspace returns only the storage and embedder files; no downstream crate pullsasync-trait.
Out-of-Band Notes
- This sub-plan amends
feat/storage-trait-phase1(tip790c0c8). The branch has not been opened upstream yet, so amending in place is safe; no force-push to a public PR. - The companion sub-plan
0001b-sqlite-split.mdlands after this one on the same branch. The trait-rewrite landing first is intentional: the SQLite split moves the impl block intostorage/sqlite/trait_impl.rs, and it is cleaner to move a small attribute-free impl than a macro-decorated one. - The companion sub-plan
0001c-async-trait-sunset.mdlands after this one (order with0001bis independent) and finishes the async_trait -> trait_variant transition for the Embedder trait, then removes theasync-traitcrate dependency. - After the Phase 1 amendment sub-plans (
0001a,0001b,0001c) land, the branch is reviewed and merged before Phase 2 sub-plans (0002a-through0002i-) begin implementation.