Browse Source

Rename Journal to Book and extract BookPolicy

'Journal' was an overloaded name. In accounting a journal is the
chronological, append-only book of entries — a role already played by
Kuatia's transfer log. The type doesn't record entries; it scopes which
accounts and assets may participate in a transfer. That is grouping, not
logging, and is conceptually a Book (a 'set of books'), so rename it to
match the concept and free 'journal' for the entry log.

Extract the three allowed_* participation rules into a BookPolicy struct,
mirroring Account { policy: AccountPolicy }, so a Book is { id, name,
policy }. Carry the rename through the storage trait, both store backends,
the SQL schema and migration, the Ledger API, and the docs, and add the
clarifying note that a Book is a transfer policy scope — not the entry
log, and not a balance partition.

Also add the previously-missing BookStore conformance tests, which
surfaced and fixed a bug where the SQL create_book returned Internal
instead of AlreadyExists on duplicate ids.
Cesar Rodas 1 week ago
parent
commit
ad644ee794

+ 1 - 1
CLAUDE.md

@@ -18,7 +18,7 @@ doc/
   crates.md         Crate reference: modules, types, APIs
   accounts.md       Account model, policies, lifecycle
   transfers.md      Transfer/Movement API, resolve algorithm
-  glossary.md       Terms, journal design, exchange & supermarket examples
+  glossary.md       Terms, book design, exchange & supermarket examples
 ```
 
 ## Key concepts

+ 14 - 14
crates/kuatia-core/src/validate.rs

@@ -417,7 +417,7 @@ mod tests {
             version: 1,
             policy,
             flags: AccountFlags::empty(),
-            journal: JournalId(0),
+            book: BookId(0),
             user_data: UserData::default(),
             metadata: BTreeMap::new(),
         }
@@ -446,7 +446,7 @@ mod tests {
                     payer: None,
                 },
             ],
-            journal: JournalId(0),
+            book: BookId(0),
             user_data: UserData::default(),
             account_snapshots: vec![],
             metadata: BTreeMap::new(),
@@ -478,7 +478,7 @@ mod tests {
         let envelope = Envelope {
             consumes: vec![],
             creates: vec![],
-            journal: JournalId(0),
+            book: BookId(0),
             user_data: UserData::default(),
             account_snapshots: vec![],
             metadata: BTreeMap::new(),
@@ -508,7 +508,7 @@ mod tests {
                 value: Cent::from(100),
                 payer: None,
             }],
-            journal: JournalId(0),
+            book: BookId(0),
             user_data: UserData::default(),
             account_snapshots: vec![],
             metadata: BTreeMap::new(),
@@ -537,7 +537,7 @@ mod tests {
         let envelope = Envelope {
             consumes: vec![missing_pid],
             creates: vec![],
-            journal: JournalId(0),
+            book: BookId(0),
             user_data: UserData::default(),
             account_snapshots: vec![],
             metadata: BTreeMap::new(),
@@ -578,7 +578,7 @@ mod tests {
                 value: Cent::from(100),
                 payer: None,
             }],
-            journal: JournalId(0),
+            book: BookId(0),
             user_data: UserData::default(),
             account_snapshots: vec![],
             metadata: BTreeMap::new(),
@@ -664,7 +664,7 @@ mod tests {
                 value: Cent::from(50),
                 payer: None,
             }],
-            journal: JournalId(0),
+            book: BookId(0),
             user_data: UserData::default(),
             account_snapshots: vec![],
             metadata: BTreeMap::new(),
@@ -714,7 +714,7 @@ mod tests {
                 value: Cent::from(100),
                 payer: None,
             }],
-            journal: JournalId(0),
+            book: BookId(0),
             user_data: UserData::default(),
             account_snapshots: vec![],
             metadata: BTreeMap::new(),
@@ -766,7 +766,7 @@ mod tests {
                 value: Cent::from(100),
                 payer: None,
             }],
-            journal: JournalId(0),
+            book: BookId(0),
             user_data: UserData::default(),
             account_snapshots: vec![],
             metadata: BTreeMap::new(),
@@ -823,7 +823,7 @@ mod tests {
                 value: Cent::from(100),
                 payer: None,
             }],
-            journal: JournalId(0),
+            book: BookId(0),
             user_data: UserData::default(),
             account_snapshots: vec![],
             metadata: BTreeMap::new(),
@@ -856,7 +856,7 @@ mod tests {
         let envelope = Envelope {
             consumes: vec![pid, pid], // duplicate
             creates: vec![],
-            journal: JournalId(0),
+            book: BookId(0),
             user_data: UserData::default(),
             account_snapshots: vec![],
             metadata: BTreeMap::new(),
@@ -906,7 +906,7 @@ mod tests {
                     payer: None,
                 },
             ],
-            journal: JournalId(0),
+            book: BookId(0),
             user_data: UserData::default(),
             account_snapshots: vec![],
             metadata: BTreeMap::new(),
@@ -950,7 +950,7 @@ mod tests {
                     payer: None,
                 },
             ],
-            journal: JournalId(0),
+            book: BookId(0),
             user_data: UserData::default(),
             account_snapshots: vec![],
             metadata: BTreeMap::new(),
@@ -989,7 +989,7 @@ mod tests {
                     payer: None,
                 },
             ],
-            journal: JournalId(0),
+            book: BookId(0),
             user_data: UserData::default(),
             account_snapshots: vec![],
             metadata: BTreeMap::new(),

+ 33 - 24
crates/kuatia-storage-sql/src/lib.rs

@@ -41,7 +41,7 @@ impl SqlStore {
             include_str!("migrations/001_init.sql"),
             include_str!("migrations/002_timestamps_and_columns.sql"),
             include_str!("migrations/003_events.sql"),
-            include_str!("migrations/004_journals.sql"),
+            include_str!("migrations/004_books.sql"),
         ] {
             for statement in sql.split(';') {
                 let trimmed = statement.trim();
@@ -108,8 +108,8 @@ fn row_to_account(row: &sqlx::any::AnyRow) -> Result<Account, StoreError> {
     let flags_bits: i32 = row
         .try_get("flags")
         .map_err(|e| StoreError::Internal(e.to_string()))?;
-    let journal: i64 = row
-        .try_get("journal")
+    let book: i64 = row
+        .try_get("book")
         .map_err(|e| StoreError::Internal(e.to_string()))?;
     let user_data_bytes: Vec<u8> = row
         .try_get("user_data")
@@ -123,7 +123,7 @@ fn row_to_account(row: &sqlx::any::AnyRow) -> Result<Account, StoreError> {
         version: version as u64,
         policy: deserialize_policy(&policy_str)?,
         flags: AccountFlags::from_bits_truncate(flags_bits as u32),
-        journal: JournalId::new(journal),
+        book: BookId::new(book),
         user_data: deserialize_blob(&user_data_bytes)?,
         metadata: deserialize_blob(&metadata_bytes)?,
     })
@@ -202,13 +202,13 @@ impl AccountStore for SqlStore {
         }
 
         sqlx::query(
-            "INSERT INTO accounts (id, version, policy, flags, journal, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7)"
+            "INSERT INTO accounts (id, version, policy, flags, book, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7)"
         )
             .bind(account.id.0)
             .bind(account.version as i64)
             .bind(serialize_policy(&account.policy)?)
             .bind(account.flags.bits() as i32)
-            .bind(account.journal.0)
+            .bind(account.book.0)
             .bind(serialize_blob(&account.user_data)?)
             .bind(serialize_blob(&account.metadata)?)
             .execute(&self.pool)
@@ -242,13 +242,13 @@ impl AccountStore for SqlStore {
         }
 
         sqlx::query(
-            "INSERT INTO accounts (id, version, policy, flags, journal, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7)"
+            "INSERT INTO accounts (id, version, policy, flags, book, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7)"
         )
             .bind(account.id.0)
             .bind(account.version as i64)
             .bind(serialize_policy(&account.policy)?)
             .bind(account.flags.bits() as i32)
-            .bind(account.journal.0)
+            .bind(account.book.0)
             .bind(serialize_blob(&account.user_data)?)
             .bind(serialize_blob(&account.metadata)?)
             .execute(&self.pool)
@@ -568,12 +568,12 @@ impl TransferStore for SqlStore {
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
 
-        sqlx::query("INSERT INTO transfers (id, transfer, receipt, created_at, journal) VALUES ($1, $2, $3, $4, $5)")
+        sqlx::query("INSERT INTO transfers (id, transfer, receipt, created_at, book) VALUES ($1, $2, $3, $4, $5)")
             .bind(tid.0.as_slice())
             .bind(&transfer_bytes)
             .bind(&receipt_bytes)
             .bind(record.created_at)
-            .bind(record.envelope.journal().0)
+            .bind(record.envelope.book().0)
             .execute(&mut *tx)
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
@@ -680,8 +680,8 @@ impl TransferStore for SqlStore {
                 {
                     return false;
                 }
-                if let Some(journal) = query.journal
-                    && r.envelope.journal() != journal
+                if let Some(book) = query.book
+                    && r.envelope.book() != book
                 {
                     return false;
                 }
@@ -795,16 +795,25 @@ impl EventStore for SqlStore {
 }
 
 // ---------------------------------------------------------------------------
-// JournalStore
+// BookStore
 // ---------------------------------------------------------------------------
 
 #[async_trait]
-impl JournalStore for SqlStore {
-    async fn create_journal(&self, journal: Journal) -> Result<(), StoreError> {
-        let data = serialize_blob(&journal)?;
-        sqlx::query("INSERT INTO journals (id, name, data) VALUES ($1, $2, $3)")
-            .bind(journal.id.0)
-            .bind(&journal.name)
+impl BookStore for SqlStore {
+    async fn create_book(&self, book: Book) -> Result<(), StoreError> {
+        let exists = sqlx::query("SELECT 1 FROM books WHERE id = $1 LIMIT 1")
+            .bind(book.id.0)
+            .fetch_optional(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        if exists.is_some() {
+            return Err(StoreError::AlreadyExists(format!("book {:?}", book.id)));
+        }
+
+        let data = serialize_blob(&book)?;
+        sqlx::query("INSERT INTO books (id, name, data) VALUES ($1, $2, $3)")
+            .bind(book.id.0)
+            .bind(&book.name)
             .bind(&data)
             .execute(&self.pool)
             .await
@@ -812,21 +821,21 @@ impl JournalStore for SqlStore {
         Ok(())
     }
 
-    async fn get_journal(&self, id: &JournalId) -> Result<Journal, StoreError> {
-        let row = sqlx::query("SELECT data FROM journals WHERE id = $1")
+    async fn get_book(&self, id: &BookId) -> Result<Book, StoreError> {
+        let row = sqlx::query("SELECT data FROM books WHERE id = $1")
             .bind(id.0)
             .fetch_optional(&self.pool)
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?
-            .ok_or_else(|| StoreError::NotFound(format!("journal {id:?}")))?;
+            .ok_or_else(|| StoreError::NotFound(format!("book {id:?}")))?;
         let data: Vec<u8> = row
             .try_get("data")
             .map_err(|e| StoreError::Internal(e.to_string()))?;
         deserialize_blob(&data)
     }
 
-    async fn list_journals(&self) -> Result<Vec<Journal>, StoreError> {
-        let rows = sqlx::query("SELECT data FROM journals")
+    async fn list_books(&self) -> Result<Vec<Book>, StoreError> {
+        let rows = sqlx::query("SELECT data FROM books")
             .fetch_all(&self.pool)
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;

+ 1 - 1
crates/kuatia-storage-sql/src/migrations/001_init.sql

@@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS accounts (
     version     BIGINT NOT NULL,
     policy      TEXT NOT NULL,
     flags       INTEGER NOT NULL,
-    journal     BIGINT NOT NULL,
+    book     BIGINT NOT NULL,
     user_data   BLOB NOT NULL,
     metadata    BLOB NOT NULL,
     PRIMARY KEY (id, version)

+ 2 - 2
crates/kuatia-storage-sql/src/migrations/002_timestamps_and_columns.sql

@@ -1,4 +1,4 @@
 ALTER TABLE transfers ADD COLUMN created_at BIGINT NOT NULL DEFAULT 0;
-ALTER TABLE transfers ADD COLUMN journal BIGINT NOT NULL DEFAULT 0;
+ALTER TABLE transfers ADD COLUMN book BIGINT NOT NULL DEFAULT 0;
 CREATE INDEX idx_transfers_created_at ON transfers(created_at);
-CREATE INDEX idx_transfers_journal ON transfers(journal);
+CREATE INDEX idx_transfers_book ON transfers(book);

+ 1 - 1
crates/kuatia-storage-sql/src/migrations/004_journals.sql → crates/kuatia-storage-sql/src/migrations/004_books.sql

@@ -1,4 +1,4 @@
-CREATE TABLE IF NOT EXISTS journals (
+CREATE TABLE IF NOT EXISTS books (
     id       BIGINT PRIMARY KEY,
     name     TEXT NOT NULL,
     data     BLOB NOT NULL

+ 18 - 18
crates/kuatia-storage/src/mem_store.rs

@@ -8,13 +8,13 @@ use tokio::sync::RwLock;
 
 use kuatia_types::autoid::AutoId;
 use kuatia_types::{
-    Account, AccountId, AssetId, EnvelopeId, Journal, JournalId, Posting, PostingId, PostingStatus,
+    Account, AccountId, AssetId, EnvelopeId, Book, BookId, Posting, PostingId, PostingStatus,
 };
 
 use crate::error::StoreError;
 use crate::events::{EventStore, LedgerEvent};
 use crate::store::{
-    AccountStore, EnvelopeRecord, JournalStore, PostingStore, SagaStore, TransferStore,
+    AccountStore, EnvelopeRecord, BookStore, PostingStore, SagaStore, TransferStore,
 };
 
 /// In-memory [`Store`](crate::store::Store) implementation backed by `RwLock<HashMap>`.
@@ -24,7 +24,7 @@ pub struct InMemoryStore {
     transfers: RwLock<HashMap<EnvelopeId, EnvelopeRecord>>,
     sagas: RwLock<HashMap<i64, Vec<u8>>>,
     events: RwLock<Vec<LedgerEvent>>,
-    journals: RwLock<HashMap<JournalId, Journal>>,
+    books: RwLock<HashMap<BookId, Book>>,
     autoid: AutoId,
 }
 
@@ -43,7 +43,7 @@ impl InMemoryStore {
             transfers: RwLock::new(HashMap::new()),
             sagas: RwLock::new(HashMap::new()),
             events: RwLock::new(Vec::new()),
-            journals: RwLock::new(HashMap::new()),
+            books: RwLock::new(HashMap::new()),
             autoid: AutoId::new(),
         }
     }
@@ -322,29 +322,29 @@ impl EventStore for InMemoryStore {
 }
 
 #[async_trait]
-impl JournalStore for InMemoryStore {
-    async fn create_journal(&self, journal: Journal) -> Result<(), StoreError> {
-        let mut journals = self.journals.write().await;
-        if journals.contains_key(&journal.id) {
+impl BookStore for InMemoryStore {
+    async fn create_book(&self, book: Book) -> Result<(), StoreError> {
+        let mut books = self.books.write().await;
+        if books.contains_key(&book.id) {
             return Err(StoreError::AlreadyExists(format!(
-                "journal {:?}",
-                journal.id
+                "book {:?}",
+                book.id
             )));
         }
-        journals.insert(journal.id, journal);
+        books.insert(book.id, book);
         Ok(())
     }
 
-    async fn get_journal(&self, id: &JournalId) -> Result<Journal, StoreError> {
-        let journals = self.journals.read().await;
-        journals
+    async fn get_book(&self, id: &BookId) -> Result<Book, StoreError> {
+        let books = self.books.read().await;
+        books
             .get(id)
             .cloned()
-            .ok_or_else(|| StoreError::NotFound(format!("journal {id:?}")))
+            .ok_or_else(|| StoreError::NotFound(format!("book {id:?}")))
     }
 
-    async fn list_journals(&self) -> Result<Vec<Journal>, StoreError> {
-        let journals = self.journals.read().await;
-        Ok(journals.values().cloned().collect())
+    async fn list_books(&self) -> Result<Vec<Book>, StoreError> {
+        let books = self.books.read().await;
+        Ok(books.values().cloned().collect())
     }
 }

+ 15 - 15
crates/kuatia-storage/src/store.rs

@@ -8,7 +8,7 @@
 
 use async_trait::async_trait;
 use kuatia_types::{
-    Account, AccountId, AssetId, Envelope, EnvelopeId, Journal, JournalId, Posting, PostingId,
+    Account, AccountId, AssetId, Envelope, EnvelopeId, Book, BookId, Posting, PostingId,
     PostingStatus, Receipt,
 };
 
@@ -50,8 +50,8 @@ pub struct TransferQuery {
     pub from_ts: Option<i64>,
     /// Exclusive upper bound (unix millis).
     pub to_ts: Option<i64>,
-    /// Filter by journal.
-    pub journal: Option<JournalId>,
+    /// Filter by book.
+    pub book: Option<BookId>,
     /// Max results to return.
     pub limit: Option<u32>,
     /// Number of results to skip.
@@ -173,8 +173,8 @@ pub trait TransferStore: Send + Sync {
                 {
                     return false;
                 }
-                if let Some(journal) = query.journal
-                    && r.envelope.journal() != journal
+                if let Some(book) = query.book
+                    && r.envelope.book() != book
                 {
                     return false;
                 }
@@ -202,15 +202,15 @@ pub trait SagaStore: Send + Sync {
     async fn delete_saga(&self, id: &i64) -> Result<(), StoreError>;
 }
 
-/// Journal persistence.
+/// Book persistence.
 #[async_trait]
-pub trait JournalStore: Send + Sync {
-    /// Create a new journal.
-    async fn create_journal(&self, journal: Journal) -> Result<(), StoreError>;
-    /// Fetch a journal by id.
-    async fn get_journal(&self, id: &JournalId) -> Result<Journal, StoreError>;
-    /// List all journals.
-    async fn list_journals(&self) -> Result<Vec<Journal>, StoreError>;
+pub trait BookStore: Send + Sync {
+    /// Create a new book.
+    async fn create_book(&self, book: Book) -> Result<(), StoreError>;
+    /// Fetch a book by id.
+    async fn get_book(&self, id: &BookId) -> Result<Book, StoreError>;
+    /// List all books.
+    async fn list_books(&self) -> Result<Vec<Book>, StoreError>;
 }
 
 // ---------------------------------------------------------------------------
@@ -219,11 +219,11 @@ pub trait JournalStore: Send + Sync {
 
 /// Async storage abstraction composing all sub-traits.
 pub trait Store:
-    AccountStore + PostingStore + TransferStore + SagaStore + EventStore + JournalStore
+    AccountStore + PostingStore + TransferStore + SagaStore + EventStore + BookStore
 {
 }
 
-impl<T: AccountStore + PostingStore + TransferStore + SagaStore + EventStore + JournalStore> Store
+impl<T: AccountStore + PostingStore + TransferStore + SagaStore + EventStore + BookStore> Store
     for T
 {
 }

+ 58 - 9
crates/kuatia-storage/src/store_tests.rs

@@ -25,7 +25,7 @@ fn make_account(id: i64, policy: AccountPolicy) -> Account {
         version: 1,
         policy,
         flags: AccountFlags::empty(),
-        journal: JournalId(0),
+        book: BookId(0),
         user_data: UserData::default(),
         metadata: BTreeMap::new(),
     }
@@ -50,7 +50,7 @@ fn make_posting(
     }
 }
 
-fn make_envelope_with_journal(journal: JournalId) -> (Envelope, EnvelopeId) {
+fn make_envelope_with_book(book: BookId) -> (Envelope, EnvelopeId) {
     let t = EnvelopeBuilder::new()
         .creates(vec![
             NewPosting {
@@ -66,11 +66,11 @@ fn make_envelope_with_journal(journal: JournalId) -> (Envelope, EnvelopeId) {
                 payer: None,
             },
         ])
-        .journal(journal)
+        .book(book)
         .build();
-    // Use journal id to create distinct EnvelopeIds.
+    // Use book id to create distinct EnvelopeIds.
     let mut tid_bytes = [0u8; 32];
-    tid_bytes[0] = journal.0 as u8;
+    tid_bytes[0] = book.0 as u8;
     tid_bytes[1] = 42;
     (t, EnvelopeId(tid_bytes))
 }
@@ -494,7 +494,7 @@ pub async fn query_transfers_by_date_range(store: &(impl Store + 'static)) {
         .await
         .unwrap();
 
-    let (e2, t2) = make_envelope_with_journal(JournalId(1));
+    let (e2, t2) = make_envelope_with_book(BookId(1));
     store
         .store_transfer(EnvelopeRecord {
             envelope: e2,
@@ -571,7 +571,7 @@ pub async fn query_transfers_by_book(store: &(impl Store + 'static)) {
         .await
         .unwrap();
 
-    let (e2, t2) = make_envelope_with_journal(JournalId(5));
+    let (e2, t2) = make_envelope_with_book(BookId(5));
     store
         .store_transfer(EnvelopeRecord {
             envelope: e2,
@@ -584,13 +584,13 @@ pub async fn query_transfers_by_book(store: &(impl Store + 'static)) {
     let page = store
         .query_transfers(&TransferQuery {
             account: Some(AccountId::new(1)),
-            journal: Some(JournalId(5)),
+            book: Some(BookId(5)),
             ..Default::default()
         })
         .await
         .unwrap();
     assert_eq!(page.total, 1);
-    assert_eq!(page.items[0].envelope.journal(), JournalId(5));
+    assert_eq!(page.items[0].envelope.book(), BookId(5));
 }
 
 // ---------------------------------------------------------------------------
@@ -673,6 +673,50 @@ pub async fn events_sequence_ordering(store: &(impl Store + 'static)) {
 }
 
 // ---------------------------------------------------------------------------
+// BookStore
+// ---------------------------------------------------------------------------
+
+fn make_book(id: i64, name: &str) -> Book {
+    BookBuilder::new(name)
+        .id(BookId::new(id))
+        .allow_asset(AssetId::new(1))
+        .build()
+}
+
+/// Create a book and read it back.
+pub async fn create_and_get_book(store: &(impl Store + 'static)) {
+    let book = make_book(1, "sales");
+    store.create_book(book.clone()).await.unwrap();
+    let got = store.get_book(&BookId::new(1)).await.unwrap();
+    assert_eq!(got, book);
+}
+
+/// Duplicate book creation fails.
+pub async fn create_duplicate_book_fails(store: &(impl Store + 'static)) {
+    let book = make_book(1, "sales");
+    store.create_book(book.clone()).await.unwrap();
+    let err = store.create_book(book).await.unwrap_err();
+    assert!(matches!(err, StoreError::AlreadyExists(_)));
+}
+
+/// Get a non-existent book returns NotFound.
+pub async fn get_missing_book_fails(store: &(impl Store + 'static)) {
+    let err = store.get_book(&BookId::new(999)).await.unwrap_err();
+    assert!(matches!(err, StoreError::NotFound(_)));
+}
+
+/// List all books.
+pub async fn list_books(store: &(impl Store + 'static)) {
+    store.create_book(make_book(1, "sales")).await.unwrap();
+    store.create_book(make_book(2, "inventory")).await.unwrap();
+    let mut books = store.list_books().await.unwrap();
+    books.sort_by_key(|b| b.id.0);
+    assert_eq!(books.len(), 2);
+    assert_eq!(books[0].name, "sales");
+    assert_eq!(books[1].name, "inventory");
+}
+
+// ---------------------------------------------------------------------------
 // Macro
 // ---------------------------------------------------------------------------
 
@@ -723,6 +767,11 @@ macro_rules! store_tests {
             // EventStore
             append_and_query_events,
             events_sequence_ordering,
+            // BookStore
+            create_and_get_book,
+            create_duplicate_book_fails,
+            get_missing_book_fails,
+            list_books,
         );
     };
 

+ 63 - 52
crates/kuatia-types/src/lib.rs

@@ -411,17 +411,17 @@ impl AssetId {
     }
 }
 
-/// Identifies a journal — a named scope for transfers.
+/// Identifies a book — a named scope for transfers.
 #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
-pub struct JournalId(pub i64);
+pub struct BookId(pub i64);
 
-impl fmt::Debug for JournalId {
+impl fmt::Debug for BookId {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(f, "JournalId({})", self.0)
+        write!(f, "BookId({})", self.0)
     }
 }
 
-impl Default for JournalId {
+impl Default for BookId {
     fn default() -> Self {
         thread_local! {
             static GEN: crate::autoid::AutoId = crate::autoid::AutoId::new();
@@ -430,25 +430,34 @@ impl Default for JournalId {
     }
 }
 
-impl JournalId {
-    /// Create a `JournalId` from an `i64`.
+impl BookId {
+    /// Create a `BookId` from an `i64`.
     pub const fn new(id: i64) -> Self {
         Self(id)
     }
 }
 
 // ---------------------------------------------------------------------------
-// Journal
+// Book
 // ---------------------------------------------------------------------------
 
-/// A journal scopes which accounts and assets may participate in transfers.
-/// Accounts and balances are global — journals only gate participation.
+/// A Book is a transfer policy scope: it gates which accounts and assets may
+/// participate in a transfer. It is **not** the chronological entry log (the
+/// transfer log plays that role), and it does **not** partition balances —
+/// balances are global; a Book only gates participation.
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct Journal {
-    /// Stable identity for this journal.
-    pub id: JournalId,
+pub struct Book {
+    /// Stable identity for this book.
+    pub id: BookId,
     /// Human-readable name.
     pub name: String,
+    /// Participation rules for this book.
+    pub policy: BookPolicy,
+}
+
+/// The participation rules for a [`Book`]. An empty field means "no restriction".
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct BookPolicy {
     /// If non-empty, only these assets may appear in movements.
     pub allowed_assets: Vec<AssetId>,
     /// If non-empty, accounts with ANY of these flags may participate.
@@ -457,52 +466,54 @@ pub struct Journal {
     pub allowed_accounts: Vec<AccountId>,
 }
 
-/// Builder for constructing [`Journal`] values.
-pub struct JournalBuilder {
-    journal: Journal,
+/// Builder for constructing [`Book`] values.
+pub struct BookBuilder {
+    book: Book,
 }
 
-impl JournalBuilder {
-    /// Create a new journal builder with the given name.
+impl BookBuilder {
+    /// Create a new book builder with the given name.
     pub fn new(name: impl Into<String>) -> Self {
         Self {
-            journal: Journal {
-                id: JournalId::default(),
+            book: Book {
+                id: BookId::default(),
                 name: name.into(),
-                allowed_assets: Vec::new(),
-                allowed_flags: AccountFlags::empty(),
-                allowed_accounts: Vec::new(),
+                policy: BookPolicy {
+                    allowed_assets: Vec::new(),
+                    allowed_flags: AccountFlags::empty(),
+                    allowed_accounts: Vec::new(),
+                },
             },
         }
     }
 
-    /// Set the journal id explicitly.
-    pub fn id(mut self, id: JournalId) -> Self {
-        self.journal.id = id;
+    /// Set the book id explicitly.
+    pub fn id(mut self, id: BookId) -> Self {
+        self.book.id = id;
         self
     }
 
     /// Add an allowed asset.
     pub fn allow_asset(mut self, asset: AssetId) -> Self {
-        self.journal.allowed_assets.push(asset);
+        self.book.policy.allowed_assets.push(asset);
         self
     }
 
     /// Set allowed account flags — accounts with ANY of these flags may participate.
     pub fn allow_flags(mut self, flags: AccountFlags) -> Self {
-        self.journal.allowed_flags = flags;
+        self.book.policy.allowed_flags = flags;
         self
     }
 
     /// Add a specific allowed account.
     pub fn allow_account(mut self, account: AccountId) -> Self {
-        self.journal.allowed_accounts.push(account);
+        self.book.policy.allowed_accounts.push(account);
         self
     }
 
-    /// Consume the builder and return the [`Journal`].
-    pub fn build(self) -> Journal {
-        self.journal
+    /// Consume the builder and return the [`Book`].
+    pub fn build(self) -> Book {
+        self.book
     }
 }
 
@@ -604,8 +615,8 @@ pub struct Envelope {
     pub creates: Vec<NewPosting>,
     /// Account version pins for optimistic concurrency.
     pub account_snapshots: Vec<AccountSnapshotId>,
-    /// Journal this envelope belongs to.
-    pub journal: JournalId,
+    /// Book this envelope belongs to.
+    pub book: BookId,
     /// Fixed-width secondary identifiers.
     pub user_data: UserData,
     /// Free-form key-value metadata.
@@ -628,9 +639,9 @@ impl Envelope {
         &self.account_snapshots
     }
 
-    /// Journal this envelope belongs to.
-    pub fn journal(&self) -> JournalId {
-        self.journal
+    /// Book this envelope belongs to.
+    pub fn book(&self) -> BookId {
+        self.book
     }
 
     /// Fixed-width secondary identifiers.
@@ -685,9 +696,9 @@ impl EnvelopeBuilder {
         self
     }
 
-    /// Set the journal.
-    pub fn journal(mut self, journal: JournalId) -> Self {
-        self.envelope.journal = journal;
+    /// Set the book.
+    pub fn book(mut self, book: BookId) -> Self {
+        self.envelope.book = book;
         self
     }
 
@@ -742,8 +753,8 @@ bitflags::bitflags! {
     /// Lifecycle and user-defined flags for an [`Account`].
     ///
     /// Bits 0–7 are reserved for system flags. Bits 8–31 are available for
-    /// user-defined flags, which can be used with [`Journal::allowed_flags`]
-    /// to scope which accounts may participate in a journal.
+    /// user-defined flags, which can be used with [`BookPolicy::allowed_flags`]
+    /// to scope which accounts may participate in a book.
     #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
     pub struct AccountFlags: u32 {
         /// Account may not be the source or destination of any transfer.
@@ -782,8 +793,8 @@ pub struct Account {
     pub policy: AccountPolicy,
     /// Lifecycle flags (frozen, closed).
     pub flags: AccountFlags,
-    /// Journal this entity belongs to.
-    pub journal: JournalId,
+    /// Book this entity belongs to.
+    pub book: BookId,
     /// Fixed-width secondary identifiers.
     pub user_data: UserData,
     /// Free-form key-value metadata.
@@ -843,8 +854,8 @@ pub struct Movement {
 pub struct Transfer {
     /// Movements to execute atomically.
     pub movements: Vec<Movement>,
-    /// Journal this entity belongs to.
-    pub journal: JournalId,
+    /// Book this entity belongs to.
+    pub book: BookId,
     /// Fixed-width secondary identifiers.
     pub user_data: UserData,
     /// Free-form key-value metadata.
@@ -912,9 +923,9 @@ impl TransferBuilder {
         self.movement(from, external, asset, amount)
     }
 
-    /// Set the journal.
-    pub fn journal(mut self, journal: JournalId) -> Self {
-        self.transfer.journal = journal;
+    /// Set the book.
+    pub fn book(mut self, book: BookId) -> Self {
+        self.transfer.book = book;
         self
     }
 
@@ -1009,7 +1020,7 @@ impl ToBytes for AccountFlags {
     }
 }
 
-impl ToBytes for JournalId {
+impl ToBytes for BookId {
     fn to_bytes(&self) -> Vec<u8> {
         self.0.to_be_bytes().to_vec()
     }
@@ -1068,7 +1079,7 @@ impl ToBytes for Envelope {
             buf.extend(snap.to_bytes());
         }
 
-        buf.extend(self.journal.to_bytes());
+        buf.extend(self.book.to_bytes());
         buf.extend(self.user_data.to_bytes());
 
         write_u32(&mut buf, self.metadata.len() as u32);
@@ -1092,7 +1103,7 @@ impl ToBytes for Account {
         write_u64(&mut buf, self.version);
         buf.extend(self.policy.to_bytes());
         buf.extend(self.flags.to_bytes());
-        buf.extend(self.journal.to_bytes());
+        buf.extend(self.book.to_bytes());
         buf.extend(self.user_data.to_bytes());
 
         write_u32(&mut buf, self.metadata.len() as u32);

+ 15 - 15
crates/kuatia/src/ledger.rs

@@ -219,7 +219,7 @@ impl Ledger {
         let mut envelope = EnvelopeBuilder::new()
             .consumes(consumes)
             .creates(creates)
-            .journal(transfer.journal)
+            .book(transfer.book)
             .user_data(transfer.user_data.clone())
             .metadata(transfer.metadata.clone())
             .build();
@@ -264,7 +264,7 @@ impl Ledger {
     /// Steps: resolve movements into envelope -> reserve consumed postings ->
     /// validate -> finalize.
     /// On failure, legend compensates completed steps in reverse order.
-    #[instrument(skip(self, transfer), fields(journal = transfer.journal.0), name = "ledger.commit")]
+    #[instrument(skip(self, transfer), fields(book = transfer.book.0), name = "ledger.commit")]
     pub async fn commit(self: &Arc<Self>, transfer: Transfer) -> Result<Receipt, LedgerError> {
         let saga = TransferSaga::new(TransferSagaInputs {
             resolve: ResolveInput {
@@ -352,7 +352,7 @@ impl Ledger {
         let reverse_envelope = EnvelopeBuilder::new()
             .consumes(created_posting_ids)
             .creates(new_postings)
-            .journal(original.journal())
+            .book(original.book())
             .metadata(original.metadata().clone())
             .build();
 
@@ -561,25 +561,25 @@ impl Ledger {
         Ok(())
     }
 
-    /// Create a new journal.
-    pub async fn create_journal(
+    /// Create a new book.
+    pub async fn create_book(
         &self,
-        journal: kuatia_core::Journal,
+        book: kuatia_core::Book,
     ) -> Result<(), LedgerError> {
-        Ok(self.store.create_journal(journal).await?)
+        Ok(self.store.create_book(book).await?)
     }
 
-    /// Fetch a journal by id.
-    pub async fn get_journal(
+    /// Fetch a book by id.
+    pub async fn get_book(
         &self,
-        id: &kuatia_core::JournalId,
-    ) -> Result<kuatia_core::Journal, LedgerError> {
-        Ok(self.store.get_journal(id).await?)
+        id: &kuatia_core::BookId,
+    ) -> Result<kuatia_core::Book, LedgerError> {
+        Ok(self.store.get_book(id).await?)
     }
 
-    /// List all journals.
-    pub async fn list_journals(&self) -> Result<Vec<kuatia_core::Journal>, LedgerError> {
-        Ok(self.store.list_journals().await?)
+    /// List all books.
+    pub async fn list_books(&self) -> Result<Vec<kuatia_core::Book>, LedgerError> {
+        Ok(self.store.list_books().await?)
     }
 
     /// Query ledger events after a given sequence number.

+ 1 - 1
crates/kuatia/tests/integration.rs

@@ -29,7 +29,7 @@ fn make_account(id: i64, policy: AccountPolicy) -> Account {
         version: 1,
         policy,
         flags: AccountFlags::empty(),
-        journal: JournalId(0),
+        book: BookId(0),
         user_data: UserData::default(),
         metadata: BTreeMap::new(),
     }

+ 1 - 1
crates/kuatia/tests/saga.rs

@@ -27,7 +27,7 @@ fn make_account(id: i64, policy: AccountPolicy) -> Account {
         version: 1,
         policy,
         flags: AccountFlags::empty(),
-        journal: JournalId(0),
+        book: BookId(0),
         user_data: UserData::default(),
         metadata: BTreeMap::new(),
     }

+ 1 - 1
doc/accounts.md

@@ -12,7 +12,7 @@ An account is a versioned entity that owns postings. Balance is never stored —
 | `version` | `u64` | Starts at 1, increments on every mutation |
 | `policy` | `AccountPolicy` | Balance floor rule (see below) |
 | `flags` | `AccountFlags` | Lifecycle flags (`FROZEN`, `CLOSED`) + user-defined (`USER_0`–`USER_7`) |
-| `journal` | `JournalId` | Journal this account belongs to |
+| `book` | `BookId` | Book this account belongs to |
 | `user_data` | `UserData` | Fixed 28 bytes: `u128 + u64 + u32` for external refs |
 | `metadata` | `Metadata` | `BTreeMap<String, Vec<u8>>` for free-form data |
 

+ 1 - 1
doc/architecture.md

@@ -180,7 +180,7 @@ The `commit()` convenience method auto-populates snapshots when none are provide
 
 The conservation invariant is: for each asset, the sum of consumed posting values must equal the sum of created posting values.
 
-Conservation boundaries are **per-asset only**. The `book` and `code` fields on transfers and accounts are grouping labels for reporting — they do not affect conservation enforcement.
+Conservation boundaries are **per-asset only**. The `book` field on transfers and accounts is a transfer policy scope (which accounts/assets may participate) — it does not affect conservation enforcement, and it does not partition balances.
 
 ## Account Policies
 

+ 2 - 2
doc/crates.md

@@ -30,7 +30,7 @@ Pure, sans-IO (Input/Output) decision logic. No async runtime, near-zero depende
 | `NewPosting` | Posting to be created (no id yet — assigned during validation) |
 | `Transfer` | Atomic unit: consumes postings + creates postings + metadata |
 | `EnvelopeBuilder` | Fluent builder for `Transfer` construction |
-| `Account` | Versioned entity with policy, flags, book/code, user_data, metadata |
+| `Account` | Versioned entity with policy, flags, book, user_data, metadata |
 | `AccountPolicy` | Balance floor rule: `NoOverdraft`, `CappedOverdraft`, `UncappedOverdraft`, `SystemAccount`, `ExternalAccount` |
 | `AccountFlags` | Bitflags: `FROZEN`, `CLOSED` |
 | `UserData` | Fixed 28 bytes (u128 + u64 + u32) for correlation IDs, external refs |
@@ -150,7 +150,7 @@ Transfers are built via `TransferBuilder` and committed with `ledger.commit(tran
 | `balance(account, asset)` | Sum of non-Inactive postings (computed by Ledger) |
 | `list_accounts()` | All current account snapshots |
 | `get_account(id)` | Latest account snapshot |
-| `query_transfers(query)` | Paginated, filtered transfer history (by date range, book, code) |
+| `query_transfers(query)` | Paginated, filtered transfer history (by date range, book) |
 | `history(account)` | All transfers involving an account |
 | `postings(account)` | All postings (any status) |
 | `query_postings(query)` | Paginated, filtered postings (by asset, status) |

+ 38 - 35
doc/glossary.md

@@ -15,7 +15,7 @@ Lifecycle: `Active` → `PendingInactive` (reserved by saga) → `Inactive` (con
 
 A versioned entity that owns postings. Balance is never stored — it is always the sum of non-inactive postings for a given (account, asset) pair.
 
-Accounts have a **policy** (balance floor rule), **flags** (lifecycle + user-defined), and a **journal** assignment.
+Accounts have a **policy** (balance floor rule), **flags** (lifecycle + user-defined), and a **book** assignment.
 
 ### Asset
 
@@ -33,16 +33,19 @@ One or more movements to execute atomically. Built via `TransferBuilder`, commit
 
 The resolved, concrete form of a transfer: which postings to consume and which to create. Produced internally by the resolve step. Available for direct use via `commit_atomic(envelope)`.
 
-### Journal
+### Book
 
-A named scope that controls which accounts and assets may participate in transfers. Journals do **not** partition balances — accounts and their balances are global. Journals only gate *who can transact with whom in what context*.
+A **Book is a transfer policy scope** — it gates which accounts and assets may participate in a transfer. Note what it is *not*:
 
-A journal has:
+- It is **not** the classical accounting journal (the chronological book of entries). That role is played by the append-only transfer log itself.
+- It does **not** partition balances. Accounts and their balances are global; a Book only gates *who can transact with whom in what context*.
+
+A book is `{ id, name, policy }`, where the `policy` (`BookPolicy`) holds:
 - `allowed_assets` — if non-empty, only these assets may appear in movements.
 - `allowed_flags` — if non-empty, accounts with ANY of these flags may participate.
 - `allowed_accounts` — if non-empty, these specific accounts may participate (in addition to flag matches).
 
-An empty journal (no restrictions) allows any account and any asset.
+An empty policy (no restrictions) allows any account and any asset.
 
 ### Conservation
 
@@ -69,29 +72,29 @@ use kuatia::prelude::*;
 let usd = AssetId::new(1);
 let eur = AssetId::new(2);
 
-// Journals — separate deposit/withdrawal flows from trading
-let deposits_journal = JournalBuilder::new("deposits")
+// Books — separate deposit/withdrawal flows from trading
+let deposits_book = BookBuilder::new("deposits")
     .allow_asset(usd)
     .allow_asset(eur)
     .allow_flags(AccountFlags::USER_0 | AccountFlags::USER_1) // wallets + bank
     .build();
 
-let trading_journal = JournalBuilder::new("trading")
+let trading_book = BookBuilder::new("trading")
     .allow_asset(usd)
     .allow_asset(eur)
     .allow_flags(AccountFlags::USER_0) // only user wallets
     .allow_account(exchange_pool)       // + the exchange pool
     .build();
 
-ledger.create_journal(deposits_journal).await?;
-ledger.create_journal(trading_journal).await?;
+ledger.create_book(deposits_book).await?;
+ledger.create_book(trading_book).await?;
 
 // Accounts
 let bank = Account {
     id: AccountId::default(),
     policy: AccountPolicy::ExternalAccount,
     flags: AccountFlags::USER_1, // bank flag
-    journal: deposits_journal.id,
+    book: deposits_book.id,
     ..Default::default()
 };
 
@@ -99,7 +102,7 @@ let alice = Account {
     id: AccountId::default(),
     policy: AccountPolicy::NoOverdraft,
     flags: AccountFlags::USER_0, // wallet flag
-    journal: deposits_journal.id,
+    book: deposits_book.id,
     ..Default::default()
 };
 
@@ -107,7 +110,7 @@ let exchange_pool = Account {
     id: AccountId::default(),
     policy: AccountPolicy::SystemAccount,
     flags: AccountFlags::empty(),
-    journal: trading_journal.id,
+    book: trading_book.id,
     ..Default::default()
 };
 ```
@@ -116,7 +119,7 @@ let exchange_pool = Account {
 
 ```rust
 let deposit = TransferBuilder::new()
-    .journal(deposits_journal.id)
+    .book(deposits_book.id)
     .deposit(alice.id, usd, Cent::from(10_000), bank.id)?
     .build();
 ledger.commit(deposit).await?;
@@ -128,7 +131,7 @@ ledger.commit(deposit).await?;
 
 ```rust
 let trade = TransferBuilder::new()
-    .journal(trading_journal.id)
+    .book(trading_book.id)
     .pay(alice.id, exchange_pool, usd, Cent::from(5_000))
     .pay(exchange_pool, alice.id, eur, Cent::from(4_600))
     .build();
@@ -141,7 +144,7 @@ ledger.commit(trade).await?;
 
 ```rust
 let withdrawal = TransferBuilder::new()
-    .journal(deposits_journal.id)
+    .book(deposits_book.id)
     .withdraw(alice.id, eur, Cent::from(4_600), bank.id)
     .build();
 ledger.commit(withdrawal).await?;
@@ -170,22 +173,22 @@ const CUSTOMER: AccountFlags = AccountFlags::USER_1;
 const REVENUE: AccountFlags = AccountFlags::USER_2;
 const BANK: AccountFlags = AccountFlags::USER_3;
 
-// Journals
-let sales_journal = JournalBuilder::new("sales")
+// Books
+let sales_book = BookBuilder::new("sales")
     .allow_asset(gs)
     .allow_asset(product_a)
     .allow_asset(product_b)
     .allow_flags(WAREHOUSE | CUSTOMER | REVENUE)
     .build();
 
-let inventory_journal = JournalBuilder::new("inventory")
+let inventory_book = BookBuilder::new("inventory")
     .allow_asset(product_a)
     .allow_asset(product_b)
     .allow_flags(WAREHOUSE)
     .allow_account(world) // issuance source
     .build();
 
-let banking_journal = JournalBuilder::new("banking")
+let banking_book = BookBuilder::new("banking")
     .allow_asset(gs)
     .allow_flags(WAREHOUSE | BANK)
     .build();
@@ -232,7 +235,7 @@ let bank = Account {
 
 ```rust
 let receipt = TransferBuilder::new()
-    .journal(inventory_journal.id)
+    .book(inventory_book.id)
     .pay(world, warehouse.id, product_a, Cent::from(50_000)) // 50.000 units (precision 3)
     .build();
 ledger.commit(receipt).await?;
@@ -244,7 +247,7 @@ ledger.commit(receipt).await?;
 
 ```rust
 let sale = TransferBuilder::new()
-    .journal(sales_journal.id)
+    .book(sales_book.id)
     // Move product from warehouse to customer (consumed by sale)
     .pay(warehouse.id, customer.id, product_a, Cent::from(2_000))
     // Customer pays cash
@@ -261,7 +264,7 @@ ledger.commit(sale).await?;
 
 ```rust
 let deposit = TransferBuilder::new()
-    .journal(banking_journal.id)
+    .book(banking_book.id)
     .pay(cash_register.id, bank.id, gs, Cent::from(30_000))
     .build();
 ledger.commit(deposit).await?;
@@ -283,19 +286,19 @@ let total_cogs = ledger.balance(&cogs.id, &gs).await?;
 // 20,000 Gs — gross profit = revenue - cogs = 10,000 Gs
 ```
 
-**Why journals matter here:** The `sales` journal prevents a bug where a bank transfer accidentally credits the revenue account. The `banking` journal ensures only cash and bank accounts participate in deposits. Each flow is isolated by scope while sharing the same global balances.
+**Why books matter here:** The `sales` book prevents a bug where a bank transfer accidentally credits the revenue account. The `banking` book ensures only cash and bank accounts participate in deposits. Each flow is isolated by scope while sharing the same global balances.
 
 ---
 
-## Journal Design
+## Book Design
 
-### When to use journals
+### When to use books
 
-- **Always** — even if you only have one flow, defining a journal documents what assets and accounts are expected.
-- **Multiple flows** — separate journals for sales, payments, inventory, banking. Prevents cross-contamination.
-- **Multi-tenant** — one journal per tenant with `allowed_accounts` restricting to that tenant's accounts.
+- **Always** — even if you only have one flow, defining a book documents what assets and accounts are expected.
+- **Multiple flows** — separate books for sales, payments, inventory, banking. Prevents cross-contamination.
+- **Multi-tenant** — one book per tenant with `allowed_accounts` restricting to that tenant's accounts.
 
-### Journal scoping rules
+### Book scoping rules
 
 | Field | Empty | Non-empty |
 |-------|-------|-----------|
@@ -303,13 +306,13 @@ let total_cogs = ledger.balance(&cogs.id, &gs).await?;
 | `allowed_flags` | Flag check skipped | Accounts with ANY matching flag pass |
 | `allowed_accounts` | Account check skipped | Listed accounts always pass (even without matching flags) |
 
-An account passes the journal check if:
+An account passes the book check if:
 1. It matches `allowed_flags` (any flag in common), OR
 2. It is explicitly listed in `allowed_accounts`, OR
-3. Both lists are empty (unrestricted journal).
+3. Both lists are empty (unrestricted book).
 
-### Journals do NOT partition balances
+### Books do NOT partition balances
 
-An account's balance is the sum of all its non-inactive postings across ALL journals. If Alice receives 100 USD via the `deposits` journal and spends 50 USD via the `trading` journal, her balance is 50 USD — not 100 in one journal and -50 in another.
+An account's balance is the sum of all its non-inactive postings across ALL books. If Alice receives 100 USD via the `deposits` book and spends 50 USD via the `trading` book, her balance is 50 USD — not 100 in one book and -50 in another.
 
-This is intentional: journals scope *access*, not *state*.
+This is intentional: books scope *access*, not *state*.

+ 2 - 2
doc/transfers.md

@@ -128,7 +128,7 @@ struct Envelope {
     consumes: Vec<PostingId>,       // postings to deactivate
     creates: Vec<NewPosting>,       // new postings to create
     account_snapshots: Vec<AccountSnapshotId>,
-    journal: JournalId,
+    book: BookId,
     user_data: UserData,
     metadata: Metadata,
 }
@@ -144,7 +144,7 @@ The `TransferBuilder` provides a fluent API for constructing transfers:
 let transfer = TransferBuilder::new()
     .deposit(alice, usd, Cent::from(1000), bank)
     .pay(alice, bob, usd, Cent::from(200))
-    .journal(sales_journal)
+    .book(sales_book)
     .metadata(metadata)
     .build();
 ```