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.
39 KiB
Sub-Plan 0001c: Sunset the async-trait crate dependency
Status: Draft
Branch: feat/storage-trait-phase1 (Phase 1 amendment, PR A)
Depends on:
0001a-trait-rewrite.md(rewritesMemoryStore/LocalMemoryStoreand the SQLite impl; lands first)0001b-sqlite-split.md(movessqlite.rsintosqlite/; lands second)
Related: docs/adr/0002-phase-2-execution.md (decision D1 closing line:
"async-trait dependency stays in Cargo.toml only if other code uses it;
otherwise removed"). This sub-plan operationalises the removal.
Context
This is the third and final Phase 1 amendment sub-plan. Sub-plan 0001a
rewrote MemoryStore / LocalMemoryStore using
#[trait_variant::make(MemoryStore: Send)] and dropped the
#[async_trait::async_trait] attribute from the SQLite impl block.
Sub-plan 0001b then split sqlite.rs into a sqlite/ directory; the
trait impl now lives in sqlite/trait_impl.rs. After 0001a lands, the
only remaining async_trait usage in the workspace is the embedder pair
(embedder/mod.rs declares the trait; embedder/fastembed.rs implements
it). This sub-plan rewrites those two files following the exact pattern
from 0001a, then removes async-trait = "0.1" from
crates/vestige-core/Cargo.toml. End state: zero async_trait references
anywhere under crates/, zero direct dependency on the async-trait
crate, workspace tests and clippy green.
The rewrite is mechanically tiny -- one trait declaration, one impl block,
one Cargo.toml line -- but it is gated behind 0001a and 0001b so the
trait-rewrite pattern is already settled and so the SQLite trait impl
attribute has already been dropped. Doing the embedder rewrite without
that pre-work would leave the async-trait dep behind for the SQLite
side and force the Cargo.toml deletion into a separate commit later.
Scope
In scope
- Rewrite
LocalEmbedderdeclaration incrates/vestige-core/src/embedder/mod.rsto use#[trait_variant::make(Embedder: Send)] pub trait LocalEmbedder. - Delete the
pub use LocalEmbedder as Embedder;alias from the same file. TheEmbeddersymbol becomes the trait thattrait_variant::makeemits at the same module path, so the existingpub use embedder::{Embedder, ..., LocalEmbedder};line incrates/vestige-core/src/lib.rs:167keeps working untouched. - Drop the
#[async_trait::async_trait]attribute from theFastembedEmbedderimpl block incrates/vestige-core/src/embedder/fastembed.rs. - Update doc comments on the trait declaration to describe
trait_variantrather thanasync_trait. - Remove
async-trait = "0.1"fromcrates/vestige-core/Cargo.toml(line 119 area). Usecargo rm async-traitfrom inside the crate directory. - Verify with
grep -rn "async_trait" crates/returning zero hits.
Out of scope
- Any change to the
MemoryStoretrait orSqliteMemoryStoreimpl; those were handled by0001a. - Any file moves in
embedder/(no parallel of0001bis required;embedder/already has themod.rs+fastembed.rsshape). - Touching
crates/vestige-mcp/or any cognitive module. None of them holdArc<dyn Embedder>orBox<dyn Embedder>in production. - Renaming the
Embedder/LocalEmbeddersymbols or changing the re-exports incrates/vestige-core/src/lib.rs.
Prerequisites
State assumed at start
0001ais merged onto the branch. After0001a:crates/vestige-core/src/storage/memory_store.rsdeclares#[trait_variant::make(MemoryStore: Send)] pub trait LocalMemoryStore.- The SQLite impl block has no
#[async_trait::async_trait]attribute. grep -rn async_trait crates/returns exactly three hits, all incrates/vestige-core/src/embedder/(two inmod.rs, one infastembed.rs), and one Cargo.toml hit.
0001bis merged onto the branch. After0001b:crates/vestige-core/src/storage/sqlite.rsno longer exists as a single file; the impl lives incrates/vestige-core/src/storage/sqlite/trait_impl.rs.- The embedder files are untouched.
Required crates
| Crate | Version | Action |
|---|---|---|
trait-variant |
0.1 |
Already declared (line 117 of Cargo.toml). Verify present. |
async-trait |
0.1 |
Remove. Only the two embedder files still use it after 0001a. |
Workspace-wide audit before starting
Run from /home/delandtj/prppl/vestige-phase2/ (or the equivalent
worktree where this sub-plan executes):
grep -rn "async_trait\|async-trait" crates/ tests/
Expected hits before this sub-plan starts (after 0001a + 0001b):
crates/vestige-core/Cargo.toml:119:async-trait = "0.1"
crates/vestige-core/src/embedder/mod.rs:24:/// `#[async_trait::async_trait]` makes every `async fn` return a
crates/vestige-core/src/embedder/mod.rs:27:#[async_trait::async_trait]
crates/vestige-core/src/embedder/mod.rs:56:/// Both names refer to the same `async_trait`-annotated trait.
crates/vestige-core/src/embedder/fastembed.rs:44:#[async_trait::async_trait]
Five hits across two source files and one Cargo.toml. After this sub-plan, the same grep must return zero hits.
grep -rn "async-trait\|async_trait" --include="Cargo.toml" crates/
Expected: exactly one hit (crates/vestige-core/Cargo.toml:119). No other
workspace crate declares async-trait as a direct dependency. This is
the precondition that lets us delete the line cleanly.
Files Touched
Trait declaration (vestige-core)
| File | Lines (approx) | Change |
|---|---|---|
crates/vestige-core/src/embedder/mod.rs |
21-57 | Replace #[async_trait::async_trait] pub trait LocalEmbedder: Send + Sync + 'static with #[trait_variant::make(Embedder: Send)] pub trait LocalEmbedder: Sync + 'static. Delete the pub use LocalEmbedder as Embedder; alias on line 57. Update doc comments (lines 21-26, 55-56). |
Impl block (vestige-core)
| File | Lines (approx) | Change |
|---|---|---|
crates/vestige-core/src/embedder/fastembed.rs |
44 | Delete the #[async_trait::async_trait] attribute. Keep the impl LocalEmbedder for FastembedEmbedder { ... } body verbatim. No Box::pin, no 'async_trait lifetimes, no manual Pin<Box<dyn Future>>. |
Other Embedder impls
None. grep -rn "impl.*LocalEmbedder\|impl.*Embedder for" crates/ tests/
returns exactly one production hit:
crates/vestige-core/src/embedder/fastembed.rs:45: impl LocalEmbedder for FastembedEmbedder.
There is no test mock implementing Embedder in the test tree (the only
test that touches the trait, tests/phase_1/embedder_trait.rs, uses the
concrete FastembedEmbedder boxed as Box<dyn Embedder>).
Call sites (production)
Verified by:
grep -rn "dyn Embedder\|dyn LocalEmbedder" crates/ tests/ --include="*.rs"
grep -rn "Box<dyn Embedder>\|Arc<dyn Embedder>" crates/ tests/ --include="*.rs"
grep -rn "use.*Embedder" crates/ tests/ --include="*.rs"
Production call sites that may need verification (and the expected change for each, even though we have already verified that none need an edit):
| File | Use | Required change |
|---|---|---|
crates/vestige-core/src/lib.rs:167 |
pub use embedder::{Embedder, EmbedderError, EmbedderResult, FastembedEmbedder, LocalEmbedder}; |
None. Both names still exist at crate::embedder::* after the rewrite; Embedder is now the trait_variant-generated trait, LocalEmbedder is the source-of-truth trait. The re-export keeps resolving. |
crates/vestige-core/src/embedder/fastembed.rs:7 |
use super::{EmbedderError, EmbedderResult, LocalEmbedder}; |
None. LocalEmbedder is still the source-of-truth trait name. |
crates/vestige-core/src/embedder/mod.rs:5 |
pub use fastembed::FastembedEmbedder; |
None. Concrete type, untouched. |
crates/vestige-mcp/src/** |
No file imports Embedder or LocalEmbedder by name; none hold Arc<dyn Embedder> or Box<dyn Embedder>. |
None. Verified by grep returning empty for dyn Embedder and dyn LocalEmbedder under crates/vestige-mcp/. |
Cognitive modules under crates/vestige-core/src/advanced/ and crates/vestige-core/src/neuroscience/ |
No file imports Embedder or LocalEmbedder by name. advanced/adaptive_embedding.rs defines its own unrelated AdaptiveEmbedder struct. |
None. The name collision is purely surface-level; the two types live in different modules and never resolve to each other. |
crates/vestige-core/src/embeddings/** |
No file imports Embedder or LocalEmbedder. The EmbeddingService struct is what FastembedEmbedder wraps internally. |
None. |
The production audit returns zero files that need editing.
Call sites (tests)
| File | Lines | Use | Required change |
|---|---|---|---|
tests/phase_1/embedder_trait.rs |
3, 19 | use vestige_core::embedder::{Embedder, FastembedEmbedder};let e: Box<dyn Embedder> = Box::new(FastembedEmbedder::new()); |
None. Embedder is the trait_variant-generated Send variant; Box<dyn Embedder> keeps compiling. FastembedEmbedder implements LocalEmbedder; the blanket impl<T: LocalEmbedder + Send> Embedder for T that trait_variant::make emits gives the boxing for free. |
The test audit returns zero files that need editing.
Cargo dependency cleanup
| File | Lines | Change |
|---|---|---|
crates/vestige-core/Cargo.toml |
119 | Remove async-trait = "0.1". Run cargo rm async-trait from inside crates/vestige-core/ so Cargo.lock updates atomically with the manifest. |
Documentation
| File | Change |
|---|---|
crates/vestige-core/src/embedder/mod.rs |
Rewrite the trait-level doc comment (lines 21-26) and the pub use doc comment (lines 55-56) to describe trait_variant, not async_trait. See "Trait declaration rewrite" below for the exact new text. |
CLAUDE.md |
No change. The repo-level architecture notes do not name the trait attribute. |
Trait Declaration Rewrite
Before (state after 0001a and 0001b land)
crates/vestige-core/src/embedder/mod.rs:1-58:
//! Text-to-vector encoding trait. Pluggable per-install.
mod fastembed;
pub use fastembed::FastembedEmbedder;
/// Error returned by every `Embedder` method.
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum EmbedderError {
#[error("embedder initialization failed: {0}")]
Init(String),
#[error("embedding generation failed: {0}")]
EmbedFailed(String),
#[error("invalid input: {0}")]
InvalidInput(String),
}
pub type EmbedderResult<T> = std::result::Result<T, EmbedderError>;
/// Pluggable embedder. The storage layer NEVER calls fastembed directly;
/// callers compute vectors via this trait and pass them into `MemoryStore`.
///
/// `#[async_trait::async_trait]` makes every `async fn` return a
/// `Pin<Box<dyn Future + Send>>`, which is required for `Box<dyn Embedder>`
/// and `Arc<dyn Embedder>` to be dyn-compatible.
#[async_trait::async_trait]
pub trait LocalEmbedder: Send + Sync + 'static {
async fn embed(&self, text: &str) -> EmbedderResult<Vec<f32>>;
fn model_name(&self) -> &str;
fn dimension(&self) -> usize;
/// Stable blake3 hash of (model_name || dimension || vestige-core crate version).
/// Lowercase hex, 64 chars.
///
/// Used by `MemoryStore::register_model` to detect silent model drift
/// (e.g. a fastembed minor upgrade that changes vector output).
fn model_hash(&self) -> String;
async fn embed_batch(&self, texts: &[&str]) -> EmbedderResult<Vec<Vec<f32>>>;
/// Returns the `ModelSignature` describing this embedder. Convenience
/// wrapper over the three accessors above.
fn signature(&self) -> crate::storage::ModelSignature {
crate::storage::ModelSignature {
name: self.model_name().to_string(),
dimension: self.dimension(),
hash: self.model_hash(),
}
}
}
/// Type alias: `Embedder` is the dyn-compatible, Send+Sync variant.
/// Both names refer to the same `async_trait`-annotated trait.
pub use LocalEmbedder as Embedder;
After
crates/vestige-core/src/embedder/mod.rs:1-55 (approximately):
//! Text-to-vector encoding trait. Pluggable per-install.
mod fastembed;
pub use fastembed::FastembedEmbedder;
/// Error returned by every `Embedder` method.
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum EmbedderError {
#[error("embedder initialization failed: {0}")]
Init(String),
#[error("embedding generation failed: {0}")]
EmbedFailed(String),
#[error("invalid input: {0}")]
InvalidInput(String),
}
pub type EmbedderResult<T> = std::result::Result<T, EmbedderError>;
/// Pluggable embedder. The storage layer NEVER calls fastembed directly;
/// callers compute vectors via this trait and pass them into `MemoryStore`.
///
/// `LocalEmbedder` is the source-of-truth trait. The
/// `#[trait_variant::make(Embedder: Send)]` attribute auto-generates an
/// `Embedder` variant whose returned futures are `Send`, so
/// `Box<dyn Embedder>` and `Arc<dyn Embedder>` are usable on tokio/axum
/// runtimes, while `Box<dyn LocalEmbedder>` 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 at the static-dispatch
/// boundary.
#[trait_variant::make(Embedder: Send)]
pub trait LocalEmbedder: Sync + 'static {
async fn embed(&self, text: &str) -> EmbedderResult<Vec<f32>>;
fn model_name(&self) -> &str;
fn dimension(&self) -> usize;
/// Stable blake3 hash of (model_name || dimension || vestige-core crate version).
/// Lowercase hex, 64 chars.
///
/// Used by `MemoryStore::register_model` to detect silent model drift
/// (e.g. a fastembed minor upgrade that changes vector output).
fn model_hash(&self) -> String;
async fn embed_batch(&self, texts: &[&str]) -> EmbedderResult<Vec<Vec<f32>>>;
/// Returns the `ModelSignature` describing this embedder. Convenience
/// wrapper over the three accessors above.
fn signature(&self) -> crate::storage::ModelSignature {
crate::storage::ModelSignature {
name: self.model_name().to_string(),
dimension: self.dimension(),
hash: self.model_hash(),
}
}
}
Both halves of the macro-generated output (for reviewer clarity)
trait_variant::make(Embedder: Send) expands the source-of-truth
LocalEmbedder declaration above into the equivalent of:
// 1. The source-of-truth trait, exactly as written.
pub trait LocalEmbedder: Sync + 'static {
fn embed(&self, text: &str) -> impl Future<Output = EmbedderResult<Vec<f32>>>;
fn model_name(&self) -> &str;
fn dimension(&self) -> usize;
fn model_hash(&self) -> String;
fn embed_batch(&self, texts: &[&str]) -> impl Future<Output = EmbedderResult<Vec<Vec<f32>>>>;
fn signature(&self) -> crate::storage::ModelSignature { /* default impl unchanged */ }
}
// 2. The generated Send variant.
pub trait Embedder: Sync + 'static {
fn embed(&self, text: &str) -> impl Future<Output = EmbedderResult<Vec<f32>>> + Send;
fn model_name(&self) -> &str;
fn dimension(&self) -> usize;
fn model_hash(&self) -> String;
fn embed_batch(&self, texts: &[&str]) -> impl Future<Output = EmbedderResult<Vec<Vec<f32>>>> + Send;
fn signature(&self) -> crate::storage::ModelSignature { /* default impl unchanged */ }
}
// 3. The blanket impl that wires any LocalEmbedder + Send through to Embedder.
impl<T> Embedder for T
where
T: LocalEmbedder + Send,
// (all returned futures of LocalEmbedder's async fns are required to be Send,
// which is satisfied for FastembedEmbedder -- see "Risks" below)
{ /* forwarders */ }
Notes:
- The
pub use LocalEmbedder as Embedder;line on the currentembedder/mod.rs:57is deleted entirely.Embedderis now the trait thattrait_variant::makeemits at the same module path; the re-export incrates/vestige-core/src/lib.rs:167(pub use embedder::{Embedder, ..., LocalEmbedder};) keeps resolving unchanged. Sync + 'staticonLocalEmbedder(and noSendbound on the trait itself) mirrors the0001apattern forLocalMemoryStore. The current trait carriesSend + Sync + 'static; the rewrite drops theSendbound from the local variant.Box<dyn LocalEmbedder>isSyncbut notSend;Box<dyn Embedder>(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 next section.
Impl Block Migration
trait_variant 0.1 attaches the attribute only to the trait declaration.
The impl side is plain impl LocalEmbedder for FastembedEmbedder; no
attribute on the impl, no #[trait_variant::make(Embedder: Send)] on the
impl block. The macro auto-generates the blanket
impl<T: LocalEmbedder + Send> Embedder for T, so any concrete type that
implements LocalEmbedder automatically also implements Embedder
provided it is Send.
FastembedEmbedder is Send + Sync because:
inner: EmbeddingServiceisSend + Sync(it wraps fastembed'sTextEmbeddingwhich isSend + Syncafter fastembed 4.x; verified incrates/vestige-core/src/embeddings/mod.rs).cached_hash: std::sync::OnceLock<String>isSend + SyncforT: Send + Sync.- The
#[cfg(not(feature = "embeddings"))]branch carries onlycached_hash, which is unconditionallySend + Sync.
No new bound is needed.
Before
crates/vestige-core/src/embedder/fastembed.rs:38-100 (relevant header):
impl Default for FastembedEmbedder {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl LocalEmbedder for FastembedEmbedder {
async fn embed(&self, text: &str) -> EmbedderResult<Vec<f32>> {
// ... body unchanged ...
}
fn model_name(&self) -> &str { /* ... */ }
fn dimension(&self) -> usize { /* ... */ }
fn model_hash(&self) -> String { /* ... */ }
async fn embed_batch(&self, texts: &[&str]) -> EmbedderResult<Vec<Vec<f32>>> {
// ... body unchanged ...
}
}
After
crates/vestige-core/src/embedder/fastembed.rs:38-99 (one fewer line):
impl Default for FastembedEmbedder {
fn default() -> Self {
Self::new()
}
}
impl LocalEmbedder for FastembedEmbedder {
async fn embed(&self, text: &str) -> EmbedderResult<Vec<f32>> {
// ... body unchanged ...
}
fn model_name(&self) -> &str { /* ... */ }
fn dimension(&self) -> usize { /* ... */ }
fn model_hash(&self) -> String { /* ... */ }
async fn embed_batch(&self, texts: &[&str]) -> EmbedderResult<Vec<Vec<f32>>> {
// ... body unchanged ...
}
}
Diff is exactly one removed line (the #[async_trait::async_trait]
attribute on line 44). 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.
Why the impl block does not need an attribute
trait_variant::make generates two things from the source trait
(see the "macro-generated output" subsection above):
- The source trait itself (
LocalEmbedder), with native async fns. - A second trait (
Embedder) 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. FastembedEmbedder writes one
impl block (against LocalEmbedder); the macro-generated blanket
guarantees FastembedEmbedder: Embedder for free. The
Box<dyn Embedder> cast on tests/phase_1/embedder_trait.rs:19 keeps
type-checking unchanged.
Call Site Audit
Verified via, from the phase2 worktree root:
grep -rn "async_trait\|LocalEmbedder\|dyn Embedder" crates/
grep -rn "use.*Embedder" crates/ tests/ --include="*.rs"
grep -rn "Box<dyn Embedder>\|Arc<dyn Embedder>\|&dyn Embedder" crates/ tests/ --include="*.rs"
grep -rn "Box<dyn LocalEmbedder>\|Arc<dyn LocalEmbedder>\|&dyn LocalEmbedder" crates/ tests/ --include="*.rs"
grep -rn "impl LocalEmbedder for\|impl Embedder for" crates/ tests/ --include="*.rs"
Files that reference the trait object form
Exactly one, test-only:
| File | Line | Use | Required change |
|---|---|---|---|
tests/phase_1/embedder_trait.rs |
3 | use vestige_core::embedder::{Embedder, FastembedEmbedder}; |
None. Embedder is the generated Send variant at the same path. |
tests/phase_1/embedder_trait.rs |
19 | let e: Box<dyn Embedder> = Box::new(FastembedEmbedder::new()); |
None. FastembedEmbedder: LocalEmbedder + Send -> blanket gives : Embedder -> Box<dyn Embedder> is well-formed. |
Files that import Embedder or LocalEmbedder by name
| File | Line | Use | Required change |
|---|---|---|---|
crates/vestige-core/src/lib.rs |
167 | pub use embedder::{Embedder, EmbedderError, EmbedderResult, FastembedEmbedder, LocalEmbedder}; |
None. Both names still resolve. |
crates/vestige-core/src/embedder/mod.rs |
5 | pub use fastembed::FastembedEmbedder; |
None. |
crates/vestige-core/src/embedder/fastembed.rs |
7 | use super::{EmbedderError, EmbedderResult, LocalEmbedder}; |
None. |
tests/phase_1/embedder_trait.rs |
3 | use vestige_core::embedder::{Embedder, FastembedEmbedder}; |
None. |
Files that implement the trait
| File | Line | Impl | Required change |
|---|---|---|---|
crates/vestige-core/src/embedder/fastembed.rs |
45 | impl LocalEmbedder for FastembedEmbedder (currently #[async_trait]) |
Drop the #[async_trait::async_trait] attr. |
No other impls exist. There is no test mock implementing Embedder or
LocalEmbedder anywhere in the workspace.
Files that import async_trait directly
After 0001a lands, only the embedder pair:
crates/vestige-core/src/embedder/mod.rs:24 (doc comment)
crates/vestige-core/src/embedder/mod.rs:27 (attribute)
crates/vestige-core/src/embedder/mod.rs:56 (doc comment)
crates/vestige-core/src/embedder/fastembed.rs:44 (attribute)
Plus the Cargo manifest:
crates/vestige-core/Cargo.toml:119:async-trait = "0.1"
Production files that hold a concrete embedder
FastembedEmbedder is constructed and used by concrete name (not via
trait object) in: the dashboard / MCP layer if it needs to embed query
strings ad-hoc. None of those call sites need an edit because the
concrete type is what they hold, and the concrete type is untouched by
this sub-plan.
Conclusion
| Category | Count |
|---|---|
| Production source files modified | 2 |
| Test source files modified | 0 |
| Cargo manifests modified | 1 |
Production source files importing Embedder / LocalEmbedder (verified unchanged) |
3 |
Test source files importing Embedder (verified unchanged) |
1 |
Direct async_trait uses outside the embedder module after 0001a |
0 |
Cargo.toml Change
From inside crates/vestige-core/:
cargo rm async-trait
This removes line 119 of Cargo.toml and updates Cargo.lock in one
step. Manual editing is acceptable as a fallback if cargo rm is
unavailable in the agent environment; in that case, follow up with
cargo check -p vestige-core to refresh the lockfile.
Verification
# Direct dependency must be gone.
grep -rn "async-trait\|async_trait" --include="Cargo.toml" crates/
# Expected: empty.
# Transitive presence is permitted (e.g. via a third-party crate).
cargo tree -p vestige-core --depth 2 | grep async-trait
# Expected: empty for the direct edges; if a sub-dependency still pulls
# async-trait transitively, the output may contain it deeper than depth 2,
# which is fine. We only care about removing the DIRECT edge.
If cargo tree --depth 2 returns any async-trait line, inspect with
cargo tree -p vestige-core -i async-trait to see what is pulling it.
Acceptable parents: any third-party crate. Unacceptable parent: anything
under vestige-*, which would mean a missed file.
Commit Sequence
Three commits, each green on
cargo test -p vestige-core --features embeddings,vector-search and
cargo test -p vestige-core --no-default-features.
Commit 1: rewrite LocalEmbedder trait declaration
-
Touches:
crates/vestige-core/src/embedder/mod.rsonly. -
Action: replace lines 21-57 per the "Trait Declaration Rewrite" section above. Delete the
pub use LocalEmbedder as Embedder;line. -
Green after:
cargo check -p vestige-core(the impl block infastembed.rsstill has its#[async_trait::async_trait]attribute; the macro is harmless when applied to a trait that the impl block targets by path, because async_trait rewrites the impl's async fns into boxed-future fns whose signatures still match the native-async declarations after trait_variant lowering, just as it did for the SQLite intermediate state in0001a's 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 FastembedEmbedder impl
- Touches:
crates/vestige-core/src/embedder/fastembed.rsonly. - Action: delete line 44 (
#[async_trait::async_trait]). - Green after:
cargo test -p vestige-core --features embeddings,vector-search.cargo test -p vestige-core --no-default-features(the#[cfg(not(feature = "embeddings"))]branches inside the impl now stand on their own).- Phase 1 integration test:
cargo test --test embedder_trait --features embeddings,vector-search.
Commit 3: drop the async-trait dependency
- Touches:
crates/vestige-core/Cargo.toml(plusCargo.lockas a side effect). - Action: from inside
crates/vestige-core/, runcargo rm async-trait. - Green after:
cargo build --workspace --all-targetsandcargo test --workspace. - Final hard ASCII gate:
! grep -rn "async_trait" crates/must exit with status 0 (i.e. the inverted grep finds nothing).
Combined alternative
Commits 1 and 2 may fold into a single commit if the per-step split
feels artificial (the patterns are identical to 0001a's commits 3
and 4). Commit 3 (the Cargo.toml removal) should stay separate so the
dependency-removal diff is visible in isolation.
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, all targets (catches any feature-gated regression
# in the vestige-mcp tools tree).
cargo build --workspace --all-targets
# 4. Whole-workspace test (vestige-mcp 406 tests + vestige-core 352
# tests per the CLAUDE.md baseline).
cargo test --workspace
# 5. Phase 1 embedder integration test (the trait-shape contract).
cargo test --test embedder_trait --features embeddings,vector-search
# 6. Clippy gate, deny warnings (matches Phase 1 PR policy).
cargo clippy --workspace --all-targets --features embeddings,vector-search -- -D warnings
# 7. Hard ASCII gate -- async_trait must be gone from source.
! grep -rn "async_trait" crates/
# Inverted grep: exit 0 iff grep found nothing.
# 8. Hard ASCII gate -- async-trait must be gone from manifests.
! grep -rn "async-trait" --include="Cargo.toml" crates/
# 9. Confirm trait_variant attribute is in place at the embedder.
grep -rn "trait_variant::make" crates/vestige-core/src/embedder/
# Expected: exactly one hit, in embedder/mod.rs.
# 10. Workspace-wide trait_variant audit (should match the count after
# 0001a -- two hits total, one for storage, one for embedder).
grep -rn "trait_variant::make" crates/vestige-core/src/
# Expected: two hits.
Expected outcomes:
- Command 1: 352 vestige-core tests pass (matches baseline).
- Command 2: smaller test count, all pass.
- Command 3: workspace builds in dev mode for all targets.
- Command 4: 758 total tests pass (matches CLAUDE.md baseline).
- Command 5:
embedder_traitintegration test passes. Thefastembed_implements_embedder_traitassertion (let e: Box<dyn Embedder> = ...) is the canary; iftrait_variant::makefailed to emit theEmbedderSend variant, this fails to compile. - Command 6: zero clippy warnings.
- Command 7: empty output.
async_traitis fully gone from source. - Command 8: empty output.
async-traitis fully gone from manifests. - Command 9: one hit.
- Command 10: two hits.
Acceptance Criteria
A reviewer should be able to check every box:
crates/vestige-core/src/embedder/mod.rsdeclares the embedder trait with#[trait_variant::make(Embedder: Send)] pub trait LocalEmbedder: Sync + 'static, noasync_traitattribute, noSendbound onLocalEmbedderitself.crates/vestige-core/src/embedder/mod.rsno longer containspub use LocalEmbedder as Embedder;.crates/vestige-core/src/embedder/fastembed.rsdeclaresimpl LocalEmbedder for FastembedEmbedderwith no attribute on the impl block.crates/vestige-core/Cargo.tomldoes not declareasync-traitas a direct dependency.grep -rn "async_trait" crates/returns zero hits.grep -rn "async-trait" --include="Cargo.toml" crates/returns zero hits.grep -rn "trait_variant::make" crates/vestige-core/src/returns exactly two hits (storage trait + embedder trait).- All 758 workspace tests pass (
cargo test --workspace). tests/phase_1/embedder_trait.rscompiles and passes with theBox<dyn Embedder>cast intact.cargo clippy --workspace --all-targets --features embeddings,vector-search -- -D warningsis clean.- No file under
crates/vestige-mcp/or undercrates/vestige-core/src/{neuroscience,advanced,consolidation, codebase,memory,embeddings}/was modified by this sub-plan. Cargo.lockwas updated as a side effect ofcargo rm async-trait(it must no longer referenceasync-trait).- Doc comments on the embedder trait declaration describe
trait_variant, notasync_trait.
Risks and Mitigations
trait_variant::makerequires returned futures to beSendfor the blanketimpl<T: LocalEmbedder + Send> Embedder for T. If anyasync fn embed/embed_batchbody insideFastembedEmbeddercaptures a non-Send local, the blanket impl fails to type-check. Mitigation: the existing impl bodies callself.inner.embed(text)/self.inner.embed_batch(texts), whereinner: EmbeddingServiceisSend + Sync(verified incrates/vestige-core/src/embeddings/mod.rs). No.awaitpoints exist inside the bodies in either feature branch; theEmbeddingService::embedcalls are synchronous. The futures are triviallySend. If a future change introduces a non-Send local (e.g. anRcor a non-Send guard), the blanket impl will surface that as a compile error at the dyn cast intests/phase_1/embedder_trait.rs, which is the correct outcome.- The macro's blanket impl interacts oddly with the default
signaturemethod. Mitigation:signatureis a synchronous method returningcrate::storage::ModelSignature, with noSendorasyncconcerns.trait_variant::makeemits it on both variants as-is. The existing Phase 1 testsignature_matches_memory_store_registryexercises this path and is part of the verification step. Box<dyn Embedder>cast intests/phase_1/embedder_trait.rsfails to resolve after the rewrite. Mitigation: the rewrite preserves theEmbeddersymbol at the same module path; only its provenance changes (now generated bytrait_variant::makeinstead of bypub use LocalEmbedder as Embedder;). The macro is specifically designed so that the generated trait is dyn-compatible at the Send-bound boundary. Verified by the identical pattern already working forMemoryStoreafter0001a.cargo rm async-traitupdatesCargo.lockbut accidentally bumps other crates. Mitigation: runcargo rm async-traitand then immediately inspect the resultingCargo.lockdiff. The expected diff is the removal of the[[package]] name = "async-trait"block and its hash. Anything else is a red flag and should be reverted before committing (git checkout -- Cargo.lockthencargo update -p async-trait --precise=remove-- or fall back to manual edit +cargo check).- A new workspace crate added in parallel with this work declares
async-traitand the dependency removal silently re-introduces it later. Mitigation: the verification stepgrep -rn "async-trait" --include="Cargo.toml" crates/is part of the acceptance criteria; a rebase that reintroduces the line will fail this gate. - MCP server uses
Embeddersomewhere we missed. Mitigation: full workspace grep (grep -rn "Embedder" crates/) returns no hits insidecrates/vestige-mcp/for the trait names; the MCP layer uses the concreteEmbeddingServicefromcrates/vestige-core/src/embeddings/for ad-hoc embedding calls. The trait surface is purely internal tovestige-core.
Out-of-Band Notes
- No other workspace crate declares
async-traitas a direct dependency. Verified bygrep -rn "async-trait" --include="Cargo.toml" crates/returning exactly one hit atcrates/vestige-core/Cargo.toml:119. There is nothing to clean up incrates/vestige-mcp/Cargo.tomlor elsewhere. - Order matters across the three Phase 1 amendment sub-plans:
0001a(trait rewrite) ->0001b(sqlite split) ->0001c(this one, async-trait sunset). Reversing the order is possible in principle but would force re-editing the embedder rewrite twice and leaves theasync-traitdep behind until very late. - This sub-plan amends
feat/storage-trait-phase1(tip790c0c8plus whatever commits0001aand0001badded). The branch has not been opened upstream yet, so amending in place is safe; no force-push to a public PR. - After this sub-plan lands, the branch is reviewed and merged before
Phase 2 sub-plans (
0002a-through0002i-) begin implementation. Phase 2 introduces no async-trait usage; the Postgres backend follows the sametrait_variant::makepattern (see ADR 0002 D1). trait-variant0.1 stays inCargo.toml. It is the only crate this sub-plan keeps;async-traitis the only one it removes.
Self-Contained /goal Brief
For a fresh Claude Code session executing this sub-plan without prior conversation context:
- Check out branch
feat/storage-trait-phase1(or a worktree off of it after0001aand0001bare merged into it). - Read this file (
docs/plans/0001c-async-trait-sunset.md) in full. - Read
docs/plans/0001a-trait-rewrite.mdsections "Trait declaration rewrite" and "Impl block migration" -- they document the exact pattern this sub-plan mirrors for the embedder. - Run the prerequisite audit grep listed under "Prerequisites". If it returns more than the five hits documented there, stop and report; the upstream state does not match what this sub-plan assumes.
- Execute Commit 1 (rewrite
embedder/mod.rs), then Commit 2 (drop the attribute on the FastembedEmbedder impl), then Commit 3 (cargo rm async-trait). Run the verification commands listed above after each commit; do not proceed if any test or clippy gate fails. - Verify every box in "Acceptance Criteria" is ticked.
- Report file paths touched, test counts, and the final two grep results (commands 7 and 8 from "Verification") in the closing message.