diff --git a/.github/workflows/ci-lite.yml b/.github/workflows/ci-lite.yml index e728ee1782..9e0fd865ec 100644 --- a/.github/workflows/ci-lite.yml +++ b/.github/workflows/ci-lite.yml @@ -959,10 +959,10 @@ jobs: fetch-depth: 1 persist-credentials: false - - name: Init tinycortex submodule + - name: Init tinycortex and tinymemory submodules run: | git config --global --add safe.directory "$GITHUB_WORKSPACE" - git submodule update --init --recursive vendor/tinycortex + git submodule update --init --recursive vendor/tinycortex vendor/tinymemory - name: Cache TinyCortex build artifacts uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/release-production.yml b/.github/workflows/release-production.yml index fe6a57fedd..b4be0ba96c 100644 --- a/.github/workflows/release-production.yml +++ b/.github/workflows/release-production.yml @@ -401,7 +401,7 @@ jobs: # fork the core image doesn't need. The Dockerfile COPYs vendor/ because # [patch.crates-io] resolves Rust SDK crates from vendor/. - name: Init vendored Rust submodules - run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace vendor/tinyhumans-sdk + run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace vendor/tinyhumans-sdk vendor/tinybus vendor/tinymemory - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Log in to GHCR diff --git a/.github/workflows/release-staging.yml b/.github/workflows/release-staging.yml index 40c2daa1db..0d5bc50de9 100644 --- a/.github/workflows/release-staging.yml +++ b/.github/workflows/release-staging.yml @@ -297,7 +297,7 @@ jobs: # fork the core image doesn't need. The Dockerfile COPYs vendor/ because # [patch.crates-io] resolves Rust SDK crates from vendor/. - name: Init vendored Rust submodules - run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace vendor/tinyhumans-sdk + run: git submodule update --init --recursive vendor/tinyagents vendor/tinyflows vendor/tinycortex vendor/tinyjuice vendor/tinychannels vendor/tinyplace vendor/tinyhumans-sdk vendor/tinybus vendor/tinymemory - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Build image (no push) diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml index 7a335966d1..431fad06d5 100644 --- a/.github/workflows/test-reusable.yml +++ b/.github/workflows/test-reusable.yml @@ -229,10 +229,10 @@ jobs: fetch-depth: 1 persist-credentials: false - - name: Init tinycortex submodule + - name: Init tinycortex and tinymemory submodules run: | git config --global --add safe.directory "$GITHUB_WORKSPACE" - git submodule update --init --recursive vendor/tinycortex + git submodule update --init --recursive vendor/tinycortex vendor/tinymemory - name: Cache TinyCortex build artifacts uses: Swatinem/rust-cache@v2 diff --git a/.gitmodules b/.gitmodules index de132aaffd..084444e410 100644 --- a/.gitmodules +++ b/.gitmodules @@ -23,6 +23,10 @@ path = vendor/tinybus url = https://github.com/tinyhumansai/tinybus.git branch = main +[submodule "vendor/tinymemory"] + path = vendor/tinymemory + url = https://github.com/tinyhumansai/tinymemory.git + branch = main [submodule "vendor/tinywallet"] path = vendor/tinywallet url = https://github.com/tinyhumansai/tinywallet diff --git a/Cargo.lock b/Cargo.lock index d8d687d2a4..aa331b13f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4727,6 +4727,9 @@ dependencies = [ "tinyflows", "tinyhumans-sdk", "tinyjuice", + "tinymemory", + "tinymemory-api", + "tinymemory-tinycortex", "tinyplace", "tinywallet", "tokio", @@ -7392,6 +7395,43 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "tinymemory" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "log", + "serde", + "serde_json", + "tinymemory-api", +] + +[[package]] +name = "tinymemory-api" +version = "0.1.1" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "uuid 1.23.1", +] + +[[package]] +name = "tinymemory-tinycortex" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "tinycortex", + "tinymemory", + "tinymemory-api", +] + [[package]] name = "tinyplace" version = "2.0.4" diff --git a/Cargo.toml b/Cargo.toml index 4e3ab6ee52..a21f5ca0d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -208,6 +208,29 @@ tinycortex = { version = "0.1", features = [ # the embedded engine. No `[patch.crates-io]` entry is needed: cargo unifies # this with the `path = "api"` dependency the engine crate already declares. tinycortex-api = { path = "vendor/tinycortex/api" } + +# tinymemory — the engine-neutral memory layer +# (https://github.com/tinyhumansai/tinymemory). Owns the parts of the memory +# subsystem that are true for *any* engine: the driver-admission rules +# (`tinymemory::registry`) and the three mandatory capability families composed +# over the `Memory` storage trait (`tinymemory::mandatory`). TinyCortex stays +# the engine; this is the layer a second engine would enter through. +# +# Vendored as a git submodule under `vendor/` beside the other tiny* crates and +# consumed by path: the crate is not published to crates.io, so there is no +# `[patch.crates-io]` entry for it, and for the same reason +# `app/src-tauri/Cargo.toml` needs no `../../` twin — an unpublished path +# dependency resolves transitively through this manifest. +# +# `tinymemory-tinycortex` is the seam between TinyCortex's contract types and +# TinyMemory's. It names `tinycortex` by version requirement, so the +# `[patch.crates-io]` entry below unifies it onto *this* checkout's engine +# rather than the one pinned inside the tinymemory submodule. +# +# After cloning: `git submodule update --init --recursive vendor/tinymemory`. +tinymemory = { path = "vendor/tinymemory" } +tinymemory-api = { path = "vendor/tinymemory/api" } +tinymemory-tinycortex = { path = "vendor/tinymemory/adapters/tinycortex" } tinychannels = { version = "0.1", features = ["relay-websocket"] } # tinybus — the message bus. Owns what `src/core/event_bus/` used to: the typed # pub/sub surface (`EventBus`, `EventHandler`, `SubscriptionHandle`), the diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index a6151895bc..f7bff76a84 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -5263,6 +5263,9 @@ dependencies = [ "tinyflows", "tinyhumans-sdk", "tinyjuice", + "tinymemory", + "tinymemory-api", + "tinymemory-tinycortex", "tinyplace", "tokio", "tokio-stream", @@ -8496,6 +8499,43 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "tinymemory" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "log", + "serde", + "serde_json", + "tinymemory-api", +] + +[[package]] +name = "tinymemory-api" +version = "0.1.1" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "uuid 1.23.1", +] + +[[package]] +name = "tinymemory-tinycortex" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "tinycortex", + "tinymemory", + "tinymemory-api", +] + [[package]] name = "tinyplace" version = "2.0.4" diff --git a/docs/specs/plan-memory.md b/docs/specs/plan-memory.md index f8bab33562..2c1f297442 100644 --- a/docs/specs/plan-memory.md +++ b/docs/specs/plan-memory.md @@ -6,6 +6,50 @@ --- +## Amendment 2026-08-10 — the destination is `tinymemory`, not `vendor/tinycortex` + +This plan was written when TinyCortex was the only engine in view, so it named +`vendor/tinycortex` as the destination for everything that moves. That is now split in two, +because "engine-specific" and "engine-neutral" are different destinations: + +| Kind of code | Destination | Why | +| --- | --- | --- | +| TinyCortex's own storage, retrieval, tree, queue and sync internals (§6.2) | `vendor/tinycortex` — unchanged | It is the implementation of *one* engine. | +| The contract, capability negotiation, driver admission, the conformance corpus, the shared mandatory families, and one adapter per engine | **`vendor/tinymemory`** (new submodule) | A second engine cannot be reached through a crate named after the first. | + +**What landed (2026-08-10).** + +- `tinymemory-api` — the contract, moved out of `tinycortex-api` byte-identical. §3.1's carve-out + still describes how it was *created*; it now lives one repository over. +- `tinymemory::registry` — driver admission, lifted out of `memory/binding.rs`. §4's driver table + is where new ids get reserved. +- `tinymemory::mandatory` — the three mandatory families composed once over the `Memory` storage + trait, so a new engine inherits the four easy-to-get-wrong parts (taint routing, all-namespace + list, scoped-recall refusal, import provenance) instead of re-deriving them. +- `tinymemory-tinycortex` — the seam, with exhaustively-destructuring conversions. +- `vendor/tinycortex` is pinned as a nested submodule of `tinymemory`. Adapters name their engine + by **version requirement**, so a host's own `[patch.crates-io]` unifies everything onto one + engine copy; a path dependency would give the host two engines with two incompatible `Memory` + traits. + +**Two contracts, converted at one seam — a deliberate deviation.** `tinycortex-api` was *not* +turned into a re-export of `tinymemory-api`. The two therefore remain distinct Rust types that +describe the same values, and `tinymemory-tinycortex` converts between them. The cost is real and +should be stated: a value crossing the seam is rebuilt, and the two contracts can drift. The +mitigation is that every conversion destructures exhaustively, so a field added on either side is +a compile error naming the field rather than a silently dropped value. + +The consequence for this plan: **openhuman stays on `tinycortex-api`** as its contract. §6.7's grep +invariant is unchanged. A future phase that unifies the two contracts would re-point the host's +~200 `tinycortex_api::` references in one mechanical pass; until then, the seam is the boundary. + +**Sequencing unchanged.** M8a (the `source_scope` inversion) is still the hard gate before any +`memory_tree/retrieval` file moves, and M8b's per-module moves still go to `vendor/tinycortex`. +This amendment changes *where the engine-neutral layer lives*, not the order in which the engine +moves. + +--- + ## 1. Goals 1. **Memory becomes an API, not an implementation.** The kernel owns a versioned memory contract; diff --git a/src/openhuman/memory/binding.rs b/src/openhuman/memory/binding.rs index 74d64429c5..f6716fa13c 100644 --- a/src/openhuman/memory/binding.rs +++ b/src/openhuman/memory/binding.rs @@ -63,27 +63,29 @@ use tinycortex_api::null::{NullMemoryProvider, NULL_DRIVER_ID}; use tinycortex_api::provider::MemoryProvider; use tinycortex_api::CONTRACT_VERSION; +use tinymemory::registry::{ + ConfigLabels, DriverClass as ContractDriverClass, DriverEntry, DriverRegistry, +}; + use crate::core::subsystem::{ BoundDriver, DriverCapabilities, DriverClass, DriverHealth, SubsystemSlot, }; use crate::openhuman::config::schema::{MemoryHooksConfig, MemorySubsystemConfig}; -use crate::openhuman::memory::driver::embedded::{EmbeddedMemoryProvider, EMBEDDED_DRIVER_ID}; +use crate::openhuman::memory::driver::embedded::EmbeddedMemoryProvider; use crate::openhuman::memory::guard::{GuardPolicy, MemoryGuard}; /// Why a bind fell back to the placeholder driver. /// -/// `reason` is operator-facing: it is logged, published on the event bus, and -/// rendered in status. It must therefore never interpolate `credential_ref` or -/// `endpoint` from [`crate::openhuman::config::schema::MemoryDriverConfig`], -/// which carries a manual redacting `Debug` for exactly that reason. Pinned by +/// Defined in [`tinymemory::registry`] alongside the admission rules that +/// produce it. `reason` is operator-facing: it is logged, published on the +/// event bus, and rendered in status, so it must never interpolate +/// `credential_ref` or `endpoint` from +/// [`crate::openhuman::config::schema::MemoryDriverConfig`], which carries a +/// manual redacting `Debug` for exactly that reason. The crate enforces this +/// structurally — [`DriverEntry`] carries neither field, so a refusal built +/// there cannot reach one. Pinned by /// `fallback_reason_never_contains_credential_ref_or_endpoint`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FallbackReason { - /// The driver id that was asked for in `[subsystems.memory] driver`. - pub configured_driver: String, - /// Why it was refused. - pub reason: String, -} +pub use tinymemory::registry::FallbackReason; /// One bound memory driver, for one workspace. pub struct MemoryBinding { @@ -220,39 +222,46 @@ pub fn unbound_default_capabilities() -> Capabilities { Capabilities::all() } +/// The registry of driver ids whose class this host fixes. +/// +/// [`DriverRegistry::builtin`] already reserves `null` and `tinycortex`, which +/// are exactly this host's two built-in ids — so the builtin set is used as-is +/// rather than re-declared. A host bundling an adapter the crate does not know +/// about would add it here with `with_reserved`. +fn registry() -> DriverRegistry { + DriverRegistry::builtin() +} + +/// The config-path spellings quoted back to the operator in refusal messages. +/// +/// The crate does not know what this host's config file looks like; these are +/// the blocks an operator would actually edit. +const CONFIG_LABELS: ConfigLabels<'static> = ConfigLabels { + section: "[subsystems.memory]", + drivers: "[subsystems.memory.drivers]", + driver_entry: "[subsystems.memory.drivers.]", +}; + /// The class a built-in driver id is *fixed* to, or `None` for any other id. /// -/// Both built-in ids name one specific implementation, so this is the authority -/// for their class in every path — the implicit one below and the explicit -/// `class = …` line in [`admit`], which may only confirm what this returns. +/// Both built-in ids name one specific implementation, so the registry is the +/// authority for their class in every path — the implicit one and the explicit +/// `class = …` line, which may only confirm what this returns. pub(crate) fn reserved_class(id: &str) -> Option { - match id { - NULL_DRIVER_ID => Some(DriverClass::Null), - EMBEDDED_DRIVER_ID => Some(DriverClass::Embedded), - _ => None, - } + registry().reserved_class(id).map(from_contract_class) } -/// The class a driver id implies when nothing says otherwise. Only the two -/// built-in ids admit: the embedded default and the null placeholder. Anything -/// else — a typo, or an external backend that forgot its `drivers.` entry — -/// is refused so the fallback machinery surfaces the mistake in status instead -/// of mislabelling the bound engine. +/// The contract's driver class in the kernel's generic vocabulary. /// -/// `context` names which part of the config was missing; the refusal echoes it -/// so the operator knows whether to add an entry or a `class` line. -fn implicit_class( - id: &str, - refuse: &impl Fn(&str) -> FallbackReason, - context: &str, -) -> Result<(String, DriverClass), FallbackReason> { - if let Some(class) = reserved_class(id) { - Ok((id.to_string(), class)) - } else { - Err(refuse(&format!( - "unknown driver id \"{id}\": {context}, and the id is neither the \ - embedded default nor \"null\"" - ))) +/// A total three-arm match, which is why both enums were shaped one-for-one. +/// The kernel's own enum is deliberately not replaced by the contract's: it is +/// shared with the subsystems that come after memory, which must not inherit +/// their vocabulary from a *memory* crate. +fn from_contract_class(class: ContractDriverClass) -> DriverClass { + match class { + ContractDriverClass::Embedded => DriverClass::Embedded, + ContractDriverClass::External => DriverClass::External, + ContractDriverClass::Null => DriverClass::Null, } } @@ -261,6 +270,20 @@ fn implicit_class( /// Pure — no I/O, no globals — so the fail-closed trust rule is unit-testable /// without booting anything. /// +/// The rules themselves live in [`tinymemory::registry`], because they are the +/// one part of binding with the same correct answer for every host: a built-in +/// id's class is fixed and an explicit `class` line may only confirm it, an +/// unknown id is refused rather than guessed, and an external driver is +/// fail-closed on trust. This function is the projection from *this* host's +/// config shape onto that decision, and the conversion back into the kernel's +/// generic driver vocabulary. +/// +/// Note what is deliberately **not** passed to the crate: only the `class` and +/// `trust_state` of the driver entry cross, never `credential_ref` or +/// `endpoint`. A refusal message is operator-facing and logged, so the narrow +/// projection is what makes "no secret can appear in a refusal" structural +/// rather than a rule someone has to remember. +/// /// # Errors /// /// Returns the [`FallbackReason`] to record and publish when the configured @@ -268,79 +291,13 @@ fn implicit_class( /// requires the subsystem stay bound, loudly. pub fn admit(cfg: &MemorySubsystemConfig) -> Result<(String, DriverClass), FallbackReason> { let id = cfg.driver.trim(); - if id.is_empty() { - return Err(FallbackReason { - configured_driver: String::new(), - reason: "[subsystems.memory] driver is empty".to_string(), - }); - } - - let refuse = |reason: &str| FallbackReason { - configured_driver: id.to_string(), - reason: reason.to_string(), - }; - - // A driver needs no `[subsystems.memory.drivers.]` entry: the embedded - // default's options still live in the existing `[memory]` blocks. But only - // the two built-in ids are implicitly admitted — anything else is a typo or - // an external backend that forgot its entry, and admitting it would silently - // run TinyCortex under an invented driver id (kernel.md §3.1, one driver per - // slot, named truthfully). Refuse it so the fallback machinery surfaces the - // mistake in status instead of mislabelling the bound engine. - let Some(entry) = cfg.drivers.get(id) else { - return implicit_class(id, &refuse, "no [subsystems.memory.drivers.] entry"); - }; - - let (admitted_id, class) = match entry.class.as_deref() { - // An entry that names no class still cannot admit an arbitrary id: the - // embedded default is the only non-null id that implies Embedded. - None => implicit_class( - id, - &refuse, - "[subsystems.memory.drivers.] has no class line", - )?, - Some(raw) => { - let class = DriverClass::parse(raw).map_err(|e| refuse(&e))?; - // The two built-in ids name a *fixed* implementation, so an - // explicit `class` line may confirm it but never override it. - // Without this, `driver = "null"` plus - // `[subsystems.memory.drivers.null] class = "embedded"` would build - // `EmbeddedMemoryProvider`, advertise all thirteen families and - // persist memory under the id documented as `/dev/null`; the - // inverse would label a store-nothing provider `tinycortex`. Either - // way the bound engine is mislabelled, which is exactly what the - // implicit-class refusal above exists to prevent (kernel.md §3.1 — - // one driver per slot, named truthfully). - if let Some(fixed) = reserved_class(id) { - if class != fixed { - return Err(refuse(&format!( - "driver id \"{id}\" is built in and is always class \ - \"{}\"; remove the conflicting class = \"{raw}\" line", - fixed.as_str() - ))); - } - } - (id.to_string(), class) - } - }; - - if class == DriverClass::External { - // kernel.md §3.4: fail-closed. Trust must be explicitly raised before - // an out-of-process driver is allowed to answer for memory. - if entry.trust_state != "trusted" { - return Err(refuse( - "external driver is untrusted: set trust_state = \"trusted\" \ - under [subsystems.memory.drivers] to allow this binding", - )); - } - // Distinct reason string from the trust refusal above, so the trust - // test cannot pass for the wrong reason. - return Err(refuse( - "external driver transport is not implemented yet (the http adapter lands in M4)", - )); - } + let entry = cfg.drivers.get(id).map(|entry| DriverEntry { + class: entry.class.as_deref(), + trust_state: entry.trust_state.as_str(), + }); - Ok((admitted_id, class)) + let admission = registry().admit(&cfg.driver, entry, CONFIG_LABELS)?; + Ok((admission.id, from_contract_class(admission.class))) } /// Build the binding for a workspace. Infallible by design: an inadmissible diff --git a/src/openhuman/memory/binding_tests.rs b/src/openhuman/memory/binding_tests.rs index 8f1ab73b04..87e2e95136 100644 --- a/src/openhuman/memory/binding_tests.rs +++ b/src/openhuman/memory/binding_tests.rs @@ -11,6 +11,11 @@ use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; +// Imported here rather than re-exported from `binding.rs`: since admission +// moved to `tinymemory::registry`, the production module no longer names this +// constant and an import kept alive only for the tests would read as dead code. +use crate::openhuman::memory::driver::embedded::EMBEDDED_DRIVER_ID; + use async_trait::async_trait; use tinycortex_api::capabilities::Capability; use tinycortex_api::error::MemoryError; diff --git a/src/openhuman/memory/driver/embedded/mod.rs b/src/openhuman/memory/driver/embedded/mod.rs index 7b8476acfe..8946c3bc85 100644 --- a/src/openhuman/memory/driver/embedded/mod.rs +++ b/src/openhuman/memory/driver/embedded/mod.rs @@ -78,7 +78,15 @@ use crate::openhuman::memory::Memory; /// Matches the `[subsystems.memory] driver` default (`default_memory_driver` /// in `config::schema::subsystems`), so a default-configured host reports the /// same id from config and from the bound provider. -pub const EMBEDDED_DRIVER_ID: &str = "tinycortex"; +/// +/// **Re-exported, not re-declared.** The same id is what +/// [`tinymemory::registry`] reserves at [`DriverClass::Embedded`], and +/// admission is decided there. Two independent string literals that must agree +/// is precisely the kind of pair that silently stops agreeing: the day one is +/// edited, `admit` would stop recognising this driver and every bind would fall +/// back to the null placeholder — loudly in the logs, but with memory writes +/// discarded for the whole run. One constant, one definition, no drift. +pub use tinymemory::registry::TINYCORTEX_DRIVER_ID as EMBEDDED_DRIVER_ID; /// The families this driver advertises: **all thirteen**. /// diff --git a/vendor/tinymemory b/vendor/tinymemory new file mode 160000 index 0000000000..c58ca499af --- /dev/null +++ b/vendor/tinymemory @@ -0,0 +1 @@ +Subproject commit c58ca499afd853e4b4f205fb88e6ddcb22d98f69