Commit graph

2 commits

Author SHA1 Message Date
b5e38825fa fix(pay): validate money at the persistence boundary (B6)
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
Payment domain, storage, platform and UI / isolated-payment-tests (push) Has been cancelled
Payment domain, storage, platform and UI / payment-ui-tests (push) Has been cancelled
B6 is the last unfinished P0 on the review's priority table.

## Why the boundary and not the format

The obvious reading of B6 is "change f64 to Money on disk", which is a data
migration and belongs with the SQLite move. But measuring first shows the
stored representation is not where the damage is:

- The f64 round trip is exact. 1500.0, 1500.5, 0.1+0.2, 1e20 and
  12345678.995 all write and re-read bit-identically.
- The read path is the damage. parts[2].parse().unwrap_or(0.0) turned any
  unreadable amount into a confident KSh 0 row.
- And NaN gets in. "NaN" and "inf" both parse as f64 and the writer emits
  them back verbatim, so they survive a round trip. One corrupt SMS then
  poisons every total it enters, permanently — once a NaN is in a sum,
  every comparison against that sum is false.

## What changed

validate_money refuses three classes, on parse, on load and on save:
non-finite; negative (direction lives in TransactionType, so a negative
amount is a contradiction); and beyond 2^53, where f64 can no longer
represent consecutive shillings.

A row whose amount cannot be trusted is skipped with a log line rather than
zeroed. Dropping a row is visible and recoverable by rescanning the inbox;
a silent KSh 0 is neither. balance and cost are optional context, so they
degrade to None rather than discarding the row, but can no longer be NaN.

The writer refuses to persist an invalid amount, which is what stops a
poisoned value becoming permanent.

Smaller parse fix: "Ksh ,5" used to read as 5. A separator before any digit
means the text is not the expected shape, and guessing is worse than
declining.

## The parser had no tests

parser.rs — the file deciding what every observed amount is — had zero
tests. It now has 13: validation, extraction, end-to-end parsing, and
unicode/NUL bodies that must not panic on a byte-index slice.

M-Pesa harness: 7 -> 24 tests.

## Verifying the tests can fail

validate_money was reduced to Some(value) and the harness re-run: 4 tests
failed. A guard that cannot fail is decoration.

## Validation

  mpesa harness: 24 tests                                        pass
  domain  : 137 tests --locked, fmt, clippy -D warnings, bench   pass
  storage : 36 + 41 sqlcipher --locked, fmt, clippy              pass
  platform: 56 + 64 ussd --locked, fmt, clippy, mock guard       pass
  nigig-pay-ui: cargo test --lib                                 pass (61)
  nigig-pay-ui / nigig-pay / nigig-mpesa / nigig-core: check     pass
  authorization / batch / settlement-tick guards                 pass
  defect injection: 4 tests fail with validation removed         pass

## What B6 still leaves open

MpesaTransaction::amount is still f64 on disk. That is deliberate and now
bounded: nothing untrustworthy can enter or leave the store, so the
remaining exposure is precision within the validated range, which for
whole-shilling amounts under 2^53 is none. Converting the field means
rewriting the PSV format and migrating existing files. ADR 0002 records B6
as partially addressed with the migration deferred to the SQLite move.
2026-07-29 04:48:50 +00:00
c6e122c3fc feat(pay): complete Phase 1 and fix M-Pesa store defects B1/B4
Some checks failed
Payment domain and storage / isolated-payment-tests (push) Has been cancelled
Phase 1 of NIGIG_PAY_CONSOLIDATED_REVIEW.md is now closed.

1.1 build governance:
- Declare license = "MIT" on both payment crates. cargo-deny correctly
  reported them as unlicensed, which would block any distribution review.
- Version-pin the nigig-pay-domain path dependency; a bare path dependency
  is a wildcard requirement.

1.2 quality gates:
- Add deny.toml and a CI job running cargo-deny over both payment crates.
  Advisories, bans, licences and sources all pass. The config bans the
  makepad-* crates outright and restricts sources to crates.io.

1.3 canonical ownership (ADR 0002):
- Record the domain/storage/platform/UI layering and its one-way deps.
- The review's A5 "fork farm" table is stale: one copy each of parser.rs,
  store.rs, pending_store.rs and pay_flow_handler.rs, not three.
- Fix B1: store.rs parsed category, sub_category, status and confidence
  from disk then overwrote them with Default::default(), losing every user
  categorisation on reload. Persistence also wrote display names, which are
  not reversible, so this adds stable storage tokens with a legacy-display
  fallback so existing rows still load.
- Fix B4: clean/restore mapped '|' to '~' and reversed every '~', so
  "JOHN~DOE" loaded as "JOHN|DOE". Replaced with bijective backslash
  escaping covering the separator, newlines and carriage returns.
- B6 (f64 money) deliberately deferred to Phase 6: it is a type change that
  ripples into UI consumers.

These were previously recorded as untestable because nigig-core is not a
workspace member. That was wrong: the three files involved need only serde,
chrono, one log! macro and one app_data_dir() helper. The new
tools/test-mpesa-store-clean.sh supplies those shims in a throwaway crate
and runs 9 tests, two of which reproduced the defects before the fix.

1.4 boundary: enforced twice, by a CI manifest/import check and by the
deny.toml ban list.

1.5 shims: three re-exports in nigig-pay/src/lib.rs had zero callers and are
deleted. The remaining four carry a caller count and a named migration
target so they have a deletion plan rather than an open-ended lifetime.

1.6 scope (ADR 0001): accepted that Nigig Pay is a read-only tracker and
launcher, not a payment processor, until an authorised provider integration
exists. This is the decision the review required before further UI work.

SECURITY: a live Cloudflare API token was found committed in README.md,
present since the initial commit and pushed to a public remote. Removed and
recorded as R-SEC-001 in PAYMENT_RISK_REGISTER.md. Redaction does not revoke
it; it remains in history and must be rotated by the owner.

Validated on rustc 1.97.1: domain and storage each pass test --locked, fmt,
clippy -D warnings and cargo-deny; storage also passes --features sqlcipher;
domain benches run; 9 M-Pesa store tests pass. 49 manifests parse.
2026-07-27 15:23:20 +00:00