nigig-org/REVIEWS/adr/0007-payment-platform-boundary.md
andodeki cf878d9e3e
Some checks failed
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
feat(pay): coordinator entry point for an externally granted authorization
The API change tranche 10 scoped, in REVIEWS/adr/0007.

## dispatch_with_authorization

Tranche 10 established that Android cannot implement BiometricAuthorizer
without reopening defect S2, and built AuthorizationAttempt as the
replacement. It named the remaining work: a coordinator entry point taking
an already-granted attempt instead of calling biometric.authenticate().

  coordinator.dispatch_with_authorization(&id, &mut attempt)

It does not prompt, does not wait, and does not consult the injected
BiometricAuthorizer at all. That is asserted rather than assumed: one test
injects an authorizer reporting no hardware that also errors, and shows a
granted attempt still dispatches. If the coordinator ever fell back to it,
that test fails.

Three refusals, each with a test that fails when the gate is removed:

- An unanswered prompt does not dispatch. PromptShown plus SensorEngaged
  is not consent — the exact shape the Android adapter would produce.
- A grant belongs to one payment. A foreign grant is refused before
  anything else, so a stray callback cannot even fail the payment on
  screen; the victim intent stays in AwaitingUserAuthorization rather
  than being transitioned to Failed by a stranger.
- A grant is spent on use. One authorization, one dispatch (B3).

Denied, still-prompting and already-spent attempts all fail the intent
closed and never reach the gateway.

## Verifying the tests can fail

The gate was removed (if !attempt.consume() -> if false) and the suite
re-run: 5 tests failed. A fail-closed test that passes against fail-open
code is worthless, so this is the check that matters.

Worth recording: a_grant_cannot_dispatch_twice still passed with the gate
removed, because the existing duplicate-dispatch budget caught it
independently. Two unrelated mechanisms refuse the second dispatch. That
is defence in depth working, and the reason that test is not sufficient
evidence on its own.

## Validation

  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
  fail-open injection: 5 tests fail with the gate removed         pass

Domain tests 129 -> 137.

## What remains

The authorization half is done and verified. PayFlowHandler still owns the
USSD session lifecycle, the pending-store writes that shadow the
coordinator's repository, and the bulk queue plumbing. Moving those makes
the coordinator own the gateway session, which changes who cancels on
teardown and who observes an out-of-order callback — ADR 0007's device
matrix, which cannot be exercised here.
2026-07-29 04:10:59 +00:00

200 lines
9.4 KiB
Markdown

# ADR 0007: The payment platform boundary, and the AccessibilityService question
- **Status:** Accepted for the code; **Blocked** for the AccessibilityService rail
- **Date:** 2026-07-29
- **Review items:** 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 7.5 (Phase 5 and Phase 7,
`NIGIG_PAY_CONSOLIDATED_REVIEW.md`)
- **Related:** ADR 0001 (payment scope), ADR 0002 (crate ownership)
## Context
ADR 0002 named four layers and reserved a slot for `nigig-pay-platform`
described as "not yet created". Phase 5 is that crate.
Until now the platform boundary was not a boundary. `pay_flow_handler.rs`
imported `robius_ussd` directly, held session state in a `thread_local!`, and
matched inbound Android callbacks against whatever payment happened to be on
screen. Three consequences followed, and all three are ordinary on a real
device rather than exotic:
1. **Uncorrelated callbacks.** A result arriving from an abandoned session
settled whichever payment was current.
2. **Error kind mistaken for safety.** `TemporarilyUnavailable` was treated as
retryable regardless of whether the USSD menu had already been driven, so a
session that died after the transfer was submitted looked identical to one
that never dialled.
3. **Untrusted input treated as an outcome.** A scraped dialog or an SMS whose
sender field said `MPESA` was sufficient to mark a payment done.
## Decision
### 1. One crate owns the seam (5.1)
`nigig-pay-platform` is the only crate that may name a platform SDK. The
dependency direction is `domain ← storage ← platform ← ui`, and CI enforces it
in both directions: the payment crates may not import Makepad, and the domain
and storage crates may not import `jni`, `robius_ussd`, `robius_sms` or
`robius_fingerprinting`.
### 2. Correlation is mandatory, and single-flight (5.3)
`SessionRegistry` holds at most one live operation. Every inbound event is
checked against it and receives one of `Accepted`, `Duplicate` or `Ignored`.
There is no constructor for an uncorrelated event: `PlatformEvent` requires a
`CorrelationId`.
A closed session id is retired permanently. Reuse is refused, because the
alternative lets a late duplicate of an old callback land on a new payment.
### 3. Progress decides retry safety, not error kind (2.8, 5.3)
`classify_failure(failure, progress)` takes two inputs. `DispatchProgress`
is the authority:
| Progress | Meaning | Classification |
|---|---|---|
| `NotSent` | never left the device | by error kind |
| `MaybeSent` | may have reached the provider | always ambiguous |
| `Sent` | provider acknowledged | always ambiguous |
An explicit provider refusal is the single exception and stays a rejection at
any progress, because it is a statement rather than an absence.
Adapters must report progress honestly and must round *up* when unsure.
Over-reporting costs a reconciliation entry; under-reporting costs a duplicate
payment. `UssdGateway::note_progress` is monotonic so a late low-progress
event cannot downgrade a dispatch that already reached the provider.
### 4. A second, stricter SMS reader (D1, D2, 7.5)
`StrictMpesaSms` is deliberately *not* a replacement for `nigig-core`'s
parser, and this is not the duplication ADR 0002 forbids. They answer
different questions:
- `nigig-core` answers "what should the history screen show?" A message it
cannot read is a missing row, so it is permissive.
- `nigig-pay-platform` answers "may this influence a payment decision?" A
message it misreads is a reconciliation resolved on forged input, so it
refuses anything not unambiguously well-formed.
Concretely, the strict reader requires an exact 10-character upper-case
alphanumeric code containing both a letter and a digit, requires an exact
sender-ID match rather than a substring (`MPESA-REFUNDS` and `FAKE-MPESA` are
refused), rejects fractional shillings instead of rounding them, caps body
length, and refuses a message that states no outcome.
What it must never grow is a "close enough" path.
### 5. Fakes cannot ship (5.4, 0.5)
`MockGateway` exists only under `cfg(any(test, feature = "mock"))`. Enabling
`mock` in a release build is a `compile_error!` unless the operator also names
`allow-mock-in-release`. Every session id it issues is prefixed `MOCK-` so a
mock session that somehow reached a ledger is identifiable on sight. CI
asserts the release build genuinely fails.
### 6. The web claim is withdrawn (5.6, U3, Q13)
There is no browser API that can drive a USSD session, and a Daraja
credential must never be shipped to a browser. `WebGateway` therefore
implements the trait and refuses every call, mapping to `Fatal` — "never
sent" — which is accurate and owes no reconciliation. The refusal names the
Android app so the message is actionable.
### 7. No `unsafe`, and no PIN (5.5, 0.3)
The crate is `#![forbid(unsafe_code)]`, so the JNI surface stays in
`robius-ussd`. `UssdGateway` is `!Send`/`!Sync` by construction, which turns
the main-thread requirement into a compile error rather than a comment.
The adapter leaves `UssdTransactionRequest::pin` empty and a test asserts it.
This crate refuses to be the place a PIN is reintroduced.
## The AccessibilityService question — unresolved, and blocking
**Review item 5.2 requires a legal and policy review of the
AccessibilityService-driven USSD automation against current Google Play
policy. That review has not happened, and no amount of engineering in this
ADR substitutes for it.**
The engineering risk is now contained. The *existential* risk is not, and it
is not an engineering risk at all:
- Google Play's Accessibility API policy requires the API be used to help
users with disabilities, with a declared and user-visible purpose. Driving a
payment menu on the user's behalf is not that.
- Enforcement outcome is binary and retroactive: app removal, developer
account termination. A rewrite does not recover a terminated account.
- The rail is also technically brittle in a way no test suite fixes:
Safaricom can renumber a menu at any time, and the automation has no
contract entitling it to notice.
### What must happen before this rail is enabled
1. A named human accepts the Play-policy risk in writing, or the rail is
abandoned in favour of an authorised provider API (Daraja) with
server-side credentials.
2. Until then, USSD dispatch stays behind the default-off `demo` feature,
which is where it is today.
### Position if the review fails
ADR 0001 already contemplates this: the product is positioned as a
**tracker/launcher**, not a payment processor. The domain, storage and
platform crates were built so that outcome costs nothing — the observation
path, the evidence model and the reconciliation queue are all still correct
for a tracker. Only the dispatch adapter is discarded.
## Amendment (2026-07-29): the biometric trait cannot be implemented on Android
Decision 1 above assigns adapter implementations to this crate. While
attempting the `PayFlowHandler``PaymentCoordinator` migration, one of those
adapters turned out to be **impossible to write correctly**, and the reason is
worth recording rather than working around quietly.
`BiometricAuthorizer` documents a blocking contract: `authenticate` waits for
the user and returns `Ok(())` on success. `robius_fingerprinting::authenticate`
issues a JNI call that displays the prompt and returns `Ok(())` **as soon as
the prompt is on screen**. The user's answer arrives later through
`next_event()`.
An adapter therefore has two options and both are defects:
- return `Ok(())` when the prompt opens, so
`PaymentCoordinator::authorize_and_dispatch` sends money *before the user
has touched the sensor* — review defect **S2**, the fail-open biometric,
reintroduced through the type system rather than by a careless branch;
- block the calling thread, which stops the pump that delivers the answer.
`AuthorizationAttempt` (`nigig-pay-domain::authorization`) replaces the trait
for callback-driven platforms. Authorization is a state machine —
`Requested → Prompting → Granted | Denied` — advanced by inbound signals, and
only `Succeeded` grants dispatch. A grant is bound to one intent, is spent on
use, and cannot be revived by a late or replayed event.
The trait keeps its blocking contract for genuinely synchronous authorizers
and test doubles, and now documents that Android must not use it.
`PaymentCoordinator::dispatch_with_authorization(id, &mut attempt)` is the
matching entry point. It does not prompt, does not wait, and does not consult
the injected `BiometricAuthorizer` at all — a test asserts that a granted
attempt dispatches even when the injected authorizer reports no hardware and
would error. The grant must name the intent being dispatched, must be
unspent, and is consumed on use.
`authorize_and_dispatch` remains for synchronous authorizers. Both paths
converge on the same `transition_and_dispatch`, so the duplicate-dispatch
budget and the state machine apply identically.
## Consequences
- Out-of-order, duplicate and foreign callbacks are refused by construction
rather than by reviewer vigilance.
- No platform error can authorise an automatic retry once the request may have
reached the provider.
- A forged SMS with a plausible code and a spoofed sender still cannot settle
a payment: the strict reader's output type has no settled state to reach.
- A release build cannot contain a fake gateway.
- The web payment claim is gone, and its absence is tested.
- **The AccessibilityService rail remains a business decision, explicitly
unresolved, and is not enabled by default.**