Jelajahi Sumber

Squashed commit of the following:

commit 2d7deddcb63e91946f64c746484dd19916de0007
Author: Cesar Rodas <cesar@rodasm.com.py>
Date:   Thu Jul 16 12:56:10 2026 -0300

    Address review: assert flags round-trip, bump canonical version

    Add a store-conformance assertion that an account's flags survive a
    create/get round-trip, guarding the SQL flags-column mapping now that
    the balance constraint lives there.

    Bump CANONICAL_VERSION 4 -> 5: removing the policy field from the Account
    preimage changes account snapshot hashes, following the same convention
    used when UserData was removed. Record the version bump and the
    former-system-account over-debit behavior change in ADR-0018.

commit fd37985682fe8b785c3c8796b78203d00ddbb471
Author: Cesar Rodas <cesar@rodasm.com.py>
Date:   Thu Jul 16 09:05:52 2026 -0300

    Collapse account policy into a single overdraft flag

    The five-variant AccountPolicy enum only ever enforced one real
    distinction: may an account's balance go negative or not? The capped
    floor, the System/External labels, and the Uncapped variant all resolved
    to the same runtime behavior (overdraft allowed, no floor) while carrying
    a serialized enum, a SQL column, resolve/validate match arms, and a
    dashboard DTO.

    Replace it with one AccountFlags bit, DEBIT_MUST_NOT_EXCEED_CREDIT.
    Overdraft is allowed by default: a shortfall becomes a negative offset
    posting and the transfer records as long as it conserves value per asset.
    Setting the flag forbids a negative balance and negative postings. The
    capped credit-line floor is dropped; a specific limit is now an
    application concern.

    Drop the policy column via migration 006. Validation and resolution
    branch on Account::forbids_overdraft() instead of a policy match; resolve
    takes the set of overdraft-permitting accounts. Rewrite the floor tests
    and turn the ignored write-skew test into a real conservation-under-
    concurrency test. ADR-0018 records the decision and supersedes ADR-0004.
Cesar Rodas 12 jam lalu
induk
melakukan
651a7dd5d6

+ 6 - 6
CLAUDE.md

@@ -17,7 +17,7 @@ crates/
 doc/
 doc/
   architecture.md   Architecture decisions and rationale
   architecture.md   Architecture decisions and rationale
   crates.md         Crate reference: modules, types, APIs
   crates.md         Crate reference: modules, types, APIs
-  accounts.md       Account model, policies, lifecycle
+  accounts.md       Account model, balance constraint, lifecycle
   transfers.md      Transfer/Movement API, resolve algorithm
   transfers.md      Transfer/Movement API, resolve algorithm
   journaling.md     Journaling: transfers as (compound) journal entries
   journaling.md     Journaling: transfers as (compound) journal entries
   glossary.md       Terms, book design, exchange & supermarket examples
   glossary.md       Terms, book design, exchange & supermarket examples
@@ -30,7 +30,7 @@ doc/
 - **Movement**: `{ from, to, asset, amount }` — the fundamental unit of intent. All operations (pay, deposit, withdraw) are one or more movements.
 - **Movement**: `{ from, to, asset, amount }` — the fundamental unit of intent. All operations (pay, deposit, withdraw) are one or more movements.
 - **Envelope**: concrete postings to consume and create — the resolved form of movements.
 - **Envelope**: concrete postings to consume and create — the resolved form of movements.
 - **Conservation**: for each asset, `sum(consumed) == sum(created)`.
 - **Conservation**: for each asset, `sum(consumed) == sum(created)`.
-- **Account policies**: NoOverdraft, CappedOverdraft, UncappedOverdraft, SystemAccount, ExternalAccount. Only `NoOverdraft` forbids negative postings; the other four permit them. An overdraft is a negative posting that covers a shortfall — down to the floor for `CappedOverdraft`, unbounded for `UncappedOverdraft`.
+- **Balance constraint**: one per-account flag, `AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT`. Default (no flag): overdraft allowed, unbounded, a shortfall becomes a negative offset posting and the transfer records if it conserves value. Flag set: balance may not go negative and the account may not hold a negative posting. Construct with `Account::debit_must_not_exceed_credit(id)`; query with `Account::forbids_overdraft()`. There is no bounded floor and no system/external policy label; a boundary/deposit account is just a default overdraft-permitting account.
 - **Dumb storage**: the `Store` is a thin instruction follower. Write methods apply one update and return the **number of affected rows** (or an I/O error) — they never interpret counts, decide state, enforce idempotency, or compensate. The saga owns all of that. There is no monolithic `commit_transfer`; commit is a sequence of dumb primitives (`reserve_postings`, `deactivate_postings`, `insert_postings`, `store_transfer`, `append_event`), each idempotent. See [doc/adr/0003-dumb-storage-saga-recovery.md](doc/adr/0003-dumb-storage-saga-recovery.md).
 - **Dumb storage**: the `Store` is a thin instruction follower. Write methods apply one update and return the **number of affected rows** (or an I/O error) — they never interpret counts, decide state, enforce idempotency, or compensate. The saga owns all of that. There is no monolithic `commit_transfer`; commit is a sequence of dumb primitives (`reserve_postings`, `deactivate_postings`, `insert_postings`, `store_transfer`, `append_event`), each idempotent. See [doc/adr/0003-dumb-storage-saga-recovery.md](doc/adr/0003-dumb-storage-saga-recovery.md).
 
 
 ## Architecture
 ## Architecture
@@ -40,14 +40,14 @@ doc/
 - **Count interpretation**: the saga reads each primitive's affected-row count — full = continue; partial = error → compensate; zero = read state and continue only if this same envelope/reservation already applied it (idempotency). `finalize_envelope` additionally verifies every end-state (all consumed postings `Inactive`, created exist, transfer stored).
 - **Count interpretation**: the saga reads each primitive's affected-row count — full = continue; partial = error → compensate; zero = read state and continue only if this same envelope/reservation already applied it (idempotency). `finalize_envelope` additionally verifies every end-state (all consumed postings `Inactive`, created exist, transfer stored).
 - **Durable recovery**: a phase-tracked write-ahead `PendingSaga {envelope, reservation, phase}` is persisted via `SagaStore` before the saga mutates anything (`Reserving`), bumped to `Finalizing` once validation passed and the consumed postings are about to turn `Inactive`. `Ledger::recover()` (call on startup) branches on phase: a `Reserving` saga is **re-run and re-validated** (aborting cleanly if a posting was taken or an account frozen); a `Finalizing` saga is rolled forward through the verified `finalize_envelope`. Roll-forward, not rollback, so there are no orphaned `PendingInactive` postings to reconcile.
 - **Durable recovery**: a phase-tracked write-ahead `PendingSaga {envelope, reservation, phase}` is persisted via `SagaStore` before the saga mutates anything (`Reserving`), bumped to `Finalizing` once validation passed and the consumed postings are about to turn `Inactive`. `Ledger::recover()` (call on startup) branches on phase: a `Reserving` saga is **re-run and re-validated** (aborting cleanly if a posting was taken or an account frozen); a `Finalizing` saga is rolled forward through the verified `finalize_envelope`. Roll-forward, not rollback, so there are no orphaned `PendingInactive` postings to reconcile.
 - **Content-addressed transfers**: EnvelopeId = double-SHA-256 of canonical bytes. Provides idempotency and tamper evidence.
 - **Content-addressed transfers**: EnvelopeId = double-SHA-256 of canonical bytes. Provides idempotency and tamper evidence.
-- **Append-only accounts**: versioned, never modified in place. Snapshot pinning (validate-time) prevents TOCTOU races; under the dumb-storage model the overdraft-floor and freeze/close guards are validate-time and best-effort under concurrency.
+- **Append-only accounts**: versioned, never modified in place. Snapshot pinning (validate-time) prevents TOCTOU races; under the dumb-storage model the no-overdraft (zero-floor) and freeze/close guards are validate-time and best-effort under concurrency.
 - **Store uses `Arc<dyn Store>`**: Ledger is non-generic, enabling concrete saga types.
 - **Store uses `Arc<dyn Store>`**: Ledger is non-generic, enabling concrete saga types.
 
 
 ## Resolve algorithm
 ## Resolve algorithm
 
 
 Two-pass:
 Two-pass:
 1. For each movement, create output posting on `to` and accumulate net debit on `from`.
 1. For each movement, create output posting on `to` and accumulate net debit on `from`.
-2. For each (account, asset) with positive net debit, select postings (greedy largest-first) and compute change. If positive postings are insufficient: `CappedOverdraft`/`UncappedOverdraft` accounts consume all positives and create a negative posting for the shortfall (floor enforced in validation); other policies fail with `InsufficientFunds`.
+2. For each (account, asset) with positive net debit, select postings (greedy largest-first) and compute change. If positive postings are insufficient: overdraft-permitting accounts (no `DEBIT_MUST_NOT_EXCEED_CREDIT` flag) consume all positives and create a negative posting for the shortfall; accounts that forbid overdraft fail with `InsufficientFunds`.
 
 
 Deposit: two movements cancel to zero net debit on the system account — no posting selection needed.
 Deposit: two movements cancel to zero net debit on the system account — no posting selection needed.
 
 
@@ -61,8 +61,8 @@ Deposit: two movements cancel to zero net debit on the system account — no pos
 6. Account snapshot pinning
 6. Account snapshot pinning
 7. Book policy (if a book is loaded): referenced assets/accounts/flags allowed by the book
 7. Book policy (if a book is loaded): referenced assets/accounts/flags allowed by the book
 8. Per-asset conservation
 8. Per-asset conservation
-9. Negative postings forbidden only on `NoOverdraft` (allowed on overdraft/system/external)
-10. Policy enforcement (balance floor)
+9. Negative postings forbidden only on accounts with `DEBIT_MUST_NOT_EXCEED_CREDIT` (allowed on overdraft-permitting accounts)
+10. Zero-floor enforcement for accounts that forbid overdraft
 
 
 ## Testing
 ## Testing
 
 

+ 4 - 4
README.md

@@ -25,17 +25,17 @@ reconstructs every balance, so the ledger is auditable by construction. See
 ┌─────────────────────────────────────────────────────┐
 ┌─────────────────────────────────────────────────────┐
 │                   kuatia (async)                    │
 │                   kuatia (async)                    │
 │                                                     │
 │                                                     │
-│  Intent layer:  TransferBuilder + commit · balance   
-│  Saga pipeline: resolve → reserve → validate → fin.  
+│  Intent layer:  TransferBuilder + commit · balance  │
+│  Saga pipeline: resolve → reserve → validate → fin. │
 │  Raw pipeline:  load  →  plan  →  apply             │
 │  Raw pipeline:  load  →  plan  →  apply             │
-│  Saga steps:    legend step adapters                 
+│  Saga steps:    legend step adapters                │
 ├─────────────────────────────────────────────────────┤
 ├─────────────────────────────────────────────────────┤
 │               kuatia-core (pure)                    │
 │               kuatia-core (pure)                    │
 │                                                     │
 │                                                     │
 │  Types:         Account · Transfer · Posting · Cent │
 │  Types:         Account · Transfer · Posting · Cent │
 │  Validation:    validate_and_plan()                 │
 │  Validation:    validate_and_plan()                 │
 │  Hashing:       double-SHA256, content-addressed    │
 │  Hashing:       double-SHA256, content-addressed    │
-│  Selection:     greedy posting selection             
+│  Selection:     greedy posting selection            │
 └─────────────────────────────────────────────────────┘
 └─────────────────────────────────────────────────────┘
 ```
 ```
 
 

+ 54 - 59
crates/kuatia-core/src/posting_resolution.rs

@@ -5,7 +5,7 @@
 //!
 //!
 //! 1. [`draft_movements`] aggregates movements into output postings and
 //! 1. [`draft_movements`] aggregates movements into output postings and
 //!    per-(account, asset) net debits. It tells the async layer exactly which
 //!    per-(account, asset) net debits. It tells the async layer exactly which
-//!    postings and account policies to load.
+//!    postings to load and which debit accounts permit overdraft.
 //! 2. [`resolve_envelope`] selects postings for each debit, computes change, and
 //! 2. [`resolve_envelope`] selects postings for each debit, computes change, and
 //!    covers an overdraft shortfall with a negative offset posting.
 //!    covers an overdraft shortfall with a negative offset posting.
 //!
 //!
@@ -13,12 +13,12 @@
 //! shortfall branches are the parts most worth property-testing, and living here
 //! shortfall branches are the parts most worth property-testing, and living here
 //! they are reachable without standing up a store.
 //! they are reachable without standing up a store.
 
 
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
 
 
 use crate::posting_selection::SelectionError;
 use crate::posting_selection::SelectionError;
 use kuatia_types::{
 use kuatia_types::{
-    AccountId, AccountPolicy, AssetId, Cent, Envelope, EnvelopeBuilder, NewPosting, OverflowError,
-    Posting, PostingId, Transfer,
+    AccountId, AssetId, Cent, Envelope, EnvelopeBuilder, NewPosting, OverflowError, Posting,
+    PostingId, Transfer,
 };
 };
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -130,8 +130,8 @@ pub fn draft_movements(transfer: &Transfer) -> Result<MovementDraft, OverflowErr
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
 /// Pre-loaded state for resolution pass 2. The async layer gathers `available`
 /// Pre-loaded state for resolution pass 2. The async layer gathers `available`
-/// and `policies` for the debits produced by [`draft_movements`]; this pass is
-/// pure.
+/// and `overdraft_allowed` for the debits produced by [`draft_movements`]; this
+/// pass is pure.
 pub struct ResolveInput<'a> {
 pub struct ResolveInput<'a> {
     /// The transfer being resolved (for its book and metadata).
     /// The transfer being resolved (for its book and metadata).
     pub transfer: &'a Transfer,
     pub transfer: &'a Transfer,
@@ -141,10 +141,13 @@ pub struct ResolveInput<'a> {
     /// Active postings available for each debit's (account, asset). A missing or
     /// Active postings available for each debit's (account, asset). A missing or
     /// empty entry means no positive postings to draw on.
     /// empty entry means no positive postings to draw on.
     pub available: &'a HashMap<(AccountId, AssetId), Vec<Posting>>,
     pub available: &'a HashMap<(AccountId, AssetId), Vec<Posting>>,
-    /// Policy for each debit's account. A missing entry is treated as "no
-    /// overdraft" — the debit fails with [`SelectionError::InsufficientFunds`]
-    /// rather than granting an offset position on unknown terms.
-    pub policies: &'a HashMap<AccountId, AccountPolicy>,
+    /// Accounts that permit overdraft (i.e. that do *not* carry
+    /// `DEBIT_MUST_NOT_EXCEED_CREDIT`). A debit short of positive postings gets a
+    /// negative offset posting only if its account is in this set; otherwise it
+    /// fails with [`SelectionError::InsufficientFunds`]. A missing account is
+    /// treated as forbidding overdraft, so an unknown account never gets an
+    /// offset position on unknown terms.
+    pub overdraft_allowed: &'a HashSet<AccountId>,
 }
 }
 
 
 /// Pass 2: for each debit, either select postings and compute change, or (for an
 /// Pass 2: for each debit, either select postings and compute change, or (for an
@@ -157,7 +160,7 @@ pub fn resolve_envelope(input: ResolveInput<'_>) -> Result<Envelope, ResolveErro
         transfer,
         transfer,
         draft,
         draft,
         available,
         available,
-        policies,
+        overdraft_allowed,
     } = input;
     } = input;
     let MovementDraft {
     let MovementDraft {
         mut creates,
         mut creates,
@@ -204,31 +207,28 @@ pub fn resolve_envelope(input: ResolveInput<'_>) -> Result<Envelope, ResolveErro
                 });
                 });
             }
             }
         } else {
         } else {
-            // Not enough positive postings. Overdraft accounts cover the
-            // shortfall with a negative posting (an offset position); any other
-            // policy — or an unknown one — fails.
-            match policies.get(&debit.account) {
-                Some(AccountPolicy::CappedOverdraft { .. } | AccountPolicy::UncappedOverdraft) => {
-                    let positives: Vec<PostingId> = avail
-                        .iter()
-                        .filter(|p| p.value.is_positive())
-                        .map(|p| p.id)
-                        .collect();
-                    consumes.extend_from_slice(&positives);
-                    let shortfall = debit.amount.checked_sub(total_positive)?;
-                    creates.push(NewPosting {
-                        owner: debit.account,
-                        asset: debit.asset,
-                        value: shortfall.checked_neg()?,
-                        payer: None,
-                    });
-                }
-                _ => {
-                    return Err(ResolveError::Selection(SelectionError::InsufficientFunds {
-                        available: total_positive,
-                        requested: debit.amount,
-                    }));
-                }
+            // Not enough positive postings. An account that permits overdraft
+            // covers the shortfall with a negative posting (an offset position);
+            // one that forbids it — or an unknown account — fails.
+            if overdraft_allowed.contains(&debit.account) {
+                let positives: Vec<PostingId> = avail
+                    .iter()
+                    .filter(|p| p.value.is_positive())
+                    .map(|p| p.id)
+                    .collect();
+                consumes.extend_from_slice(&positives);
+                let shortfall = debit.amount.checked_sub(total_positive)?;
+                creates.push(NewPosting {
+                    owner: debit.account,
+                    asset: debit.asset,
+                    value: shortfall.checked_neg()?,
+                    payer: None,
+                });
+            } else {
+                return Err(ResolveError::Selection(SelectionError::InsufficientFunds {
+                    available: total_positive,
+                    requested: debit.amount,
+                }));
             }
             }
         }
         }
     }
     }
@@ -310,12 +310,12 @@ mod tests {
             (acct(1), AssetId::new(1)),
             (acct(1), AssetId::new(1)),
             vec![posting(acct(1), 0, 60), posting(acct(1), 1, 40)],
             vec![posting(acct(1), 0, 60), posting(acct(1), 1, 40)],
         )]);
         )]);
-        let policies = HashMap::new();
+        let overdraft_allowed = HashSet::new();
         let env = resolve_envelope(ResolveInput {
         let env = resolve_envelope(ResolveInput {
             transfer: &transfer,
             transfer: &transfer,
             draft,
             draft,
             available: &available,
             available: &available,
-            policies: &policies,
+            overdraft_allowed: &overdraft_allowed,
         })
         })
         .unwrap();
         .unwrap();
         assert_eq!(env.consumes().len(), 2);
         assert_eq!(env.consumes().len(), 2);
@@ -330,12 +330,12 @@ mod tests {
         let draft = draft_movements(&transfer).unwrap();
         let draft = draft_movements(&transfer).unwrap();
         let available =
         let available =
             HashMap::from([((acct(1), AssetId::new(1)), vec![posting(acct(1), 0, 100)])]);
             HashMap::from([((acct(1), AssetId::new(1)), vec![posting(acct(1), 0, 100)])]);
-        let policies = HashMap::new();
+        let overdraft_allowed = HashSet::new();
         let env = resolve_envelope(ResolveInput {
         let env = resolve_envelope(ResolveInput {
             transfer: &transfer,
             transfer: &transfer,
             draft,
             draft,
             available: &available,
             available: &available,
-            policies: &policies,
+            overdraft_allowed: &overdraft_allowed,
         })
         })
         .unwrap();
         .unwrap();
         assert_eq!(env.consumes().len(), 1);
         assert_eq!(env.consumes().len(), 1);
@@ -363,12 +363,12 @@ mod tests {
                 posting(acct(1), 2, 50),
                 posting(acct(1), 2, 50),
             ],
             ],
         )]);
         )]);
-        let policies = HashMap::new();
+        let overdraft_allowed = HashSet::new();
         let env = resolve_envelope(ResolveInput {
         let env = resolve_envelope(ResolveInput {
             transfer: &transfer,
             transfer: &transfer,
             draft,
             draft,
             available: &available,
             available: &available,
-            policies: &policies,
+            overdraft_allowed: &overdraft_allowed,
         })
         })
         .unwrap();
         .unwrap();
         assert_eq!(env.consumes().len(), 1);
         assert_eq!(env.consumes().len(), 1);
@@ -387,12 +387,12 @@ mod tests {
         let draft = draft_movements(&transfer).unwrap();
         let draft = draft_movements(&transfer).unwrap();
         let available =
         let available =
             HashMap::from([((acct(1), AssetId::new(1)), vec![posting(acct(1), 0, 40)])]);
             HashMap::from([((acct(1), AssetId::new(1)), vec![posting(acct(1), 0, 40)])]);
-        let policies = HashMap::from([(acct(1), AccountPolicy::NoOverdraft)]);
+        let overdraft_allowed = HashSet::new();
         let err = resolve_envelope(ResolveInput {
         let err = resolve_envelope(ResolveInput {
             transfer: &transfer,
             transfer: &transfer,
             draft,
             draft,
             available: &available,
             available: &available,
-            policies: &policies,
+            overdraft_allowed: &overdraft_allowed,
         })
         })
         .unwrap_err();
         .unwrap_err();
         assert_eq!(
         assert_eq!(
@@ -405,16 +405,16 @@ mod tests {
     }
     }
 
 
     #[test]
     #[test]
-    fn missing_policy_is_treated_as_no_overdraft() {
+    fn missing_account_is_treated_as_no_overdraft() {
         let transfer = pay(acct(1), acct(2), 100);
         let transfer = pay(acct(1), acct(2), 100);
         let draft = draft_movements(&transfer).unwrap();
         let draft = draft_movements(&transfer).unwrap();
         let available = HashMap::new();
         let available = HashMap::new();
-        let policies = HashMap::new();
+        let overdraft_allowed = HashSet::new();
         let err = resolve_envelope(ResolveInput {
         let err = resolve_envelope(ResolveInput {
             transfer: &transfer,
             transfer: &transfer,
             draft,
             draft,
             available: &available,
             available: &available,
-            policies: &policies,
+            overdraft_allowed: &overdraft_allowed,
         })
         })
         .unwrap_err();
         .unwrap_err();
         assert_eq!(
         assert_eq!(
@@ -432,12 +432,12 @@ mod tests {
         let draft = draft_movements(&transfer).unwrap();
         let draft = draft_movements(&transfer).unwrap();
         let available =
         let available =
             HashMap::from([((acct(1), AssetId::new(1)), vec![posting(acct(1), 0, 30)])]);
             HashMap::from([((acct(1), AssetId::new(1)), vec![posting(acct(1), 0, 30)])]);
-        let policies = HashMap::from([(acct(1), AccountPolicy::UncappedOverdraft)]);
+        let overdraft_allowed = HashSet::from([acct(1)]);
         let env = resolve_envelope(ResolveInput {
         let env = resolve_envelope(ResolveInput {
             transfer: &transfer,
             transfer: &transfer,
             draft,
             draft,
             available: &available,
             available: &available,
-            policies: &policies,
+            overdraft_allowed: &overdraft_allowed,
         })
         })
         .unwrap();
         .unwrap();
         // The single positive posting is consumed.
         // The single positive posting is consumed.
@@ -453,23 +453,18 @@ mod tests {
     }
     }
 
 
     #[test]
     #[test]
-    fn capped_overdraft_covers_shortfall() {
-        // The floor is enforced in validation, not here — resolve still creates
-        // the offset posting for a capped-overdraft account.
+    fn overdraft_covers_full_shortfall_with_no_positives() {
+        // With no positive postings at all, an overdraft account still gets the
+        // full amount as a negative offset posting.
         let transfer = pay(acct(1), acct(2), 100);
         let transfer = pay(acct(1), acct(2), 100);
         let draft = draft_movements(&transfer).unwrap();
         let draft = draft_movements(&transfer).unwrap();
         let available = HashMap::new();
         let available = HashMap::new();
-        let policies = HashMap::from([(
-            acct(1),
-            AccountPolicy::CappedOverdraft {
-                floor: Cent::from(-1000),
-            },
-        )]);
+        let overdraft_allowed = HashSet::from([acct(1)]);
         let env = resolve_envelope(ResolveInput {
         let env = resolve_envelope(ResolveInput {
             transfer: &transfer,
             transfer: &transfer,
             draft,
             draft,
             available: &available,
             available: &available,
-            policies: &policies,
+            overdraft_allowed: &overdraft_allowed,
         })
         })
         .unwrap();
         .unwrap();
         assert!(env.consumes().is_empty());
         assert!(env.consumes().is_empty());

+ 77 - 105
crates/kuatia-core/src/validate.rs

@@ -1,7 +1,7 @@
 //! Pure, sync validation — the auditable heart of the ledger.
 //! Pure, sync validation — the auditable heart of the ledger.
 //!
 //!
 //! [`validate_and_plan`] enforces every invariant (conservation, double-spend,
 //! [`validate_and_plan`] enforces every invariant (conservation, double-spend,
-//! ownership, account policy) and produces a [`Plan`] describing the effects to
+//! ownership, overdraft) and produces a [`Plan`] describing the effects to
 //! apply. It takes no IO, no clock, and no randomness, so it is deterministic
 //! apply. It takes no IO, no clock, and no randomness, so it is deterministic
 //! and testable with golden vectors. The caller provides pre-loaded state via
 //! and testable with golden vectors. The caller provides pre-loaded state via
 //! [`PlanInput`]; this module never touches storage.
 //! [`PlanInput`]; this module never touches storage.
@@ -82,13 +82,13 @@ pub enum ValidationError {
         /// Total value of created postings for this asset.
         /// Total value of created postings for this asset.
         created_sum: Cent,
         created_sum: Cent,
     },
     },
-    /// Projected balance would fall below the account's floor.
+    /// Projected balance would go negative on an account that forbids overdraft.
     OverdraftExceeded {
     OverdraftExceeded {
         /// The account that would be overdrawn.
         /// The account that would be overdrawn.
         account: AccountId,
         account: AccountId,
         /// The asset involved.
         /// The asset involved.
         asset: AssetId,
         asset: AssetId,
-        /// The minimum allowed balance.
+        /// The minimum allowed balance (always zero: the overdraft floor).
         floor: Cent,
         floor: Cent,
         /// The balance that would result from this transfer.
         /// The balance that would result from this transfer.
         projected: Cent,
         projected: Cent,
@@ -102,7 +102,7 @@ pub enum ValidationError {
         /// The actual current snapshot hash.
         /// The actual current snapshot hash.
         actual: [u8; 32],
         actual: [u8; 32],
     },
     },
-    /// A negative posting targets an account whose policy forbids offset positions.
+    /// A negative posting targets an account that forbids overdraft.
     NegativePostingOnNonSystemAccount {
     NegativePostingOnNonSystemAccount {
         /// The account that would receive the negative posting.
         /// The account that would receive the negative posting.
         account: AccountId,
         account: AccountId,
@@ -186,7 +186,7 @@ impl std::fmt::Display for ValidationError {
             } => {
             } => {
                 write!(
                 write!(
                     f,
                     f,
-                    "negative posting ({value}) on account {account:?}/{asset:?} whose policy forbids offsets"
+                    "negative posting ({value}) on account {account:?}/{asset:?} that forbids overdraft"
                 )
                 )
             }
             }
             Self::BookAssetNotAllowed { book, asset } => {
             Self::BookAssetNotAllowed { book, asset } => {
@@ -361,32 +361,26 @@ pub fn validate_and_plan(input: PlanInput<'_>) -> Result<Plan, ValidationError>
         }
         }
     }
     }
 
 
-    // 7. Negative postings (offset positions) may target system, external, or
-    //    overdraft accounts. Overdraft floors are enforced separately in step 8.
-    //    Only NoOverdraft forbids holding a negative posting.
+    // 7. A negative posting (offset position) is only allowed on an account
+    //    that permits overdraft. Only DEBIT_MUST_NOT_EXCEED_CREDIT forbids it.
     for np in envelope.creates() {
     for np in envelope.creates() {
         if np.value.is_negative() {
         if np.value.is_negative() {
             let account = input
             let account = input
                 .accounts
                 .accounts
                 .get(&np.owner)
                 .get(&np.owner)
                 .ok_or(ValidationError::AccountNotFound(np.owner))?;
                 .ok_or(ValidationError::AccountNotFound(np.owner))?;
-            match account.policy {
-                AccountPolicy::SystemAccount
-                | AccountPolicy::ExternalAccount
-                | AccountPolicy::UncappedOverdraft
-                | AccountPolicy::CappedOverdraft { .. } => {}
-                AccountPolicy::NoOverdraft => {
-                    return Err(ValidationError::NegativePostingOnNonSystemAccount {
-                        account: np.owner,
-                        asset: np.asset,
-                        value: np.value,
-                    });
-                }
+            if account.forbids_overdraft() {
+                return Err(ValidationError::NegativePostingOnNonSystemAccount {
+                    account: np.owner,
+                    asset: np.asset,
+                    value: np.value,
+                });
             }
             }
         }
         }
     }
     }
 
 
-    // 8. Policy: projected balance satisfies account's floor
+    // 8. An account that forbids overdraft must not project to a negative
+    //    balance. Accounts that permit overdraft have no floor.
     let mut deltas: HashMap<(AccountId, AssetId), Cent> = HashMap::new();
     let mut deltas: HashMap<(AccountId, AssetId), Cent> = HashMap::new();
     for pid in envelope.consumes() {
     for pid in envelope.consumes() {
         let posting = consumed_by_id[pid];
         let posting = consumed_by_id[pid];
@@ -401,40 +395,23 @@ pub fn validate_and_plan(input: PlanInput<'_>) -> Result<Plan, ValidationError>
     }
     }
 
 
     for ((account_id, asset_id), delta) in &deltas {
     for ((account_id, asset_id), delta) in &deltas {
+        let account = &input.accounts[account_id];
+        if !account.forbids_overdraft() {
+            continue;
+        }
         let current_balance = input
         let current_balance = input
             .balances
             .balances
             .get(&(*account_id, *asset_id))
             .get(&(*account_id, *asset_id))
             .copied()
             .copied()
             .unwrap_or(Cent::ZERO);
             .unwrap_or(Cent::ZERO);
         let projected = current_balance.checked_add(*delta)?;
         let projected = current_balance.checked_add(*delta)?;
-
-        let account = &input.accounts[account_id];
-        match &account.policy {
-            AccountPolicy::NoOverdraft => {
-                if projected.is_negative() {
-                    return Err(ValidationError::OverdraftExceeded {
-                        account: *account_id,
-                        asset: *asset_id,
-                        floor: Cent::ZERO,
-                        projected,
-                    });
-                }
-            }
-            AccountPolicy::CappedOverdraft { floor } => {
-                if projected < *floor {
-                    return Err(ValidationError::OverdraftExceeded {
-                        account: *account_id,
-                        asset: *asset_id,
-                        floor: *floor,
-                        projected,
-                    });
-                }
-            }
-            AccountPolicy::UncappedOverdraft
-            | AccountPolicy::SystemAccount
-            | AccountPolicy::ExternalAccount => {
-                // No floor check
-            }
+        if projected.is_negative() {
+            return Err(ValidationError::OverdraftExceeded {
+                account: *account_id,
+                asset: *asset_id,
+                floor: Cent::ZERO,
+                projected,
+            });
         }
         }
     }
     }
 
 
@@ -472,12 +449,11 @@ mod tests {
     use super::*;
     use super::*;
     use std::collections::BTreeMap;
     use std::collections::BTreeMap;
 
 
-    fn make_account(id: i64, policy: AccountPolicy) -> Account {
+    fn make_account(id: i64, flags: AccountFlags) -> Account {
         Account {
         Account {
             id: AccountId::new(id),
             id: AccountId::new(id),
             version: 1,
             version: 1,
-            policy,
-            flags: AccountFlags::empty(),
+            flags,
             book: BookId(0),
             book: BookId(0),
             metadata: BTreeMap::new(),
             metadata: BTreeMap::new(),
         }
         }
@@ -516,8 +492,8 @@ mod tests {
     fn valid_deposit() {
     fn valid_deposit() {
         let envelope = deposit_envelope();
         let envelope = deposit_envelope();
         let accounts = accounts_map(vec![
         let accounts = accounts_map(vec![
-            make_account(1, AccountPolicy::NoOverdraft),
-            make_account(99, AccountPolicy::ExternalAccount),
+            make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+            make_account(99, AccountFlags::empty()),
         ]);
         ]);
         let balances = HashMap::new();
         let balances = HashMap::new();
         let input = PlanInput {
         let input = PlanInput {
@@ -572,7 +548,10 @@ mod tests {
             account_snapshots: vec![],
             account_snapshots: vec![],
             metadata: BTreeMap::new(),
             metadata: BTreeMap::new(),
         };
         };
-        let accounts = accounts_map(vec![make_account(1, AccountPolicy::NoOverdraft)]);
+        let accounts = accounts_map(vec![make_account(
+            1,
+            AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT,
+        )]);
         let balances = HashMap::new();
         let balances = HashMap::new();
         let input = PlanInput {
         let input = PlanInput {
             envelope: &envelope,
             envelope: &envelope,
@@ -620,9 +599,9 @@ mod tests {
     #[test]
     #[test]
     fn account_frozen_rejected() {
     fn account_frozen_rejected() {
         let envelope = deposit_envelope();
         let envelope = deposit_envelope();
-        let mut acc = make_account(1, AccountPolicy::NoOverdraft);
+        let mut acc = make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
         acc.flags = AccountFlags::FROZEN;
         acc.flags = AccountFlags::FROZEN;
-        let accounts = accounts_map(vec![acc, make_account(99, AccountPolicy::ExternalAccount)]);
+        let accounts = accounts_map(vec![acc, make_account(99, AccountFlags::empty())]);
         let balances = HashMap::new();
         let balances = HashMap::new();
         let input = PlanInput {
         let input = PlanInput {
             envelope: &envelope,
             envelope: &envelope,
@@ -641,9 +620,9 @@ mod tests {
     #[test]
     #[test]
     fn account_closed_rejected() {
     fn account_closed_rejected() {
         let envelope = deposit_envelope();
         let envelope = deposit_envelope();
-        let mut acc = make_account(1, AccountPolicy::NoOverdraft);
+        let mut acc = make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
         acc.flags = AccountFlags::CLOSED;
         acc.flags = AccountFlags::CLOSED;
-        let accounts = accounts_map(vec![acc, make_account(99, AccountPolicy::ExternalAccount)]);
+        let accounts = accounts_map(vec![acc, make_account(99, AccountFlags::empty())]);
         let balances = HashMap::new();
         let balances = HashMap::new();
         let input = PlanInput {
         let input = PlanInput {
             envelope: &envelope,
             envelope: &envelope,
@@ -686,8 +665,8 @@ mod tests {
             metadata: BTreeMap::new(),
             metadata: BTreeMap::new(),
         };
         };
         let accounts = accounts_map(vec![
         let accounts = accounts_map(vec![
-            make_account(1, AccountPolicy::NoOverdraft),
-            make_account(2, AccountPolicy::NoOverdraft),
+            make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+            make_account(2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
         ]);
         ]);
         // account1 has balance 50, consuming 50 leaves 0, that's fine.
         // account1 has balance 50, consuming 50 leaves 0, that's fine.
         // Let's test when balance is insufficient: balance=30, consuming 50-value posting
         // Let's test when balance is insufficient: balance=30, consuming 50-value posting
@@ -735,15 +714,11 @@ mod tests {
             metadata: BTreeMap::new(),
             metadata: BTreeMap::new(),
         };
         };
         let accounts = accounts_map(vec![
         let accounts = accounts_map(vec![
-            make_account(
-                1,
-                AccountPolicy::CappedOverdraft {
-                    floor: Cent::from(-50),
-                },
-            ),
-            make_account(2, AccountPolicy::NoOverdraft),
+            // Overdraft allowed (the default): no floor.
+            make_account(1, AccountFlags::empty()),
+            make_account(2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
         ]);
         ]);
-        // balance=80, consuming 100 → projected = 80 - 100 = -20 >= -50 → OK
+        // balance=80, consuming 100 → projected = 80 - 100 = -20, allowed with no floor
         let mut balances = HashMap::new();
         let mut balances = HashMap::new();
         balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(80));
         balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(80));
 
 
@@ -755,13 +730,13 @@ mod tests {
             book: None,
             book: None,
         };
         };
 
 
-        // A CappedOverdraft spend within the floor validates and produces a plan.
+        // An overdraft account spending into a negative balance validates.
         let plan = validate_and_plan(input).unwrap();
         let plan = validate_and_plan(input).unwrap();
         assert!(!plan.postings_to_create.is_empty());
         assert!(!plan.postings_to_create.is_empty());
     }
     }
 
 
     #[test]
     #[test]
-    fn capped_overdraft_exceeded() {
+    fn debit_must_not_exceed_credit_rejects_negative_projection() {
         let pid = PostingId {
         let pid = PostingId {
             transfer: EnvelopeId([1; 32]),
             transfer: EnvelopeId([1; 32]),
             index: 0,
             index: 0,
@@ -785,15 +760,10 @@ mod tests {
             metadata: BTreeMap::new(),
             metadata: BTreeMap::new(),
         };
         };
         let accounts = accounts_map(vec![
         let accounts = accounts_map(vec![
-            make_account(
-                1,
-                AccountPolicy::CappedOverdraft {
-                    floor: Cent::from(-50),
-                },
-            ),
-            make_account(2, AccountPolicy::NoOverdraft),
+            make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+            make_account(2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
         ]);
         ]);
-        // balance=30, consuming 100 → projected = 30 - 100 = -70 < -50 → FAIL
+        // balance=30, consuming 100 → projected = 30 - 100 = -70 < 0 → FAIL
         let mut balances = HashMap::new();
         let mut balances = HashMap::new();
         balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(30));
         balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(30));
 
 
@@ -809,7 +779,7 @@ mod tests {
             Err(ValidationError::OverdraftExceeded {
             Err(ValidationError::OverdraftExceeded {
                 floor, projected, ..
                 floor, projected, ..
             }) => {
             }) => {
-                assert_eq!(floor, Cent::from(-50));
+                assert_eq!(floor, Cent::ZERO);
                 assert_eq!(projected, Cent::from(-70));
                 assert_eq!(projected, Cent::from(-70));
             }
             }
             other => panic!("expected OverdraftExceeded, got {other:?}"),
             other => panic!("expected OverdraftExceeded, got {other:?}"),
@@ -817,7 +787,7 @@ mod tests {
     }
     }
 
 
     #[test]
     #[test]
-    fn uncapped_overdraft_allows_negative() {
+    fn overdraft_allows_negative_balance() {
         let pid = PostingId {
         let pid = PostingId {
             transfer: EnvelopeId([1; 32]),
             transfer: EnvelopeId([1; 32]),
             index: 0,
             index: 0,
@@ -841,8 +811,8 @@ mod tests {
             metadata: BTreeMap::new(),
             metadata: BTreeMap::new(),
         };
         };
         let accounts = accounts_map(vec![
         let accounts = accounts_map(vec![
-            make_account(1, AccountPolicy::UncappedOverdraft),
-            make_account(2, AccountPolicy::NoOverdraft),
+            make_account(1, AccountFlags::empty()),
+            make_account(2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
         ]);
         ]);
         // balance=10, consuming 100 → projected = 10 - 100 = -90 → allowed
         // balance=10, consuming 100 → projected = 10 - 100 = -90 → allowed
         let mut balances = HashMap::new();
         let mut balances = HashMap::new();
@@ -924,8 +894,8 @@ mod tests {
             metadata: BTreeMap::new(),
             metadata: BTreeMap::new(),
         };
         };
         let accounts = accounts_map(vec![
         let accounts = accounts_map(vec![
-            make_account(1, AccountPolicy::NoOverdraft),
-            make_account(2, AccountPolicy::NoOverdraft),
+            make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+            make_account(2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
         ]);
         ]);
         let mut balances = HashMap::new();
         let mut balances = HashMap::new();
         balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(100));
         balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(100));
@@ -945,22 +915,21 @@ mod tests {
         // account2 projected: 0 + 60 = 60 >= 0 ✓
         // account2 projected: 0 + 60 = 60 >= 0 ✓
     }
     }
 
 
-    fn make_subaccount(id: i64, sub: i64, policy: AccountPolicy) -> Account {
+    fn make_subaccount(id: i64, sub: i64, flags: AccountFlags) -> Account {
         Account {
         Account {
             id: AccountId::with_sub(id, sub),
             id: AccountId::with_sub(id, sub),
             version: 1,
             version: 1,
-            policy,
-            flags: AccountFlags::empty(),
+            flags,
             book: BookId(0),
             book: BookId(0),
             metadata: BTreeMap::new(),
             metadata: BTreeMap::new(),
         }
         }
     }
     }
 
 
     #[test]
     #[test]
-    fn subaccount_carries_own_policy() {
-        // Base (1,0) is NoOverdraft but subaccount (1,7) is UncappedOverdraft.
-        // A negative posting on the subaccount is allowed because the check keys
-        // on the full owner and uses the subaccount's own policy.
+    fn subaccount_carries_own_overdraft_flag() {
+        // Base (1,0) forbids overdraft but subaccount (1,7) allows it. A negative
+        // posting on the subaccount is allowed because the check keys on the full
+        // owner and uses the subaccount's own flags.
         let sub = AccountId::with_sub(1, 7);
         let sub = AccountId::with_sub(1, 7);
         let envelope = Envelope {
         let envelope = Envelope {
             consumes: vec![],
             consumes: vec![],
@@ -983,9 +952,9 @@ mod tests {
             metadata: BTreeMap::new(),
             metadata: BTreeMap::new(),
         };
         };
         let accounts = accounts_map(vec![
         let accounts = accounts_map(vec![
-            make_account(1, AccountPolicy::NoOverdraft),
-            make_subaccount(1, 7, AccountPolicy::UncappedOverdraft),
-            make_account(2, AccountPolicy::NoOverdraft),
+            make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+            make_subaccount(1, 7, AccountFlags::empty()),
+            make_account(2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
         ]);
         ]);
         let balances = HashMap::new();
         let balances = HashMap::new();
         let input = PlanInput {
         let input = PlanInput {
@@ -1002,9 +971,9 @@ mod tests {
 
 
     #[test]
     #[test]
     fn subaccount_floor_is_segregated_from_base() {
     fn subaccount_floor_is_segregated_from_base() {
-        // Base (1,0) holds 100, but NoOverdraft subaccount (1,7) holds nothing.
-        // The base's balance must not rescue the subaccount: a negative posting
-        // on the subaccount is rejected on its own policy.
+        // Base (1,0) holds 100, but the overdraft-forbidding subaccount (1,7)
+        // holds nothing. The base's balance must not rescue the subaccount: a
+        // negative posting on the subaccount is rejected on its own flags.
         let sub = AccountId::with_sub(1, 7);
         let sub = AccountId::with_sub(1, 7);
         let envelope = Envelope {
         let envelope = Envelope {
             consumes: vec![],
             consumes: vec![],
@@ -1027,9 +996,9 @@ mod tests {
             metadata: BTreeMap::new(),
             metadata: BTreeMap::new(),
         };
         };
         let accounts = accounts_map(vec![
         let accounts = accounts_map(vec![
-            make_account(1, AccountPolicy::NoOverdraft),
-            make_subaccount(1, 7, AccountPolicy::NoOverdraft),
-            make_account(2, AccountPolicy::NoOverdraft),
+            make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+            make_subaccount(1, 7, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+            make_account(2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
         ]);
         ]);
         let mut balances = HashMap::new();
         let mut balances = HashMap::new();
         balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(100));
         balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(100));
@@ -1075,7 +1044,7 @@ mod tests {
             metadata: BTreeMap::new(),
             metadata: BTreeMap::new(),
         };
         };
         // Only external account exists, account 999 doesn't
         // Only external account exists, account 999 doesn't
-        let accounts = accounts_map(vec![make_account(99, AccountPolicy::ExternalAccount)]);
+        let accounts = accounts_map(vec![make_account(99, AccountFlags::empty())]);
         let balances = HashMap::new();
         let balances = HashMap::new();
         let input = PlanInput {
         let input = PlanInput {
             envelope: &envelope,
             envelope: &envelope,
@@ -1113,7 +1082,10 @@ mod tests {
             account_snapshots: vec![],
             account_snapshots: vec![],
             metadata: BTreeMap::new(),
             metadata: BTreeMap::new(),
         };
         };
-        let accounts = accounts_map(vec![make_account(1, AccountPolicy::NoOverdraft)]);
+        let accounts = accounts_map(vec![make_account(
+            1,
+            AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT,
+        )]);
         let balances = HashMap::new();
         let balances = HashMap::new();
         let input = PlanInput {
         let input = PlanInput {
             envelope: &envelope,
             envelope: &envelope,
@@ -1137,8 +1109,8 @@ mod tests {
     fn negative_posting_allowed_on_system_account() {
     fn negative_posting_allowed_on_system_account() {
         let envelope = deposit_envelope();
         let envelope = deposit_envelope();
         let accounts = accounts_map(vec![
         let accounts = accounts_map(vec![
-            make_account(1, AccountPolicy::NoOverdraft),
-            make_account(99, AccountPolicy::SystemAccount),
+            make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+            make_account(99, AccountFlags::empty()),
         ]);
         ]);
         let balances = HashMap::new();
         let balances = HashMap::new();
         let input = PlanInput {
         let input = PlanInput {

+ 5 - 34
crates/kuatia-dashboard/src/data.rs

@@ -11,7 +11,7 @@ use axum::{
     response::{IntoResponse, Response},
     response::{IntoResponse, Response},
 };
 };
 use kuatia::ledger::Ledger;
 use kuatia::ledger::Ledger;
-use kuatia_core::{Account, AccountId, AccountPolicy, AssetId, Cent, PostingId, PostingState};
+use kuatia_core::{Account, AccountId, AssetId, Cent, PostingId, PostingState};
 use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
 use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
 use kuatia_storage::store::{EnvelopeRecord, TransferQuery};
 use kuatia_storage::store::{EnvelopeRecord, TransferQuery};
 use serde::Serialize;
 use serde::Serialize;
@@ -47,19 +47,15 @@ pub struct AccountDto {
     pub sub: i64,
     pub sub: i64,
     pub label: Option<&'static str>,
     pub label: Option<&'static str>,
     pub version: u64,
     pub version: u64,
-    pub policy: PolicyDto,
+    /// Whether the account carries the `DEBIT_MUST_NOT_EXCEED_CREDIT` flag
+    /// (balance may not go negative). When `false` the account may overdraw.
+    pub debit_must_not_exceed_credit: bool,
     pub frozen: bool,
     pub frozen: bool,
     pub closed: bool,
     pub closed: bool,
     pub balances: Vec<BalanceDto>,
     pub balances: Vec<BalanceDto>,
 }
 }
 
 
 #[derive(Serialize)]
 #[derive(Serialize)]
-pub struct PolicyDto {
-    pub kind: &'static str,
-    pub floor: Option<Cent>,
-}
-
-#[derive(Serialize)]
 pub struct PostingDto {
 pub struct PostingDto {
     pub id: String,
     pub id: String,
     pub owner: AccountId,
     pub owner: AccountId,
@@ -132,31 +128,6 @@ fn posting_id(id: &PostingId) -> String {
     format!("{}:{}", hex32(&id.transfer.0), id.index)
     format!("{}:{}", hex32(&id.transfer.0), id.index)
 }
 }
 
 
-fn policy_dto(policy: &AccountPolicy) -> PolicyDto {
-    match policy {
-        AccountPolicy::NoOverdraft => PolicyDto {
-            kind: "NoOverdraft",
-            floor: None,
-        },
-        AccountPolicy::CappedOverdraft { floor } => PolicyDto {
-            kind: "CappedOverdraft",
-            floor: Some(*floor),
-        },
-        AccountPolicy::UncappedOverdraft => PolicyDto {
-            kind: "UncappedOverdraft",
-            floor: None,
-        },
-        AccountPolicy::SystemAccount => PolicyDto {
-            kind: "SystemAccount",
-            floor: None,
-        },
-        AccountPolicy::ExternalAccount => PolicyDto {
-            kind: "ExternalAccount",
-            floor: None,
-        },
-    }
-}
-
 async fn account_dto(state: &AppState, account: &Account) -> Result<AccountDto, ApiError> {
 async fn account_dto(state: &AppState, account: &Account) -> Result<AccountDto, ApiError> {
     let mut balances = Vec::new();
     let mut balances = Vec::new();
     for asset in state.assets.iter() {
     for asset in state.assets.iter() {
@@ -175,7 +146,7 @@ async fn account_dto(state: &AppState, account: &Account) -> Result<AccountDto,
         sub: account.id.sub,
         sub: account.id.sub,
         label: account_label(account.id),
         label: account_label(account.id),
         version: account.version,
         version: account.version,
-        policy: policy_dto(&account.policy),
+        debit_must_not_exceed_credit: account.forbids_overdraft(),
         frozen: account.is_frozen(),
         frozen: account.is_frozen(),
         closed: account.is_closed(),
         closed: account.is_closed(),
         balances,
         balances,

+ 19 - 19
crates/kuatia-dashboard/src/seed.rs

@@ -5,7 +5,7 @@
 use std::sync::Arc;
 use std::sync::Arc;
 
 
 use kuatia::ledger::Ledger;
 use kuatia::ledger::Ledger;
-use kuatia_core::{Account, AccountId, AccountPolicy, Amount, AssetId, Cent, TransferBuilder};
+use kuatia_core::{Account, AccountId, Amount, AssetId, Cent, TransferBuilder};
 use kuatia_storage_sql::SqlStore;
 use kuatia_storage_sql::SqlStore;
 
 
 use crate::assets::{BTC, EUR, USD};
 use crate::assets::{BTC, EUR, USD};
@@ -87,21 +87,16 @@ pub async fn populate(ledger: &Arc<Ledger>) -> Result<(), Box<dyn std::error::Er
     let fiat = Amount::new(2);
     let fiat = Amount::new(2);
     let btc = Amount::new(8);
     let btc = Amount::new(8);
 
 
-    create(ledger, TREASURY, AccountPolicy::SystemAccount).await?;
-    create(ledger, EXTERNAL, AccountPolicy::ExternalAccount).await?;
-    create(ledger, ALICE, AccountPolicy::NoOverdraft).await?;
-    create(ledger, ALICE_SAVINGS, AccountPolicy::NoOverdraft).await?;
-    create(ledger, BOB, AccountPolicy::NoOverdraft).await?;
-    // Carol may overdraw down to -$500.00.
-    create(
-        ledger,
-        CAROL,
-        AccountPolicy::CappedOverdraft {
-            floor: fiat.parse("-500.00")?,
-        },
-    )
-    .await?;
-    create(ledger, MERCHANT, AccountPolicy::NoOverdraft).await?;
+    // Treasury and the external boundary permit overdraft (they hold the
+    // negative side of issuance/deposits); the user accounts forbid it.
+    create(ledger, TREASURY, false).await?;
+    create(ledger, EXTERNAL, false).await?;
+    create(ledger, ALICE, true).await?;
+    create(ledger, ALICE_SAVINGS, true).await?;
+    create(ledger, BOB, true).await?;
+    // Carol may overdraw (no floor under the single-flag model).
+    create(ledger, CAROL, false).await?;
+    create(ledger, MERCHANT, true).await?;
 
 
     // Fund accounts from the external boundary.
     // Fund accounts from the external boundary.
     deposit(ledger, ALICE, USD, fiat.parse("1000.00")?).await?;
     deposit(ledger, ALICE, USD, fiat.parse("1000.00")?).await?;
@@ -114,7 +109,7 @@ pub async fn populate(ledger: &Arc<Ledger>) -> Result<(), Box<dyn std::error::Er
     pay(ledger, BOB, MERCHANT, EUR, fiat.parse("80.00")?).await?;
     pay(ledger, BOB, MERCHANT, EUR, fiat.parse("80.00")?).await?;
     pay(ledger, ALICE, MERCHANT, BTC, btc.parse("0.10000000")?).await?;
     pay(ledger, ALICE, MERCHANT, BTC, btc.parse("0.10000000")?).await?;
 
 
-    // Carol spends past her balance, into the capped overdraft.
+    // Carol spends past her balance, into overdraft.
     pay(ledger, CAROL, MERCHANT, USD, fiat.parse("250.00")?).await?;
     pay(ledger, CAROL, MERCHANT, USD, fiat.parse("250.00")?).await?;
 
 
     // Alice earmarks part of her balance into her savings subaccount. The two
     // Alice earmarks part of her balance into her savings subaccount. The two
@@ -134,9 +129,14 @@ pub async fn populate(ledger: &Arc<Ledger>) -> Result<(), Box<dyn std::error::Er
 async fn create(
 async fn create(
     ledger: &Arc<Ledger>,
     ledger: &Arc<Ledger>,
     id: AccountId,
     id: AccountId,
-    policy: AccountPolicy,
+    debit_must_not_exceed_credit: bool,
 ) -> Result<(), Box<dyn std::error::Error>> {
 ) -> Result<(), Box<dyn std::error::Error>> {
-    ledger.create_account(Account::new(id, policy)).await?;
+    let account = if debit_must_not_exceed_credit {
+        Account::debit_must_not_exceed_credit(id)
+    } else {
+        Account::new(id)
+    };
+    ledger.create_account(account).await?;
     Ok(())
     Ok(())
 }
 }
 
 

+ 5 - 14
crates/kuatia-dashboard/src/ui.rs

@@ -123,7 +123,6 @@ struct AccountView {
     name: String,
     name: String,
     version: u64,
     version: u64,
     policy_kind: &'static str,
     policy_kind: &'static str,
-    floor: Option<MoneyView>,
     frozen: bool,
     frozen: bool,
     closed: bool,
     closed: bool,
     balances: Vec<BalanceView>,
     balances: Vec<BalanceView>,
@@ -257,16 +256,6 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) {
     (if m <= 2 { y + 1 } else { y }, m, d)
     (if m <= 2 { y + 1 } else { y }, m, d)
 }
 }
 
 
-/// The symbol/decimals to render an overdraft floor, which carries no asset.
-/// Use the first two-decimal asset (fiat) if present.
-fn floor_style(assets: &[AssetMeta]) -> (u8, &str) {
-    assets
-        .iter()
-        .find(|a| a.decimals == 2)
-        .map(|a| (a.decimals, a.symbol))
-        .unwrap_or((2, ""))
-}
-
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 // View builders — DTO -> view model.
 // View builders — DTO -> view model.
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
@@ -283,7 +272,6 @@ fn acct_link(id: kuatia_core::AccountId) -> String {
 }
 }
 
 
 fn account_view(dto: &AccountDto, assets: &[AssetMeta]) -> AccountView {
 fn account_view(dto: &AccountDto, assets: &[AssetMeta]) -> AccountView {
-    let (floor_dec, floor_sym) = floor_style(assets);
     let display_id = acct_display(dto.id);
     let display_id = acct_display(dto.id);
     let link = acct_link(dto.id);
     let link = acct_link(dto.id);
     AccountView {
     AccountView {
@@ -297,8 +285,11 @@ fn account_view(dto: &AccountDto, assets: &[AssetMeta]) -> AccountView {
             .unwrap_or_else(|| display_id.clone()),
             .unwrap_or_else(|| display_id.clone()),
         display_id,
         display_id,
         version: dto.version,
         version: dto.version,
-        policy_kind: dto.policy.kind,
-        floor: dto.policy.floor.map(|f| fmt(f, floor_dec, floor_sym)),
+        policy_kind: if dto.debit_must_not_exceed_credit {
+            "DebitMustNotExceedCredit"
+        } else {
+            "Overdraft"
+        },
         frozen: dto.frozen,
         frozen: dto.frozen,
         closed: dto.closed,
         closed: dto.closed,
         balances: dto
         balances: dto

+ 0 - 3
crates/kuatia-dashboard/templates/partials/account.html

@@ -1,8 +1,5 @@
 <div class="detail-meta">
 <div class="detail-meta">
   <span class="policy {{ account.policy_kind }}">{{ account.policy_kind }}</span>
   <span class="policy {{ account.policy_kind }}">{{ account.policy_kind }}</span>
-  {% if account.floor %}
-    <span class="muted">floor <span class="money neg">{{ account.floor.text }}</span></span>
-  {% endif %}
   <span class="muted">version {{ account.version }}</span>
   <span class="muted">version {{ account.version }}</span>
   {% if account.frozen %}<span class="flag frozen">frozen</span>{% endif %}
   {% if account.frozen %}<span class="flag frozen">frozen</span>{% endif %}
   {% if account.closed %}<span class="flag closed">closed</span>{% endif %}
   {% if account.closed %}<span class="flag closed">closed</span>{% endif %}

+ 6 - 17
crates/kuatia-storage-sql/src/lib.rs

@@ -114,6 +114,10 @@ impl SqlStore {
                 "005_account_head",
                 "005_account_head",
                 include_str!("migrations/005_account_head.sql"),
                 include_str!("migrations/005_account_head.sql"),
             ),
             ),
+            (
+                "006_drop_policy",
+                include_str!("migrations/006_drop_policy.sql"),
+            ),
         ];
         ];
 
 
         for (name, sql) in migrations {
         for (name, sql) in migrations {
@@ -166,15 +170,6 @@ impl SqlStore {
 // Serialization helpers
 // Serialization helpers
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
-fn serialize_policy(policy: &AccountPolicy) -> Result<String, StoreError> {
-    serde_json::to_string(policy)
-        .map_err(|e| StoreError::Internal(format!("policy serialization: {e}")))
-}
-
-fn deserialize_policy(s: &str) -> Result<AccountPolicy, StoreError> {
-    serde_json::from_str(s).map_err(|e| StoreError::Internal(format!("bad policy: {e}")))
-}
-
 /// Serialize a value to a JSON string. Payload columns store JSON as `TEXT` so
 /// Serialize a value to a JSON string. Payload columns store JSON as `TEXT` so
 /// the database is directly readable for auditing; the ledger never queries
 /// the database is directly readable for auditing; the ledger never queries
 /// into the JSON, so no binary or indexed representation is needed.
 /// into the JSON, so no binary or indexed representation is needed.
@@ -234,9 +229,6 @@ fn row_to_account(row: &sqlx::any::AnyRow) -> Result<Account, StoreError> {
     let version: i64 = row
     let version: i64 = row
         .try_get("version")
         .try_get("version")
         .map_err(|e| StoreError::Internal(e.to_string()))?;
         .map_err(|e| StoreError::Internal(e.to_string()))?;
-    let policy_str: String = row
-        .try_get("policy")
-        .map_err(|e| StoreError::Internal(e.to_string()))?;
     let flags_bits: i32 = row
     let flags_bits: i32 = row
         .try_get("flags")
         .try_get("flags")
         .map_err(|e| StoreError::Internal(e.to_string()))?;
         .map_err(|e| StoreError::Internal(e.to_string()))?;
@@ -250,7 +242,6 @@ fn row_to_account(row: &sqlx::any::AnyRow) -> Result<Account, StoreError> {
     Ok(Account {
     Ok(Account {
         id: AccountId::with_sub(id, subaccount),
         id: AccountId::with_sub(id, subaccount),
         version: version as u64,
         version: version as u64,
-        policy: deserialize_policy(&policy_str)?,
         flags: AccountFlags::from_bits_truncate(flags_bits as u32),
         flags: AccountFlags::from_bits_truncate(flags_bits as u32),
         book: BookId::new(book),
         book: BookId::new(book),
         metadata: deserialize_json(&metadata_json)?,
         metadata: deserialize_json(&metadata_json)?,
@@ -397,12 +388,11 @@ impl AccountStore for SqlStore {
 
 
         // Append the immutable first version, then point the head at it.
         // Append the immutable first version, then point the head at it.
         sqlx::query(
         sqlx::query(
-            "INSERT INTO accounts (id, subaccount, version, policy, flags, book, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id, subaccount, version) DO NOTHING"
+            "INSERT INTO accounts (id, subaccount, version, flags, book, metadata) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (id, subaccount, version) DO NOTHING"
         )
         )
             .bind(account.id.id)
             .bind(account.id.id)
             .bind(account.id.sub)
             .bind(account.id.sub)
             .bind(account.version as i64)
             .bind(account.version as i64)
-            .bind(serialize_policy(&account.policy)?)
             .bind(account.flags.bits() as i32)
             .bind(account.flags.bits() as i32)
             .bind(account.book.0)
             .bind(account.book.0)
             .bind(serialize_json(&account.metadata)?)
             .bind(serialize_json(&account.metadata)?)
@@ -473,12 +463,11 @@ impl AccountStore for SqlStore {
         }
         }
 
 
         let res = sqlx::query(
         let res = sqlx::query(
-            "INSERT INTO accounts (id, subaccount, version, policy, flags, book, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id, subaccount, version) DO NOTHING"
+            "INSERT INTO accounts (id, subaccount, version, flags, book, metadata) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (id, subaccount, version) DO NOTHING"
         )
         )
             .bind(account.id.id)
             .bind(account.id.id)
             .bind(account.id.sub)
             .bind(account.id.sub)
             .bind(account.version as i64)
             .bind(account.version as i64)
-            .bind(serialize_policy(&account.policy)?)
             .bind(account.flags.bits() as i32)
             .bind(account.flags.bits() as i32)
             .bind(account.book.0)
             .bind(account.book.0)
             .bind(serialize_json(&account.metadata)?)
             .bind(serialize_json(&account.metadata)?)

+ 5 - 0
crates/kuatia-storage-sql/src/migrations/006_drop_policy.sql

@@ -0,0 +1,5 @@
+-- The overdraft/balance policy is no longer a separate column. The single
+-- balance constraint (debit must not exceed credit) now lives in the account
+-- flags bitfield, so the `policy` column is dropped. Both SQLite (>= 3.35) and
+-- PostgreSQL support ALTER TABLE ... DROP COLUMN.
+ALTER TABLE accounts DROP COLUMN policy;

+ 3 - 4
crates/kuatia-storage-sql/tests/sqlite.rs

@@ -35,8 +35,7 @@ async fn columns_store_hex_ids_and_json_text() {
     let account = Account {
     let account = Account {
         id: AccountId::new(1),
         id: AccountId::new(1),
         version: 1,
         version: 1,
-        policy: AccountPolicy::NoOverdraft,
-        flags: AccountFlags::empty(),
+        flags: AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT,
         book: BookId(0),
         book: BookId(0),
         metadata: std::collections::BTreeMap::new(),
         metadata: std::collections::BTreeMap::new(),
     };
     };
@@ -82,12 +81,12 @@ async fn subaccount_columns_round_trip() {
 
 
     let sub = AccountId::with_sub(1, 7);
     let sub = AccountId::with_sub(1, 7);
     store
     store
-        .create_account(Account::new(sub, AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(sub))
         .await
         .await
         .unwrap();
         .unwrap();
     // The main account (1, 0) is a separate record.
     // The main account (1, 0) is a separate record.
     store
     store
-        .create_account(Account::new(AccountId::new(1), AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(AccountId::new(1)))
         .await
         .await
         .unwrap();
         .unwrap();
 
 

+ 14 - 13
crates/kuatia-storage/src/store_tests.rs

@@ -19,12 +19,11 @@ use crate::store::*;
 // Helpers
 // Helpers
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
-fn make_account(id: i64, policy: AccountPolicy) -> Account {
+fn make_account(id: i64, flags: AccountFlags) -> Account {
     Account {
     Account {
         id: AccountId::new(id),
         id: AccountId::new(id),
         version: 1,
         version: 1,
-        policy,
-        flags: AccountFlags::empty(),
+        flags,
         book: BookId(0),
         book: BookId(0),
         metadata: BTreeMap::new(),
         metadata: BTreeMap::new(),
     }
     }
@@ -173,16 +172,18 @@ async fn commit_envelope(
 
 
 /// Create an account and retrieve it.
 /// Create an account and retrieve it.
 pub async fn create_and_get_account(store: &(impl Store + 'static)) {
 pub async fn create_and_get_account(store: &(impl Store + 'static)) {
-    let acc = make_account(1, AccountPolicy::NoOverdraft);
+    let acc = make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
     store.create_account(acc.clone()).await.unwrap();
     store.create_account(acc.clone()).await.unwrap();
     let got = store.get_account(&AccountId::new(1)).await.unwrap();
     let got = store.get_account(&AccountId::new(1)).await.unwrap();
     assert_eq!(got.id, acc.id);
     assert_eq!(got.id, acc.id);
     assert_eq!(got.version, 1);
     assert_eq!(got.version, 1);
+    // The balance constraint lives in `flags`; it must survive the round-trip.
+    assert_eq!(got.flags, acc.flags);
 }
 }
 
 
 /// Duplicate account creation fails.
 /// Duplicate account creation fails.
 pub async fn create_duplicate_account_fails(store: &(impl Store + 'static)) {
 pub async fn create_duplicate_account_fails(store: &(impl Store + 'static)) {
-    let acc = make_account(1, AccountPolicy::NoOverdraft);
+    let acc = make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
     store.create_account(acc.clone()).await.unwrap();
     store.create_account(acc.clone()).await.unwrap();
     let err = store.create_account(acc).await.unwrap_err();
     let err = store.create_account(acc).await.unwrap_err();
     assert!(matches!(err, StoreError::AlreadyExists(_)));
     assert!(matches!(err, StoreError::AlreadyExists(_)));
@@ -197,11 +198,11 @@ pub async fn get_missing_account_fails(store: &(impl Store + 'static)) {
 /// Fetch multiple accounts in one call.
 /// Fetch multiple accounts in one call.
 pub async fn get_accounts_batch(store: &(impl Store + 'static)) {
 pub async fn get_accounts_batch(store: &(impl Store + 'static)) {
     store
     store
-        .create_account(make_account(1, AccountPolicy::NoOverdraft))
+        .create_account(make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT))
         .await
         .await
         .unwrap();
         .unwrap();
     store
     store
-        .create_account(make_account(2, AccountPolicy::NoOverdraft))
+        .create_account(make_account(2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT))
         .await
         .await
         .unwrap();
         .unwrap();
     let accs = store
     let accs = store
@@ -213,7 +214,7 @@ pub async fn get_accounts_batch(store: &(impl Store + 'static)) {
 
 
 /// Append a new version and verify get returns the latest.
 /// Append a new version and verify get returns the latest.
 pub async fn append_account_version(store: &(impl Store + 'static)) {
 pub async fn append_account_version(store: &(impl Store + 'static)) {
-    let acc = make_account(1, AccountPolicy::NoOverdraft);
+    let acc = make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
     store.create_account(acc.clone()).await.unwrap();
     store.create_account(acc.clone()).await.unwrap();
 
 
     let mut v2 = acc.clone();
     let mut v2 = acc.clone();
@@ -228,7 +229,7 @@ pub async fn append_account_version(store: &(impl Store + 'static)) {
 
 
 /// Appending with wrong version number fails.
 /// Appending with wrong version number fails.
 pub async fn append_version_conflict(store: &(impl Store + 'static)) {
 pub async fn append_version_conflict(store: &(impl Store + 'static)) {
-    let acc = make_account(1, AccountPolicy::NoOverdraft);
+    let acc = make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
     store.create_account(acc.clone()).await.unwrap();
     store.create_account(acc.clone()).await.unwrap();
 
 
     let mut bad = acc.clone();
     let mut bad = acc.clone();
@@ -241,7 +242,7 @@ pub async fn append_version_conflict(store: &(impl Store + 'static)) {
 /// intact: no gap, no duplicate. Exercises the version guard and the insert
 /// intact: no gap, no duplicate. Exercises the version guard and the insert
 /// backstop of the locking append.
 /// backstop of the locking append.
 pub async fn append_duplicate_version_rejected(store: &(impl Store + 'static)) {
 pub async fn append_duplicate_version_rejected(store: &(impl Store + 'static)) {
-    let acc = make_account(1, AccountPolicy::NoOverdraft);
+    let acc = make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
     store.create_account(acc.clone()).await.unwrap();
     store.create_account(acc.clone()).await.unwrap();
 
 
     let mut v2 = acc.clone();
     let mut v2 = acc.clone();
@@ -267,7 +268,7 @@ pub async fn append_duplicate_version_rejected(store: &(impl Store + 'static)) {
 
 
 /// Account history returns all versions.
 /// Account history returns all versions.
 pub async fn get_account_history(store: &(impl Store + 'static)) {
 pub async fn get_account_history(store: &(impl Store + 'static)) {
-    let acc = make_account(1, AccountPolicy::NoOverdraft);
+    let acc = make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
     store.create_account(acc.clone()).await.unwrap();
     store.create_account(acc.clone()).await.unwrap();
 
 
     let mut v2 = acc.clone();
     let mut v2 = acc.clone();
@@ -283,11 +284,11 @@ pub async fn get_account_history(store: &(impl Store + 'static)) {
 /// List accounts returns latest version of each.
 /// List accounts returns latest version of each.
 pub async fn list_accounts(store: &(impl Store + 'static)) {
 pub async fn list_accounts(store: &(impl Store + 'static)) {
     store
     store
-        .create_account(make_account(1, AccountPolicy::NoOverdraft))
+        .create_account(make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT))
         .await
         .await
         .unwrap();
         .unwrap();
     store
     store
-        .create_account(make_account(2, AccountPolicy::ExternalAccount))
+        .create_account(make_account(2, AccountFlags::empty()))
         .await
         .await
         .unwrap();
         .unwrap();
     let list = store.list_accounts().await.unwrap();
     let list = store.list_accounts().await.unwrap();

+ 5 - 21
crates/kuatia-types/src/canonical.rs

@@ -13,8 +13,8 @@
 //! and [`Account`](crate::Account) preimages begin with [`CANONICAL_VERSION`].
 //! and [`Account`](crate::Account) preimages begin with [`CANONICAL_VERSION`].
 
 
 use crate::{
 use crate::{
-    Account, AccountFlags, AccountId, AccountPolicy, AccountSnapshotId, AssetId, BookId, Cent,
-    Envelope, EnvelopeId, NewPosting, Posting, PostingId, Receipt,
+    Account, AccountFlags, AccountId, AccountSnapshotId, AssetId, BookId, Cent, Envelope,
+    EnvelopeId, NewPosting, Posting, PostingId, Receipt,
 };
 };
 
 
 /// Deterministic binary serialization. Every domain type can produce its
 /// Deterministic binary serialization. Every domain type can produce its
@@ -30,7 +30,9 @@ pub trait ToBytes {
 /// canonical bytes (ADR-0012).
 /// canonical bytes (ADR-0012).
 /// Bumped to 4 when the vestigial `UserData` fields were removed from the
 /// Bumped to 4 when the vestigial `UserData` fields were removed from the
 /// `Envelope` and `Account` preimages.
 /// `Envelope` and `Account` preimages.
-pub const CANONICAL_VERSION: u8 = 4;
+/// Bumped to 5 when the `policy` field was removed from the `Account` preimage
+/// (ADR-0018): the single balance constraint now lives in `flags`.
+pub const CANONICAL_VERSION: u8 = 5;
 
 
 /// Append a `u16` in big-endian to `buf`.
 /// Append a `u16` in big-endian to `buf`.
 pub fn write_u16(buf: &mut Vec<u8>, v: u16) {
 pub fn write_u16(buf: &mut Vec<u8>, v: u16) {
@@ -104,23 +106,6 @@ impl ToBytes for PostingId {
     }
     }
 }
 }
 
 
-impl ToBytes for AccountPolicy {
-    fn to_bytes(&self) -> Vec<u8> {
-        let mut buf = Vec::with_capacity(9);
-        match self {
-            Self::NoOverdraft => buf.push(0),
-            Self::CappedOverdraft { floor } => {
-                buf.push(1);
-                buf.extend(floor.to_bytes());
-            }
-            Self::UncappedOverdraft => buf.push(2),
-            Self::SystemAccount => buf.push(3),
-            Self::ExternalAccount => buf.push(4),
-        }
-        buf
-    }
-}
-
 impl ToBytes for AccountFlags {
 impl ToBytes for AccountFlags {
     fn to_bytes(&self) -> Vec<u8> {
     fn to_bytes(&self) -> Vec<u8> {
         self.bits().to_be_bytes().to_vec()
         self.bits().to_be_bytes().to_vec()
@@ -202,7 +187,6 @@ impl ToBytes for Account {
         buf.push(CANONICAL_VERSION);
         buf.push(CANONICAL_VERSION);
         buf.extend(self.id.to_bytes());
         buf.extend(self.id.to_bytes());
         write_u64(&mut buf, self.version);
         write_u64(&mut buf, self.version);
-        buf.extend(self.policy.to_bytes());
         buf.extend(self.flags.to_bytes());
         buf.extend(self.flags.to_bytes());
         buf.extend(self.book.to_bytes());
         buf.extend(self.book.to_bytes());
 
 

+ 74 - 38
crates/kuatia-types/src/lib.rs

@@ -554,32 +554,14 @@ impl EnvelopeBuilder {
 // Account
 // Account
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
-/// Controls how much an account can spend beyond its posting-backed balance.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub enum AccountPolicy {
-    /// Balance must stay >= 0.
-    NoOverdraft,
-    /// Balance must stay >= `floor` (floor < 0).
-    CappedOverdraft {
-        /// Minimum allowed balance (must be negative).
-        floor: Cent,
-    },
-    /// No floor — the account can go arbitrarily negative.
-    UncappedOverdraft,
-    /// Fees, settlement, market-making, minting. No balance constraints.
-    SystemAccount,
-    /// Boundary account representing value entering/leaving the ledger; holds
-    /// the offset (negative) side of deposits.
-    ExternalAccount,
-}
-
 bitflags::bitflags! {
 bitflags::bitflags! {
-    /// Lifecycle and user-defined flags for an [`Account`].
+    /// Lifecycle and balance-constraint flags for an [`Account`].
     ///
     ///
     /// Bits 0–7 are the system range: bits 0–2 carry lifecycle meaning
     /// Bits 0–7 are the system range: bits 0–2 carry lifecycle meaning
-    /// (`FROZEN`, `CLOSED`, `INFLIGHT`) and bits 3–7 (`RESERVED_3..RESERVED_7`)
-    /// are held for future system flags. Bits 8–31 are the user range
-    /// (`USER_0..USER_23`), meant to be combined with
+    /// (`FROZEN`, `CLOSED`, `INFLIGHT`), bit 3 is the balance constraint
+    /// (`DEBIT_MUST_NOT_EXCEED_CREDIT`), and bits 4–7
+    /// (`RESERVED_4..RESERVED_7`) are held for future system flags. Bits 8–31
+    /// are the user range (`USER_0..USER_23`), meant to be combined with
     /// [`BookPolicy::allowed_flags`] to scope which accounts may participate in
     /// [`BookPolicy::allowed_flags`] to scope which accounts may participate in
     /// a book.
     /// a book.
     ///
     ///
@@ -594,8 +576,12 @@ bitflags::bitflags! {
         /// Holding account for an inflight (authorize/confirm/void) transaction.
         /// Holding account for an inflight (authorize/confirm/void) transaction.
         /// Parks funds between authorize and settlement; closed once drained.
         /// Parks funds between authorize and settlement; closed once drained.
         const INFLIGHT = 1 << 2;
         const INFLIGHT = 1 << 2;
-        /// Reserved for a future system flag; not for user assignment.
-        const RESERVED_3 = 1 << 3;
+        /// The account's debits may never exceed its credits: its balance may
+        /// not go negative and it may not hold a negative posting. When unset
+        /// (the default), the account may overdraw without bound: a shortfall is
+        /// covered by a negative offset posting, and the ledger records the
+        /// transfer as long as it conserves value per asset.
+        const DEBIT_MUST_NOT_EXCEED_CREDIT = 1 << 3;
         /// Reserved for a future system flag; not for user assignment.
         /// Reserved for a future system flag; not for user assignment.
         const RESERVED_4 = 1 << 4;
         const RESERVED_4 = 1 << 4;
         /// Reserved for a future system flag; not for user assignment.
         /// Reserved for a future system flag; not for user assignment.
@@ -662,9 +648,8 @@ pub struct Account {
     pub id: AccountId,
     pub id: AccountId,
     /// Monotonically increasing version, starts at 1 on creation.
     /// Monotonically increasing version, starts at 1 on creation.
     pub version: u64,
     pub version: u64,
-    /// Overdraft / balance policy.
-    pub policy: AccountPolicy,
-    /// Lifecycle flags (frozen, closed).
+    /// Lifecycle and balance-constraint flags. The balance constraint lives in
+    /// [`AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT`].
     pub flags: AccountFlags,
     pub flags: AccountFlags,
     /// Book this entity belongs to.
     /// Book this entity belongs to.
     pub book: BookId,
     pub book: BookId,
@@ -673,25 +658,46 @@ pub struct Account {
 }
 }
 
 
 impl Account {
 impl Account {
-    /// Create a version-1 main-subaccount account with the given policy: no flags,
-    /// the default book, and empty metadata. Convenience for the common case; set
-    /// the other fields explicitly when you need them.
-    pub fn new(id: AccountId, policy: AccountPolicy) -> Self {
-        Self::new_ref(id, policy)
-    }
-
-    /// Like [`Account::new`] but for a specific subaccount reference.
-    pub fn new_ref(id: AccountId, policy: AccountPolicy) -> Self {
+    /// Create a version-1 main-subaccount account: no flags, the default book,
+    /// and empty metadata. With no flags the account may overdraw without bound
+    /// (a shortfall becomes a negative offset posting); set
+    /// [`AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT`] to forbid that, or use
+    /// [`Account::debit_must_not_exceed_credit`]. Set the other fields
+    /// explicitly when you need them.
+    pub fn new(id: AccountId) -> Self {
+        Self::new_ref(id)
+    }
+
+    /// Like [`Account::new`] but named for the subaccount-reference case; the
+    /// signature is identical.
+    pub fn new_ref(id: AccountId) -> Self {
         Self {
         Self {
             id,
             id,
             version: 1,
             version: 1,
-            policy,
             flags: AccountFlags::empty(),
             flags: AccountFlags::empty(),
             book: DEFAULT_BOOK,
             book: DEFAULT_BOOK,
             metadata: Metadata::new(),
             metadata: Metadata::new(),
         }
         }
     }
     }
 
 
+    /// A version-1 account whose debits may never exceed its credits: its
+    /// balance may not go negative and it may not hold a negative posting.
+    /// Equivalent to `Account::new(id)` with
+    /// [`AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT`] set.
+    pub fn debit_must_not_exceed_credit(id: AccountId) -> Self {
+        let mut account = Self::new(id);
+        account.flags |= AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT;
+        account
+    }
+
+    /// Whether this account forbids overdraft, i.e. carries the
+    /// [`AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT`] flag. When `false` (the
+    /// default) the account may overdraw without bound.
+    pub fn forbids_overdraft(&self) -> bool {
+        self.flags
+            .contains(AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT)
+    }
+
     /// Returns `true` if the account has the `FROZEN` flag set.
     /// Returns `true` if the account has the `FROZEN` flag set.
     pub fn is_frozen(&self) -> bool {
     pub fn is_frozen(&self) -> bool {
         self.flags.contains(AccountFlags::FROZEN)
         self.flags.contains(AccountFlags::FROZEN)
@@ -875,4 +881,34 @@ mod tests {
         let loaded = AccountFlags::from_bits_truncate(stored as u32);
         let loaded = AccountFlags::from_bits_truncate(stored as u32);
         assert_eq!(loaded, flags);
         assert_eq!(loaded, flags);
     }
     }
+
+    #[test]
+    fn debit_must_not_exceed_credit_sets_the_flag() {
+        let id = AccountId::new(100);
+        let acc = Account::debit_must_not_exceed_credit(id);
+        assert!(acc.forbids_overdraft());
+        assert!(
+            acc.flags
+                .contains(AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT)
+        );
+        // It differs from the default only by that one flag.
+        let mut expected = Account::new(id);
+        expected.flags |= AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT;
+        assert_eq!(acc, expected);
+    }
+
+    #[test]
+    fn new_account_allows_overdraft_by_default() {
+        let acc = Account::new(AccountId::new(101));
+        assert!(!acc.forbids_overdraft());
+        assert_eq!(acc.version, 1);
+        assert_eq!(acc.flags, AccountFlags::empty());
+        assert_eq!(acc.book, DEFAULT_BOOK);
+        assert!(acc.metadata.is_empty());
+    }
+
+    #[test]
+    fn debit_must_not_exceed_credit_bit_is_bit_3() {
+        assert_eq!(AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT.bits(), 1 << 3);
+    }
 }
 }

+ 9 - 13
crates/kuatia/examples/create_accounts.rs

@@ -16,30 +16,26 @@ use kuatia_storage_sql::SqlStore;
 async fn main() -> Result<(), Box<dyn std::error::Error>> {
 async fn main() -> Result<(), Box<dyn std::error::Error>> {
     let ledger = connect().await?;
     let ledger = connect().await?;
 
 
-    // The common case is one line: a version-1 account with the given policy.
+    // The common case is one line: a version-1 account.
     ledger
     ledger
-        .create_account(Account::new(AccountId::new(1), AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(AccountId::new(1)))
         .await?;
         .await?;
     ledger
     ledger
-        .create_account(Account::new(AccountId::new(2), AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(AccountId::new(2)))
         .await?;
         .await?;
     // A system account (fees, settlement, market-making) — no balance floor.
     // A system account (fees, settlement, market-making) — no balance floor.
     ledger
     ledger
-        .create_account(Account::new(
-            AccountId::new(50),
-            AccountPolicy::SystemAccount,
-        ))
+        .create_account(Account::new(AccountId::new(50)))
         .await?;
         .await?;
 
 
     // The same thing spelled out, so you can see every field of an `Account`.
     // The same thing spelled out, so you can see every field of an `Account`.
     // This boundary account is where value enters/leaves the ledger.
     // This boundary account is where value enters/leaves the ledger.
     let external = Account {
     let external = Account {
         id: AccountId::new(99),
         id: AccountId::new(99),
-        version: 1,                             // accounts always start at version 1
-        policy: AccountPolicy::ExternalAccount, // boundary for deposits/withdrawals
-        flags: AccountFlags::empty(),           // not frozen, not closed
-        book: DEFAULT_BOOK,                     // the implicit default book
-        metadata: BTreeMap::new(),              // free-form key/value metadata
+        version: 1,                   // accounts always start at version 1
+        flags: AccountFlags::empty(), // not frozen, not closed
+        book: DEFAULT_BOOK,           // the implicit default book
+        metadata: BTreeMap::new(),    // free-form key/value metadata
     };
     };
     ledger.create_account(external).await?;
     ledger.create_account(external).await?;
 
 
@@ -48,7 +44,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     let mut accounts = ledger.list_accounts().await?;
     let mut accounts = ledger.list_accounts().await?;
     accounts.sort_by_key(|a| (a.id.id, a.id.sub));
     accounts.sort_by_key(|a| (a.id.id, a.id.sub));
     for a in &accounts {
     for a in &accounts {
-        println!("  {:?}  policy={:?}  v{}", a.id, a.policy, a.version);
+        println!("  {:?}  flags={:?}  v{}", a.id, a.flags, a.version);
     }
     }
 
 
     Ok(())
     Ok(())

+ 3 - 5
crates/kuatia/examples/fund_and_trade.rs

@@ -25,14 +25,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     let money = Amount::new(2);
     let money = Amount::new(2);
 
 
     ledger
     ledger
-        .create_account(Account::new(alice, AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(alice))
         .await?;
         .await?;
     ledger
     ledger
-        .create_account(Account::new(bob, AccountPolicy::NoOverdraft))
-        .await?;
-    ledger
-        .create_account(Account::new(external, AccountPolicy::ExternalAccount))
+        .create_account(Account::debit_must_not_exceed_credit(bob))
         .await?;
         .await?;
+    ledger.create_account(Account::new(external)).await?;
 
 
     // Fund: $100.00 to Alice, €90.00 to Bob.
     // Fund: $100.00 to Alice, €90.00 to Bob.
     ledger
     ledger

+ 4 - 8
crates/kuatia/examples/inflight_hold.rs

@@ -30,18 +30,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     let money = Amount::new(2); // two-decimal money
     let money = Amount::new(2); // two-decimal money
 
 
     ledger
     ledger
-        .create_account(Account::new(customer, AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(customer))
         .await?;
         .await?;
     ledger
     ledger
-        .create_account(Account::new(merchant, AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(merchant))
         .await?;
         .await?;
     // The fee account collects the processing fee; a system account has no floor.
     // The fee account collects the processing fee; a system account has no floor.
-    ledger
-        .create_account(Account::new(fee, AccountPolicy::SystemAccount))
-        .await?;
-    ledger
-        .create_account(Account::new(external, AccountPolicy::ExternalAccount))
-        .await?;
+    ledger.create_account(Account::new(fee)).await?;
+    ledger.create_account(Account::new(external)).await?;
 
 
     // Fund the customer with $100.00.
     // Fund the customer with $100.00.
     ledger
     ledger

+ 2 - 4
crates/kuatia/examples/withdraw.rs

@@ -21,11 +21,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     let money = Amount::new(2);
     let money = Amount::new(2);
 
 
     ledger
     ledger
-        .create_account(Account::new(alice, AccountPolicy::NoOverdraft))
-        .await?;
-    ledger
-        .create_account(Account::new(external, AccountPolicy::ExternalAccount))
+        .create_account(Account::debit_must_not_exceed_credit(alice))
         .await?;
         .await?;
+    ledger.create_account(Account::new(external)).await?;
 
 
     // Fund Alice with $100.00.
     // Fund Alice with $100.00.
     ledger
     ledger

+ 6 - 4
crates/kuatia/src/inflight.rs

@@ -16,8 +16,8 @@ use std::collections::{BTreeMap, BTreeSet};
 use std::sync::Arc;
 use std::sync::Arc;
 
 
 use kuatia_core::{
 use kuatia_core::{
-    Account, AccountFlags, AccountId, AccountPolicy, AssetId, BookId, Cent, EnvelopeId, Metadata,
-    Receipt, SelectionError, Transfer, TransferBuilder, hash::double_sha256,
+    Account, AccountFlags, AccountId, AssetId, BookId, Cent, EnvelopeId, Metadata, Receipt,
+    SelectionError, Transfer, TransferBuilder, hash::double_sha256,
 };
 };
 use kuatia_storage::error::StoreError;
 use kuatia_storage::error::StoreError;
 use kuatia_storage::store::EnvelopeRecord;
 use kuatia_storage::store::EnvelopeRecord;
@@ -206,8 +206,10 @@ impl Ledger {
         // trade is already inflight, so different trades (different subs) can hold
         // trade is already inflight, so different trades (different subs) can hold
         // against the same destination at once.
         // against the same destination at once.
         for (dest, hold) in &dest_to_hold {
         for (dest, hold) in &dest_to_hold {
-            let mut acct = Account::new_ref(*hold, AccountPolicy::NoOverdraft);
-            acct.flags = AccountFlags::INFLIGHT;
+            let mut acct = Account::new_ref(*hold);
+            // A hold parks funds between authorize and settlement; it forbids
+            // overdraft so it can never go negative.
+            acct.flags = AccountFlags::INFLIGHT | AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT;
             acct.book = transfer.book;
             acct.book = transfer.book;
             acct.metadata = meta_map(&InflightMeta::Hold { destination: *dest })?;
             acct.metadata = meta_map(&InflightMeta::Hold { destination: *dest })?;
             match self.create_account(acct).await {
             match self.create_account(acct).await {

+ 27 - 21
crates/kuatia/src/ledger/commit.rs

@@ -6,17 +6,17 @@
 //! lets [`Ledger::recover`] complete or safely abandon a commit interrupted by a
 //! lets [`Ledger::recover`] complete or safely abandon a commit interrupted by a
 //! crash.
 //! crash.
 
 
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
 use std::sync::Arc;
 
 
 use legend::ExecutionResult;
 use legend::ExecutionResult;
 use tracing::instrument;
 use tracing::instrument;
 
 
 use kuatia_core::{
 use kuatia_core::{
-    AccountId, AccountPolicy, AccountSnapshotId, AssetId, Book, Cent, DEFAULT_BOOK, Envelope,
-    EnvelopeBuilder, EnvelopeId, NewPosting, PlanInput, Posting, PostingFilter, PostingId,
-    PostingState, Receipt, ResolveInput, Transfer, account_snapshot_id, draft_movements,
-    envelope_id, resolve_envelope, validate_and_plan,
+    AccountId, AccountSnapshotId, AssetId, Book, Cent, DEFAULT_BOOK, Envelope, EnvelopeBuilder,
+    EnvelopeId, NewPosting, PlanInput, Posting, PostingFilter, PostingId, PostingState, Receipt,
+    ResolveInput, Transfer, account_snapshot_id, draft_movements, envelope_id, resolve_envelope,
+    validate_and_plan,
 };
 };
 
 
 use kuatia_storage::error::StoreError;
 use kuatia_storage::error::StoreError;
@@ -147,17 +147,18 @@ impl Ledger {
     /// The decision is pure ([`kuatia_core::draft_movements`] +
     /// The decision is pure ([`kuatia_core::draft_movements`] +
     /// [`kuatia_core::resolve_envelope`]); this method only loads the state those
     /// [`kuatia_core::resolve_envelope`]); this method only loads the state those
     /// functions need. Pass 1 aggregates net debits and tells us which postings
     /// functions need. Pass 1 aggregates net debits and tells us which postings
-    /// and account policies to load; pass 2 selects postings, computes change,
-    /// and covers any overdraft shortfall.
+    /// to load and which accounts permit overdraft; pass 2 selects postings,
+    /// computes change, and covers any overdraft shortfall.
     #[instrument(skip(self, transfer), name = "ledger.resolve")]
     #[instrument(skip(self, transfer), name = "ledger.resolve")]
     pub async fn resolve(&self, transfer: &Transfer) -> Result<Envelope, LedgerError> {
     pub async fn resolve(&self, transfer: &Transfer) -> Result<Envelope, LedgerError> {
         let draft = draft_movements(transfer)?;
         let draft = draft_movements(transfer)?;
 
 
-        // Load the active postings and account policy for each debit. A deposit
-        // nets to zero on the system account, so it produces no debit and loads
-        // nothing here.
+        // Load the active postings for each debit, and note which debit accounts
+        // permit overdraft. A deposit nets to zero on the system account, so it
+        // produces no debit and loads nothing here.
         let mut available: HashMap<(AccountId, AssetId), Vec<Posting>> = HashMap::new();
         let mut available: HashMap<(AccountId, AssetId), Vec<Posting>> = HashMap::new();
-        let mut policies: HashMap<AccountId, AccountPolicy> = HashMap::new();
+        let mut overdraft_allowed: HashSet<AccountId> = HashSet::new();
+        let mut checked: HashSet<AccountId> = HashSet::new();
         for debit in &draft.debits {
         for debit in &draft.debits {
             let postings = self
             let postings = self
                 .store
                 .store
@@ -169,8 +170,14 @@ impl Ledger {
                 )
                 )
                 .await?;
                 .await?;
             available.insert((debit.account, debit.asset), postings);
             available.insert((debit.account, debit.asset), postings);
-            if let std::collections::hash_map::Entry::Vacant(slot) = policies.entry(debit.account) {
-                slot.insert(self.store.get_account(&debit.account).await?.policy);
+            if checked.insert(debit.account)
+                && !self
+                    .store
+                    .get_account(&debit.account)
+                    .await?
+                    .forbids_overdraft()
+            {
+                overdraft_allowed.insert(debit.account);
             }
             }
         }
         }
 
 
@@ -178,7 +185,7 @@ impl Ledger {
             transfer,
             transfer,
             draft,
             draft,
             available: &available,
             available: &available,
-            policies: &policies,
+            overdraft_allowed: &overdraft_allowed,
         })?;
         })?;
 
 
         // Resolve account snapshots for optimistic concurrency
         // Resolve account snapshots for optimistic concurrency
@@ -590,12 +597,11 @@ mod recovery_tests {
     use kuatia_storage::mem_store::InMemoryStore;
     use kuatia_storage::mem_store::InMemoryStore;
     use std::collections::BTreeMap;
     use std::collections::BTreeMap;
 
 
-    fn acct(id: i64, policy: AccountPolicy) -> Account {
+    fn acct(id: i64, flags: AccountFlags) -> Account {
         Account {
         Account {
             id: AccountId::new(id),
             id: AccountId::new(id),
             version: 1,
             version: 1,
-            policy,
-            flags: AccountFlags::empty(),
+            flags,
             book: kuatia_core::BookId(0),
             book: kuatia_core::BookId(0),
             metadata: BTreeMap::new(),
             metadata: BTreeMap::new(),
         }
         }
@@ -604,10 +610,10 @@ mod recovery_tests {
     async fn funded_ledger() -> Arc<Ledger> {
     async fn funded_ledger() -> Arc<Ledger> {
         let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
         let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
         for (id, p) in [
         for (id, p) in [
-            (1, AccountPolicy::NoOverdraft),
-            (2, AccountPolicy::NoOverdraft),
-            (3, AccountPolicy::NoOverdraft),
-            (99, AccountPolicy::ExternalAccount),
+            (1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+            (2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+            (3, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+            (99, AccountFlags::empty()),
         ] {
         ] {
             ledger.store().create_account(acct(id, p)).await.unwrap();
             ledger.store().create_account(acct(id, p)).await.unwrap();
         }
         }

+ 20 - 37
crates/kuatia/tests/concurrency.rs

@@ -31,30 +31,29 @@ fn external() -> AccountId {
     AccountId::new(99)
     AccountId::new(99)
 }
 }
 
 
-fn make_account(id: i64, policy: AccountPolicy) -> Account {
+fn make_account(id: i64, flags: AccountFlags) -> Account {
     Account {
     Account {
         id: AccountId::new(id),
         id: AccountId::new(id),
         version: 1,
         version: 1,
-        policy,
-        flags: AccountFlags::empty(),
+        flags,
         book: BookId(0),
         book: BookId(0),
         metadata: BTreeMap::new(),
         metadata: BTreeMap::new(),
     }
     }
 }
 }
 
 
-/// A ledger with `NoOverdraft` accounts `1..=n` plus an external account.
+/// A ledger with overdraft-forbidding accounts `1..=n` plus an external account.
 async fn ledger_with_accounts(n: i64) -> Arc<Ledger> {
 async fn ledger_with_accounts(n: i64) -> Arc<Ledger> {
     let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
     let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
     for id in 1..=n {
     for id in 1..=n {
         ledger
         ledger
             .store()
             .store()
-            .create_account(make_account(id, AccountPolicy::NoOverdraft))
+            .create_account(make_account(id, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT))
             .await
             .await
             .unwrap();
             .unwrap();
     }
     }
     ledger
     ledger
         .store()
         .store()
-        .create_account(make_account(99, AccountPolicy::ExternalAccount))
+        .create_account(make_account(99, AccountFlags::empty()))
         .await
         .await
         .unwrap();
         .unwrap();
     ledger
     ledger
@@ -331,47 +330,39 @@ async fn disjoint_transfers_all_commit_and_conserve() {
 }
 }
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
-// 5. Overdraft floor is best-effort under concurrency (documented limitation)
+// 5. Concurrent overdraft conserves value
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
-/// Documents a known, accepted limitation: the `CappedOverdraft` floor is
-/// re-checked at the last step before writing, but that check is not atomic
-/// with the write. Two overdrafts that each pass the floor check against the
-/// same pre-transfer balance can both commit and jointly push the account below
-/// its floor. See `doc/transfers.md`.
-///
-/// This test is `#[ignore]`d because the breach is timing-dependent, so it is
-/// executable documentation rather than a CI assertion. What always holds, and
-/// what it does assert, is per-asset conservation: the overdraft's negative
-/// postings are real value owed, never minted. If a run drives the account below
-/// the floor, that is the documented behavior, not a conservation failure.
+/// An overdraft account (no `DEBIT_MUST_NOT_EXCEED_CREDIT` flag) can be spent
+/// past its balance concurrently; every shortfall becomes a real negative
+/// posting, never minted value. This asserts the invariant that always holds:
+/// per-asset conservation, even under concurrent overdraft.
 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
-#[ignore = "documents the best-effort overdraft floor; breach is timing-dependent"]
-async fn overdraft_floor_is_best_effort_under_concurrency() {
-    let floor = Cent::from(-100);
-    let mut observed_breach = false;
-
+async fn concurrent_overdraft_conserves_value() {
     const PAYEES: i64 = 8;
     const PAYEES: i64 = 8;
     for _ in 0..64 {
     for _ in 0..64 {
         let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
         let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
+        // Account 1 permits overdraft (the default); payees forbid it.
         ledger
         ledger
             .store()
             .store()
-            .create_account(make_account(1, AccountPolicy::CappedOverdraft { floor }))
+            .create_account(make_account(1, AccountFlags::empty()))
             .await
             .await
             .unwrap();
             .unwrap();
         for payee in 2..=(1 + PAYEES) {
         for payee in 2..=(1 + PAYEES) {
             ledger
             ledger
                 .store()
                 .store()
-                .create_account(make_account(payee, AccountPolicy::NoOverdraft))
+                .create_account(make_account(
+                    payee,
+                    AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT,
+                ))
                 .await
                 .await
                 .unwrap();
                 .unwrap();
         }
         }
 
 
         // One payment of 60 to each distinct payee from an empty overdraft
         // One payment of 60 to each distinct payee from an empty overdraft
         // account (distinct payees keep the envelopes distinct, so they are not
         // account (distinct payees keep the envelopes distinct, so they are not
-        // collapsed by content-addressed idempotency). Each alone projects to
-        // -60 (within the -100 floor); any two that slip through the last-step
-        // floor check together already breach it.
+        // collapsed by content-addressed idempotency). Each shortfall is covered
+        // by a negative offset posting.
         let mut handles = Vec::new();
         let mut handles = Vec::new();
         for payee in 2..=(1 + PAYEES) {
         for payee in 2..=(1 + PAYEES) {
             let ledger = Arc::clone(&ledger);
             let ledger = Arc::clone(&ledger);
@@ -395,15 +386,7 @@ async fn overdraft_floor_is_best_effort_under_concurrency() {
         assert_eq!(
         assert_eq!(
             total,
             total,
             Cent::ZERO,
             Cent::ZERO,
-            "value is conserved even when the floor is breached"
+            "value is conserved under concurrent overdraft"
         );
         );
-        if ledger.balance(&account(1), &usd()).await.unwrap() < floor {
-            observed_breach = true;
-        }
     }
     }
-
-    eprintln!(
-        "overdraft floor breach observed under concurrency: {observed_breach} \
-         (best-effort by design; see doc/transfers.md)"
-    );
 }
 }

+ 4 - 5
crates/kuatia/tests/inflight.rs

@@ -37,12 +37,11 @@ fn ext() -> AccountId {
     AccountId::new(99)
     AccountId::new(99)
 }
 }
 
 
-fn make_account(id: i64, policy: AccountPolicy) -> Account {
+fn make_account(id: i64, flags: AccountFlags) -> Account {
     Account {
     Account {
         id: AccountId::new(id),
         id: AccountId::new(id),
         version: 1,
         version: 1,
-        policy,
-        flags: AccountFlags::empty(),
+        flags,
         book: BookId(0),
         book: BookId(0),
         metadata: BTreeMap::new(),
         metadata: BTreeMap::new(),
     }
     }
@@ -63,13 +62,13 @@ async fn setup() -> Arc<Ledger> {
     for id in [1, 2, 3] {
     for id in [1, 2, 3] {
         ledger
         ledger
             .store()
             .store()
-            .create_account(make_account(id, AccountPolicy::NoOverdraft))
+            .create_account(make_account(id, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT))
             .await
             .await
             .unwrap();
             .unwrap();
     }
     }
     ledger
     ledger
         .store()
         .store()
-        .create_account(make_account(99, AccountPolicy::ExternalAccount))
+        .create_account(make_account(99, AccountFlags::empty()))
         .await
         .await
         .unwrap();
         .unwrap();
     deposit(&ledger, a(), eur(), 100).await;
     deposit(&ledger, a(), eur(), 100).await;

+ 36 - 47
crates/kuatia/tests/integration.rs

@@ -23,12 +23,11 @@ fn external() -> AccountId {
     AccountId::new(99)
     AccountId::new(99)
 }
 }
 
 
-fn make_account(id: i64, policy: AccountPolicy) -> Account {
+fn make_account(id: i64, flags: AccountFlags) -> Account {
     Account {
     Account {
         id: AccountId::new(id),
         id: AccountId::new(id),
         version: 1,
         version: 1,
-        policy,
-        flags: AccountFlags::empty(),
+        flags,
         book: BookId(0),
         book: BookId(0),
         metadata: BTreeMap::new(),
         metadata: BTreeMap::new(),
     }
     }
@@ -40,22 +39,22 @@ async fn setup_ledger() -> Arc<Ledger> {
 
 
     ledger
     ledger
         .store()
         .store()
-        .create_account(make_account(1, AccountPolicy::NoOverdraft))
+        .create_account(make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT))
         .await
         .await
         .unwrap();
         .unwrap();
     ledger
     ledger
         .store()
         .store()
-        .create_account(make_account(2, AccountPolicy::NoOverdraft))
+        .create_account(make_account(2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT))
         .await
         .await
         .unwrap();
         .unwrap();
     ledger
     ledger
         .store()
         .store()
-        .create_account(make_account(3, AccountPolicy::NoOverdraft))
+        .create_account(make_account(3, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT))
         .await
         .await
         .unwrap();
         .unwrap();
     ledger
     ledger
         .store()
         .store()
-        .create_account(make_account(99, AccountPolicy::ExternalAccount))
+        .create_account(make_account(99, AccountFlags::empty()))
         .await
         .await
         .unwrap();
         .unwrap();
 
 
@@ -326,12 +325,12 @@ async fn frozen_account_rejected() {
     let store = InMemoryStore::new();
     let store = InMemoryStore::new();
     let ledger = Arc::new(Ledger::new(store));
     let ledger = Arc::new(Ledger::new(store));
 
 
-    let mut frozen = make_account(1, AccountPolicy::NoOverdraft);
+    let mut frozen = make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
     frozen.flags = AccountFlags::FROZEN;
     frozen.flags = AccountFlags::FROZEN;
     ledger.store().create_account(frozen).await.unwrap();
     ledger.store().create_account(frozen).await.unwrap();
     ledger
     ledger
         .store()
         .store()
-        .create_account(make_account(99, AccountPolicy::ExternalAccount))
+        .create_account(make_account(99, AccountFlags::empty()))
         .await
         .await
         .unwrap();
         .unwrap();
 
 
@@ -390,9 +389,9 @@ async fn fx_trade_via_market_account() {
 
 
     // Setup accounts
     // Setup accounts
     for (id, policy) in [
     for (id, policy) in [
-        (1, AccountPolicy::NoOverdraft),
-        (50, AccountPolicy::SystemAccount), // FX market account
-        (99, AccountPolicy::ExternalAccount),
+        (1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+        (50, AccountFlags::empty()), // FX market account
+        (99, AccountFlags::empty()),
     ] {
     ] {
         ledger
         ledger
             .store()
             .store()
@@ -619,7 +618,7 @@ async fn get_account_by_id() {
 
 
     let acc = ledger.get_account(&account(1)).await.unwrap();
     let acc = ledger.get_account(&account(1)).await.unwrap();
     assert_eq!(acc.id, account(1));
     assert_eq!(acc.id, account(1));
-    assert_eq!(acc.policy, AccountPolicy::NoOverdraft);
+    assert!(acc.forbids_overdraft());
 }
 }
 
 
 #[tokio::test]
 #[tokio::test]
@@ -725,7 +724,7 @@ async fn stale_snapshot_rejected() {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn account_hash_deterministic() {
 async fn account_hash_deterministic() {
-    let acc = make_account(42, AccountPolicy::NoOverdraft);
+    let acc = make_account(42, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
     let h1 = kuatia_core::account_hash(&acc);
     let h1 = kuatia_core::account_hash(&acc);
     let h2 = kuatia_core::account_hash(&acc);
     let h2 = kuatia_core::account_hash(&acc);
     assert_eq!(h1, h2);
     assert_eq!(h1, h2);
@@ -733,7 +732,7 @@ async fn account_hash_deterministic() {
 
 
 #[tokio::test]
 #[tokio::test]
 async fn account_hash_changes_with_version() {
 async fn account_hash_changes_with_version() {
-    let mut acc = make_account(42, AccountPolicy::NoOverdraft);
+    let mut acc = make_account(42, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT);
     let h1 = kuatia_core::account_hash(&acc);
     let h1 = kuatia_core::account_hash(&acc);
     acc.version = 2;
     acc.version = 2;
     acc.flags |= AccountFlags::FROZEN;
     acc.flags |= AccountFlags::FROZEN;
@@ -746,22 +745,17 @@ async fn account_hash_changes_with_version() {
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------
 
 
 #[tokio::test]
 #[tokio::test]
-async fn capped_overdraft_creates_negative_posting() {
+async fn overdraft_creates_negative_posting() {
     let store = InMemoryStore::new();
     let store = InMemoryStore::new();
     let ledger = Arc::new(Ledger::new(store));
     let ledger = Arc::new(Ledger::new(store));
-    for (id, policy) in [
-        (
-            10,
-            AccountPolicy::CappedOverdraft {
-                floor: Cent::from(-200),
-            },
-        ),
-        (2, AccountPolicy::NoOverdraft),
-        (99, AccountPolicy::ExternalAccount),
+    for (id, flags) in [
+        (10, AccountFlags::empty()),
+        (2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+        (99, AccountFlags::empty()),
     ] {
     ] {
         ledger
         ledger
             .store()
             .store()
-            .create_account(make_account(id, policy))
+            .create_account(make_account(id, flags))
             .await
             .await
             .unwrap();
             .unwrap();
     }
     }
@@ -789,27 +783,22 @@ async fn capped_overdraft_creates_negative_posting() {
 }
 }
 
 
 #[tokio::test]
 #[tokio::test]
-async fn capped_overdraft_respects_floor() {
+async fn debit_must_not_exceed_credit_rejects_overspend() {
     let store = InMemoryStore::new();
     let store = InMemoryStore::new();
     let ledger = Arc::new(Ledger::new(store));
     let ledger = Arc::new(Ledger::new(store));
-    for (id, policy) in [
-        (
-            10,
-            AccountPolicy::CappedOverdraft {
-                floor: Cent::from(-80),
-            },
-        ),
-        (2, AccountPolicy::NoOverdraft),
-        (99, AccountPolicy::ExternalAccount),
+    for (id, flags) in [
+        (10, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+        (2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+        (99, AccountFlags::empty()),
     ] {
     ] {
         ledger
         ledger
             .store()
             .store()
-            .create_account(make_account(id, policy))
+            .create_account(make_account(id, flags))
             .await
             .await
             .unwrap();
             .unwrap();
     }
     }
 
 
-    // Paying 100 from an empty account would project to -100, below the -80 floor.
+    // Account 10 forbids overdraft, so paying 100 from an empty balance fails.
     let transfer = TransferBuilder::new()
     let transfer = TransferBuilder::new()
         .pay(account(10), account(2), usd(), Cent::from(100))
         .pay(account(10), account(2), usd(), Cent::from(100))
         .build();
         .build();
@@ -821,17 +810,17 @@ async fn capped_overdraft_respects_floor() {
 }
 }
 
 
 #[tokio::test]
 #[tokio::test]
-async fn uncapped_overdraft_allows_arbitrary_negative() {
+async fn overdraft_allows_arbitrary_negative() {
     let store = InMemoryStore::new();
     let store = InMemoryStore::new();
     let ledger = Arc::new(Ledger::new(store));
     let ledger = Arc::new(Ledger::new(store));
-    for (id, policy) in [
-        (10, AccountPolicy::UncappedOverdraft),
-        (2, AccountPolicy::NoOverdraft),
-        (99, AccountPolicy::ExternalAccount),
+    for (id, flags) in [
+        (10, AccountFlags::empty()),
+        (2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+        (99, AccountFlags::empty()),
     ] {
     ] {
         ledger
         ledger
             .store()
             .store()
-            .create_account(make_account(id, policy))
+            .create_account(make_account(id, flags))
             .await
             .await
             .unwrap();
             .unwrap();
     }
     }
@@ -922,7 +911,7 @@ async fn subaccount_balances_are_segregated() {
     // A subaccount is a full account record with its own policy.
     // A subaccount is a full account record with its own policy.
     ledger
     ledger
         .store()
         .store()
-        .create_account(Account::new(sub, AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(sub))
         .await
         .await
         .unwrap();
         .unwrap();
 
 
@@ -966,7 +955,7 @@ async fn pay_moves_value_between_subaccounts() {
     let sub = AccountId::with_sub(1, 7);
     let sub = AccountId::with_sub(1, 7);
     ledger
     ledger
         .store()
         .store()
-        .create_account(Account::new(sub, AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(sub))
         .await
         .await
         .unwrap();
         .unwrap();
 
 
@@ -987,7 +976,7 @@ async fn closed_subaccounts_drop_out_of_aggregate_reads() {
     let sub = AccountId::with_sub(1, 7);
     let sub = AccountId::with_sub(1, 7);
     ledger
     ledger
         .store()
         .store()
-        .create_account(Account::new(sub, AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(sub))
         .await
         .await
         .unwrap();
         .unwrap();
 
 

+ 6 - 7
crates/kuatia/tests/saga.rs

@@ -22,12 +22,11 @@ fn external() -> AccountId {
     AccountId::new(99)
     AccountId::new(99)
 }
 }
 
 
-fn make_account(id: i64, policy: AccountPolicy) -> Account {
+fn make_account(id: i64, flags: AccountFlags) -> Account {
     Account {
     Account {
         id: AccountId::new(id),
         id: AccountId::new(id),
         version: 1,
         version: 1,
-        policy,
-        flags: AccountFlags::empty(),
+        flags,
         book: BookId(0),
         book: BookId(0),
         metadata: BTreeMap::new(),
         metadata: BTreeMap::new(),
     }
     }
@@ -38,10 +37,10 @@ async fn setup_ledger() -> Arc<Ledger> {
     let ledger = Arc::new(Ledger::new(store));
     let ledger = Arc::new(Ledger::new(store));
 
 
     for (id, policy) in [
     for (id, policy) in [
-        (1, AccountPolicy::NoOverdraft),
-        (2, AccountPolicy::NoOverdraft),
-        (3, AccountPolicy::NoOverdraft),
-        (99, AccountPolicy::ExternalAccount),
+        (1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+        (2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+        (3, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
+        (99, AccountFlags::empty()),
     ] {
     ] {
         ledger
         ledger
             .store()
             .store()

+ 55 - 58
doc/accounts.md

@@ -14,8 +14,7 @@ the immutable table, are excluded.
 |-------|------|-------------|
 |-------|------|-------------|
 | `id` | `AccountId { id: i64, sub: i64 }` | Stable identity: a base id plus a subaccount (`sub = 0` is the main account) |
 | `id` | `AccountId { id: i64, sub: i64 }` | Stable identity: a base id plus a subaccount (`sub = 0` is the main account) |
 | `version` | `u64` | Starts at 1, increments on every mutation |
 | `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` to `USER_7`) |
+| `flags` | `AccountFlags` | Lifecycle (`FROZEN`, `CLOSED`, `INFLIGHT`), the balance constraint (`DEBIT_MUST_NOT_EXCEED_CREDIT`), and user-defined bits |
 | `book` | `BookId` | Book this account belongs to |
 | `book` | `BookId` | Book this account belongs to |
 | `metadata` | `Metadata` | `BTreeMap<String, Vec<u8>>` for free-form data |
 | `metadata` | `Metadata` | `BTreeMap<String, Vec<u8>>` for free-form data |
 
 
@@ -23,10 +22,10 @@ the immutable table, are excluded.
 
 
 An `AccountId` is a base `id` plus a `sub`. `sub = 0` is the account's main
 An `AccountId` is a base `id` plus a `sub`. `sub = 0` is the account's main
 account; a non-zero `sub` is a subaccount of the same base id. Each `(id, sub)`
 account; a non-zero `sub` is a subaccount of the same base id. Each `(id, sub)`
-is a full account record with its own policy, flags, book, version, and
+is a full account record with its own flags, book, version, and
 lifecycle, created, versioned, frozen, and closed exactly like any other
 lifecycle, created, versioned, frozen, and closed exactly like any other
-account. A subaccount can be `NoOverdraft` while its base account is not, or the
-reverse, because every check keys on the full `AccountId`.
+account. A subaccount can forbid overdraft while its base account does not, or
+the reverse, because every check keys on the full `AccountId`.
 
 
 Subaccounts partition one owner's holdings into several individually addressable
 Subaccounts partition one owner's holdings into several individually addressable
 balances (sub-ledgers, earmarks, reservations) without minting unrelated
 balances (sub-ledgers, earmarks, reservations) without minting unrelated
@@ -60,35 +59,35 @@ optional subaccount filter (`get_postings_by_account`,
 account admits all of that account's subaccounts. See
 account admits all of that account's subaccounts. See
 [adr/0012-subaccounts.md](adr/0012-subaccounts.md).
 [adr/0012-subaccounts.md](adr/0012-subaccounts.md).
 
 
-## Policies
-
-Each account has a policy that controls what balance constraints apply:
-
-| Policy | Balance floor | Negative postings | CAS guard |
-|--------|--------------|-------------------|-----------|
-| `NoOverdraft` | `>= 0` | No | No |
-| `CappedOverdraft { floor }` | `>= floor` | Yes (down to floor) | Yes |
-| `UncappedOverdraft` | None | Yes (unbounded) | No |
-| `SystemAccount` | None | Yes | No |
-| `ExternalAccount` | None | Yes | No |
-
-An overdraft is represented as a negative posting (an offset position)
-assigned to the account to cover a shortfall. When an account's positive
-postings are insufficient for a debit, the resolve step consumes them all
-and creates a negative posting for the remainder. `NoOverdraft` accounts
-forbid this; validation rejects any transfer that would create a negative
-posting on a `NoOverdraft` account. `CappedOverdraft`'s floor bounds how
-negative the balance may go; `UncappedOverdraft`, `SystemAccount`, and
-`ExternalAccount` are unbounded.
-
-`CappedOverdraft`'s floor is re-validated as the last step before finalize
-writes (the finalize step re-loads balances and account versions and
-re-runs validation just before deactivating). This is the tightest
-best-effort: the check-to-write window is one step, not the whole saga. It
-is not strictly atomic. A concurrent commit in that last gap can still
-breach the floor (write-skew). Double-spend safety is unaffected. The
-reservation protocol (an atomic conditional `reserve_postings`) guarantees
-a posting cannot be consumed twice. See
+## Balance constraint
+
+An account carries one balance rule, held in its flags:
+
+| State | Balance floor | Negative postings |
+|-------|--------------|-------------------|
+| default (no flag) | none | yes (overdraft allowed, unbounded) |
+| `DEBIT_MUST_NOT_EXCEED_CREDIT` | `>= 0` | no |
+
+By default an account may overdraw without bound. An overdraft is a negative
+posting (an offset position) assigned to the account to cover a shortfall: when
+the account's positive postings are insufficient for a debit, the resolve step
+consumes them all and creates a negative posting for the remainder. The transfer
+is recorded as long as it conserves value per asset.
+
+Setting `DEBIT_MUST_NOT_EXCEED_CREDIT` forbids this: the account's debits may
+never exceed its credits, so its balance may not go negative and it may not hold
+a negative posting. Validation rejects any transfer that would create a negative
+posting on such an account or project its balance below zero. The convenience
+constructor `Account::debit_must_not_exceed_credit(id)` names the invariant
+directly, and `Account::forbids_overdraft()` reports it. There is no bounded
+"credit line" floor between the two: a credit-line limit, if needed, is enforced
+by the application above the ledger.
+
+The zero-floor check is re-validated as the last step before finalize writes
+(the finalize step re-loads balances and account versions and re-runs validation
+just before deactivating). Double-spend safety is exact regardless: the
+reservation protocol (an atomic conditional `reserve_postings`) guarantees a
+posting cannot be consumed twice. See
 [accounting-mapping.md](accounting-mapping.md) and the ADR at
 [accounting-mapping.md](accounting-mapping.md) and the ADR at
 [adr/0003-dumb-storage-saga-recovery.md](adr/0003-dumb-storage-saga-recovery.md).
 [adr/0003-dumb-storage-saga-recovery.md](adr/0003-dumb-storage-saga-recovery.md).
 
 
@@ -117,9 +116,9 @@ Created (v1) → Frozen (v2) → Unfrozen (v3) → Closed (v4)
 Accounts are never modified in place. Each mutation appends a new version:
 Accounts are never modified in place. Each mutation appends a new version:
 
 
 ```
 ```
-Version 1: { policy: NoOverdraft, flags: ∅ }         ← created
-Version 2: { policy: NoOverdraft, flags: FROZEN }     ← frozen
-Version 3: { policy: NoOverdraft, flags: ∅ }         ← unfrozen
+Version 1: { flags: DEBIT_MUST_NOT_EXCEED_CREDIT }          ← created
+Version 2: { flags: DEBIT_MUST_NOT_EXCEED_CREDIT | FROZEN } ← frozen
+Version 3: { flags: DEBIT_MUST_NOT_EXCEED_CREDIT }          ← unfrozen
 ```
 ```
 
 
 The store enforces `version_new == version_current + 1`, preventing gaps or
 The store enforces `version_new == version_current + 1`, preventing gaps or
@@ -141,7 +140,7 @@ The saga `commit()` path auto-populates snapshots when none are provided.
 
 
 An account is identified by a base id plus an `i64` **subaccount**, written
 An account is identified by a base id plus an `i64` **subaccount**, written
 `AccountId { id, sub }`; `sub = 0` is the main account. Each `(id,
 `AccountId { id, sub }`; `sub = 0` is the main account. Each `(id,
-sub)` is its own record with its own policy, flags, book, and version, so a
+sub)` is its own record with its own flags, book, and version, so a
 subaccount is a full account that happens to share a base id. Subaccounts are how
 subaccount is a full account that happens to share a base id. Subaccounts are how
 one account holds many concurrent inflights: an inflight hold is a subaccount of
 one account holds many concurrent inflights: an inflight hold is a subaccount of
 its destination, keyed by a value derived from the trade (see
 its destination, keyed by a value derived from the trade (see
@@ -172,29 +171,27 @@ and the underlying postings.
 
 
 ## Account Types in Practice
 ## Account Types in Practice
 
 
-### Regular user accounts (`NoOverdraft`)
+The single flag divides accounts into two kinds. Intent (wallet vs. boundary
+vs. system) is a matter of how you use the account, not a distinct type.
 
 
-Hold positive postings only. Cannot go negative. Used for end-user wallets,
-merchant accounts, etc.
-
-### System accounts (`SystemAccount`)
+### Overdraft-forbidding accounts (`DEBIT_MUST_NOT_EXCEED_CREDIT`)
 
 
-Operational accounts representing issuance, sink, revenue, COGS, fees, or
-internal balancing. Can hold negative postings (offset positions, e.g. a
-liability when the account is the deposit counterparty). Used as the
-counterparty in deposits: the system account takes on a negative balance to
-offset the value credited elsewhere.
+Hold positive postings only. Cannot go negative. Used for end-user wallets,
+merchant accounts, and any account that must never spend value it does not hold.
+Construct with `Account::debit_must_not_exceed_credit(id)`.
 
 
-### External accounts (`ExternalAccount`)
+### Overdraft-permitting accounts (default)
 
 
-Boundary accounts representing the outside world (banks, payment
-processors). They represent value entering and leaving the ledger boundary,
-and like system accounts they can hold negative postings (offset positions).
+An ordinary `Account::new(id)` may go negative without bound. This covers:
 
 
-### Credit accounts (`CappedOverdraft`)
+- **Issuance / system balancing**: revenue, COGS, fees, or internal balancing
+  accounts that hold offset positions.
+- **Ledger boundary**: the counterparty in deposits and withdrawals, where value
+  enters or leaves the ledger. It takes on a negative balance to offset the value
+  credited elsewhere; this is normal, not a bug.
+- **Credit-like accounts**: where the balance is allowed below zero. The ledger
+  enforces no upper bound on the overdraft; a specific credit limit is the
+  application's responsibility.
 
 
-Accounts with a negative floor (e.g. credit lines). The floor is the maximum
-allowed overdraft. When the account's positive postings are insufficient for
-a debit, a negative posting is created to cover the shortfall, down to the
-floor. The floor is re-validated as the last step before finalize and is
-best-effort under concurrency (see above).
+When such an account's positive postings are insufficient for a debit, a negative
+posting covers the shortfall.

+ 7 - 1
doc/adr/0004-account-policies-overdraft-model.md

@@ -1,11 +1,17 @@
 # Account policies as the negative-posting and floor gate
 # Account policies as the negative-posting and floor gate
 
 
-* Status: accepted
+* Status: superseded by [ADR-0018](0018-single-debit-must-not-exceed-credit-flag.md)
 * Authors: Cesar Rodas
 * Authors: Cesar Rodas
 * Date: 2026-06-29
 * Date: 2026-06-29
 * Targeted modules: `kuatia-types` (`AccountPolicy`), `kuatia-core` (`validate.rs`)
 * Targeted modules: `kuatia-types` (`AccountPolicy`), `kuatia-core` (`validate.rs`)
 * Associated tickets/PRs: N/A
 * Associated tickets/PRs: N/A
 
 
+> **Superseded.** The closed `AccountPolicy` enum described here was collapsed
+> into a single `AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT` bit, with overdraft
+> allowed by default and the capped floor removed. See
+> [ADR-0018](0018-single-debit-must-not-exceed-credit-flag.md). This document is
+> retained as the historical record of the enum-based model.
+
 ## Context and Problem Statement
 ## Context and Problem Statement
 
 
 ADR-0001 makes value *signable*: a posting may be negative ("offset
 ADR-0001 makes value *signable*: a posting may be negative ("offset

+ 113 - 0
doc/adr/0018-single-debit-must-not-exceed-credit-flag.md

@@ -0,0 +1,113 @@
+# Collapse account policy into a single debit-must-not-exceed-credit flag
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-07-16
+* Targeted modules: `kuatia-types` (`AccountFlags`, `Account`), `kuatia-core`
+  (`validate.rs`, `posting_resolution.rs`), `kuatia-storage-sql`
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+[ADR-0004](0004-account-policies-overdraft-model.md) modeled per-account
+balance rules as a closed `AccountPolicy` enum with five variants
+(`NoOverdraft`, `CappedOverdraft { floor }`, `UncappedOverdraft`,
+`SystemAccount`, `ExternalAccount`). In practice only one distinction earned
+its keep: may this account's balance go negative, or not? The capped floor, the
+`System`/`External` labels, and the `Uncapped` variant all resolved to the same
+runtime behavior (overdraft allowed, no floor), while adding an enum to
+serialize, a SQL column, resolve/validate match arms, and a dashboard DTO.
+
+We want a single, legible knob for that one question.
+
+## Decision Drivers
+
+* **One real distinction**: overdraft allowed vs. forbidden is the only balance
+  rule the domain actually enforces once the floor is dropped.
+* **Fewer moving parts**: a flag rides the existing `AccountFlags` round-trip
+  and needs no separate enum, column, or DTO.
+* **Legible vocabulary**: `debit_must_not_exceed_credit` names the invariant
+  directly.
+* **Safe default**: deposits, withdrawals, and system/boundary accounts all need
+  to hold the negative side of value, so the default must permit overdraft.
+
+## Considered Options
+
+#### Option 1: Keep the `AccountPolicy` enum (ADR-0004)
+
+**Cons:**
+
+* Bad, because four of five variants are behaviorally identical once the floor
+  is gone.
+* Bad, because it carries a serialized enum, a SQL column, resolve/validate
+  matches, and a dashboard DTO for a single boolean's worth of information.
+
+#### Option 2: Keep a bounded floor (`CappedOverdraft`)
+
+**Cons:**
+
+* Bad, because the floor is only best-effort under concurrency (see
+  [ADR-0003](0003-dumb-storage-saga-recovery.md)), so it never was a hard
+  guarantee.
+* Bad, because a credit-line limit, when actually needed, is better enforced by
+  the application above the ledger than by a soft ledger-level bound.
+
+#### Option 3: A single `AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT` bit
+
+The account either carries the flag (balance may not go negative, no negative
+posting allowed) or does not (overdraft allowed without bound; a shortfall
+becomes a negative offset posting; the transfer records as long as it conserves
+value per asset). Overdraft is the default.
+
+**Pros:**
+
+* Good, because it keeps the one distinction that matters and drops the rest.
+* Good, because it reuses the `AccountFlags` bitfield and its storage round-trip
+  (a system-range bit), so there is no new column beyond dropping `policy`.
+* Good, because `Account::debit_must_not_exceed_credit(id)` names the invariant.
+
+**Cons:**
+
+* Bad, because the capped credit-line floor is no longer expressible in the
+  ledger (moved to the application, if needed).
+* Bad, because the `System`/`External` intent labels are gone; a boundary
+  account is now just an ordinary overdraft-permitting account.
+
+## Decision Outcome
+
+Chosen option: **Option 3, a single `DEBIT_MUST_NOT_EXCEED_CREDIT` flag**, with
+overdraft allowed by default. Validation forbids a negative posting, and rejects
+a negative projected balance, only for accounts carrying the flag. Resolution
+covers a shortfall with a negative offset posting only for accounts that permit
+overdraft. The `policy` column is dropped from the SQL schema (migration
+`006_drop_policy.sql`).
+
+### Positive Consequences
+
+* One legible knob per account; the ledger enforces exactly one balance rule.
+* Smaller surface: no `AccountPolicy` enum, no policy column, no policy DTO.
+
+### Negative Consequences
+
+* No ledger-enforced capped floor (credit lines are an application concern).
+* The mirror constraint (a balance ceiling, where credits may not exceed debits)
+  is still not modeled; balance rules remain floor-only, now with a single floor
+  of zero.
+* Account intent (customer vs. boundary vs. system) is no longer readable from a
+  type; it must be inferred from the flag plus context.
+* A former `SystemAccount`/`ExternalAccount` debited past its available postings
+  now absorbs the shortfall as a negative offset posting instead of failing with
+  `InsufficientFunds`. This is moot for deposits (they net to zero on the
+  boundary account) but is a behavior change for a direct over-debit.
+* Removing `policy` from the `Account` canonical preimage bumps
+  `CANONICAL_VERSION` (4 → 5), changing account snapshot hashes. Persisted
+  transfers keep their original `EnvelopeId`s and stay self-consistent; a saga
+  in flight across the upgrade re-validates on recovery and aborts cleanly (or
+  rolls forward) rather than corrupting state, per ADR-0003.
+
+## Links
+
+* Supersedes [ADR-0004](0004-account-policies-overdraft-model.md).
+* Builds on [ADR-0001](0001-modified-utxo-signed-postings.md) (signed postings).
+* Floor-under-concurrency background: [ADR-0003](0003-dumb-storage-saga-recovery.md).
+* Background: [accounts.md](../accounts.md), [accounting-mapping.md](../accounting-mapping.md).

+ 2 - 1
doc/adr/README.md

@@ -13,7 +13,7 @@ to reverse a decision. Instead, a new ADR supersedes it.
 | [0001](0001-modified-utxo-signed-postings.md) | Modified UTXO: value as signed postings | accepted | Value is signed postings (negative = "offset positions"), not mutable balances; conservation is structural; balances are projections. |
 | [0001](0001-modified-utxo-signed-postings.md) | Modified UTXO: value as signed postings | accepted | Value is signed postings (negative = "offset positions"), not mutable balances; conservation is structural; balances are projections. |
 | [0002](0002-saga-commit-pipeline.md) | Saga commit pipeline | accepted | Commit is a compensating saga (`reserve → finalize`), not a single/distributed transaction: composable, coordinator-free, crash-recoverable. |
 | [0002](0002-saga-commit-pipeline.md) | Saga commit pipeline | accepted | Commit is a compensating saga (`reserve → finalize`), not a single/distributed transaction: composable, coordinator-free, crash-recoverable. |
 | [0003](0003-dumb-storage-saga-recovery.md) | Dumb storage + durable saga recovery | accepted | Storage returns affected-row counts and makes no decisions; the saga owns interpretation/idempotency; crash-safety is phase-tracked write-ahead + roll-forward. Refines 0002. |
 | [0003](0003-dumb-storage-saga-recovery.md) | Dumb storage + durable saga recovery | accepted | Storage returns affected-row counts and makes no decisions; the saga owns interpretation/idempotency; crash-safety is phase-tracked write-ahead + roll-forward. Refines 0002. |
-| [0004](0004-account-policies-overdraft-model.md) | Account policies & overdraft model | accepted | A closed `AccountPolicy` enum per account gates negative postings + floor; intent is explicit, illegal states unrepresentable. Refines 0001. |
+| [0004](0004-account-policies-overdraft-model.md) | Account policies & overdraft model | superseded by 0018 | A closed `AccountPolicy` enum per account gated negative postings + floor. Refined 0001. Superseded by 0018, which collapses the enum into a single flag. |
 | [0005](0005-intent-api-movements-vs-envelopes.md) | Intent API: movements vs. envelopes | accepted | Callers express `Movement`/`Transfer` intent; `resolve()` produces the concrete `Envelope`. UTXO mechanics stay internal; idempotency keys on the resolved id. |
 | [0005](0005-intent-api-movements-vs-envelopes.md) | Intent API: movements vs. envelopes | accepted | Callers express `Movement`/`Transfer` intent; `resolve()` produces the concrete `Envelope`. UTXO mechanics stay internal; idempotency keys on the resolved id. |
 | [0006](0006-reservation-protocol-posting-lifecycle.md) | Reservation protocol & posting lifecycle | accepted (storage representation superseded by 0016) | `Active → PendingInactive → Inactive` + a durable `ReservationId` give lock-free, recoverable, exclusive ownership of inputs. The primitive behind 0002/0003. |
 | [0006](0006-reservation-protocol-posting-lifecycle.md) | Reservation protocol & posting lifecycle | accepted (storage representation superseded by 0016) | `Active → PendingInactive → Inactive` + a durable `ReservationId` give lock-free, recoverable, exclusive ownership of inputs. The primitive behind 0002/0003. |
 | [0007](0007-reversal-via-compensating-transfers.md) | Reversal via compensating transfers | accepted | Undo is an inverse envelope committed through the normal path (never deletion/mutation), preserving the append-only audit log. |
 | [0007](0007-reversal-via-compensating-transfers.md) | Reversal via compensating transfers | accepted | Undo is an inverse envelope committed through the normal path (never deletion/mutation), preserving the append-only audit log. |
@@ -27,6 +27,7 @@ to reverse a decision. Instead, a new ADR supersedes it.
 | [0015](0015-fixed-width-account-code.md) | Fixed-width 20-character account code | accepted | The IBAN-style code becomes a fixed 20 chars (18-char body + 2 trailing check digits, five groups of four) by packing id (63 bits) and subaccount (30 bits) into one permuted value. Presentation-only; caps the subaccount at `SUB_BITS`. Supersedes the code section of 0012. |
 | [0015](0015-fixed-width-account-code.md) | Fixed-width 20-character account code | accepted | The IBAN-style code becomes a fixed 20 chars (18-char body + 2 trailing check digits, five groups of four) by packing id (63 bits) and subaccount (30 bits) into one permuted value. Presentation-only; caps the subaccount at `SUB_BITS`. Supersedes the code section of 0012. |
 | [0016](0016-immutable-postings-index-tables.md) | Immutable postings with active/reserved index tables | accepted (hot-table representation refined by 0017) | Postings become an insert-only immutable table; lifecycle state moves to two index tables (`active_postings`, `reserved_postings`). Append-only integrity + least privilege (no `UPDATE`) + hot working set by segregation. Supersedes the storage representation of 0006. |
 | [0016](0016-immutable-postings-index-tables.md) | Immutable postings with active/reserved index tables | accepted (hot-table representation refined by 0017) | Postings become an insert-only immutable table; lifecycle state moves to two index tables (`active_postings`, `reserved_postings`). Append-only integrity + least privilege (no `UPDATE`) + hot working set by segregation. Supersedes the storage representation of 0006. |
 | [0017](0017-correctness-first-append-only-hot-indexes.md) | Correctness-first storage: append-only value tables, disposable hot indexes | accepted | The guiding principle: value/audit tables (`postings`, `accounts`) are strictly append-only source of truth; disposable hot tables (`active_postings`, `reserved_postings`, `account_head`) index the live subset by `INSERT`/`DELETE` only and are rebuildable. Correctness first; no `UPDATE` anywhere, enforceable by DB grants. Generalizes 0016 (hot tables now hold full row copies). |
 | [0017](0017-correctness-first-append-only-hot-indexes.md) | Correctness-first storage: append-only value tables, disposable hot indexes | accepted | The guiding principle: value/audit tables (`postings`, `accounts`) are strictly append-only source of truth; disposable hot tables (`active_postings`, `reserved_postings`, `account_head`) index the live subset by `INSERT`/`DELETE` only and are rebuildable. Correctness first; no `UPDATE` anywhere, enforceable by DB grants. Generalizes 0016 (hot tables now hold full row copies). |
+| [0018](0018-single-debit-must-not-exceed-credit-flag.md) | Collapse account policy into a single debit-must-not-exceed-credit flag | accepted | The `AccountPolicy` enum is removed; the only per-account balance constraint is the `AccountFlags` bit `DEBIT_MUST_NOT_EXCEED_CREDIT`. Overdraft is allowed by default (unbounded); the flag forbids a negative balance and negative postings. Credit-line floors become an application concern. Supersedes 0004. |
 
 
 ## Recommended future ADRs
 ## Recommended future ADRs
 
 

+ 28 - 25
doc/architecture.md

@@ -112,8 +112,8 @@ idempotent. The saga sequences them and interprets their counts; a crash
 mid-sequence is completed by roll-forward recovery (see below).
 mid-sequence is completed by roll-forward recovery (see below).
 
 
 The store only persists and reads. All domain logic (balance computation,
 The store only persists and reads. All domain logic (balance computation,
-validation, policy enforcement, and the interpretation of primitive counts)
-lives in the Ledger/saga and `kuatia-core`.
+validation, balance-constraint enforcement, and the interpretation of primitive
+counts) lives in the Ledger/saga and `kuatia-core`.
 
 
 ## Saga Commit Pipeline
 ## Saga Commit Pipeline
 
 
@@ -211,7 +211,7 @@ using big-endian encoding with a version prefix (`CANONICAL_VERSION = 4`).
 ## Append-Only Account Versioning
 ## Append-Only Account Versioning
 
 
 Accounts are never modified in place. Each account mutation (freeze, unfreeze,
 Accounts are never modified in place. Each account mutation (freeze, unfreeze,
-close, or a policy/flags change) appends a new snapshot with an incremented
+close, or a flags change) appends a new snapshot with an incremented
 `version` field (starts at 1 on creation). Note that transfers do **not** bump
 `version` field (starts at 1 on creation). Note that transfers do **not** bump
 account versions: balances are derived from postings, not stored on the
 account versions: balances are derived from postings, not stored on the
 account.
 account.
@@ -244,29 +244,33 @@ and accounts is a transfer policy scope (which accounts/assets may
 participate). It does not affect conservation enforcement, and it does not
 participate). It does not affect conservation enforcement, and it does not
 partition balances.
 partition balances.
 
 
-## Account Policies
+## Account Balance Constraint
 
 
-Each account has a policy controlling its balance floor and whether it may hold
-negative postings:
+The single per-account balance constraint is the `AccountFlags` bit
+`DEBIT_MUST_NOT_EXCEED_CREDIT`:
 
 
-| Policy | Balance floor | Negative postings |
-|--------|--------------|-------------------|
-| `NoOverdraft` | `>= 0` | No |
-| `CappedOverdraft { floor }` | `>= floor` | Yes (down to floor) |
-| `UncappedOverdraft` | None | Yes (unbounded) |
-| `SystemAccount` | None | Yes |
-| `ExternalAccount` | None | Yes |
+| Flag | Balance | Negative postings |
+|------|---------|-------------------|
+| unset (default) | may go negative, unbounded | Yes (unbounded) |
+| `DEBIT_MUST_NOT_EXCEED_CREDIT` | `>= 0` | No |
 
 
-An overdraft is a **negative posting** assigned to the account to cover a
-shortfall. Only `NoOverdraft` forbids negative postings; validation rejects a
-negative posting on a `NoOverdraft` account. `CappedOverdraft`'s floor (checked
-in validation) bounds the negative balance; the other policies are unbounded.
+By default overdraft is allowed. An overdraft is a **negative posting** assigned
+to the account to cover a shortfall; a debit short of the account's positive
+postings is covered by a negative offset posting, and the transfer is recorded
+as long as it conserves value per asset. Credit-line limits are an application
+concern, not ledger-enforced.
 
 
-## The CappedOverdraft Floor Under Concurrency
+With the flag set the account's debits may not exceed its credits: its balance
+may not go negative and it may not hold a negative posting. Validation rejects a
+negative posting on such an account. Use
+`Account::debit_must_not_exceed_credit(id)` to set it and
+`Account::forbids_overdraft()` to query it.
 
 
-`CappedOverdraft` accounts have a balance floor that is not backed by the UTXO
-model alone: two concurrent transfers could each pass validation but together
-push the balance below the floor (write-skew).
+## The Debit-Must-Not-Exceed-Credit Constraint Under Concurrency
+
+An account that forbids overdraft has a balance floor at zero that is not backed
+by the UTXO model alone: two concurrent transfers could each pass validation but
+together push the balance negative (write-skew).
 
 
 Under the dumb-storage model the floor (and the freeze/close snapshot check) is
 Under the dumb-storage model the floor (and the freeze/close snapshot check) is
 re-validated **as the last thing the finalize step does before it writes**: the
 re-validated **as the last thing the finalize step does before it writes**: the
@@ -280,12 +284,11 @@ can still slip through. Double-spend safety is unaffected and holds
 unconditionally: the reservation protocol (`reserve_postings` is a single
 unconditionally: the reservation protocol (`reserve_postings` is a single
 atomic conditional update, so two sagas cannot both claim the same posting)
 atomic conditional update, so two sagas cannot both claim the same posting)
 prevents
 prevents
-consuming a posting twice. Only the floor on a `CappedOverdraft` account is
-best-effort. This tradeoff is recorded in
+consuming a posting twice. Only the floor on an account that forbids overdraft
+is best-effort. This tradeoff is recorded in
 [doc/adr/0003-dumb-storage-saga-recovery.md](adr/0003-dumb-storage-saga-recovery.md).
 [doc/adr/0003-dumb-storage-saga-recovery.md](adr/0003-dumb-storage-saga-recovery.md).
 
 
-`NoOverdraft` is fully UTXO-backed (you can only spend postings you own), and
-the unconstrained policies have no floor to violate.
+An overdraft-permitting account has no floor to violate.
 
 
 ## No Sequential Hash Chain
 ## No Sequential Hash Chain
 
 

+ 7 - 6
doc/crates.md

@@ -33,9 +33,8 @@ dependencies (`sha2`, `serde`, `bitflags`).
 | `NewPosting` | Posting to be created (no id yet, assigned during validation) |
 | `NewPosting` | Posting to be created (no id yet, assigned during validation) |
 | `Transfer` | Atomic unit: consumes postings + creates postings + metadata |
 | `Transfer` | Atomic unit: consumes postings + creates postings + metadata |
 | `EnvelopeBuilder` | Fluent builder for `Transfer` construction |
 | `EnvelopeBuilder` | Fluent builder for `Transfer` construction |
-| `Account` | Versioned entity with policy, flags, book, metadata |
-| `AccountPolicy` | Balance floor rule: `NoOverdraft`, `CappedOverdraft`, `UncappedOverdraft`, `SystemAccount`, `ExternalAccount` |
-| `AccountFlags` | Bitflags: `FROZEN`, `CLOSED` |
+| `Account` | Versioned entity with flags, book, metadata. `Account::new(id)` allows overdraft by default; `Account::debit_must_not_exceed_credit(id)` forbids it; `Account::forbids_overdraft()` queries the constraint |
+| `AccountFlags` | Bitflags: `FROZEN`, `CLOSED`, `DEBIT_MUST_NOT_EXCEED_CREDIT` (when set, the account's debits may not exceed its credits: balance stays `>= 0`, no negative postings) |
 | `Metadata` | `BTreeMap<String, Vec<u8>>` for free-form key-value data |
 | `Metadata` | `BTreeMap<String, Vec<u8>>` for free-form key-value data |
 | `Receipt` | Confirmation of a committed transfer (contains `transfer_id`) |
 | `Receipt` | Confirmation of a committed transfer (contains `transfer_id`) |
 | `AutoId` | Snowflake-inspired i64 ID generator: `[0][40-bit ms][23-bit CRC32 or counter]`. The ms field counts from `KUATIA_EPOCH_MS` (2026-01-01T00:00:00Z), giving ~34.8 years forward. Lives in `kuatia-types::autoid` |
 | `AutoId` | Snowflake-inspired i64 ID generator: `[0][40-bit ms][23-bit CRC32 or counter]`. The ms field counts from `KUATIA_EPOCH_MS` (2026-01-01T00:00:00Z), giving ~34.8 years forward. Lives in `kuatia-types::autoid` |
@@ -54,7 +53,7 @@ graph TD
     F --> BP[7. Book policy]
     F --> BP[7. Book policy]
     BP --> G[8. Per-asset conservation]
     BP --> G[8. Per-asset conservation]
     G --> H[9. Negative posting restriction]
     G --> H[9. Negative posting restriction]
-    H --> J[10. Policy enforcement]
+    H --> J[10. Balance-constraint enforcement]
     J --> I[Plan]
     J --> I[Plan]
     style I fill:#e8f5e9
     style I fill:#e8f5e9
 ```
 ```
@@ -72,8 +71,10 @@ graph TD
    must be allowed by the book
    must be allowed by the book
 8. **Per-asset conservation**: `sum(consumed) == sum(created)` for each asset
 8. **Per-asset conservation**: `sum(consumed) == sum(created)` for each asset
 9. **Negative posting restriction**: negative postings forbidden only on
 9. **Negative posting restriction**: negative postings forbidden only on
-   `NoOverdraft` (allowed on overdraft/system/external)
-10. **Policy enforcement**: projected balance satisfies account's floor
+   accounts that forbid overdraft (the `DEBIT_MUST_NOT_EXCEED_CREDIT` flag is
+   set); allowed on overdraft-permitting accounts
+10. **Balance-constraint enforcement**: for an account that forbids overdraft,
+    the projected balance stays `>= 0`
 
 
 Output is a `Plan` containing `transfer_id`, `postings_to_deactivate`, and
 Output is a `Plan` containing `transfer_id`, `postings_to_deactivate`, and
 `postings_to_create`.
 `postings_to_create`.

+ 24 - 22
doc/glossary.md

@@ -13,10 +13,10 @@ value in the ledger. Postings are immutable once created. Consumed postings
 are marked `Inactive` but never deleted.
 are marked `Inactive` but never deleted.
 
 
 - **Positive posting**: value controlled by the account.
 - **Positive posting**: value controlled by the account.
-- **Negative posting**: an offset position, allowed on any policy except
-  `NoOverdraft`. It represents issuance, external flow, system balancing
-  (`SystemAccount`, `ExternalAccount`), or an overdraft
-  (`CappedOverdraft`/`UncappedOverdraft`).
+- **Negative posting**: an offset position, allowed on any account that permits
+  overdraft (the `DEBIT_MUST_NOT_EXCEED_CREDIT` flag is not set). It represents
+  issuance, external flow, boundary/system balancing, or an overdraft. An
+  account that forbids overdraft may not hold one.
 
 
 Lifecycle: `Active` → `PendingInactive` (reserved by a saga, stamped with its
 Lifecycle: `Active` → `PendingInactive` (reserved by a saga, stamped with its
 `ReservationId`) → `Inactive` (consumed). **Ledger balance** sums
 `ReservationId`) → `Inactive` (consumed). **Ledger balance** sums
@@ -28,13 +28,13 @@ Lifecycle: `Active` → `PendingInactive` (reserved by a saga, stamped with its
 A versioned entity that owns postings. Balance is never stored. It is always
 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.
 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 **book** assignment.
+Accounts have **flags** (lifecycle, the `DEBIT_MUST_NOT_EXCEED_CREDIT` balance
+constraint, and user-defined bits) and a **book** assignment.
 
 
 An account is identified by `AccountId { id: i64, sub: i64 }`: a base `id` plus a
 An account is identified by `AccountId { id: i64, sub: i64 }`: a base `id` plus a
 **subaccount**. `sub = 0` is the main account; a non-zero `sub` is a subaccount
 **subaccount**. `sub = 0` is the main account; a non-zero `sub` is a subaccount
 of the same base id, and each `(id, sub)` is a full account record with its own
 of the same base id, and each `(id, sub)` is a full account record with its own
-policy and lifecycle. Balances are reported per subaccount and never summed
+flags and lifecycle. Balances are reported per subaccount and never summed
 across them. See the Subaccount entry and
 across them. See the Subaccount entry and
 [adr/0012-subaccounts.md](adr/0012-subaccounts.md).
 [adr/0012-subaccounts.md](adr/0012-subaccounts.md).
 
 
@@ -172,15 +172,17 @@ ledger.create_book(trading_book).await?;
 
 
 // Accounts — `Account::new` sets version 1, no flags, and the default book;
 // Accounts — `Account::new` sets version 1, no flags, and the default book;
 // set the other fields explicitly where the common case is not enough.
 // set the other fields explicitly where the common case is not enough.
-let mut bank = Account::new(AccountId::default(), AccountPolicy::ExternalAccount);
+// Boundary account: overdraft allowed by default, so it can hold the offset.
+let mut bank = Account::new(AccountId::default());
 bank.flags = AccountFlags::USER_1; // bank flag
 bank.flags = AccountFlags::USER_1; // bank flag
 bank.book = deposits_book.id;
 bank.book = deposits_book.id;
 
 
-let mut alice = Account::new(AccountId::default(), AccountPolicy::NoOverdraft);
-alice.flags = AccountFlags::USER_0; // wallet flag
+// Wallet: forbids overdraft, so its balance may not go negative.
+let mut alice = Account::debit_must_not_exceed_credit(AccountId::default());
+alice.flags |= AccountFlags::USER_0; // wallet flag
 alice.book = deposits_book.id;
 alice.book = deposits_book.id;
 
 
-let mut exchange_pool = Account::new(AccountId::default(), AccountPolicy::SystemAccount);
+let mut exchange_pool = Account::new(AccountId::default());
 exchange_pool.book = trading_book.id;
 exchange_pool.book = trading_book.id;
 ```
 ```
 
 
@@ -263,24 +265,24 @@ let banking_book = BookBuilder::new("banking")
     .allow_flags(WAREHOUSE | BANK)
     .allow_flags(WAREHOUSE | BANK)
     .build();
     .build();
 
 
-// Accounts — start from `Account::new`, then set flags where needed.
-// issuance source: mints product tokens on receipt
-let world = Account::new(AccountId::default(), AccountPolicy::SystemAccount);
+// Accounts — start from a constructor, then set flags where needed.
+// issuance source: mints product tokens on receipt (overdraft allowed)
+let world = Account::new(AccountId::default());
 
 
-let mut warehouse = Account::new(AccountId::default(), AccountPolicy::NoOverdraft);
-warehouse.flags = WAREHOUSE;
+let mut warehouse = Account::debit_must_not_exceed_credit(AccountId::default());
+warehouse.flags |= WAREHOUSE;
 
 
-let mut cash_register = Account::new(AccountId::default(), AccountPolicy::NoOverdraft);
-cash_register.flags = WAREHOUSE;
+let mut cash_register = Account::debit_must_not_exceed_credit(AccountId::default());
+cash_register.flags |= WAREHOUSE;
 
 
-let mut revenue = Account::new(AccountId::default(), AccountPolicy::SystemAccount);
+let mut revenue = Account::new(AccountId::default());
 revenue.flags = REVENUE;
 revenue.flags = REVENUE;
 
 
-let mut cogs = Account::new(AccountId::default(), AccountPolicy::SystemAccount); // cost of goods sold
+let mut cogs = Account::new(AccountId::default()); // cost of goods sold
 cogs.flags = REVENUE;
 cogs.flags = REVENUE;
 
 
-let mut bank = Account::new(AccountId::default(), AccountPolicy::NoOverdraft);
-bank.flags = BANK;
+let mut bank = Account::debit_must_not_exceed_credit(AccountId::default());
+bank.flags |= BANK;
 ```
 ```
 
 
 **Receive inventory from supplier (50 units of rice):**
 **Receive inventory from supplier (50 units of rice):**

+ 7 - 5
doc/inflight.md

@@ -11,8 +11,9 @@ This page is the usage guide.
 ## Model
 ## Model
 
 
 An inflight transaction is an ordinary trade whose every destination is
 An inflight transaction is an ordinary trade whose every destination is
-rewritten to a fresh per-destination **holding account** (`NoOverdraft`, flagged
-`INFLIGHT`). Committing that rewritten transfer parks the funds:
+rewritten to a fresh per-destination **holding account** (forbids overdraft via
+the `DEBIT_MUST_NOT_EXCEED_CREDIT` flag, and flagged `INFLIGHT`). Committing that
+rewritten transfer parks the funds:
 
 
 ```text
 ```text
 Confirmed trade            Inflight form
 Confirmed trade            Inflight form
@@ -89,9 +90,10 @@ let open = ledger.list_open_inflights().await?;
 
 
 ## Guarantees
 ## Guarantees
 
 
-- **Over-confirmation is impossible.** A hold is `NoOverdraft`, so confirming
-  more than it holds fails validation. The sum of confirmations can never exceed
-  the authorized amount.
+- **Over-confirmation is impossible.** A hold forbids overdraft (the
+  `DEBIT_MUST_NOT_EXCEED_CREDIT` flag is set), so confirming more than it holds
+  fails validation. The sum of confirmations can never exceed the authorized
+  amount.
 - **No double-spend under concurrency.** Concurrent confirmations serialize on
 - **No double-spend under concurrency.** Concurrent confirmations serialize on
   the shared holding posting via the reservation protocol. On contention, one
   the shared holding posting via the reservation protocol. On contention, one
   wins and the caller retries the other against the new remaining balance.
   wins and the caller retries the other against the new remaining balance.

+ 21 - 17
doc/transfers.md

@@ -52,8 +52,10 @@ returns change to A if the selected postings exceed 50.
 
 
 ### Deposit
 ### Deposit
 
 
-Fund an account from a system/external source. Creates an offset posting on
-the source and a credit on the target.
+Fund an account from a boundary source account. The source is an ordinary
+account that permits overdraft (no `DEBIT_MUST_NOT_EXCEED_CREDIT` flag), so it
+can hold the negative offset posting. Creates an offset posting on the source
+and a credit on the target.
 
 
 ```rust
 ```rust
 TransferBuilder::new()
 TransferBuilder::new()
@@ -127,14 +129,14 @@ For each `(account, asset)` pair where net debit > 0:
    selection, compute change = selected sum − net debit, and (if change > 0)
    selection, compute change = selected sum − net debit, and (if change > 0)
    create a change posting returning the remainder to the account.
    create a change posting returning the remainder to the account.
 3. If positive postings are **insufficient**:
 3. If positive postings are **insufficient**:
-   - For `CappedOverdraft` / `UncappedOverdraft` accounts: consume all positive
-     postings and create a **negative posting** for the shortfall
-     (`net_debit − total_positive`). The `CappedOverdraft` floor is enforced
-     later in validation.
-   - For any other policy: fail with `InsufficientFunds`.
+   - For accounts that allow overdraft (the `DEBIT_MUST_NOT_EXCEED_CREDIT` flag
+     is not set): consume all positive postings and create a **negative
+     posting** for the shortfall (`net_debit − total_positive`).
+   - For accounts that forbid overdraft (the flag is set): fail with
+     `InsufficientFunds`.
 
 
-Pairs with net debit <= 0 (e.g. the external account in a deposit) are skipped.
-No posting selection needed.
+Pairs with net debit <= 0 (e.g. the overdraft-permitting account in a deposit)
+are skipped. No posting selection needed.
 
 
 ### Aggregation benefit
 ### Aggregation benefit
 
 
@@ -224,9 +226,11 @@ validation steps are:
 7. Book policy (if a book is loaded): referenced assets/accounts/flags allowed
 7. Book policy (if a book is loaded): referenced assets/accounts/flags allowed
    by the book
    by the book
 8. Per-asset conservation: `sum(consumed) == sum(created)`
 8. Per-asset conservation: `sum(consumed) == sum(created)`
-9. Negative postings forbidden only on `NoOverdraft` accounts (allowed on
-   overdraft/system/external)
-10. Policy enforcement: projected balance satisfies account floor
+9. Negative postings forbidden only on accounts that forbid overdraft (the
+   `DEBIT_MUST_NOT_EXCEED_CREDIT` flag is set); allowed on overdraft-permitting
+   accounts
+10. Balance-constraint enforcement: for an account that forbids overdraft, the
+    projected balance stays `>= 0`
 
 
 Validation runs inside the finalize step, immediately before it writes (the
 Validation runs inside the finalize step, immediately before it writes (the
 last-step floor / freeze-close re-check). The finalize step then applies the
 last-step floor / freeze-close re-check). The finalize step then applies the
@@ -234,12 +238,12 @@ effects through a sequence of dumb, idempotent store primitives
 (`deactivate_postings` → `insert_postings` → `store_transfer` → `append_event`),
 (`deactivate_postings` → `insert_postings` → `store_transfer` → `append_event`),
 verifying every end-state. There is no single transaction; crash-safety comes
 verifying every end-state. There is no single transaction; crash-safety comes
 from a phase-tracked write-ahead `PendingSaga` record plus `recover()`
 from a phase-tracked write-ahead `PendingSaga` record plus `recover()`
-roll-forward. The `CappedOverdraft` floor is re-checked as that last step
-and is best-effort (not strictly atomic) under concurrency: two transfers
-that each pass the floor check against the same pre-transfer balance can
-both commit and jointly push the account below its floor. Per-asset
+roll-forward. The zero floor of an account that forbids overdraft is re-checked
+as that last step and is best-effort (not strictly atomic) under concurrency:
+two transfers that each pass the floor check against the same pre-transfer
+balance can both commit and jointly push the account below zero. Per-asset
 conservation still holds in that case (the negative postings are real
 conservation still holds in that case (the negative postings are real
-value owed, not minted). The overdraft floor is the only guard with this
+value owed, not minted). That floor is the only guard with this
 property; double-spend prevention is exact (see
 property; double-spend prevention is exact (see
 `crates/kuatia/tests/concurrency.rs`, which asserts the exact guarantees
 `crates/kuatia/tests/concurrency.rs`, which asserts the exact guarantees
 and documents the floor race with an ignored test). See
 and documents the floor race with an ignored test). See