Whole-tree sync: cad-core/cad-ui split sources, nigig-build
construction_frame migration, pdf port progress, mpesa/pay/uikit/doc
updates, workspace members/profiles/lock, CI workflows and reviews.
See individual file history for details.
b9a083c26 carried an upstream merge that dropped enter_isolate /
leave_isolate / IsolateEntry from widget_async while lib.rs and
apps/flow-ui + apps/wm still use them, breaking makepad-widgets.
66cc4f15f restores them (verified: makepad-widgets, makepad-app-flow-ui,
makepad-wm check clean). Urgent follow-up to c901f1d which pinned the
broken rev.
Fork nigig-makepad-test-android now at b9a083c26 (upstream merge +
robot secondary-dex bundling + app_main wrapper fix, on top of the
game-libs/arcade port). All 16 workspace makepad revs follow.
Also takes the in-tree workspace updates: exclude nested
crates/nimanyatta workspace, add crates/apps/nigig-site member, and
PERF-OPS Phase 1 profile tuning (thin LTO + engine opt-level 3).
cad-core/cad-ui split and nigig-traffic members stay WIP.
ADR 0035 left one of p2p-intel's four limits open: no OS notification
backend, so alerts stopped when the window closed. The seam existed with one
implementation that truthfully did nothing. This is the crate that fills it.
Posting a notification on Linux is one D-Bus method call. Three ways to make
it were measured rather than assumed: notify-rust with libdbus is twelve
crates but a C library, which needs pkg-config and breaks the Android and
iOS cross-compile this crate family keeps clean; notify-rust with zbus is
pure Rust and 169 crates including an async executor; writing the wire
format out is about 250 lines and nothing at all. robius-sms deleted polkit
and gio for exactly this reason -- its E9 note records they were the sole
source of two RUSTSEC advisories and an LGPL question for every consumer --
so pulling a 169-crate tree back into the same family for one method call
would reverse that decision for a worse reason.
The cost of hand-rolling is that the protocol has to be exactly right, and a
mistake makes the daemon disconnect with no diagnostic. That cost was paid
in tests: the suite starts a private dbus-daemon per test and talks to it.
This matters more than it sounds. The marshaller and the parser were written
from the same reading of the specification, so them agreeing with each other
proves only that I was consistently wrong or consistently right; only a
third party can say which.
It found three bugs no unit test would have.
The first is the one worth dwelling on. Every error reply parsed as success.
The header-field walk assumed all fields were strings, but REPLY_SERIAL is a
u32, and reading its four bytes as a string length desynchronised the cursor
so ERROR_NAME was never reached. `post` returned Ok against a bus with no
notification service running. That is precisely the bug this crate was
written to eliminate -- a notifier that reports success and delivers nothing
-- reintroduced by accident inside its own parser. I cannot think of a
stronger argument for testing against something you did not write.
Second, is_available() was true on a bare bus, because NameHasOwner
*succeeds* and answers false in its body; checking only for an error
reported a working notifier on a machine with no notification daemon.
Third, replies were not correlated. The bus sends NameAcquired unprompted
right after Hello, so "read the next message" consumed a signal and treated
it as the answer. Replies are now matched on REPLY_SERIAL, and a single read
carrying several messages is walked rather than truncated.
The suite also serialises every test that mutates DBUS_SESSION_BUS_ADDRESS
behind a mutex. The variable is process-wide and cargo runs tests in
parallel threads; three consecutive parallel runs are now green.
--test-threads=1 would have made the failures go away too, and would have
hidden a real hazard from whoever reads the file next.
On the four platforms, honestly. Linux is implemented and tested. Android is
implemented and *compiles* -- cargo check and clippy both pass for
aarch64-linux-android -- but has never run on a device, and the module says
so in its first paragraph. It handles the two things Android drops silently,
missing POST_NOTIFICATIONS on API 33+ and a missing channel on API 26+,
because both are the same accepted-and-discarded failure this crate exists
to remove.
Apple and Windows are deliberately not written. Neither could be compiled
here -- no macOS or Windows toolchain and no way to add one -- and objc2
message sends or WinRT calls that no compiler has ever seen are not an
implementation. They are plausible-looking text that would sit in the same
crate as tested code and be read as equally finished. Both return
PermanentlyUnavailable with a reason naming ADR 0036, and their module docs
record the call sequence so the next person starts from a design rather than
a blank file. The support table says "written" and "verified" in separate
columns for the same reason.
p2p-intel's dashboard now uses SystemNotifications instead of
UnavailableNotifications. The latter stays: on a platform with no backend it
is still the truthful answer, and a test needs something that reliably
cannot deliver. Alerts are tagged per fiat so a market replaces its own
previous notification rather than stacking -- a 30-second poll would
otherwise fill the shade, and a full shade is what makes someone turn
notifications off for the app entirely, which costs more than the feature is
worth. Two new tests pin the invariant that a sink must never report
delivery it did not achieve.
CI gains a notifications job that installs dbus and sets
ROBIUS_NOTIFICATION_REQUIRE_DBUS=1. The bus-backed tests skip when
dbus-daemon is absent so the suite stays green on a bare machine, but a
silent skip in CI would mean the integration tests quietly stopped running
while the build stayed green. I verified the guard fails by hiding
dbus-daemon behind a stub that exits 127.
50 tests in the new crate, 225 in p2p-intel, clippy clean on host and
Android, and the app still starts under Xvfb.
A new nested workspace under crates/apps/p2p-intel: six engine crates, a
CLI, and a Makepad dashboard that builds for desktop, Android and iOS. It
reads public P2P adverts, measures the spread that is actually fillable,
alerts when one is worth acting on, and tracks what the float really cost.
It never places an order. Binance publishes no P2P trading API, and
automating an escrow release is how a merchant loses their float to
chargeback fraud. This is the intelligence layer; execution stays manual.
The design came from a live capture rather than a sketch, and the capture
contradicted the sketch three times. All three are now pinned by tests
against checked-in real payloads.
**The best price is routinely the least fillable one.** In the KES book the
top sell advert was 134.60 from a merchant with three completed trades,
implying a 3.6% spread; the next was 130.30. Another advert showed a 0%
completion rate. A best-price scan with no quality floor does not find
opportunities, it finds outliers, and outliers on a P2P book are bait or a
merchant about to run dry. QualityFilter defaults to 95% completion and 50
orders, and analyse() reports every exclusion with its reason rather than
dropping it silently.
The honest consequence is recorded in the integration suite: at those
defaults **not one sell-side advert in the captured KES book qualified**.
There was no fillable arbitrage. A tool that reported the raw best-price
number would have sent its user after a trade that does not exist, so the
test asserts best_sell is None and net_bps is None rather than asserting a
comfortable number.
**tradeType is inverted between request and response.** Asking the endpoint
for tradeType "BUY" returns adverts whose own adv.tradeType reads "SELL".
Both are correct: the request parameter is what you want to do, the response
field is what the advertiser is doing. Conflating them inverts every spread
and the result still looks plausible, which makes it the most expensive
mistake available here. Side keeps the two apart with
request_trade_type()/advert_trade_type(), and a test asserts they are never
equal.
**An empty market answers HTTP 200 with success: true.** NGN returned zero
adverts. "No ads" and "no answer" need opposite responses, so
is_empty_market() is a named predicate and ScanError separates Malformed
(Binance changed the payload; retrying makes it worse) from Network
(transient). basis_points_above returns None against a zero base rather than
an infinity, so an empty book cannot read as an infinite opportunity at 3am.
Money is never a float, following the rule in nigig-pay-domain/src/money.rs.
IEEE 754 cannot represent 0.1 and a spread is a difference of two nearly
equal numbers, which is exactly where binary floating point loses the digits
that matter. Binance sends prices as decimal strings, so Price parses them
straight into scaled i128 integers and never passes through f64. i128 rather
than i64 because the intermediate in a bps calculation overflows, not the
result. Excess precision is refused rather than rounded and a thousands
separator is refused rather than dropped: "1,299.92" read as 129992 is a
1000x error that still looks like a price. Tests pin 0.1 + 0.2 == 0.3 and
rotate 100 round trips at one price asserting exactly zero P&L.
Alerting is mostly restraint. At a 30-second poll one wide spread would fire
120 identical messages an hour, and a channel that cries wolf gets muted, at
which point the tool has negative value because the user believes they are
covered. AlertGate suppresses repeats inside a cooldown and re-alerts early
only when the spread improves materially -- a collapsing spread is not worth
waking someone for. Telegram MarkdownV2 escaping is tested against a real
merchant name from the capture, Twin_traders00, whose underscore would
otherwise make Telegram reject the message with a 400 and deliver nothing.
Writing the dashboard view model found a bug in my own comparator: sorting
descending by swapping the tuple to (b, a) also silently swaps the meaning
of the None arms, which put dead markets at the top of the opportunity list.
The test that caught it was written first and named for the behaviour, not
the implementation.
Networking is behind a non-default `live` feature, so an ordinary cargo test
cannot make a request and CI never depends on Binance being reachable. A CI
step asserts reqwest is absent from the default dependency graph so this
cannot regress quietly. A live scan was run once to confirm the fixtures
match reality; it reported a negative spread for KES and an empty NGN book,
which is the tool working correctly.
Conventions follow the repo rather than the generic layout in the request:
.forgejo/workflows/p2p-intel.yml rather than .github, and no Dockerfile,
since the stack is pure Rust and nothing else here is containerised.
error_set is used instead of anyhow, matching nigig-core. The root
Cargo.toml excludes the nested workspace by name, as it already does for
makepad_table, so the isolation is intentional rather than dependent on a
table inside someone else's manifest.
106 tests, coverage 96.86% with per-file floors enforced by
tools/test-p2p-coverage.sh. Both the total and per-file gates were verified
to actually fail by running them with impossible floors; a gate that cannot
fail is decoration. Two files are excluded and only because they were first
emptied of decisions: the Makepad widget, which needs a GPU and a windowing
backend this repo has no headless backend for, and the CLI main, which is
argument parsing and println. Every rule the widget renders lives in
view_model.rs, measured at 97%. That split is deliberate --
spreadsheet-ui/grid.rs once hid 36 pure functions behind a file-level
exclusion, and excluding a file you have not emptied of logic is how that
happens.
The Makepad desktop binary was built and linked in the sandbox to prove the
app half is real and not just a compiling stub.
Declare the 12 makepad crate deps once in root [workspace.dependencies]
pointing at the gitdab fork rev 4a166606c (which now also carries
[workspace.dependencies] into the android wrapper manifest). Member
crates switch to workspace = true; 5 non-member workspaces get an inline
rev bump. Fix 9 nigig-app script_mod indent errors surfaced by the newer
upstream macro parser.
The fork rev includes: NIGIG test-mode forwarding, custom AndroidManifest
hook, ortho camera support, and the wrapper workspace-deps fix.
Move 40+ files (~17K lines) from nigig-build/src/construction_frame/pages/workspace/doc/
to crates/apps/doc/doc-ui/src/. Rewrite internal paths from
crate::construction_frame::pages::workspace::doc:: to crate::.
doc-ui depends on doc-engine, makepad-widgets, nigig-core, serde, serde_json.
nigig-build now depends on doc-ui instead of doc-engine directly.
`crates/apps/makepad_table` is a nested workspace that has never been
compiled — its README says as much: "written without a local cargo/rust
toolchain, so the first compile on your machine is the verification step."
This is that step. Four crates, all of which build, and three defects in
the money code that only a compiler and a test runner could have found.
Workspace containment, which is what was asked for:
- The root manifest now names `crates/apps/makepad_table` in `exclude`.
Cargo already declined to absorb it, because the crate carries its own
`[workspace]` table — but that made the isolation a property of someone
else's manifest. Deleting that table would have pulled four crates and a
second makepad checkout into every workspace-wide build. Verified: the
root workspace resolves 58 members and none of them are these.
- `examples/table_demo` belonged to no workspace at all and had no
`[workspace]` table of its own, so `cargo metadata` failed outright in
that directory. It is now a member of the nested workspace. Kept rather
than deleted: it is the template the README's "drop into makepad"
section refers to.
- All three manifests pinned to the fork revision the rest of the repo
uses (`gitdab.com/andodeki/makepad` @ ecf5a57) instead of tracking
`github.com/makepad/makepad` branch `dev`. A floating branch means the
same commit of this repo builds against a different makepad from one day
to the next, and against a different makepad from every other crate
here. All four crates verified to compile against the pin.
The defects, in the order they surfaced — each was hidden by the one
before it:
1. `format_with_thousands` computed `(i - first_group_len)` before the
`i >= first_group_len` guard that protects it. `&&` short-circuits left
to right, so the check never ran in time. Any number whose leading
group is short of three digits — 2, 3, 5, 6, 8, 9, 11, 12 digits wide —
underflowed a usize: a panic in debug, silent wrapping and misplaced
commas in release. Every currency string in the application went
through it. The two existing tests used 1234 and 1234567, the two
widths that happen to work.
2. With the panic gone, `Currency::format` was visibly wrong on negatives.
The symbol was emitted before a signed whole part, giving "$-12.34"
instead of "-$12.34"; and `whole` truncated toward zero while `frac`
used `rem_euclid`, so the two disagreed below zero. -1234 formatted as
"$-12.66" and -1 as "$0.99" — the wrong sign, the wrong place, and the
wrong amount.
3. `invoice_totals_arithmetic` asserted `1_840_00` where the sample data
totals 1_840_000 minor units. The prose in the same comment said
18,400.00, which is right; the literals were a factor of ten low. The
arithmetic was never wrong, the expectations were. The two loose range
assertions on tax and grand total are now exact equalities.
Tests 12 -> 16 across the two crates, and all 16 pass; previously 6 of 12
failed. Each fix was verified by reintroducing the defect on its own:
the guard-order bug fails 5 tests, the sign bug fails 4 with the overflow
fix left in place, and dropping the per-line discount from the tax
calculation fails the arithmetic test by 71.32 — an error the old range
assertions were wide enough to have accepted.
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.