Ver Fonte

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 há 7 horas atrás
pai
commit
651a7dd5d6

+ 6 - 6
CLAUDE.md

@@ -17,7 +17,7 @@ crates/
 doc/
   architecture.md   Architecture decisions and rationale
   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
   journaling.md     Journaling: transfers as (compound) journal entries
   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.
 - **Envelope**: concrete postings to consume and create — the resolved form of movements.
 - **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).
 
 ## 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).
 - **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.
-- **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.
 
 ## Resolve algorithm
 
 Two-pass:
 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.
 
@@ -61,8 +61,8 @@ Deposit: two movements cancel to zero net debit on the system account — no pos
 6. Account snapshot pinning
 7. Book policy (if a book is loaded): referenced assets/accounts/flags allowed by the book
 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
 

+ 4 - 4
README.md

@@ -25,17 +25,17 @@ reconstructs every balance, so the ledger is auditable by construction. See
 ┌─────────────────────────────────────────────────────┐
 │                   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             │
-│  Saga steps:    legend step adapters                 
+│  Saga steps:    legend step adapters                │
 ├─────────────────────────────────────────────────────┤
 │               kuatia-core (pure)                    │
 │                                                     │
 │  Types:         Account · Transfer · Posting · Cent │
 │  Validation:    validate_and_plan()                 │
 │  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
 //!    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
 //!    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
 //! they are reachable without standing up a store.
 
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
 
 use crate::posting_selection::SelectionError;
 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`
-/// 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> {
     /// The transfer being resolved (for its book and metadata).
     pub transfer: &'a Transfer,
@@ -141,10 +141,13 @@ pub struct ResolveInput<'a> {
     /// Active postings available for each debit's (account, asset). A missing or
     /// empty entry means no positive postings to draw on.
     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
@@ -157,7 +160,7 @@ pub fn resolve_envelope(input: ResolveInput<'_>) -> Result<Envelope, ResolveErro
         transfer,
         draft,
         available,
-        policies,
+        overdraft_allowed,
     } = input;
     let MovementDraft {
         mut creates,
@@ -204,31 +207,28 @@ pub fn resolve_envelope(input: ResolveInput<'_>) -> Result<Envelope, ResolveErro
                 });
             }
         } 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)),
             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 {
             transfer: &transfer,
             draft,
             available: &available,
-            policies: &policies,
+            overdraft_allowed: &overdraft_allowed,
         })
         .unwrap();
         assert_eq!(env.consumes().len(), 2);
@@ -330,12 +330,12 @@ mod tests {
         let draft = draft_movements(&transfer).unwrap();
         let available =
             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 {
             transfer: &transfer,
             draft,
             available: &available,
-            policies: &policies,
+            overdraft_allowed: &overdraft_allowed,
         })
         .unwrap();
         assert_eq!(env.consumes().len(), 1);
@@ -363,12 +363,12 @@ mod tests {
                 posting(acct(1), 2, 50),
             ],
         )]);
-        let policies = HashMap::new();
+        let overdraft_allowed = HashSet::new();
         let env = resolve_envelope(ResolveInput {
             transfer: &transfer,
             draft,
             available: &available,
-            policies: &policies,
+            overdraft_allowed: &overdraft_allowed,
         })
         .unwrap();
         assert_eq!(env.consumes().len(), 1);
@@ -387,12 +387,12 @@ mod tests {
         let draft = draft_movements(&transfer).unwrap();
         let available =
             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 {
             transfer: &transfer,
             draft,
             available: &available,
-            policies: &policies,
+            overdraft_allowed: &overdraft_allowed,
         })
         .unwrap_err();
         assert_eq!(
@@ -405,16 +405,16 @@ mod tests {
     }
 
     #[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 draft = draft_movements(&transfer).unwrap();
         let available = HashMap::new();
-        let policies = HashMap::new();
+        let overdraft_allowed = HashSet::new();
         let err = resolve_envelope(ResolveInput {
             transfer: &transfer,
             draft,
             available: &available,
-            policies: &policies,
+            overdraft_allowed: &overdraft_allowed,
         })
         .unwrap_err();
         assert_eq!(
@@ -432,12 +432,12 @@ mod tests {
         let draft = draft_movements(&transfer).unwrap();
         let available =
             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 {
             transfer: &transfer,
             draft,
             available: &available,
-            policies: &policies,
+            overdraft_allowed: &overdraft_allowed,
         })
         .unwrap();
         // The single positive posting is consumed.
@@ -453,23 +453,18 @@ mod tests {
     }
 
     #[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 draft = draft_movements(&transfer).unwrap();
         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 {
             transfer: &transfer,
             draft,
             available: &available,
-            policies: &policies,
+            overdraft_allowed: &overdraft_allowed,
         })
         .unwrap();
         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.
 //!
 //! [`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
 //! and testable with golden vectors. The caller provides pre-loaded state via
 //! [`PlanInput`]; this module never touches storage.
@@ -82,13 +82,13 @@ pub enum ValidationError {
         /// Total value of created postings for this asset.
         created_sum: Cent,
     },
-    /// Projected balance would fall below the account's floor.
+    /// Projected balance would go negative on an account that forbids overdraft.
     OverdraftExceeded {
         /// The account that would be overdrawn.
         account: AccountId,
         /// The asset involved.
         asset: AssetId,
-        /// The minimum allowed balance.
+        /// The minimum allowed balance (always zero: the overdraft floor).
         floor: Cent,
         /// The balance that would result from this transfer.
         projected: Cent,
@@ -102,7 +102,7 @@ pub enum ValidationError {
         /// The actual current snapshot hash.
         actual: [u8; 32],
     },
-    /// A negative posting targets an account whose policy forbids offset positions.
+    /// A negative posting targets an account that forbids overdraft.
     NegativePostingOnNonSystemAccount {
         /// The account that would receive the negative posting.
         account: AccountId,
@@ -186,7 +186,7 @@ impl std::fmt::Display for ValidationError {
             } => {
                 write!(
                     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 } => {
@@ -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() {
         if np.value.is_negative() {
             let account = input
                 .accounts
                 .get(&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();
     for pid in envelope.consumes() {
         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 {
+        let account = &input.accounts[account_id];
+        if !account.forbids_overdraft() {
+            continue;
+        }
         let current_balance = input
             .balances
             .get(&(*account_id, *asset_id))
             .copied()
             .unwrap_or(Cent::ZERO);
         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 std::collections::BTreeMap;
 
-    fn make_account(id: i64, policy: AccountPolicy) -> Account {
+    fn make_account(id: i64, flags: AccountFlags) -> Account {
         Account {
             id: AccountId::new(id),
             version: 1,
-            policy,
-            flags: AccountFlags::empty(),
+            flags,
             book: BookId(0),
             metadata: BTreeMap::new(),
         }
@@ -516,8 +492,8 @@ mod tests {
     fn valid_deposit() {
         let envelope = deposit_envelope();
         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 input = PlanInput {
@@ -572,7 +548,10 @@ mod tests {
             account_snapshots: vec![],
             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 input = PlanInput {
             envelope: &envelope,
@@ -620,9 +599,9 @@ mod tests {
     #[test]
     fn account_frozen_rejected() {
         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;
-        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 input = PlanInput {
             envelope: &envelope,
@@ -641,9 +620,9 @@ mod tests {
     #[test]
     fn account_closed_rejected() {
         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;
-        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 input = PlanInput {
             envelope: &envelope,
@@ -686,8 +665,8 @@ mod tests {
             metadata: BTreeMap::new(),
         };
         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.
         // Let's test when balance is insufficient: balance=30, consuming 50-value posting
@@ -735,15 +714,11 @@ mod tests {
             metadata: BTreeMap::new(),
         };
         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();
         balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(80));
 
@@ -755,13 +730,13 @@ mod tests {
             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();
         assert!(!plan.postings_to_create.is_empty());
     }
 
     #[test]
-    fn capped_overdraft_exceeded() {
+    fn debit_must_not_exceed_credit_rejects_negative_projection() {
         let pid = PostingId {
             transfer: EnvelopeId([1; 32]),
             index: 0,
@@ -785,15 +760,10 @@ mod tests {
             metadata: BTreeMap::new(),
         };
         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();
         balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(30));
 
@@ -809,7 +779,7 @@ mod tests {
             Err(ValidationError::OverdraftExceeded {
                 floor, projected, ..
             }) => {
-                assert_eq!(floor, Cent::from(-50));
+                assert_eq!(floor, Cent::ZERO);
                 assert_eq!(projected, Cent::from(-70));
             }
             other => panic!("expected OverdraftExceeded, got {other:?}"),
@@ -817,7 +787,7 @@ mod tests {
     }
 
     #[test]
-    fn uncapped_overdraft_allows_negative() {
+    fn overdraft_allows_negative_balance() {
         let pid = PostingId {
             transfer: EnvelopeId([1; 32]),
             index: 0,
@@ -841,8 +811,8 @@ mod tests {
             metadata: BTreeMap::new(),
         };
         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
         let mut balances = HashMap::new();
@@ -924,8 +894,8 @@ mod tests {
             metadata: BTreeMap::new(),
         };
         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();
         balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(100));
@@ -945,22 +915,21 @@ mod tests {
         // 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 {
             id: AccountId::with_sub(id, sub),
             version: 1,
-            policy,
-            flags: AccountFlags::empty(),
+            flags,
             book: BookId(0),
             metadata: BTreeMap::new(),
         }
     }
 
     #[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 envelope = Envelope {
             consumes: vec![],
@@ -983,9 +952,9 @@ mod tests {
             metadata: BTreeMap::new(),
         };
         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 input = PlanInput {
@@ -1002,9 +971,9 @@ mod tests {
 
     #[test]
     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 envelope = Envelope {
             consumes: vec![],
@@ -1027,9 +996,9 @@ mod tests {
             metadata: BTreeMap::new(),
         };
         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();
         balances.insert((AccountId::new(1), AssetId::new(1)), Cent::from(100));
@@ -1075,7 +1044,7 @@ mod tests {
             metadata: BTreeMap::new(),
         };
         // 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 input = PlanInput {
             envelope: &envelope,
@@ -1113,7 +1082,10 @@ mod tests {
             account_snapshots: vec![],
             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 input = PlanInput {
             envelope: &envelope,
@@ -1137,8 +1109,8 @@ mod tests {
     fn negative_posting_allowed_on_system_account() {
         let envelope = deposit_envelope();
         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 input = PlanInput {

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

@@ -11,7 +11,7 @@ use axum::{
     response::{IntoResponse, Response},
 };
 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::store::{EnvelopeRecord, TransferQuery};
 use serde::Serialize;
@@ -47,19 +47,15 @@ pub struct AccountDto {
     pub sub: i64,
     pub label: Option<&'static str>,
     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 closed: bool,
     pub balances: Vec<BalanceDto>,
 }
 
 #[derive(Serialize)]
-pub struct PolicyDto {
-    pub kind: &'static str,
-    pub floor: Option<Cent>,
-}
-
-#[derive(Serialize)]
 pub struct PostingDto {
     pub id: String,
     pub owner: AccountId,
@@ -132,31 +128,6 @@ fn posting_id(id: &PostingId) -> String {
     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> {
     let mut balances = Vec::new();
     for asset in state.assets.iter() {
@@ -175,7 +146,7 @@ async fn account_dto(state: &AppState, account: &Account) -> Result<AccountDto,
         sub: account.id.sub,
         label: account_label(account.id),
         version: account.version,
-        policy: policy_dto(&account.policy),
+        debit_must_not_exceed_credit: account.forbids_overdraft(),
         frozen: account.is_frozen(),
         closed: account.is_closed(),
         balances,

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

@@ -5,7 +5,7 @@
 use std::sync::Arc;
 
 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 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 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.
     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, 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?;
 
     // 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(
     ledger: &Arc<Ledger>,
     id: AccountId,
-    policy: AccountPolicy,
+    debit_must_not_exceed_credit: bool,
 ) -> 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(())
 }
 

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

@@ -123,7 +123,6 @@ struct AccountView {
     name: String,
     version: u64,
     policy_kind: &'static str,
-    floor: Option<MoneyView>,
     frozen: bool,
     closed: bool,
     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)
 }
 
-/// 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.
 // ---------------------------------------------------------------------------
@@ -283,7 +272,6 @@ fn acct_link(id: kuatia_core::AccountId) -> String {
 }
 
 fn account_view(dto: &AccountDto, assets: &[AssetMeta]) -> AccountView {
-    let (floor_dec, floor_sym) = floor_style(assets);
     let display_id = acct_display(dto.id);
     let link = acct_link(dto.id);
     AccountView {
@@ -297,8 +285,11 @@ fn account_view(dto: &AccountDto, assets: &[AssetMeta]) -> AccountView {
             .unwrap_or_else(|| display_id.clone()),
         display_id,
         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,
         closed: dto.closed,
         balances: dto

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

@@ -1,8 +1,5 @@
 <div class="detail-meta">
   <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>
   {% if account.frozen %}<span class="flag frozen">frozen</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",
                 include_str!("migrations/005_account_head.sql"),
             ),
+            (
+                "006_drop_policy",
+                include_str!("migrations/006_drop_policy.sql"),
+            ),
         ];
 
         for (name, sql) in migrations {
@@ -166,15 +170,6 @@ impl SqlStore {
 // 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
 /// the database is directly readable for auditing; the ledger never queries
 /// 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
         .try_get("version")
         .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
         .try_get("flags")
         .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 {
         id: AccountId::with_sub(id, subaccount),
         version: version as u64,
-        policy: deserialize_policy(&policy_str)?,
         flags: AccountFlags::from_bits_truncate(flags_bits as u32),
         book: BookId::new(book),
         metadata: deserialize_json(&metadata_json)?,
@@ -397,12 +388,11 @@ impl AccountStore for SqlStore {
 
         // Append the immutable first version, then point the head at it.
         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.sub)
             .bind(account.version as i64)
-            .bind(serialize_policy(&account.policy)?)
             .bind(account.flags.bits() as i32)
             .bind(account.book.0)
             .bind(serialize_json(&account.metadata)?)
@@ -473,12 +463,11 @@ impl AccountStore for SqlStore {
         }
 
         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.sub)
             .bind(account.version as i64)
-            .bind(serialize_policy(&account.policy)?)
             .bind(account.flags.bits() as i32)
             .bind(account.book.0)
             .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 {
         id: AccountId::new(1),
         version: 1,
-        policy: AccountPolicy::NoOverdraft,
-        flags: AccountFlags::empty(),
+        flags: AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT,
         book: BookId(0),
         metadata: std::collections::BTreeMap::new(),
     };
@@ -82,12 +81,12 @@ async fn subaccount_columns_round_trip() {
 
     let sub = AccountId::with_sub(1, 7);
     store
-        .create_account(Account::new(sub, AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(sub))
         .await
         .unwrap();
     // The main account (1, 0) is a separate record.
     store
-        .create_account(Account::new(AccountId::new(1), AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(AccountId::new(1)))
         .await
         .unwrap();
 

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

@@ -19,12 +19,11 @@ use crate::store::*;
 // Helpers
 // ---------------------------------------------------------------------------
 
-fn make_account(id: i64, policy: AccountPolicy) -> Account {
+fn make_account(id: i64, flags: AccountFlags) -> Account {
     Account {
         id: AccountId::new(id),
         version: 1,
-        policy,
-        flags: AccountFlags::empty(),
+        flags,
         book: BookId(0),
         metadata: BTreeMap::new(),
     }
@@ -173,16 +172,18 @@ async fn commit_envelope(
 
 /// Create an account and retrieve it.
 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();
     let got = store.get_account(&AccountId::new(1)).await.unwrap();
     assert_eq!(got.id, acc.id);
     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.
 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();
     let err = store.create_account(acc).await.unwrap_err();
     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.
 pub async fn get_accounts_batch(store: &(impl Store + 'static)) {
     store
-        .create_account(make_account(1, AccountPolicy::NoOverdraft))
+        .create_account(make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT))
         .await
         .unwrap();
     store
-        .create_account(make_account(2, AccountPolicy::NoOverdraft))
+        .create_account(make_account(2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT))
         .await
         .unwrap();
     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.
 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();
 
     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.
 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();
 
     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
 /// backstop of the locking append.
 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();
 
     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.
 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();
 
     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.
 pub async fn list_accounts(store: &(impl Store + 'static)) {
     store
-        .create_account(make_account(1, AccountPolicy::NoOverdraft))
+        .create_account(make_account(1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT))
         .await
         .unwrap();
     store
-        .create_account(make_account(2, AccountPolicy::ExternalAccount))
+        .create_account(make_account(2, AccountFlags::empty()))
         .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`].
 
 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
@@ -30,7 +30,9 @@ pub trait ToBytes {
 /// canonical bytes (ADR-0012).
 /// Bumped to 4 when the vestigial `UserData` fields were removed from the
 /// `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`.
 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 {
     fn to_bytes(&self) -> Vec<u8> {
         self.bits().to_be_bytes().to_vec()
@@ -202,7 +187,6 @@ impl ToBytes for Account {
         buf.push(CANONICAL_VERSION);
         buf.extend(self.id.to_bytes());
         write_u64(&mut buf, self.version);
-        buf.extend(self.policy.to_bytes());
         buf.extend(self.flags.to_bytes());
         buf.extend(self.book.to_bytes());
 

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

@@ -554,32 +554,14 @@ impl EnvelopeBuilder {
 // 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! {
-    /// 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
-    /// (`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
     /// a book.
     ///
@@ -594,8 +576,12 @@ bitflags::bitflags! {
         /// Holding account for an inflight (authorize/confirm/void) transaction.
         /// Parks funds between authorize and settlement; closed once drained.
         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.
         const RESERVED_4 = 1 << 4;
         /// Reserved for a future system flag; not for user assignment.
@@ -662,9 +648,8 @@ pub struct Account {
     pub id: AccountId,
     /// Monotonically increasing version, starts at 1 on creation.
     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,
     /// Book this entity belongs to.
     pub book: BookId,
@@ -673,25 +658,46 @@ pub struct 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 {
             id,
             version: 1,
-            policy,
             flags: AccountFlags::empty(),
             book: DEFAULT_BOOK,
             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.
     pub fn is_frozen(&self) -> bool {
         self.flags.contains(AccountFlags::FROZEN)
@@ -875,4 +881,34 @@ mod tests {
         let loaded = AccountFlags::from_bits_truncate(stored as u32);
         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>> {
     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
-        .create_account(Account::new(AccountId::new(1), AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(AccountId::new(1)))
         .await?;
     ledger
-        .create_account(Account::new(AccountId::new(2), AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(AccountId::new(2)))
         .await?;
     // A system account (fees, settlement, market-making) — no balance floor.
     ledger
-        .create_account(Account::new(
-            AccountId::new(50),
-            AccountPolicy::SystemAccount,
-        ))
+        .create_account(Account::new(AccountId::new(50)))
         .await?;
 
     // The same thing spelled out, so you can see every field of an `Account`.
     // This boundary account is where value enters/leaves the ledger.
     let external = Account {
         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?;
 
@@ -48,7 +44,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     let mut accounts = ledger.list_accounts().await?;
     accounts.sort_by_key(|a| (a.id.id, a.id.sub));
     for a in &accounts {
-        println!("  {:?}  policy={:?}  v{}", a.id, a.policy, a.version);
+        println!("  {:?}  flags={:?}  v{}", a.id, a.flags, a.version);
     }
 
     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);
 
     ledger
-        .create_account(Account::new(alice, AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(alice))
         .await?;
     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?;
+    ledger.create_account(Account::new(external)).await?;
 
     // Fund: $100.00 to Alice, €90.00 to Bob.
     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
 
     ledger
-        .create_account(Account::new(customer, AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(customer))
         .await?;
     ledger
-        .create_account(Account::new(merchant, AccountPolicy::NoOverdraft))
+        .create_account(Account::debit_must_not_exceed_credit(merchant))
         .await?;
     // 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.
     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);
 
     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?;
+    ledger.create_account(Account::new(external)).await?;
 
     // Fund Alice with $100.00.
     ledger

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

@@ -16,8 +16,8 @@ use std::collections::{BTreeMap, BTreeSet};
 use std::sync::Arc;
 
 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::store::EnvelopeRecord;
@@ -206,8 +206,10 @@ impl Ledger {
         // trade is already inflight, so different trades (different subs) can hold
         // against the same destination at once.
         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.metadata = meta_map(&InflightMeta::Hold { destination: *dest })?;
             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
 //! crash.
 
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
 
 use legend::ExecutionResult;
 use tracing::instrument;
 
 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;
@@ -147,17 +147,18 @@ impl Ledger {
     /// The decision is pure ([`kuatia_core::draft_movements`] +
     /// [`kuatia_core::resolve_envelope`]); this method only loads the state those
     /// 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")]
     pub async fn resolve(&self, transfer: &Transfer) -> Result<Envelope, LedgerError> {
         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 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 {
             let postings = self
                 .store
@@ -169,8 +170,14 @@ impl Ledger {
                 )
                 .await?;
             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,
             draft,
             available: &available,
-            policies: &policies,
+            overdraft_allowed: &overdraft_allowed,
         })?;
 
         // Resolve account snapshots for optimistic concurrency
@@ -590,12 +597,11 @@ mod recovery_tests {
     use kuatia_storage::mem_store::InMemoryStore;
     use std::collections::BTreeMap;
 
-    fn acct(id: i64, policy: AccountPolicy) -> Account {
+    fn acct(id: i64, flags: AccountFlags) -> Account {
         Account {
             id: AccountId::new(id),
             version: 1,
-            policy,
-            flags: AccountFlags::empty(),
+            flags,
             book: kuatia_core::BookId(0),
             metadata: BTreeMap::new(),
         }
@@ -604,10 +610,10 @@ mod recovery_tests {
     async fn funded_ledger() -> Arc<Ledger> {
         let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
         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();
         }

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

@@ -31,30 +31,29 @@ fn external() -> AccountId {
     AccountId::new(99)
 }
 
-fn make_account(id: i64, policy: AccountPolicy) -> Account {
+fn make_account(id: i64, flags: AccountFlags) -> Account {
     Account {
         id: AccountId::new(id),
         version: 1,
-        policy,
-        flags: AccountFlags::empty(),
+        flags,
         book: BookId(0),
         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> {
     let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
     for id in 1..=n {
         ledger
             .store()
-            .create_account(make_account(id, AccountPolicy::NoOverdraft))
+            .create_account(make_account(id, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT))
             .await
             .unwrap();
     }
     ledger
         .store()
-        .create_account(make_account(99, AccountPolicy::ExternalAccount))
+        .create_account(make_account(99, AccountFlags::empty()))
         .await
         .unwrap();
     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)]
-#[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;
     for _ in 0..64 {
         let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
+        // Account 1 permits overdraft (the default); payees forbid it.
         ledger
             .store()
-            .create_account(make_account(1, AccountPolicy::CappedOverdraft { floor }))
+            .create_account(make_account(1, AccountFlags::empty()))
             .await
             .unwrap();
         for payee in 2..=(1 + PAYEES) {
             ledger
                 .store()
-                .create_account(make_account(payee, AccountPolicy::NoOverdraft))
+                .create_account(make_account(
+                    payee,
+                    AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT,
+                ))
                 .await
                 .unwrap();
         }
 
         // One payment of 60 to each distinct payee from an empty overdraft
         // 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();
         for payee in 2..=(1 + PAYEES) {
             let ledger = Arc::clone(&ledger);
@@ -395,15 +386,7 @@ async fn overdraft_floor_is_best_effort_under_concurrency() {
         assert_eq!(
             total,
             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)
 }
 
-fn make_account(id: i64, policy: AccountPolicy) -> Account {
+fn make_account(id: i64, flags: AccountFlags) -> Account {
     Account {
         id: AccountId::new(id),
         version: 1,
-        policy,
-        flags: AccountFlags::empty(),
+        flags,
         book: BookId(0),
         metadata: BTreeMap::new(),
     }
@@ -63,13 +62,13 @@ async fn setup() -> Arc<Ledger> {
     for id in [1, 2, 3] {
         ledger
             .store()
-            .create_account(make_account(id, AccountPolicy::NoOverdraft))
+            .create_account(make_account(id, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT))
             .await
             .unwrap();
     }
     ledger
         .store()
-        .create_account(make_account(99, AccountPolicy::ExternalAccount))
+        .create_account(make_account(99, AccountFlags::empty()))
         .await
         .unwrap();
     deposit(&ledger, a(), eur(), 100).await;

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

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

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

@@ -22,12 +22,11 @@ fn external() -> AccountId {
     AccountId::new(99)
 }
 
-fn make_account(id: i64, policy: AccountPolicy) -> Account {
+fn make_account(id: i64, flags: AccountFlags) -> Account {
     Account {
         id: AccountId::new(id),
         version: 1,
-        policy,
-        flags: AccountFlags::empty(),
+        flags,
         book: BookId(0),
         metadata: BTreeMap::new(),
     }
@@ -38,10 +37,10 @@ async fn setup_ledger() -> Arc<Ledger> {
     let ledger = Arc::new(Ledger::new(store));
 
     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
             .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) |
 | `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 |
 | `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
 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
-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
 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
 [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
 [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:
 
 ```
-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
@@ -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
 `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
 one account holds many concurrent inflights: an inflight hold is a subaccount of
 its destination, keyed by a value derived from the trade (see
@@ -172,29 +171,27 @@ and the underlying postings.
 
 ## 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
 
-* Status: accepted
+* Status: superseded by [ADR-0018](0018-single-debit-must-not-exceed-credit-flag.md)
 * Authors: Cesar Rodas
 * Date: 2026-06-29
 * Targeted modules: `kuatia-types` (`AccountPolicy`), `kuatia-core` (`validate.rs`)
 * 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
 
 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. |
 | [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. |
-| [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. |
 | [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. |
@@ -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. |
 | [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). |
+| [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
 

+ 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).
 
 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
 
@@ -211,7 +211,7 @@ using big-endian encoding with a version prefix (`CANONICAL_VERSION = 4`).
 ## Append-Only Account Versioning
 
 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
 account versions: balances are derived from postings, not stored on the
 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
 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
 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
 atomic conditional update, so two sagas cannot both claim the same posting)
 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).
 
-`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
 

+ 7 - 6
doc/crates.md

@@ -33,9 +33,8 @@ dependencies (`sha2`, `serde`, `bitflags`).
 | `NewPosting` | Posting to be created (no id yet, assigned during validation) |
 | `Transfer` | Atomic unit: consumes postings + creates postings + metadata |
 | `EnvelopeBuilder` | Fluent builder for `Transfer` construction |
-| `Account` | Versioned entity with policy, flags, book, 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 |
 | `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` |
@@ -54,7 +53,7 @@ graph TD
     F --> BP[7. Book policy]
     BP --> G[8. Per-asset conservation]
     G --> H[9. Negative posting restriction]
-    H --> J[10. Policy enforcement]
+    H --> J[10. Balance-constraint enforcement]
     J --> I[Plan]
     style I fill:#e8f5e9
 ```
@@ -72,8 +71,10 @@ graph TD
    must be allowed by the book
 8. **Per-asset conservation**: `sum(consumed) == sum(created)` for each asset
 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
 `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.
 
 - **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
 `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
 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
 **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
-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
 [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;
 // 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.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;
 
-let mut exchange_pool = Account::new(AccountId::default(), AccountPolicy::SystemAccount);
+let mut exchange_pool = Account::new(AccountId::default());
 exchange_pool.book = trading_book.id;
 ```
 
@@ -263,24 +265,24 @@ let banking_book = BookBuilder::new("banking")
     .allow_flags(WAREHOUSE | BANK)
     .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;
 
-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;
 
-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):**

+ 7 - 5
doc/inflight.md

@@ -11,8 +11,9 @@ This page is the usage guide.
 ## Model
 
 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
 Confirmed trade            Inflight form
@@ -89,9 +90,10 @@ let open = ledger.list_open_inflights().await?;
 
 ## 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
   the shared holding posting via the reservation protocol. On contention, one
   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
 
-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
 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)
    create a change posting returning the remainder to the account.
 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
 
@@ -224,9 +226,11 @@ validation steps are:
 7. Book policy (if a book is loaded): referenced assets/accounts/flags allowed
    by the book
 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
 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`),
 verifying every end-state. There is no single transaction; crash-safety comes
 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
-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
 `crates/kuatia/tests/concurrency.rs`, which asserts the exact guarantees
 and documents the floor race with an ignored test). See