nigig-org/REVIEWS/REMAINING_EXECUTION_PLAN.md
andodeki 7fdf436510
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
Payment domain, storage, platform and UI / isolated-payment-tests (push) Successful in 2m36s
Payment domain, storage, platform and UI / payment-ui-tests (push) Successful in 3m17s
PDF engine / engine (push) Successful in 46s
PDF engine / makepad-integration (push) Successful in 3m23s
PDF engine / fuzz (push) Has been skipped
test(pay): Phase 5 lifecycle matrix as domain tests (R2.3)
The exit criterion names five scenarios: permission denial, cancellation,
backgrounding, app restart, out-of-order callbacks.

Four of the five are state questions, not hardware questions. A device adds
confidence that Android really emits a given callback sequence; it cannot
tell you how the domain reacts, because the state machine decides that. So
the matrix runs against the real coordinator on every commit instead of when
a phone is free, and a regression names the invariant it broke.

13 tests in crates/nigig-pay-domain/tests/lifecycle_matrix.rs, including the
cases that only exist as races: a success arriving after a cancellation;
backgrounding before a grant (must refuse) versus after one (must be
preserved — the user did authorise); restart before dispatch versus after; a
foreign grant; a replayed grant. Plus a clean-path test so the matrix cannot
pass by refusing everything.

## A coverage hole the matrix found

a_restart_after_dispatch_cannot_redispatch passed with the duplicate-dispatch
budget removed. The state machine refuses Submitted -> Dispatching first, so
the budget was never reached. That is good defence in depth and bad
coverage — nothing proved the budget still worked.

the_dispatch_budget_survives_a_state_machine_walk_back forces the intent back
to Dispatching, exactly as a faulty recovery path would, leaving the budget
as the only guard. It fails when the budget is removed.

The forcing hook is behind a `test-hooks` feature, not #[cfg(test)]: an
integration test is a separate crate and does not see cfg(test), so the
method was simply missing. The isolated runner enables it explicitly,
otherwise that test is silently filtered out and proves nothing.

## Verified by injection

  authorization gate removed  -> 7 of 13 fail
  dispatch budget removed     -> 1 fails (the new one)

## What still needs hardware

That Android actually produces these sequences: permission dialogs,
process-death timing, callback ordering under memory pressure. This file
asserts the response is correct for each sequence; a device confirms the
sequences are the real ones. Different claims, both needed. Tracked as R2.3b.

## Validation

  domain 148 unit + 13 matrix, fmt, clippy -D warnings, bench    pass
  storage 46 / platform 64 / mpesa 29 / pay-ui 78                pass
  clippy -p nigig-pay-ui --no-deps -D warnings                   0 errors
2026-08-02 09:47:52 +00:00

419 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Nigig Pay — execution plan for the remaining review items
- **Date:** 2026-07-29
- **Baseline:** `REVIEWS/IMPLEMENTATION_AUDIT.md` (code-verified, not notes)
- **Scope:** the payment stack only. `nigig-map`, CAD, doc, spreadsheet and
the Valhalla port are explicitly out of scope.
## Completion standard
A phase is complete only when **all** of the following hold. This was applied
retroactively to R1 after the first attempt fell short of it.
1. **No item deferred.** Every numbered item in the phase is implemented, or
the reason it cannot be is a named external dependency (a device, a legal
decision) rather than a judgement call about effort.
2. **No functionality removed to satisfy a review item.** Containment is
achieved by gating and packaging, never by deleting a capability the
product needs.
3. **Defects found while implementing are fixed in the same phase**, even
when they are not in the review. Three such defects surfaced during R1.
4. **Every fix carries a test that fails without it.** Each new guard is
verified by reintroducing the defect and confirming the suite goes red. A
guard that cannot fail is decoration.
5. **CI enforces it.** A fix without a gate regrows.
## How this plan is ordered
Not by review phase number. By **what blocks what**, and by whether the work
can be done at all without something I do not have.
Three gating resources decide everything below:
| Gate | Blocks |
|---|---|
| **A human decision** on Google Play policy (5.2) | Whether the USSD rail has a future; therefore 6.6, and the value of R3 |
| **An Android device** | Session ownership, Keystore, permission/cancel/restart matrix |
| **Neither** | Everything in Phase R1 |
Work that needs neither is scheduled first, because it is the only work that
can be finished rather than merely started.
---
## Phase R1 — **COMPLETE** (2026-07-29)
All four items done. Details of what each turned up are below; the summary is
that R1.2 and R1.3 both uncovered defects that were not in the review, and
R1.4's corpus found a live phone-normalisation bug.
### R1.1 — Versioned fee policy (U9) — **DONE**
**Defect.** `mpesa_bands.rs` carries a static table labelled "effective Jan
2024" in six places. Safaricom revises tariffs. When they do, the app quotes
a fee that is simply wrong, with no signal — the user sees a confident number
and is charged a different one.
**Work.**
- Add an effective-date and a version to the band table.
- Add a staleness horizon: past it, `mpesa_fee_for_kind_amount` returns a
distinguishable "policy may be out of date" result rather than a number.
- Thread that through `compute_adjusted_amount` so an out-of-date quote
surfaces the same way an unknown band already does — the machinery for
refusing to show a fee exists and is tested.
- Confirmation screen states the tariff version it quoted.
**Done when.** A test sets the clock past the horizon and asserts no
confident fee is displayed. Fee-band tests still pass.
**Effort.** Small. ~1 tranche. Touches `robius-ussd` and `nigig-pay-domain`.
**Risk if skipped.** Silent overcharging. This is the only open item that
produces a wrong number in front of a user during normal operation.
---
### R1.2 — Quality gate for `nigig-pay-ui` (Phase 1.2 gap, Q2) — **DONE**
**Correction to an earlier claim of mine.** I reported "10 `unwrap()` in the
payment UI". Checked properly: **9 are inside `#[cfg(test)]`** and are
legitimate. Exactly **one** is production code:
```rust
// pay_flow_handler.rs:245
h.pending_request.as_ref().unwrap().amount
```
It sits on the payment dispatch path, inside the biometric branch. It is
reachable only if `pending_request` is `None` while `current` is `Some`,
which the surrounding code does not currently allow — but nothing enforces
that, and it panics the app mid-payment if it ever does.
**Work.**
- Replace that one `unwrap` with a fail-closed path (no request, no prompt,
no dispatch).
- Add `nigig-pay-ui` to the CI clippy job with `-D warnings` and
`unwrap_used`/`expect_used` denied outside tests, matching what the three
pure crates already ratchet against.
- Expect a handful of follow-on fixes once the lint is on.
**Done when.** `cargo clippy -p nigig-pay-ui --all-targets -- -D warnings`
passes in CI.
**Effort.** Smallmedium. ~1 tranche; the lint will find things.
---
### R1.3 — Exchange API hardening (S7, S8, S10) — **DONE except a product call**
**Defect, verified in `exchange/api.rs`.** The client forges browser
identity to reach endpoints that are not public APIs:
```rust
req.set_header("User-Agent", "Mozilla/5.0 Robrix/1.0 Makepad");
req.set_header("origin", "https://www.bybit.com");
req.set_header("referer", "https://www.bybit.com/");
```
Three problems, in order of seriousness:
1. **Forged `Origin`/`Referer`** against `api2.bybit.com` and
`p2p.binance.com` — these are internal endpoints, and impersonating their
own web client is likely a terms-of-service violation and can break
without notice.
2. **No certificate pinning** on any exchange call.
3. **`Robrix/1.0 Makepad`** identifies the framework to every endpoint.
**Work.** This is a *decision* wrapped in a small code change, so it needs
your input:
- **Option 1 (recommended):** drop the forged headers, use each exchange's
documented public API, and accept reduced coverage where none exists.
- **Option 2:** keep the endpoints, remove the impersonation headers, and
accept that some calls will start failing.
- **Option 3:** remove the exchange feature until there is a supported
integration.
Certificate pinning and a neutral UA apply under all three.
**Done when.** No request sets a `origin`/`referer` it is not entitled to; UA
carries no framework identity; pinning is in place for retained endpoints.
**Effort.** Small once the option is chosen. Blocked on choosing.
---
### R1.4 — Adversarial corpus for the UI layer (7.5 gap) — **DONE**
The platform SMS reader has a full adversarial corpus. The UI's own parsing
and formatting paths do not. Lower value than R1.1R1.3 but cheap.
**Effort.** Small.
---
## Phase R2 — needs an Android device
Nothing here can be honestly completed in this environment. Each item's exit
criterion is a device behaviour, not a compile.
### R2.1 — USSD session correlation — **DONE**; ownership move still open
**What was actually wrong, and is now fixed.**
`SessionRegistry` was built in Phase 5 and never wired to anything. The pump
still read:
```rust
while let Some(ev) = robius_ussd::next_event() {
... if let Some(id) = h.current.take() { ... }
}
```
`next_event()` drains a **process-wide** queue and its entries carry no
session id, so every event was applied to whatever `current` happened to be.
Reproduced before changing anything: payment A is abandoned with events still
queued, payment B starts, and A's `ResultText`/`SessionEnded` settle B.
Now: dispatch claims the single in-flight slot, every event is admitted
against the live operation before it can touch an intent, terminal events are
de-duplicated, and all six terminal/teardown paths retire the session id so a
late duplicate cannot revive it. 6 tests, verified by removing the close and
watching the abandoned-session test go red. CI gate added.
**What this does not do.** The pump still lives in `PayFlowHandler`, and the
thread-local still owns the pending-store writes and the bulk queue. Moving
*ownership* to `PaymentCoordinator` changes who cancels on teardown, which is
the part ADR 0007's device matrix exists to check. The correlation defect —
the one that could settle the wrong payment — is closed without a device.
### R2.1b — move session ownership to the coordinator (device required)
The last architectural item. `PayFlowHandler` still owns the session
lifecycle (`begin_transaction`, `cancel_session`, the `next_event` pump), the
pending-store writes that shadow the coordinator's repository, and the bulk
queue.
The authorization half is done: `AuthorizationAttempt` plus
`dispatch_with_authorization` were built and tested precisely so this
migration does not reopen S2.
**Exit criterion (ADR 0007).** A device matrix: permission denial,
cancellation, backgrounding, app restart mid-session, out-of-order callbacks.
**Effort.** Medium-large, and it must not be done blind.
### R2.2 — Keystore key provisioning (3.1) — **rotation done; JNI remains**
Examined the same way as R2.1 rather than assumed device-blocked. Most of it
was already present: `DatabaseKeyProvider`, `open_encrypted`, wrong-key
rejection distinct from corruption, keystore-unavailable failing closed, and
a test asserting no PII appears in the raw file.
**The real gap was rotation, and it is pure logic.** A `StaticTestKeyProvider`
key lives forever; an Android Keystore key does not. It is invalidated by
fingerprint re-enrolment, adding or removing a screen lock, or a device
restore — `KeyPermanentlyInvalidatedException` is ordinary, not exceptional.
With no rotation path the only responses were "lose the ledger" or "keep using
a key that no longer exists".
`rotate_key` uses `PRAGMA rekey`, which re-encrypts every page inside
SQLCipher's own transaction, then proves the new key reads the data before
returning. 5 tests: records survive, the superseded key stops working, an
empty key is refused without damaging the file, rotation is repeatable, and
the schema version is untouched. Verified by neutering `rotate_key`: 3 fail.
Storage tests 41 → 46.
**Ordering matters and is documented at the trait.** The caller persists the
new key to the keystore only *after* `rotate_key` returns `Ok`. The reverse
leaves a stored key that does not open the file; this order leaves at worst a
re-keyed file whose key was not saved, recoverable by rotating again from the
old key still in the keystore.
**What remains is the JNI call itself**`KeyGenParameterSpec`, the
AndroidKeyStore provider, and catching `KeyPermanentlyInvalidatedException`.
The full contract is documented on `DatabaseKeyProvider` so it is not
rediscovered. Everything except the platform call is already exercised.
### R2.2b — the Android JNI key provider (device required)
SQLCipher works and is tested. The `DatabaseKeyProvider` backed by Android
Keystore does not exist, so encryption at rest has no real key hierarchy.
**Effort.** Medium. Android-side.
### R2.3 — Lifecycle matrix — **DONE as domain tests**; hardware claim separate
The Phase 5 exit criterion names five scenarios: permission denial,
cancellation, backgrounding, app restart, out-of-order callbacks.
Four of the five are **state questions, not hardware questions**. A device
adds confidence that Android really emits a given callback sequence; it
cannot tell you how the domain reacts, because that is decided by the state
machine. So the matrix lives in `crates/nigig-pay-domain/tests/`, runs against
the real coordinator on every commit, and names the invariant it protects
rather than surfacing as odd behaviour on a handset.
13 tests. Coverage per row, plus the cases that only appear as races:
a success arriving after a cancellation; backgrounding before a grant (must
refuse) versus after one (must be preserved, the user did authorise);
restart before dispatch versus after; a foreign grant; a replayed grant.
**A coverage hole this found.** `a_restart_after_dispatch_cannot_redispatch`
passed with the duplicate-dispatch budget removed — the state machine refuses
`Submitted -> Dispatching` first, so the budget was never reached. Good
defence in depth, bad coverage: nothing proved the budget still worked.
`the_dispatch_budget_survives_a_state_machine_walk_back` forces the intent
back to `Dispatching`, exactly as a faulty recovery path would, leaving the
budget as the only guard. It fails when the budget is removed.
That hook is behind a `test-hooks` feature rather than `#[cfg(test)]`, because
an integration test is a separate crate and does not see `cfg(test)`. The
isolated runner enables it explicitly, or the test is silently filtered out.
Verified by injection: removing the authorization gate fails 7 of 13;
removing the dispatch budget fails 1.
### R2.3b — confirm the sequences on hardware (device required)
What remains is the other half of the claim: that Android actually produces
these sequences — permission dialogs, process-death timing, callback ordering
under memory pressure. This file asserts the *response* is correct for each
sequence; a device confirms the *sequences* are the real ones. Both are
needed, and they are different claims.
The matrix R2.1 depends on, run as a suite rather than ad hoc.
---
## Phase R3 — needs a business decision first
### R3.1 — Item 5.2, the AccessibilityService policy review — **blocking**
Not an engineering task and no amount of code closes it. Google Play's
Accessibility API policy requires the API serve users with disabilities;
driving a payment menu is not that. Enforcement is app removal and developer
account termination, and it is retroactive.
**Decide one of:**
- accept the risk in writing and continue with the USSD rail; or
- pursue an authorised provider API (Daraja) with server-side credentials; or
- position the product as a tracker, per ADR 0001.
**Why it gates other work.** If this goes against the USSD rail, R2.1 and
6.6 are wasted effort and R1.3's exchange work changes shape. It should be
answered before R2 starts.
### R3.2 — Bulk controls (6.6)
Per-item review, limits, pause/resume, audit export. The *accounting* is
already correct and tested (`PaymentBatch`); the controls are not built.
The review is explicit that bulk should exist only "after official/API-backed
idempotency/reconciliation exists" — which is R3.1's outcome.
---
## Phase R4 — programme discipline, ongoing
Not code, and not completable by an agent:
- **8.3** parser fuzzing and a consent-safe SMS corpus
- **8.4** security review, SBOM, dependency audit, privacy policy
- **8.5** operational telemetry with redacted correlation IDs
- **8.6** pilot against an authorised sandbox with manual reconciliation
---
## Deferred deliberately
**B6 stored representation.** `MpesaTransaction::amount` is still `f64` on
disk. The boundary is validated — nothing non-finite, negative or beyond 2^53
can enter or leave — so residual exposure for whole-shilling amounts is nil.
Converting the field means rewriting the PSV format and migrating existing
files, which belongs with the SQLite move, not before it.
---
## R1 outcome — what it actually found
**R1.1.** `FeePolicy` in the domain crate attaches provenance and a 400-day
trust horizon to a tariff. Past it, `fee_for` returns
`FeeError::PolicyOutOfDate` instead of a number, and the sheet refuses to
quote exactly as it already does for an unknown band. Verified by disabling
the check: 4 tests fail. Domain tests 137 → 148.
**R1.2.** The one production `unwrap` is gone, replaced by a fail-closed path
(no request, no prompt, no dispatch). `nigig-pay-ui` now denies
`unwrap_used`/`expect_used` outside tests, and CI runs
`clippy --no-deps -D warnings` — scoped that way because `matrix_client` and
`robius-ussd` carry pre-existing warnings that are not this crate's to fix,
and a gate that fails on someone else's code gets disabled.
Turning the lint on surfaced **13** further issues, one of which was a real
defect: `normalise_phone` had two identical branches, and the 13-digit
`254…` arm produced an **11-digit** result — not a valid MSISDN, but
non-empty, so it flowed onward as a recipient. The duplication was hiding it.
**R1.3.** The forged `origin`/`referer` headers are gone from **both** copies
of the exchange client (`nigig-pay` and `nigig-mpesa` — review item A5
again). The framework-identifying User-Agent is replaced with `nigig-pay`.
CI rejects either regrowing.
The requests are still made, now honestly identified. If those endpoints
reject an honest client the P2P panes fall back to their offline cache, which
is the true state of the integration rather than a disguised one.
**S8, resolved as far as the platform allows.** Certificate pinning was the
ask. Makepad's `HttpRequest` exposes no pinning API — its only TLS control is
`set_ignore_ssl_cert`, which *weakens* verification. Pinning cannot be
implemented at this layer without patching the platform crate.
What is enforceable is the property pinning mostly buys: a mistyped, injected
or attacker-supplied URL cannot be dialled. `check_transport` gates every
request on HTTPS plus a four-host allowlist, at all three dial sites in both
copies of the client. 7 tests cover lookalike hosts
(`api.coingecko.com.evil.example`), embedded credentials
(`https://evil@real/`), explicit ports, plain HTTP and malformed URLs. CI
asserts TLS is never disabled and the allowlist is present.
**Still needs your decision:** whether to keep `api2.bybit.com` and
`p2p.binance.com` at all — documented public API, drop the feature, or accept
reduced coverage. That is a product call, not a defect.
**Gaps closed on review.** The first pass at R1 left four items short of the
standard above, all now closed: a regression test naming the 13-digit phone
defect and a property test that normalisation output is *either* empty or
exactly valid; four tests for the fee-policy UI wiring, so the domain guard is
proven to be connected rather than merely present; the S8 transport allowlist
above; and tests for the exchange client, which previously had none.
One of those tests failed on its first run for an instructive reason: it
asserted the module never calls `set_ignore_ssl_cert`, and the literal in the
assertion put the string in the file. The needle is now assembled at runtime.
**R1.4.** An adversarial CSV corpus covering empty input, injection-shaped
fields, overflow, NUL, RTL override, full-width digits, a 5,000-row file, and
malformed phone numbers. Verified it can fail by making `normalise_phone`
fabricate a number: the corpus catches it. UI tests 61 → 66.
## Recommended order
~~1. R1 items~~**complete**.
1. **R3.1** — ask for the policy decision now. It is a human decision with a
long lead time and it gates R2 entirely. Everything else can proceed in
parallel while it is pending.
2. **R1.1** (fee policy) — the only open item that shows a user a wrong
number during normal use.
3. **R1.2** (clippy gate) — one real panic risk on the dispatch path, plus a
ratchet so the class does not regrow.
4. **R1.3** (exchange) — once you pick an option.
5. **R1.4** — cheap, do it alongside.
6. **R2.x** — when a device is available and R3.1 has been answered.
## Out of scope, but blocking you
`nigig-map` does not compile on clean HEAD (12 errors,
`no field center_lat on ViewportState`). No APK can be produced until that is
fixed by whoever owns it. None of the plan above changes that.