nigig-org/crates/nigig-pay-platform/README.md
andodeki d0f5e74435
Some checks failed
nigig-map / test (push) Has been cancelled
Payment domain, storage and platform / isolated-payment-tests (push) Has been cancelled
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
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
feat(pay): Phase 5 platform gateway boundary, and verify Phase 4
Phase 5 of REVIEWS/NIGIG_PAY_CONSOLIDATED_REVIEW.md. Design and the one
item engineering cannot close are in REVIEWS/adr/0007.

## Phase 4 is now verified, not just written

Tranche 5 implemented the draw_walk fixes and said plainly that the
nigig-pay widget edits were unbuilt. That blocker was environmental:
installing the packages tools/makepad-native-libs.sh already lists makes
the UI graph compile. Both commands the status doc listed as required
now pass:

  cargo check -p nigig-pay      pass
  cargo check -p nigig-pay-ui   pass

No code changed for this; the claim is now evidence rather than assertion.

## Phase 5: new nigig-pay-platform crate

ADR 0002 reserved this crate and marked it "not yet created".

5.1 One crate owns the seam. It is the only crate in the payment stack
that may name a platform SDK. CI enforces both directions: payment crates
may not import Makepad, and domain/storage may not import jni or the
robius platform crates.

5.3 Correlation is mandatory and single-flight. SessionRegistry admits an
event as Accepted, Duplicate or Ignored; PlatformEvent cannot be built
without a CorrelationId. Closed session ids are retired permanently, so
an abandoned session's confirmation cannot settle the payment that
replaced it. There is a test named after exactly that scenario.

2.8/5.3 Progress decides retry safety, not error kind. classify_failure
takes the failure and the DispatchProgress reached before it, and
progress is the authority. The same TemporarilyUnavailable is safely
retryable before the dial and ambiguous once the menu is being driven —
the distinction the old code could not make, which is defect B3's
mechanism. A property test asserts across the whole failure space that
nothing which may have reached the provider authorises a fresh attempt.

5.4 Fakes cannot ship. MockGateway is cfg-gated, is a compile_error! in a
release build unless allow-mock-in-release is named explicitly, and
stamps every session id with MOCK-. CI asserts the release build fails.

5.5 No unsafe, no PIN. The crate is #![forbid(unsafe_code)] so the JNI
surface stays in robius-ussd. UssdGateway is !Send/!Sync by construction,
making the main-thread requirement a compile error. The adapter leaves
the pin field empty and a test asserts it.

5.6 The web claim is withdrawn. No browser API can drive USSD and a
Daraja credential must never reach a browser, so WebGateway refuses every
call and maps to Fatal — "never sent" — which owes no reconciliation.

7.5 Adversarial SMS corpus. StrictMpesaSms is the payment-boundary
reader, deliberately separate from nigig-core's permissive tracker parser
(ADR 0007 explains why this is not the duplication ADR 0002 forbids). It
requires an exact 10-char code, exact sender-ID match so MPESA-REFUNDS
and FAKE-MPESA are refused, rejects fractional shillings instead of
rounding, and caps body length. Corpus covers spoofing, forged code
shapes, out-of-range amounts, unicode and NUL injection, and replay. The
closing test asserts the honest limit: a well-crafted forgery is still
only evidence, because the output type has no settled state to reach.

## 5.2 is not done and is not closeable here

The AccessibilityService Play-policy review is a business decision. ADR
0007 records it as blocking, states the termination exposure, and names
what must happen before the rail is enabled. USSD dispatch stays behind
the default-off demo feature. If the review fails, ADR 0001's
tracker/launcher position applies and only the dispatch adapter is lost.

## Validation

  platform: 56 tests, 64 with --features ussd, fmt, clippy -D warnings
            (both feature sets), cargo-deny, mock-in-release guard
            asserted to fail                                      pass
  domain:   75 tests --locked                                     pass
  storage:  36 + 41 tests --locked, incl. sqlcipher                pass
  nigig-pay, nigig-pay-ui: cargo check                            pass

cargo-deny reports advisories/bans/licenses/sources ok. The isolated
runner gained a `platform` target and it runs in CI on every push that
touches the crate.
2026-07-29 02:07:19 +00:00

69 lines
3 KiB
Markdown

# nigig-pay-platform
The seam between the payment domain and the device.
`nigig-pay-domain` decides what a payment *means*. `nigig-pay-storage` decides
how it is *remembered*. Neither may know that the provider is driven by an
Android AccessibilityService typing digits into a USSD menu. This crate is
where that knowledge is allowed to live, and it is the only crate in the
payment stack permitted to name a platform SDK.
Design rationale and the unresolved AccessibilityService policy question are
in [`REVIEWS/adr/0007-payment-platform-boundary.md`](../../REVIEWS/adr/0007-payment-platform-boundary.md).
## What it owns
| Module | Job | Review items |
|---|---|---|
| `correlation` | Single-flight session registry. Refuses out-of-order, duplicate and foreign callbacks. | 2.7, 5.3, 8.2 |
| `classify` | Fail-closed error classification driven by dispatch progress. | 2.8, 5.3, B3 |
| `events` | Typed platform events that cannot exist without a correlation id. | 5.3 |
| `sms` | Strict M-Pesa reader. Emits untrusted evidence, never settlement. | D1, D2, 0.4, 7.5 |
| `mock` | Test-only gateway that cannot compile into a release build. | 5.4, 0.5 |
| `web` | Honest refusal instead of a fake success path. | 5.6, U3 |
| `ussd` | Android adapter. The only module that names `robius_ussd`. | 5.1, 5.5 |
## The rule that matters most
When a platform call fails there is one question: **did the provider see it?**
```rust
classify_failure(&failure, progress)
```
`DispatchProgress` is the authority, not the error kind. The same
`TemporarilyUnavailable` is safely retryable before the dial and *ambiguous*
once the menu is being driven, because a session that died after the transfer
was submitted may have moved money.
Adapters must round progress **up** when unsure. Over-reporting costs a
reconciliation entry. Under-reporting costs a duplicate payment.
## Features
| Feature | Default | Purpose |
|---|---|---|
| `ussd` | off | The real Android USSD adapter. Needs `robius-ussd`. |
| `mock` | off | Test doubles. **Refuses to compile in a release build.** |
| `allow-mock-in-release` | off | Deliberate escape hatch for an integration harness. Named explicitly so nobody enables it by accident. |
## Testing
```bash
TEST_TARGET=platform ./tools/test-rust-clean.sh
```
The isolated runner installs the toolchain from `rust-toolchain.toml`, runs
`--locked` tests with and without the `ussd` feature, gates on
`clippy -D warnings`, asserts the mock gateway cannot be built into a release,
and deletes its temporary environment on any exit.
## Invariants worth preserving
- `#![forbid(unsafe_code)]` — the JNI surface stays in `robius-ussd`.
- `UssdGateway` is `!Send`/`!Sync`, so the main-thread requirement is a
compile error rather than a comment.
- The adapter never populates a PIN, and a test asserts it.
- Nothing here returns a settled `PaymentOutcome` from scraped or SMS input.
A confirmation may only come from an authorised provider channel via
`PaymentCoordinator::record_provider_outcome`.