Bladeren bron

Squashed commit of the following:

commit 92108a8c4ca6f53970c4a47719993a4ee162ace8
Author: Cesar Rodas <cesar@rodasm.com.py>
Date:   Fri Jul 10 19:04:35 2026 -0300

    Extract prelude and envelope_saga into file modules

    The workspace hygiene rules forbid inline module bodies (mod NAME { ... }) in
    non-test code so that every namespace is visible in the directory tree.  Two
    modules still used the inline form: the crate prelude and the legend!  saga
    wrapper. Move both into their own files (prelude.rs and
    ledger/envelope_saga.rs) and switch the declarations to file modules. The
    prelude doc comment moves to inner form, dropping now-redundant explicit
    intra-doc link targets that resolve on their own once the re-exports are in
    scope.

commit e4514148a5b0bb1d476c4a85390b202ed11793e8
Author: Cesar Rodas <cesar@rodasm.com.py>
Date:   Thu Jul 9 18:18:00 2026 -0300

    Shorten the account code to a fixed 20-character form

    The IBAN-style account code was 28 characters: two leading check digits and a
    26-character base-36 body that encoded both i64 legs at full width.  That is
    long to read or speak and does not group evenly, so the code read worse than an
    IBAN or a card number.

    Pack the base id (63 bits) and the subaccount (30 bits) into one 93-bit value,
    run it through a keyed format-preserving permutation (a 94-bit Feistel with
    cycle-walking, replacing the 128-bit one), and base-36 encode it in 18
    characters, then append the two mod-97 check digits. The result is a fixed 20
    characters, five groups of four, with the checksum at the tail.

    The change is presentation-only: ToBytes, serde, the SQL schema, and every
    content hash keep the two full i64 legs, so there is no migration. The cost is
    a 30-bit subaccount range, which caps the hash-derived inflight hold
    subaccounts, so they are now masked to that width. See ADR-0015.
Cesar Rodas 1 dag geleden
bovenliggende
commit
e56b34bc04

+ 185 - 77
crates/kuatia-types/src/lib.rs

@@ -68,14 +68,21 @@ pub fn write_u128(buf: &mut Vec<u8>, v: u128) {
 ///
 /// An account is a base `id` plus a `subaccount`. `sub = 0` is the main account
 /// (the default when subaccounts are not used); a non-zero `sub` is a
-/// subaccount of the same base id. `sub` is an opaque id (an `i64`, like the
-/// base id), so the whole range is usable. Each `(id, sub)` is a full account
-/// record with its own policy and lifecycle. See ADR-0012.
+/// subaccount of the same base id. Each `(id, sub)` is a full account record
+/// with its own policy and lifecycle. See ADR-0012 and ADR-0015.
+///
+/// Both legs are stored as `i64` (they hash and persist as full `i64`), but the
+/// IBAN-style string form ([`Display`](fmt::Display) / [`FromStr`](std::str::FromStr))
+/// encodes only the low `ID_BITS` of `id` (a 63-bit snowflake never sets the
+/// sign bit) and the low `SUB_BITS` of `sub`. That is what lets the code fit in
+/// a fixed 20 characters. Values outside those ranges still hash, persist, and
+/// compare correctly, but do not round-trip through the string form.
 #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct AccountId {
-    /// Base account id.
+    /// Base account id (a 63-bit snowflake; the sign bit is always 0).
     pub id: i64,
-    /// Subaccount id; `0` is the main account.
+    /// Subaccount id; `0` is the main account. The string form encodes the low
+    /// [`SUB_BITS`] bits, so a subaccount id must fit in that range to round-trip.
     pub sub: i64,
 }
 
@@ -140,16 +147,16 @@ impl fmt::Debug for AccountId {
 }
 
 impl fmt::Display for AccountId {
-    /// IBAN-style machine format: two ISO 7064 mod-97 check digits, then a
-    /// 26-character base-36 body. There is no country code. The `(id, sub)` pair
-    /// is run through a keyed 128-bit Feistel permutation (see [`set_id_seed`])
-    /// before encoding, so the body does not reveal the raw ids. Round-trips via
+    /// IBAN-style machine format: a fixed 20 characters, an 18-character base-36
+    /// body followed by two trailing ISO 7064 mod-97 check digits, with no
+    /// country code. The `(id, sub)` pair is packed into a 93-bit value and run
+    /// through a keyed format-preserving permutation (see [`set_id_seed`]) before
+    /// encoding, so the body does not reveal the raw ids. Round-trips via
     /// [`FromStr`](std::str::FromStr); [`to_grouped`](AccountId::to_grouped) adds
-    /// the presentation spacing.
+    /// the presentation spacing (five groups of four).
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        let (l, r) = feistel(self.id as u64, self.sub as u64, id_seed());
-        let body = format!("{}{}", base36_u64(l), base36_u64(r));
-        write!(f, "{:02}{body}", check_digits(&body))
+        let body = base36_body(obfuscate(pack(self.id, self.sub), id_seed()));
+        write!(f, "{body}{:02}", check_digits(&body))
     }
 }
 
@@ -182,12 +189,44 @@ fn hex(bytes: &[u8]) -> String {
 // IBAN-style string view for AccountId (ADR-0012)
 // ---------------------------------------------------------------------------
 
-/// Encode a `u64` as exactly 13 base-36 digits (`0-9A-Z`), zero-padded on the
-/// left. 13 digits is the widest a `u64` needs (`36^13 > u64::MAX`), so this
-/// never truncates.
-fn base36_u64(mut v: u64) -> String {
+/// Bits of the base `id` the string form encodes. A snowflake id keeps the sign
+/// bit clear, so its value fits in 63 bits.
+pub const ID_BITS: u32 = 63;
+
+/// Bits of the `sub` leg the string form encodes. Together with [`ID_BITS`] this
+/// is the 93-bit packed value the code carries, which fits in the 18 base-36
+/// characters of the code body. A subaccount id must fit in this many bits to
+/// round-trip through the string form.
+pub const SUB_BITS: u32 = 30;
+
+/// Number of base-36 characters in the code body. `36^18 > 2^93`, so the packed
+/// `(id, sub)` value always fits.
+const BODY_LEN: usize = 18;
+
+/// Mask selecting the low [`ID_BITS`] + [`SUB_BITS`] = 93 bits of the packed value.
+const PACK_MASK: u128 = (1u128 << (ID_BITS + SUB_BITS)) - 1;
+
+/// Pack `(id, sub)` into the 93-bit value the code encodes: the low [`ID_BITS`]
+/// of `id` in the high part, the low [`SUB_BITS`] of `sub` in the low part.
+fn pack(id: i64, sub: i64) -> u128 {
+    let id = (id as u128) & ((1u128 << ID_BITS) - 1);
+    let sub = (sub as u128) & ((1u128 << SUB_BITS) - 1);
+    (id << SUB_BITS) | sub
+}
+
+/// Inverse of [`pack`]: split the 93-bit value back into the two legs. The `id`
+/// part is at most 63 bits, so it is always a non-negative `i64`.
+fn unpack(p: u128) -> (i64, i64) {
+    let sub = (p & ((1u128 << SUB_BITS) - 1)) as i64;
+    let id = (p >> SUB_BITS) as i64;
+    (id, sub)
+}
+
+/// Encode a value `< 2^93` as exactly [`BODY_LEN`] base-36 digits (`0-9A-Z`),
+/// zero-padded on the left. `36^18 > 2^93`, so this never truncates.
+fn base36_body(mut v: u128) -> String {
     const D: &[u8; 36] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
-    let mut out = [b'0'; 13];
+    let mut out = [b'0'; BODY_LEN];
     let mut i = out.len();
     while v > 0 && i > 0 {
         i -= 1;
@@ -229,12 +268,16 @@ fn check_digits(body: &str) -> u32 {
 }
 
 // ---------------------------------------------------------------------------
-// Account-code obfuscation (ADR-0012)
+// Account-code obfuscation (ADR-0012, ADR-0015)
+//
+// The account code's body is a base-36 rendering of the packed (id, sub) value.
+// Without mixing, small ids render as long runs of zeros that reveal their value
+// and sequence. To hide that from outsiders, the 93-bit packed value is run
+// through a keyed format-preserving permutation before encoding, and inverted on
+// parse. The permutation is a Feistel network over 2^94 restricted to the 93-bit
+// domain by cycle-walking (re-encrypt while the result exceeds the domain), which
+// keeps it a bijection on the exact set of packable values.
 //
-// The account code's body is a base-36 rendering of the two i64 legs. Without
-// mixing, small ids render as long runs of zeros that reveal their value and
-// sequence. To hide that from outsiders, the (id, sub) pair is run through a
-// keyed 128-bit Feistel permutation before encoding, and inverted on parse.
 // This is obfuscation, not security: anyone with the seed can decode it, so it
 // is not a substitute for authorization. The seed has a default and can be set
 // once at startup via `set_id_seed`; changing it changes every code, so it must
@@ -277,24 +320,65 @@ fn round_key(seed: u64, round: usize) -> u64 {
     mix64(seed ^ (round as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15))
 }
 
-/// Keyed 128-bit Feistel permutation over the two halves `(l, r)`.
-fn feistel(mut l: u64, mut r: u64, seed: u64) -> (u64, u64) {
+/// Bits per Feistel half. Two halves give a 94-bit block, the smallest even
+/// width that covers the 93-bit packed domain (one bit of headroom that
+/// cycle-walking absorbs).
+const FEISTEL_HALF_BITS: u32 = 47;
+
+/// Mask selecting one [`FEISTEL_HALF_BITS`]-wide half.
+const FEISTEL_HALF_MASK: u128 = (1u128 << FEISTEL_HALF_BITS) - 1;
+
+/// Keyed Feistel permutation over the 94-bit block `(l, r)` (two 47-bit halves).
+/// A balanced Feistel with any round function is a bijection on the full 2^94
+/// space, which is what [`obfuscate`] cycle-walks down to the 93-bit domain.
+fn feistel94(block: u128, seed: u64) -> u128 {
+    let mut l = (block >> FEISTEL_HALF_BITS) & FEISTEL_HALF_MASK;
+    let mut r = block & FEISTEL_HALF_MASK;
     for round in 0..FEISTEL_ROUNDS {
-        let next = l ^ mix64(r ^ round_key(seed, round));
+        let f = (mix64(r as u64 ^ round_key(seed, round)) as u128) & FEISTEL_HALF_MASK;
+        let next = (l ^ f) & FEISTEL_HALF_MASK;
         l = r;
         r = next;
     }
-    (l, r)
+    (l << FEISTEL_HALF_BITS) | r
 }
 
-/// Inverse of [`feistel`] under the same seed.
-fn feistel_inv(mut l: u64, mut r: u64, seed: u64) -> (u64, u64) {
+/// Inverse of [`feistel94`] under the same seed.
+fn feistel94_inv(block: u128, seed: u64) -> u128 {
+    let mut l = (block >> FEISTEL_HALF_BITS) & FEISTEL_HALF_MASK;
+    let mut r = block & FEISTEL_HALF_MASK;
     for round in (0..FEISTEL_ROUNDS).rev() {
-        let prev = r ^ mix64(l ^ round_key(seed, round));
+        let f = (mix64(l as u64 ^ round_key(seed, round)) as u128) & FEISTEL_HALF_MASK;
+        let prev = (r ^ f) & FEISTEL_HALF_MASK;
         r = l;
         l = prev;
     }
-    (l, r)
+    (l << FEISTEL_HALF_BITS) | r
+}
+
+/// Keyed format-preserving permutation over the 93-bit packed domain. Applies
+/// [`feistel94`] and cycle-walks (re-encrypts while the result exceeds the
+/// domain) so the output is always a valid packed value. Since the domain is
+/// half of 2^94, this averages about two iterations and always terminates.
+fn obfuscate(p: u128, seed: u64) -> u128 {
+    let mut v = p;
+    loop {
+        v = feistel94(v, seed);
+        if v <= PACK_MASK {
+            return v;
+        }
+    }
+}
+
+/// Inverse of [`obfuscate`] under the same seed: cycle-walk [`feistel94_inv`].
+fn deobfuscate(y: u128, seed: u64) -> u128 {
+    let mut v = y;
+    loop {
+        v = feistel94_inv(v, seed);
+        if v <= PACK_MASK {
+            return v;
+        }
+    }
 }
 
 // ---------------------------------------------------------------------------
@@ -338,8 +422,9 @@ impl AccountId {
     }
 
     /// IBAN-style presentation format: the machine [`Display`](fmt::Display)
-    /// form grouped into blocks of four with a single space
-    /// (e.g. `9200 0000 0000 0050 0000 0000 07`).
+    /// form grouped into blocks of four with a single space. The code is a fixed
+    /// 20 characters, so this is always five groups of four
+    /// (e.g. `K3P9 WM2X Q7ND V8HT 2R47`).
     pub fn to_grouped(&self) -> String {
         let machine = self.to_string();
         let mut out = String::with_capacity(machine.len() + machine.len() / 4);
@@ -375,26 +460,26 @@ impl std::error::Error for ParseAccountIdError {}
 impl std::str::FromStr for AccountId {
     type Err = ParseAccountIdError;
 
-    /// Parse an IBAN-style account code back into the two i64 legs. Any spaces
+    /// Parse an IBAN-style account code back into the two legs. Any spaces
     /// (grouped display format) and dashes (URL-safe separator) are ignored and
-    /// the input is upper-cased first, so `5000...`, `5000 0000 ...`, and
-    /// `5000-0000-...` all parse to the same id. The value must reduce to two
-    /// check digits followed by a 26-character base-36 body, and the ISO 7064
-    /// mod-97 checksum must pass — so a mistyped or otherwise invalid id is
-    /// rejected here rather than reaching the store. Each 13-char half is read as
-    /// a `u64` bit pattern and reinterpreted as `i64`.
+    /// the input is upper-cased first, so `K3P9...`, `K3P9 WM2X ...`, and
+    /// `K3P9-WM2X-...` all parse to the same id. The value must reduce to an
+    /// 18-character base-36 body followed by two check digits, the ISO 7064
+    /// mod-97 checksum must pass, and the decoded body must lie in the packable
+    /// domain — so a mistyped or otherwise invalid id is rejected here rather
+    /// than reaching the store.
     fn from_str(s: &str) -> Result<Self, Self::Err> {
         let cleaned: String = s
             .chars()
             .filter(|c| !c.is_whitespace() && *c != '-')
             .map(|c| c.to_ascii_uppercase())
             .collect();
-        // 2 check digits + 26-char base-36 body.
-        if cleaned.len() != 28 {
+        // 18-char base-36 body + 2 trailing check digits.
+        if cleaned.len() != BODY_LEN + 2 {
             return Err(ParseAccountIdError);
         }
-        let check = &cleaned[0..2];
-        let body = &cleaned[2..28];
+        let body = &cleaned[0..BODY_LEN];
+        let check = &cleaned[BODY_LEN..BODY_LEN + 2];
         let is_base36 = |b: u8| b.is_ascii_digit() || b.is_ascii_uppercase();
         if !check.bytes().all(|b| b.is_ascii_digit()) || !body.bytes().all(is_base36) {
             return Err(ParseAccountIdError);
@@ -404,15 +489,14 @@ impl std::str::FromStr for AccountId {
         if mod97(&iban_expand(&format!("{body}{check}"))) != 1 {
             return Err(ParseAccountIdError);
         }
-        // Decode the two halves, then invert the Feistel permutation to recover
-        // the raw legs.
-        let l = u64::from_str_radix(&body[0..13], 36).map_err(|_| ParseAccountIdError)?;
-        let r = u64::from_str_radix(&body[13..26], 36).map_err(|_| ParseAccountIdError)?;
-        let (id, sub) = feistel_inv(l, r, id_seed());
-        Ok(Self {
-            id: id as i64,
-            sub: sub as i64,
-        })
+        // Decode the body, reject anything outside the 93-bit packable domain
+        // (the permutation's image), then invert the permutation and unpack.
+        let obf = u128::from_str_radix(body, 36).map_err(|_| ParseAccountIdError)?;
+        if obf > PACK_MASK {
+            return Err(ParseAccountIdError);
+        }
+        let (id, sub) = unpack(deobfuscate(obf, id_seed()));
+        Ok(Self { id, sub })
     }
 }
 
@@ -1194,15 +1278,21 @@ mod account_id_tests {
     use super::*;
     use std::str::FromStr;
 
+    /// Largest subaccount id that round-trips through the string form.
+    const MAX_SUB: i64 = (1i64 << SUB_BITS) - 1;
+
     #[test]
     fn code_structure() {
         let s = AccountId::with_sub(5, 7).to_string();
-        // 2 check digits + 26-char base-36 body. No country code.
-        assert_eq!(s.len(), 28);
-        assert!(s[0..2].bytes().all(|b| b.is_ascii_digit()));
-        // The body is Feistel-permuted, so it does NOT expose the raw legs the
-        // way an unmixed base-36 rendering (all zeros then "5"/"7") would.
-        assert_ne!(&s[2..], "00000000000050000000000007");
+        // Fixed 20 chars: an 18-char base-36 body then two check digits, no
+        // country code.
+        assert_eq!(s.len(), 20);
+        assert_eq!(s.len(), BODY_LEN + 2);
+        // The two trailing check digits are numeric.
+        assert!(s[BODY_LEN..].bytes().all(|b| b.is_ascii_digit()));
+        // The body is permuted, so it does NOT expose the raw legs the way an
+        // unmixed base-36 rendering (a run of zeros then "5"/"7") would.
+        assert_ne!(&s[..BODY_LEN], "000000000000000527");
     }
 
     #[test]
@@ -1211,26 +1301,42 @@ mod account_id_tests {
             AccountId::new(0),
             AccountId::new(100),
             AccountId::with_sub(5, 7),
-            // High-bit subaccount: exercises the u64-bit-pattern reinterpretation.
-            AccountId::with_sub(1, -1),
-            AccountId::with_sub(i64::MAX, i64::MIN),
+            AccountId::with_sub(987654321, 12345),
+            // The corners of the encodable domain: a full 63-bit id and the
+            // widest subaccount.
+            AccountId::new(i64::MAX),
+            AccountId::with_sub(i64::MAX, MAX_SUB),
+            AccountId::with_sub(0, MAX_SUB),
         ] {
             let s = acc.to_string();
+            assert_eq!(s.len(), 20, "length {s}");
             assert_eq!(AccountId::from_str(&s).unwrap(), acc, "round-trip {s}");
         }
     }
 
     #[test]
+    fn out_of_range_legs_truncate_to_the_encodable_domain() {
+        // The string form encodes only the low ID_BITS / SUB_BITS. A subaccount
+        // one past the encodable range wraps to 0 (documented narrowing), so it
+        // shares its code with the main account rather than corrupting it.
+        let over = AccountId::with_sub(7, 1i64 << SUB_BITS);
+        assert_eq!(
+            AccountId::from_str(&over.to_string()).unwrap(),
+            AccountId::new(7)
+        );
+    }
+
+    #[test]
     fn parses_a_fixed_vector() {
         // A hardcoded, checksum-valid code (under DEFAULT_ID_SEED) pins the
         // exact encoding, permutation, and checksum, so an accidental change to
         // any of them is caught by a failing parse.
-        let code = "123PER2Q81K52QL1HA26CYE1IZH5";
+        let code = "KUJLQEL8IX2XGTBK4425";
         let expected = AccountId::with_sub(987654321, 12345);
         assert_eq!(AccountId::from_str(code).unwrap(), expected);
         // The grouped (spaced, lower-cased) form parses to the same value.
         assert_eq!(
-            AccountId::from_str("123p er2q 81k5 2ql1 ha26 cye1 izh5").unwrap(),
+            AccountId::from_str("kujl qel8 ix2x gtbk 4425").unwrap(),
             expected
         );
         // Display reproduces the exact machine form.
@@ -1238,11 +1344,12 @@ mod account_id_tests {
     }
 
     #[test]
-    fn feistel_is_invertible_across_seeds() {
+    fn obfuscation_permutation_is_invertible_across_seeds() {
         for &seed in &[0u64, 1, DEFAULT_ID_SEED, u64::MAX] {
-            for &(l, r) in &[(0u64, 0u64), (5, 7), (u64::MAX, 1), (42, u64::MAX)] {
-                let (el, er) = feistel(l, r, seed);
-                assert_eq!(feistel_inv(el, er, seed), (l, r), "seed={seed} l={l} r={r}");
+            for &p in &[0u128, 1, 5 << SUB_BITS | 7, PACK_MASK, PACK_MASK - 1] {
+                let y = obfuscate(p, seed);
+                assert!(y <= PACK_MASK, "output escapes the domain: {y}");
+                assert_eq!(deobfuscate(y, seed), p, "seed={seed} p={p}");
             }
         }
     }
@@ -1263,11 +1370,12 @@ mod account_id_tests {
     }
 
     #[test]
-    fn grouped_format_groups_by_four_and_re_parses() {
+    fn grouped_format_is_five_groups_of_four_and_re_parses() {
         let acc = AccountId::with_sub(5, 7);
         let grouped = acc.to_grouped();
-        assert!(grouped.contains(' '));
-        assert!(grouped.split(' ').all(|g| g.len() <= 4));
+        let groups: Vec<&str> = grouped.split(' ').collect();
+        assert_eq!(groups.len(), 5);
+        assert!(groups.iter().all(|g| g.len() == 4));
         // Grouped format (with spaces) and lower case both parse back.
         assert_eq!(AccountId::from_str(&grouped).unwrap(), acc);
         assert_eq!(AccountId::from_str(&grouped.to_lowercase()).unwrap(), acc);
@@ -1276,12 +1384,12 @@ mod account_id_tests {
     #[test]
     fn parses_with_spaces_or_dashes_for_url_safety() {
         let acc = AccountId::with_sub(987654321, 12345);
-        let machine = acc.to_string(); // 28 chars, no separators (URL-safe)
+        let machine = acc.to_string(); // 20 chars, no separators (URL-safe)
         // The same code grouped with spaces (display) or dashes (URL-safe
         // separator) parses back to the same id, as does a mixed/irregular form.
         let spaced = acc.to_grouped();
         let dashed = spaced.replace(' ', "-");
-        let mixed = format!("{}-{} {}", &machine[0..4], &machine[4..20], &machine[20..]);
+        let mixed = format!("{}-{} {}", &machine[0..4], &machine[4..12], &machine[12..]);
         for s in [&machine, &spaced, &dashed, &mixed] {
             assert_eq!(AccountId::from_str(s).unwrap(), acc, "parse {s}");
         }
@@ -1299,7 +1407,7 @@ mod account_id_tests {
             v.into_iter().collect::<String>()
         };
 
-        // Flip the last body digit: still base-36 and right length, but the
+        // Flip the last check digit: still numeric and right length, but the
         // checksum no longer matches, so it is rejected.
         let last = good.len() - 1;
         let flipped = with_char_at(last, if good.ends_with('8') { '9' } else { '8' });
@@ -1308,14 +1416,14 @@ mod account_id_tests {
         // Structurally malformed inputs are all rejected.
         assert!(AccountId::from_str("").is_err(), "empty");
         assert!(AccountId::from_str("not-a-code").is_err(), "junk");
-        assert!(AccountId::from_str(&good[..27]).is_err(), "too short");
+        assert!(AccountId::from_str(&good[..19]).is_err(), "too short");
         assert!(
             AccountId::from_str(&format!("{good}0")).is_err(),
             "too long"
         );
-        // A check digit that is not a digit.
+        // A check digit that is not a digit (the last two positions).
         assert!(
-            AccountId::from_str(&with_char_at(0, 'A')).is_err(),
+            AccountId::from_str(&with_char_at(BODY_LEN, 'A')).is_err(),
             "alpha check"
         );
         // A non-base-36 character in the body (survives space/dash stripping).

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

@@ -562,8 +562,10 @@ fn inflight_subaccount(transfer: &Transfer) -> i64 {
     let hash = double_sha256(&buf);
     let mut first = [0u8; 8];
     first.copy_from_slice(&hash[..8]);
-    // Mask the sign bit so the subaccount id is always positive.
-    (u64::from_be_bytes(first) & i64::MAX as u64) as i64
+    // Keep only the low SUB_BITS so the hold's subaccount id fits the account
+    // code's encodable range (ADR-0015). The result is always positive.
+    let mask = (1u64 << kuatia_types::SUB_BITS) - 1;
+    (u64::from_be_bytes(first) & mask) as i64
 }
 
 fn holds_of(legs: &[InflightLeg]) -> BTreeSet<AccountId> {

+ 1 - 9
crates/kuatia/src/ledger.rs

@@ -29,15 +29,7 @@ use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
 use kuatia_storage::store::{EnvelopeRecord, Store};
 
 #[allow(missing_docs)]
-mod envelope_saga {
-    use super::*;
-    legend! {
-        EnvelopeSaga<LedgerCtx, SagaError> {
-            reserve: ReservePostingsStep,
-            finalize: FinalizeTransferStep,
-        }
-    }
-}
+mod envelope_saga;
 use envelope_saga::*;
 
 /// Phase of an in-flight commit, persisted with the write-ahead record so

+ 8 - 0
crates/kuatia/src/ledger/envelope_saga.rs

@@ -0,0 +1,8 @@
+use super::*;
+
+legend! {
+    EnvelopeSaga<LedgerCtx, SagaError> {
+        reserve: ReservePostingsStep,
+        finalize: FinalizeTransferStep,
+    }
+}

+ 1 - 18
crates/kuatia/src/lib.rs

@@ -12,21 +12,4 @@ pub mod saga;
 // Re-export storage crate for convenience.
 pub use kuatia_storage::{error as store_error, mem_store, store, store_tests};
 
-/// Common imports for building on the ledger.
-///
-/// `use kuatia::prelude::*;` brings the domain types and intent builders from
-/// [`kuatia_core`] into scope, along with the [`Ledger`](crate::ledger::Ledger)
-/// resource, the [`Store`](kuatia_storage::store::Store) trait, and the
-/// [`InMemoryStore`](kuatia_storage::mem_store::InMemoryStore). Reach for the
-/// individual crates when you need types the prelude does not surface.
-pub mod prelude {
-    pub use kuatia_core::*;
-
-    pub use crate::error::LedgerError;
-    pub use crate::inflight::{
-        Authorization, InflightLeg, InflightLegStatus, InflightState, InflightStatus,
-    };
-    pub use crate::ledger::Ledger;
-    pub use kuatia_storage::mem_store::InMemoryStore;
-    pub use kuatia_storage::store::Store;
-}
+pub mod prelude;

+ 16 - 0
crates/kuatia/src/prelude.rs

@@ -0,0 +1,16 @@
+//! Common imports for building on the ledger.
+//!
+//! `use kuatia::prelude::*;` brings the domain types and intent builders from
+//! [`kuatia_core`] into scope, along with the [`Ledger`] resource, the
+//! [`Store`] trait, and the [`InMemoryStore`]. Reach for the individual crates
+//! when you need types the prelude does not surface.
+
+pub use kuatia_core::*;
+
+pub use crate::error::LedgerError;
+pub use crate::inflight::{
+    Authorization, InflightLeg, InflightLegStatus, InflightState, InflightStatus,
+};
+pub use crate::ledger::Ledger;
+pub use kuatia_storage::mem_store::InMemoryStore;
+pub use kuatia_storage::store::Store;

+ 11 - 9
doc/accounts.md

@@ -34,15 +34,17 @@ balances (sub-ledgers, earmarks, reservations) without minting unrelated
 top-level accounts. Helpers on `AccountId`: `new(id)` (main account),
 `with_sub(id, sub)`, `base()` (the main account of an id), and `is_main()`.
 
-`AccountId` also has an IBAN-style string form (`Display` / `FromStr`): two ISO
-7064 mod-97 check digits, then a 26-character base-36 body carrying the base id
-and the subaccount (13 characters each; no country code).
-The `(id, sub)` pair is run through a keyed 128-bit Feistel permutation before
-encoding (and inverted on parse), so a code does not reveal the raw ids; the key
-is a global seed with a default, configurable via `set_id_seed`. Parsing
-validates the checksum, so a mistyped identifier is rejected. This is
-obfuscation, not security (the seed decodes it), and a presentation/routing form
-only; storage keeps the two `i64` legs.
+`AccountId` also has an IBAN-style string form (`Display` / `FromStr`): a fixed
+20 characters, an 18-character base-36 body then two trailing ISO 7064 mod-97
+check digits, with no country code (e.g. `KUJL QEL8 IX2X GTBK 4425`). The body
+packs the base id (63 bits) and the subaccount (`SUB_BITS` bits) into one 93-bit
+value and runs it through a keyed format-preserving permutation before encoding
+(and inverts it on parse), so a code does not reveal the raw ids; the key is a
+global seed with a default, configurable via `set_id_seed`. Parsing validates the
+checksum, so a mistyped identifier is rejected. This is obfuscation, not security
+(the seed decodes it), and a presentation/routing form only; storage keeps the
+two `i64` legs. The fixed width is what caps the subaccount at `SUB_BITS`; see
+ADR-0015.
 
 Balances are always reported per subaccount and are never summed across them:
 

+ 149 - 0
doc/adr/0015-fixed-width-account-code.md

@@ -0,0 +1,149 @@
+# Shorten the account code to a fixed 20 characters
+
+* Status: accepted
+* Authors: Cesar Rodas
+* Date: 2026-07-09
+* Targeted modules: `kuatia-types` (`AccountId` string form), `kuatia`
+  (`inflight`)
+* Associated tickets/PRs: N/A
+
+## Context and Problem Statement
+
+ADR-0012 gave `AccountId` an IBAN-style string form: two leading mod-97 check
+digits and a 26-character base-36 body, 28 characters in total. The body encoded
+both `i64` legs at full width (a 128-bit Feistel permutation, then 13 base-36
+characters per 64-bit half). 28 characters is long to read, transcribe, or speak,
+and it does not group evenly, so the presentation form was more awkward than an
+IBAN or a card number.
+
+The length is driven by encoding two full 64-bit values. Can the code be
+materially shorter while keeping the checksum, the obfuscation, and round-trip
+parsing?
+
+## Decision Drivers
+
+* **Shorter, evenly grouped**: the human-facing code should be short and group
+  cleanly (an IBAN/card-number feel), not an odd-length 28.
+* **Keep the checksum**: a mistyped id must still be rejected before it reaches
+  the store.
+* **Keep the obfuscation**: sequential ids must not produce visibly related
+  codes, and a base account and its subaccount must not share a prefix.
+* **No storage or content-hash impact**: the change is presentation-only;
+  `ToBytes`, serde, the SQL schema, and every content hash keep the two full
+  `i64` legs, so there is no migration.
+
+## Considered Options
+
+#### Option 1: Fixed 20 characters, an 18-char body plus two trailing check digits (chosen)
+
+Pack the base id (63 bits, since a snowflake never sets the sign bit) and the
+subaccount (30 bits) into one 93-bit value, permute it, and base-36 encode it in
+18 characters, then append two mod-97 check digits. `36^18 > 2^93`, so 18
+characters always fit. Total 20 characters, five groups of four.
+
+**Pros:**
+
+* Good, because 20 characters group evenly into five blocks of four, reading like
+  an IBAN or a card number.
+* Good, because it keeps the mod-97 checksum and the keyed obfuscation, so
+  mistyped ids are still rejected and codes still look unrelated.
+* Good, because storage and content hashes are untouched (still two full `i64`
+  legs), so there is no migration, exactly as in ADR-0012.
+
+**Cons:**
+
+* Bad, because the subaccount now encodes only 30 bits, so a subaccount id must
+  fit in that range to round-trip through the string form. Hash-derived inflight
+  subaccounts (ADR-0014) must be masked to 30 bits, which raises their collision
+  domain (see Negative Consequences).
+* Bad, because the string form is now lossy for out-of-range legs: an `id`
+  beyond 63 bits or a `sub` beyond 30 bits truncates rather than round-trips. All
+  real ids (snowflake `id`, masked inflight `sub`, small explicit buckets) are in
+  range.
+
+#### Option 2: Keep 28 characters
+
+Leave the ADR-0012 form unchanged.
+
+**Pros:**
+
+* Good, because it round-trips the entire `(i64, i64)` space and needs no change.
+
+**Cons:**
+
+* Bad, because 28 characters is long and does not group evenly, which was the
+  complaint.
+
+#### Option 3: Variable-length (main account short, subaccount long)
+
+Encode only the id leg when `sub == 0` (about 15 characters) and both legs
+otherwise (about 21).
+
+**Pros:**
+
+* Good, because a main account, the common case, becomes very short with no range
+  loss on either leg.
+
+**Cons:**
+
+* Bad, because codes then have two different lengths, which reads less like a
+  uniform account number and complicates any fixed-width UI or validation.
+* Bad, because 15 is not a clean multiple of four, so the grouping is uneven.
+
+## Decision Outcome
+
+Chosen option: **Option 1, a fixed 20-character code**, because it is short, groups
+into a uniform five-by-four, keeps the checksum and the obfuscation, and stays
+presentation-only with no migration. The only real cost is capping the subaccount
+at 30 bits, which is ample for explicit buckets and acceptable for the
+hash-derived inflight subaccounts.
+
+### The encoding
+
+* Constants `ID_BITS = 63` and `SUB_BITS = 30` (93 bits packed) are public on
+  `kuatia-types`. `pack` places the low `ID_BITS` of `id` above the low
+  `SUB_BITS` of `sub`; `unpack` inverts it.
+* Obfuscation is a keyed format-preserving permutation over the 93-bit domain: a
+  Feistel network over two 47-bit halves (a 94-bit block) restricted to the
+  domain by cycle-walking (re-encrypt while the result exceeds `2^93 - 1`). Since
+  the domain is half of `2^94`, this averages about two iterations and is a
+  bijection on exactly the packable values. The seed is still the global
+  `set_id_seed` key.
+* The body is 18 base-36 characters of the permuted value; the two trailing check
+  digits are the same ISO 7064 mod-97 scheme as ADR-0012, moved to the end so the
+  code is body-then-check. `FromStr` strips spaces and dashes, upper-cases,
+  requires 20 characters, validates the checksum, rejects a decoded body outside
+  the 93-bit domain, then inverts the permutation and unpacks.
+* Under the default seed, `AccountId { id: 5, sub: 7 }` renders
+  `FK9RA6QALU15JZ7DZM81` (grouped `FK9R A6QA LU15 JZ7D ZM81`).
+
+### Inflight subaccounts
+
+The per-destination inflight hold subaccount (ADR-0014) was a 63-bit truncation
+of a trade hash. It is now masked to the low `SUB_BITS` so every hold has an
+encodable code. It stays deterministic and trade-specific.
+
+### Positive Consequences
+
+* The human-facing code drops from 28 to 20 characters and groups evenly.
+* No storage, serde, or content-hash change; no migration. Debug output is
+  unchanged (`id` / `id.sub`).
+
+### Negative Consequences
+
+* The subaccount space is 30 bits (~1.07 billion) instead of a full `i64`.
+  Explicit buckets are unaffected; hash-derived inflight subaccounts collide
+  sooner: at 30 bits the birthday bound is roughly a 1% chance around 4,600
+  concurrent inflight trades to a single destination. Deployments with very high
+  concurrent inflight fan-in to one destination should account for this.
+* The string form is lossy for legs outside the encodable ranges; such values
+  still hash, persist, and compare correctly but do not round-trip through the
+  code. No real id source produces out-of-range legs.
+
+## Links
+
+* Supersedes the IBAN-style account code section of
+  [ADR-0012](0012-subaccounts.md); the rest of ADR-0012 (the identity model,
+  reads, balances) stands.
+* Constrains the inflight hold subaccount of
+  [ADR-0014](0014-inflight-holds-via-holding-accounts.md).

+ 1 - 0
doc/adr/README.md

@@ -24,6 +24,7 @@ to reverse a decision. Instead, a new ADR supersedes it.
 | [0012](0012-subaccounts.md) | Subaccount dimension on account identity | accepted | Account identity gains a subaccount dimension (used for per-destination inflight holding subaccounts). |
 | [0013](0013-journaling-model.md) | Journaling model: transfer as journal entry | accepted | A committed `Transfer`/`Envelope` is a (compound) journal entry; the transfer log is the accounting journal; `Book` is a policy scope, not the journal. Frames 0001/0005/0010 in accounting terms. |
 | [0014](0014-inflight-holds-via-holding-accounts.md) | Inflight holds via per-destination holding accounts | accepted | A hold is a subaccount of its destination; committing routes funds through the holding subaccount so pending value stays visible and reconcilable until settle or cancel. |
+| [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. |
 
 ## Recommended future ADRs
 

+ 6 - 6
doc/glossary.md

@@ -48,12 +48,12 @@ reads take a base `id` plus an optional subaccount filter; exact entity
 operations take the full `AccountId`. Subaccounts let one account hold many
 concurrent inflights (a hold is a subaccount of its destination).
 
-`AccountId` also has an IBAN-style string view via `Display` / `FromStr`: two
-mod-97 check digits + a base-36 body of the two legs (no country code), e.g.
-`221RDWNSN4VCQNK2NN42KJFSAOLI` for `{ id: 5, sub: 7 }` (grouped:
-`221R DWNS N4VC QNK2 NN42 KJFS AOLI`). Parsing validates the checksum, rejecting
-mistyped ids. It is a presentation/routing form (the dashboard URLs); storage
-keeps the two `i64` legs.
+`AccountId` also has an IBAN-style string view via `Display` / `FromStr`: a fixed
+20 characters, an 18-char base-36 body of the packed legs then two mod-97 check
+digits (no country code), e.g. `FK9RA6QALU15JZ7DZM81` for `{ id: 5, sub: 7 }`
+(grouped: `FK9R A6QA LU15 JZ7D ZM81`, five groups of four). Parsing validates the
+checksum, rejecting mistyped ids. It is a presentation/routing form (the
+dashboard URLs); storage keeps the two `i64` legs. See ADR-0015.
 
 ### Asset