diff --git a/_primitives/_rust/kei-chat-store/src/schema.rs b/_primitives/_rust/kei-chat-store/src/schema.rs
index 56d58d7..24822cd 100644
--- a/_primitives/_rust/kei-chat-store/src/schema.rs
+++ b/_primitives/_rust/kei-chat-store/src/schema.rs
@@ -1,37 +1,27 @@
-//! kei-chat-store EntitySchema — declarative spec consumed by
+//! kei-chat-store EntitySchemas — declarative specs consumed by
//! `kei_entity_store::Store` and its verb templates.
//!
-//! Shape (Layer-A convergence, 2026-04-23; cost-column re-migration
-//! 2026-04-23 wave 8):
+//! Shape (multi-schema convergence, 2026-04-23):
//!
-//! - Primary entity = `chat_messages` (INTEGER PK; required by engine).
-//! Engine owns: create / get / list / search verbs + FTS reindex.
-//! - Bespoke: `chat_sessions` has a TEXT UUID primary key. The engine
-//! gained `FieldKind::TextPk` in wave 8 but Store::open currently
-//! takes a SINGLE EntitySchema, so a second managed schema for
-//! sessions would require engine multi-schema support. Until that
-//! lands, the session DDL rides `custom_migrations` and its CRUD
-//! stays in `sessions.rs` (analogous to kei-task's milestones / deps
-//! / graph). **Known open:** promote chat_sessions to a second
-//! EntitySchema (`TextPk` + `TextArchiveEnum`) once engine gains
-//! multi-schema support.
-//! - Archive: sessions use a TEXT `status` enum ('active' | 'archived').
-//! Session archival stays bespoke (same reason as above).
-//! - Per-message `cost` (REAL) is restored via
-//! `FieldKind::RealDefault(0.0)` — wave-8 addition. Engine-managed
-//! INSERT/SELECT, no bespoke SQL needed. Previously dropped when
-//! the engine had no REAL kind; re-instated 2026-04-23.
+//! - `MESSAGES_SCHEMA`: primary entity `chat_messages` (INTEGER PK;
+//! engine-owned create/get/list/search + FTS reindex).
+//! - `SESSIONS_SCHEMA`: second entity `chat_sessions` (TEXT UUID PK +
+//! `TextArchiveEnum` status column, engine-owned create/get/archive).
+//! Previously rode `custom_migrations`; now a first-class schema
+//! since `Store::open` accepts a slice of schemas.
+//! - `ALL_SCHEMAS`: the `&[&EntitySchema]` slice the `Store` wrapper
+//! hands to the engine on open.
//!
-//! FTS column-name change vs pre-convergence shape:
-//! legacy fts_chat(message_id, session_id UNINDEXED, content)
-//! engine fts_chat_messages(chat_messages_id UNINDEXED, content)
-//! The `session_id` shadow column was UNINDEXED (never matched on) so
-//! no search path regresses. Fresh databases only — on-disk DBs from
-//! the pre-engine era need recreation.
+//! The session aggregates (`message_count`, `total_tokens`, `total_cost`)
+//! are still updated via bespoke SQL in `sessions.rs` because the
+//! engine has no `increment-on-related-insert` verb. That bespoke path
+//! shrank from "whole row lifecycle" to "UPDATE counters only".
use kei_entity_store::schema::{EdgeKeyKind, EntitySchema, FieldDef};
-static FIELDS: &[FieldDef] = &[
+// ---- chat_messages ---------------------------------------------------
+
+static MESSAGE_FIELDS: &[FieldDef] = &[
FieldDef::pk("id"),
FieldDef::text_nn("session_id"),
FieldDef::text_nn("role"),
@@ -42,33 +32,58 @@ static FIELDS: &[FieldDef] = &[
FieldDef::created_at(),
];
-const DDL_SECONDARY: &str = r#"
- CREATE INDEX IF NOT EXISTS idx_cm_session ON chat_messages(session_id);
+/// Keep the idx_cm_session index around — generic schema has no
+/// `indexed` flag for one-off single-column indexes on non-PK fields.
+const MESSAGES_INDEX_DDL: &str =
+ "CREATE INDEX IF NOT EXISTS idx_cm_session ON chat_messages(session_id);";
- CREATE TABLE IF NOT EXISTS chat_sessions (
- id TEXT PRIMARY KEY,
- project TEXT NOT NULL,
- title TEXT DEFAULT '',
- model TEXT DEFAULT '',
- status TEXT DEFAULT 'active',
- message_count INTEGER DEFAULT 0,
- total_tokens INTEGER DEFAULT 0,
- total_cost REAL DEFAULT 0.0,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
- );
- CREATE INDEX IF NOT EXISTS idx_cs_project ON chat_sessions(project);
- CREATE INDEX IF NOT EXISTS idx_cs_status ON chat_sessions(status);
-"#;
-
-pub static CHAT_SCHEMA: EntitySchema = EntitySchema {
+pub static MESSAGES_SCHEMA: EntitySchema = EntitySchema {
name: "chat_message",
table: "chat_messages",
- fields: FIELDS,
+ fields: MESSAGE_FIELDS,
enabled_verbs: &["create", "get", "list", "search"],
fts_columns: Some(&["content"]),
edge_table: None,
edge_key_kind: EdgeKeyKind::IntegerPair,
archived_field: None,
- custom_migrations: &[DDL_SECONDARY],
+ custom_migrations: &[MESSAGES_INDEX_DDL],
};
+
+// ---- chat_sessions ---------------------------------------------------
+
+static SESSION_FIELDS: &[FieldDef] = &[
+ FieldDef::text_pk("id"),
+ FieldDef::text_nn("project"),
+ FieldDef::text_default("title", ""),
+ FieldDef::text_default("model", ""),
+ FieldDef::text_archive_enum("status", "active", "archived"),
+ FieldDef::integer("status_at"),
+ FieldDef::integer("message_count"),
+ FieldDef::integer("total_tokens"),
+ FieldDef::real_default("total_cost", 0.0),
+ FieldDef::created_at(),
+ FieldDef::updated_at(),
+];
+
+/// Legacy indexes on chat_sessions (project, status). `indexed` flag on
+/// FieldDef only covers single-column indexes with a deterministic
+/// `idx_
_` name — matches what we need here.
+const SESSIONS_INDEX_DDL: &str = "\
+ CREATE INDEX IF NOT EXISTS idx_cs_project ON chat_sessions(project);\n\
+ CREATE INDEX IF NOT EXISTS idx_cs_status ON chat_sessions(status);";
+
+pub static SESSIONS_SCHEMA: EntitySchema = EntitySchema {
+ name: "chat_session",
+ table: "chat_sessions",
+ fields: SESSION_FIELDS,
+ enabled_verbs: &["create", "get", "archive", "update"],
+ fts_columns: None,
+ edge_table: None,
+ edge_key_kind: EdgeKeyKind::IntegerPair,
+ archived_field: Some("status"),
+ custom_migrations: &[SESSIONS_INDEX_DDL],
+};
+
+// ---- aggregate slice for Store::open -------------------------------
+
+pub static ALL_SCHEMAS: &[&EntitySchema] = &[&MESSAGES_SCHEMA, &SESSIONS_SCHEMA];
diff --git a/_primitives/_rust/kei-chat-store/src/search.rs b/_primitives/_rust/kei-chat-store/src/search.rs
index 198b7e8..d21fe18 100644
--- a/_primitives/_rust/kei-chat-store/src/search.rs
+++ b/_primitives/_rust/kei-chat-store/src/search.rs
@@ -1,13 +1,13 @@
//! FTS over messages.
//!
//! Layer-A convergence (2026-04-23): delegates to
-//! `kei_entity_store::verbs::search` using `CHAT_SCHEMA`. The engine
+//! `kei_entity_store::verbs::search` using `MESSAGES_SCHEMA`. The engine
//! handles FTS5 JOIN + rank ordering; this module maps the generic
//! JSON result back to typed `ChatMessage` rows for legacy callers.
//! Per-message `cost` is persisted (engine `RealDefault` field);
//! `row_to_message` reads it back as f64.
-use crate::schema::CHAT_SCHEMA;
+use crate::schema::MESSAGES_SCHEMA;
use crate::sessions::ChatMessage;
use crate::store::Store;
use anyhow::{anyhow, Result};
@@ -16,7 +16,7 @@ use serde_json::{json, Value};
pub fn search(store: &Store, query: &str, limit: i64) -> Result> {
let lim = if limit <= 0 { 20 } else { limit };
- let v = v_search::run(store.conn(), &CHAT_SCHEMA, json!({ "query": query, "limit": lim }))
+ let v = v_search::run(store.conn(), &MESSAGES_SCHEMA, json!({ "query": query, "limit": lim }))
.map_err(|e| anyhow!("{e}"))?;
let arr = v["results"]
.as_array()
diff --git a/_primitives/_rust/kei-chat-store/src/sessions.rs b/_primitives/_rust/kei-chat-store/src/sessions.rs
index 39cd56e..84033c5 100644
--- a/_primitives/_rust/kei-chat-store/src/sessions.rs
+++ b/_primitives/_rust/kei-chat-store/src/sessions.rs
@@ -1,20 +1,23 @@
//! Session + message operations.
//!
-//! Layer-A convergence (2026-04-23): message INSERT + FTS reindex
-//! delegate to `kei_entity_store::verbs::create` via `CHAT_SCHEMA`.
-//! Session rows still use bespoke SQL (TEXT UUID PK + TEXT status enum
-//! are outside the engine's INTEGER-PK / INTEGER-archived-field model).
-//! `save_message` still owns the bespoke session-counter UPDATE after
-//! each message insert — same semantics as pre-convergence.
+//! Multi-schema convergence (2026-04-23): BOTH sessions and messages
+//! now flow through `kei_entity_store::verbs::*`. `start_session` uses
+//! `create` against `SESSIONS_SCHEMA` (TextPk + TextArchiveEnum);
+//! `archive_session` uses `archive`; `get_session` uses `get`;
+//! `save_message` uses `create` against `MESSAGES_SCHEMA`.
+//!
+//! Only the per-message aggregate update on `chat_sessions`
+//! (message_count / total_tokens / total_cost) stays bespoke — the
+//! engine has no "update-on-related-insert" verb.
-use crate::schema::CHAT_SCHEMA;
+use crate::schema::{MESSAGES_SCHEMA, SESSIONS_SCHEMA};
use crate::store::Store;
use anyhow::{anyhow, Result};
use chrono::Utc;
-use kei_entity_store::verbs::create as v_create;
+use kei_entity_store::verbs::{archive as v_archive, create as v_create, get as v_get};
use rusqlite::params;
use serde::{Deserialize, Serialize};
-use serde_json::json;
+use serde_json::{json, Value};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChatSession {
@@ -44,12 +47,12 @@ pub struct ChatMessage {
pub fn start_session(store: &Store, project: &str, title: &str, model: &str) -> Result {
let id = uuid::Uuid::new_v4().to_string();
- let now = Utc::now().timestamp();
- store.conn().execute(
- "INSERT INTO chat_sessions (id, project, title, model, status, created_at, updated_at)
- VALUES (?1,?2,?3,?4,'active',?5,?5)",
- params![id, project, title, model, now],
- )?;
+ v_create::run(
+ store.conn(),
+ &SESSIONS_SCHEMA,
+ json!({ "id": id, "project": project, "title": title, "model": model }),
+ )
+ .map_err(|e| anyhow!("{e}"))?;
Ok(id)
}
@@ -64,43 +67,68 @@ pub fn save_message(store: &Store, msg: &ChatMessage) -> Result {
"cost": msg.cost,
"created_at": msg.created_at,
});
- let v = v_create::run(store.conn(), &CHAT_SCHEMA, payload)
+ let v = v_create::run(store.conn(), &MESSAGES_SCHEMA, payload)
.map_err(|e| anyhow!("{e}"))?;
let id = v["id"]
.as_i64()
.ok_or_else(|| anyhow!("missing id in create response"))?;
- store.conn().execute(
- "UPDATE chat_sessions SET message_count = message_count + 1,
- total_tokens = total_tokens + ?1, total_cost = total_cost + ?2,
- updated_at = ?3 WHERE id = ?4",
- params![msg.tokens_in + msg.tokens_out, msg.cost, now, msg.session_id],
- )?;
+ bump_session_totals(store, &msg.session_id, msg.tokens_in + msg.tokens_out, msg.cost, now)?;
Ok(id)
}
-pub fn archive_session(store: &Store, session_id: &str) -> Result<()> {
- let n = store.conn().execute(
- "UPDATE chat_sessions SET status='archived', updated_at=?1 WHERE id=?2",
- params![Utc::now().timestamp(), session_id],
+/// Bespoke aggregate update — engine has no "increment-on-related-insert"
+/// verb. Keeps the per-session counters in sync with what was just
+/// inserted into chat_messages.
+fn bump_session_totals(
+ store: &Store,
+ session_id: &str,
+ tokens_delta: i64,
+ cost_delta: f64,
+ now: i64,
+) -> Result<()> {
+ store.conn().execute(
+ "UPDATE chat_sessions
+ SET message_count = message_count + 1,
+ total_tokens = total_tokens + ?1,
+ total_cost = total_cost + ?2,
+ updated_at = ?3
+ WHERE id = ?4",
+ params![tokens_delta, cost_delta, now, session_id],
)?;
- if n == 0 {
- return Err(anyhow!("session {session_id} not found"));
- }
+ Ok(())
+}
+
+pub fn archive_session(store: &Store, session_id: &str) -> Result<()> {
+ v_archive::run(store.conn(), &SESSIONS_SCHEMA, json!({ "id": session_id }))
+ .map_err(|e| anyhow!("{e}"))?;
Ok(())
}
pub fn get_session(store: &Store, id: &str) -> Result