Просмотр исходного кода

Replace book/code with journal scoping to prepare for CRM2 integration

The opaque book/code u32 fields had no semantic meaning — they were just
numbers with no enforcement. Journals give transfers and accounts a named
scope that controls which assets and accounts may participate, which is
essential for CRM2's separation of sales, payments, and inventory flows.

Journals enforce scope (allowed assets, account flags, specific accounts)
while sharing global account balances — an account exists once but can
participate in multiple journals based on its flags.

Also extends AccountFlags with user-defined bits (USER_0–USER_7) so
journals can gate participation by flag without listing every account.
AccountId and JournalId now implement Default via AutoId for ergonomic
construction.
Cesar Rodas 1 неделя назад
Родитель
Сommit
8591653f6b

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

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

+ 59 - 23
crates/kuatia-storage-sql/src/lib.rs

@@ -41,6 +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"),
         ] {
             for statement in sql.split(';') {
                 let trimmed = statement.trim();
@@ -107,11 +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 book: i32 = row
-        .try_get("book")
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
-    let code: i32 = row
-        .try_get("code")
+    let journal: i64 = row
+        .try_get("journal")
         .map_err(|e| StoreError::Internal(e.to_string()))?;
     let user_data_bytes: Vec<u8> = row
         .try_get("user_data")
@@ -125,8 +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),
-        book: book as u32,
-        code: code as u32,
+        journal: JournalId::new(journal),
         user_data: deserialize_blob(&user_data_bytes)?,
         metadata: deserialize_blob(&metadata_bytes)?,
     })
@@ -205,14 +202,13 @@ impl AccountStore for SqlStore {
         }
 
         sqlx::query(
-            "INSERT INTO accounts (id, version, policy, flags, book, code, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"
+            "INSERT INTO accounts (id, version, policy, flags, journal, 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.book as i32)
-            .bind(account.code as i32)
+            .bind(account.journal.0)
             .bind(serialize_blob(&account.user_data)?)
             .bind(serialize_blob(&account.metadata)?)
             .execute(&self.pool)
@@ -246,14 +242,13 @@ impl AccountStore for SqlStore {
         }
 
         sqlx::query(
-            "INSERT INTO accounts (id, version, policy, flags, book, code, user_data, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)"
+            "INSERT INTO accounts (id, version, policy, flags, journal, 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.book as i32)
-            .bind(account.code as i32)
+            .bind(account.journal.0)
             .bind(serialize_blob(&account.user_data)?)
             .bind(serialize_blob(&account.metadata)?)
             .execute(&self.pool)
@@ -573,13 +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, book, code) VALUES ($1, $2, $3, $4, $5, $6)")
+        sqlx::query("INSERT INTO transfers (id, transfer, receipt, created_at, journal) VALUES ($1, $2, $3, $4, $5)")
             .bind(tid.0.as_slice())
             .bind(&transfer_bytes)
             .bind(&receipt_bytes)
             .bind(record.created_at)
-            .bind(record.envelope.book() as i32)
-            .bind(record.envelope.code() as i32)
+            .bind(record.envelope.journal().0)
             .execute(&mut *tx)
             .await
             .map_err(|e| StoreError::Internal(e.to_string()))?;
@@ -686,13 +680,8 @@ impl TransferStore for SqlStore {
                 {
                     return false;
                 }
-                if let Some(book) = query.book
-                    && r.envelope.book() != book
-                {
-                    return false;
-                }
-                if let Some(code) = query.code
-                    && r.envelope.code() != code
+                if let Some(journal) = query.journal
+                    && r.envelope.journal() != journal
                 {
                     return false;
                 }
@@ -804,3 +793,50 @@ impl EventStore for SqlStore {
         Ok(events)
     }
 }
+
+// ---------------------------------------------------------------------------
+// JournalStore
+// ---------------------------------------------------------------------------
+
+#[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)
+            .bind(&data)
+            .execute(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        Ok(())
+    }
+
+    async fn get_journal(&self, id: &JournalId) -> Result<Journal, StoreError> {
+        let row = sqlx::query("SELECT data FROM journals 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:?}")))?;
+        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")
+            .fetch_all(&self.pool)
+            .await
+            .map_err(|e| StoreError::Internal(e.to_string()))?;
+        rows.iter()
+            .map(|row| {
+                let data: Vec<u8> = row
+                    .try_get("data")
+                    .map_err(|e| StoreError::Internal(e.to_string()))?;
+                deserialize_blob(&data)
+            })
+            .collect()
+    }
+}

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

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

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

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

+ 5 - 0
crates/kuatia-storage-sql/src/migrations/004_journals.sql

@@ -0,0 +1,5 @@
+CREATE TABLE IF NOT EXISTS journals (
+    id       BIGINT PRIMARY KEY,
+    name     TEXT NOT NULL,
+    data     BLOB NOT NULL
+);

+ 36 - 2
crates/kuatia-storage/src/mem_store.rs

@@ -7,11 +7,15 @@ use std::collections::HashMap;
 use tokio::sync::RwLock;
 
 use kuatia_types::autoid::AutoId;
-use kuatia_types::{Account, AccountId, AssetId, EnvelopeId, Posting, PostingId, PostingStatus};
+use kuatia_types::{
+    Account, AccountId, AssetId, EnvelopeId, Journal, JournalId, Posting, PostingId, PostingStatus,
+};
 
 use crate::error::StoreError;
 use crate::events::{EventStore, LedgerEvent};
-use crate::store::{AccountStore, EnvelopeRecord, PostingStore, SagaStore, TransferStore};
+use crate::store::{
+    AccountStore, EnvelopeRecord, JournalStore, PostingStore, SagaStore, TransferStore,
+};
 
 /// In-memory [`Store`](crate::store::Store) implementation backed by `RwLock<HashMap>`.
 pub struct InMemoryStore {
@@ -20,6 +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>>,
     autoid: AutoId,
 }
 
@@ -38,6 +43,7 @@ impl InMemoryStore {
             transfers: RwLock::new(HashMap::new()),
             sagas: RwLock::new(HashMap::new()),
             events: RwLock::new(Vec::new()),
+            journals: RwLock::new(HashMap::new()),
             autoid: AutoId::new(),
         }
     }
@@ -314,3 +320,31 @@ impl EventStore for InMemoryStore {
             .collect())
     }
 }
+
+#[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) {
+            return Err(StoreError::AlreadyExists(format!(
+                "journal {:?}",
+                journal.id
+            )));
+        }
+        journals.insert(journal.id, journal);
+        Ok(())
+    }
+
+    async fn get_journal(&self, id: &JournalId) -> Result<Journal, StoreError> {
+        let journals = self.journals.read().await;
+        journals
+            .get(id)
+            .cloned()
+            .ok_or_else(|| StoreError::NotFound(format!("journal {id:?}")))
+    }
+
+    async fn list_journals(&self) -> Result<Vec<Journal>, StoreError> {
+        let journals = self.journals.read().await;
+        Ok(journals.values().cloned().collect())
+    }
+}

+ 25 - 14
crates/kuatia-storage/src/store.rs

@@ -8,7 +8,8 @@
 
 use async_trait::async_trait;
 use kuatia_types::{
-    Account, AccountId, AssetId, Envelope, EnvelopeId, Posting, PostingId, PostingStatus, Receipt,
+    Account, AccountId, AssetId, Envelope, EnvelopeId, Journal, JournalId, Posting, PostingId,
+    PostingStatus, Receipt,
 };
 
 use crate::error::StoreError;
@@ -49,10 +50,8 @@ pub struct TransferQuery {
     pub from_ts: Option<i64>,
     /// Exclusive upper bound (unix millis).
     pub to_ts: Option<i64>,
-    /// Filter by book label.
-    pub book: Option<u32>,
-    /// Filter by code.
-    pub code: Option<u32>,
+    /// Filter by journal.
+    pub journal: Option<JournalId>,
     /// Max results to return.
     pub limit: Option<u32>,
     /// Number of results to skip.
@@ -174,13 +173,8 @@ pub trait TransferStore: Send + Sync {
                 {
                     return false;
                 }
-                if let Some(book) = query.book
-                    && r.envelope.book() != book
-                {
-                    return false;
-                }
-                if let Some(code) = query.code
-                    && r.envelope.code() != code
+                if let Some(journal) = query.journal
+                    && r.envelope.journal() != journal
                 {
                     return false;
                 }
@@ -208,11 +202,28 @@ pub trait SagaStore: Send + Sync {
     async fn delete_saga(&self, id: &i64) -> Result<(), StoreError>;
 }
 
+/// Journal 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>;
+}
+
 // ---------------------------------------------------------------------------
 // Composite trait
 // ---------------------------------------------------------------------------
 
 /// Async storage abstraction composing all sub-traits.
-pub trait Store: AccountStore + PostingStore + TransferStore + SagaStore + EventStore {}
+pub trait Store:
+    AccountStore + PostingStore + TransferStore + SagaStore + EventStore + JournalStore
+{
+}
 
-impl<T: AccountStore + PostingStore + TransferStore + SagaStore + EventStore> Store for T {}
+impl<T: AccountStore + PostingStore + TransferStore + SagaStore + EventStore + JournalStore> Store
+    for T
+{
+}

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

@@ -25,8 +25,7 @@ fn make_account(id: i64, policy: AccountPolicy) -> Account {
         version: 1,
         policy,
         flags: AccountFlags::empty(),
-        book: 0,
-        code: 0,
+        journal: JournalId(0),
         user_data: UserData::default(),
         metadata: BTreeMap::new(),
     }
@@ -51,7 +50,7 @@ fn make_posting(
     }
 }
 
-fn make_envelope_with_book(book: u32) -> (Envelope, EnvelopeId) {
+fn make_envelope_with_journal(journal: JournalId) -> (Envelope, EnvelopeId) {
     let t = EnvelopeBuilder::new()
         .creates(vec![
             NewPosting {
@@ -67,11 +66,11 @@ fn make_envelope_with_book(book: u32) -> (Envelope, EnvelopeId) {
                 payer: None,
             },
         ])
-        .book(book)
+        .journal(journal)
         .build();
-    // Use book value to create distinct EnvelopeIds.
+    // Use journal id to create distinct EnvelopeIds.
     let mut tid_bytes = [0u8; 32];
-    tid_bytes[0] = book as u8;
+    tid_bytes[0] = journal.0 as u8;
     tid_bytes[1] = 42;
     (t, EnvelopeId(tid_bytes))
 }
@@ -495,7 +494,7 @@ pub async fn query_transfers_by_date_range(store: &(impl Store + 'static)) {
         .await
         .unwrap();
 
-    let (e2, t2) = make_envelope_with_book(1);
+    let (e2, t2) = make_envelope_with_journal(JournalId(1));
     store
         .store_transfer(EnvelopeRecord {
             envelope: e2,
@@ -572,7 +571,7 @@ pub async fn query_transfers_by_book(store: &(impl Store + 'static)) {
         .await
         .unwrap();
 
-    let (e2, t2) = make_envelope_with_book(5);
+    let (e2, t2) = make_envelope_with_journal(JournalId(5));
     store
         .store_transfer(EnvelopeRecord {
             envelope: e2,
@@ -585,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)),
-            book: Some(5),
+            journal: Some(JournalId(5)),
             ..Default::default()
         })
         .await
         .unwrap();
     assert_eq!(page.total, 1);
-    assert_eq!(page.items[0].envelope.book(), 5);
+    assert_eq!(page.items[0].envelope.journal(), JournalId(5));
 }
 
 // ---------------------------------------------------------------------------

+ 150 - 43
crates/kuatia-types/src/lib.rs

@@ -382,6 +382,15 @@ fn hex(bytes: &[u8]) -> String {
 // Identifier constructors
 // ---------------------------------------------------------------------------
 
+impl Default for AccountId {
+    fn default() -> Self {
+        thread_local! {
+            static GEN: crate::autoid::AutoId = crate::autoid::AutoId::new();
+        }
+        GEN.with(|g| Self(g.next()))
+    }
+}
+
 impl AccountId {
     /// Create an `AccountId` from an `i64`.
     pub const fn new(id: i64) -> Self {
@@ -402,6 +411,101 @@ impl AssetId {
     }
 }
 
+/// Identifies a journal — a named scope for transfers.
+#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct JournalId(pub i64);
+
+impl fmt::Debug for JournalId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "JournalId({})", self.0)
+    }
+}
+
+impl Default for JournalId {
+    fn default() -> Self {
+        thread_local! {
+            static GEN: crate::autoid::AutoId = crate::autoid::AutoId::new();
+        }
+        GEN.with(|g| Self(g.next()))
+    }
+}
+
+impl JournalId {
+    /// Create a `JournalId` from an `i64`.
+    pub const fn new(id: i64) -> Self {
+        Self(id)
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Journal
+// ---------------------------------------------------------------------------
+
+/// A journal scopes which accounts and assets may participate in transfers.
+/// Accounts and balances are global — journals only gate participation.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Journal {
+    /// Stable identity for this journal.
+    pub id: JournalId,
+    /// Human-readable name.
+    pub name: String,
+    /// 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.
+    pub allowed_flags: AccountFlags,
+    /// If non-empty, these specific accounts may participate (in addition to flag matches).
+    pub allowed_accounts: Vec<AccountId>,
+}
+
+/// Builder for constructing [`Journal`] values.
+pub struct JournalBuilder {
+    journal: Journal,
+}
+
+impl JournalBuilder {
+    /// Create a new journal builder with the given name.
+    pub fn new(name: impl Into<String>) -> Self {
+        Self {
+            journal: Journal {
+                id: JournalId::default(),
+                name: name.into(),
+                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;
+        self
+    }
+
+    /// Add an allowed asset.
+    pub fn allow_asset(mut self, asset: AssetId) -> Self {
+        self.journal.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
+    }
+
+    /// Add a specific allowed account.
+    pub fn allow_account(mut self, account: AccountId) -> Self {
+        self.journal.allowed_accounts.push(account);
+        self
+    }
+
+    /// Consume the builder and return the [`Journal`].
+    pub fn build(self) -> Journal {
+        self.journal
+    }
+}
+
 // ---------------------------------------------------------------------------
 // Posting
 // ---------------------------------------------------------------------------
@@ -496,10 +600,8 @@ pub struct Envelope {
     pub creates: Vec<NewPosting>,
     /// Account version pins for optimistic concurrency.
     pub account_snapshots: Vec<AccountSnapshotId>,
-    /// Grouping label (e.g. tenant or product).
-    pub book: u32,
-    /// Category code for this envelope.
-    pub code: u32,
+    /// Journal this envelope belongs to.
+    pub journal: JournalId,
     /// Fixed-width secondary identifiers.
     pub user_data: UserData,
     /// Free-form key-value metadata.
@@ -522,14 +624,9 @@ impl Envelope {
         &self.account_snapshots
     }
 
-    /// Grouping label (e.g. tenant or product).
-    pub fn book(&self) -> u32 {
-        self.book
-    }
-
-    /// Category code for this envelope.
-    pub fn code(&self) -> u32 {
-        self.code
+    /// Journal this envelope belongs to.
+    pub fn journal(&self) -> JournalId {
+        self.journal
     }
 
     /// Fixed-width secondary identifiers.
@@ -584,15 +681,9 @@ impl EnvelopeBuilder {
         self
     }
 
-    /// Set the book label.
-    pub fn book(mut self, book: u32) -> Self {
-        self.envelope.book = book;
-        self
-    }
-
-    /// Set the category code.
-    pub fn code(mut self, code: u32) -> Self {
-        self.envelope.code = code;
+    /// Set the journal.
+    pub fn journal(mut self, journal: JournalId) -> Self {
+        self.envelope.journal = journal;
         self
     }
 
@@ -643,13 +734,35 @@ pub enum AccountPolicy {
 }
 
 bitflags::bitflags! {
-    /// Lifecycle flags for an [`Account`].
+    /// 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.
     #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
     pub struct AccountFlags: u32 {
         /// Account may not be the source or destination of any transfer.
         const FROZEN = 1 << 0;
         /// Terminal — no further activity.
         const CLOSED = 1 << 1;
+        // Bits 2–7: reserved for future system flags.
+        // Bits 8–31: user-defined.
+        /// User-defined flag 0.
+        const USER_0 = 1 << 8;
+        /// User-defined flag 1.
+        const USER_1 = 1 << 9;
+        /// User-defined flag 2.
+        const USER_2 = 1 << 10;
+        /// User-defined flag 3.
+        const USER_3 = 1 << 11;
+        /// User-defined flag 4.
+        const USER_4 = 1 << 12;
+        /// User-defined flag 5.
+        const USER_5 = 1 << 13;
+        /// User-defined flag 6.
+        const USER_6 = 1 << 14;
+        /// User-defined flag 7.
+        const USER_7 = 1 << 15;
     }
 }
 
@@ -664,10 +777,8 @@ pub struct Account {
     pub policy: AccountPolicy,
     /// Lifecycle flags (frozen, closed).
     pub flags: AccountFlags,
-    /// Grouping label (e.g. tenant or product).
-    pub book: u32,
-    /// Category code.
-    pub code: u32,
+    /// Journal this entity belongs to.
+    pub journal: JournalId,
     /// Fixed-width secondary identifiers.
     pub user_data: UserData,
     /// Free-form key-value metadata.
@@ -727,10 +838,8 @@ pub struct Movement {
 pub struct Transfer {
     /// Movements to execute atomically.
     pub movements: Vec<Movement>,
-    /// Grouping label (e.g. tenant or product).
-    pub book: u32,
-    /// Category code.
-    pub code: u32,
+    /// Journal this entity belongs to.
+    pub journal: JournalId,
     /// Fixed-width secondary identifiers.
     pub user_data: UserData,
     /// Free-form key-value metadata.
@@ -798,15 +907,9 @@ impl TransferBuilder {
         self.movement(from, external, asset, amount)
     }
 
-    /// Set the book label.
-    pub fn book(mut self, book: u32) -> Self {
-        self.transfer.book = book;
-        self
-    }
-
-    /// Set the category code.
-    pub fn code(mut self, code: u32) -> Self {
-        self.transfer.code = code;
+    /// Set the journal.
+    pub fn journal(mut self, journal: JournalId) -> Self {
+        self.transfer.journal = journal;
         self
     }
 
@@ -901,6 +1004,12 @@ impl ToBytes for AccountFlags {
     }
 }
 
+impl ToBytes for JournalId {
+    fn to_bytes(&self) -> Vec<u8> {
+        self.0.to_be_bytes().to_vec()
+    }
+}
+
 impl ToBytes for NewPosting {
     fn to_bytes(&self) -> Vec<u8> {
         let mut buf = Vec::new();
@@ -954,8 +1063,7 @@ impl ToBytes for Envelope {
             buf.extend(snap.to_bytes());
         }
 
-        write_u32(&mut buf, self.book);
-        write_u32(&mut buf, self.code);
+        buf.extend(self.journal.to_bytes());
         buf.extend(self.user_data.to_bytes());
 
         write_u32(&mut buf, self.metadata.len() as u32);
@@ -979,8 +1087,7 @@ impl ToBytes for Account {
         write_u64(&mut buf, self.version);
         buf.extend(self.policy.to_bytes());
         buf.extend(self.flags.to_bytes());
-        write_u32(&mut buf, self.book);
-        write_u32(&mut buf, self.code);
+        buf.extend(self.journal.to_bytes());
         buf.extend(self.user_data.to_bytes());
 
         write_u32(&mut buf, self.metadata.len() as u32);

+ 24 - 5
crates/kuatia/src/ledger.rs

@@ -219,8 +219,7 @@ impl Ledger {
         let mut envelope = EnvelopeBuilder::new()
             .consumes(consumes)
             .creates(creates)
-            .book(transfer.book)
-            .code(transfer.code)
+            .journal(transfer.journal)
             .user_data(transfer.user_data.clone())
             .metadata(transfer.metadata.clone())
             .build();
@@ -265,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(book = transfer.book, code = transfer.code), name = "ledger.commit")]
+    #[instrument(skip(self, transfer), fields(journal = transfer.journal.0), name = "ledger.commit")]
     pub async fn commit(self: &Arc<Self>, transfer: Transfer) -> Result<Receipt, LedgerError> {
         let saga = TransferSaga::new(TransferSagaInputs {
             resolve: ResolveInput {
@@ -353,8 +352,7 @@ impl Ledger {
         let reverse_envelope = EnvelopeBuilder::new()
             .consumes(created_posting_ids)
             .creates(new_postings)
-            .book(original.book())
-            .code(original.code())
+            .journal(original.journal())
             .metadata(original.metadata().clone())
             .build();
 
@@ -563,6 +561,27 @@ impl Ledger {
         Ok(())
     }
 
+    /// Create a new journal.
+    pub async fn create_journal(
+        &self,
+        journal: kuatia_core::Journal,
+    ) -> Result<(), LedgerError> {
+        Ok(self.store.create_journal(journal).await?)
+    }
+
+    /// Fetch a journal by id.
+    pub async fn get_journal(
+        &self,
+        id: &kuatia_core::JournalId,
+    ) -> Result<kuatia_core::Journal, LedgerError> {
+        Ok(self.store.get_journal(id).await?)
+    }
+
+    /// List all journals.
+    pub async fn list_journals(&self) -> Result<Vec<kuatia_core::Journal>, LedgerError> {
+        Ok(self.store.list_journals().await?)
+    }
+
     /// Query ledger events after a given sequence number.
     pub async fn get_events_since(
         &self,

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

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

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

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