Commit graph

93 commits

Author SHA1 Message Date
Arena Agent
5d2d890f70 feat(nigig-site): enforce SITE-01 fail-closed containment
Some checks failed
nigig-build (CAD) / full-crate-check (push) Waiting to run
nigig-build (CAD) / doc-workspace-coverage (push) Waiting to run
nigig-build (CAD) / cad-widget-coverage (push) Waiting to run
nigig-build (CAD) / cad-engine-coverage (push) Waiting to run
nigig-map / test (push) Waiting to run
sms / android (push) Waiting to run
sms / nigig-sms (push) Waiting to run
sms / supply-chain (push) Waiting to run
sms / gates (push) Waiting to run
sms / robius-sms (push) Waiting to run
spreadsheet / engine-coverage (push) Waiting to run
spreadsheet / ui-controller-coverage (push) Waiting to run
traffic / gates (push) Waiting to run
traffic / nigig-traffic (push) Waiting to run
traffic / supply-chain (push) Waiting to run
nigig-site / Owned paths and honest test contracts (push) Has been cancelled
nigig-site / Cargo check-all-targets (push) Has been cancelled
nigig-site / Cargo clippy-site-owned (push) Has been cancelled
nigig-site / Cargo contained-media-export-fixtures (push) Has been cancelled
nigig-site / Cargo containment-storage-crypto (push) Has been cancelled
nigig-site / Cargo integration-non-live (push) Has been cancelled
nigig-site / Cargo production-dependency-containment (push) Has been cancelled
nigig-site / Cargo unit (push) Has been cancelled
nigig-site / Runtime UI (explicitly skipped until enabled) (push) Has been cancelled
nigig-site / Migration and recovery (explicitly skipped until enabled) (push) Has been cancelled
nigig-site / Media limits (explicitly skipped until enabled) (push) Has been cancelled
nigig-site / Real server interoperability (explicitly skipped until enabled) (push) Has been cancelled
nigig-site / Security and supply-chain baseline (push) Has been cancelled
nigig-site / Release capability gate (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Contain production capabilities, remove the production sync surface, and keep legacy media/export/transport implementations test-only.

Require authenticated existing-key storage with preservation-first recovery and sticky write disablement, backed by deterministic fault and concurrency tests plus dependency and workflow contracts.
2026-09-12 15:46:30 +00:00
Arena Agent
f49d8b16ac ci(nigig-site): establish truthful SITE-00 gates
Some checks failed
nigig-site / Owned paths and honest test contracts (push) Has been cancelled
nigig-site / Cargo check-all-targets (push) Has been cancelled
nigig-site / Cargo clippy-site-owned (push) Has been cancelled
nigig-site / Cargo integration-non-live (push) Has been cancelled
nigig-site / Cargo media-export-baseline (push) Has been cancelled
nigig-site / Cargo storage-crypto-baseline (push) Has been cancelled
nigig-site / Cargo unit (push) Has been cancelled
nigig-site / Runtime UI (explicitly skipped until enabled) (push) Has been cancelled
nigig-site / Migration and recovery (explicitly skipped until enabled) (push) Has been cancelled
nigig-site / Media limits (explicitly skipped until enabled) (push) Has been cancelled
nigig-site / Real server interoperability (explicitly skipped until enabled) (push) Has been cancelled
nigig-site / Security and supply-chain baseline (push) Has been cancelled
nigig-site / Release capability gate (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
2026-09-12 09:08:22 +00:00
ac8f8aa002 chore: sync full working tree to gitdab
Some checks failed
Payment domain, storage, platform and UI / isolated-payment-tests (push) Waiting to run
Payment domain, storage, platform and UI / payment-ui-tests (push) Waiting to run
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (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
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
traffic / gates (push) Has been cancelled
traffic / nigig-traffic (push) Has been cancelled
traffic / supply-chain (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
nigig-map / test (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
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.
2026-09-12 07:15:24 +03:00
3ef182414e feat(robius-notification): real macOS and Windows backends, type-checked against the actual frameworks
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
p2p-intel / engine (push) Has been cancelled
p2p-intel / notifications (push) Has been cancelled
p2p-intel / coverage (push) Has been cancelled
p2p-intel / makepad-app (push) Has been cancelled
p2p-intel / exchange-tab (push) Has been cancelled
The previous commit refused to write these two, on the grounds that code no
compiler has ever seen is not an implementation -- it is plausible-looking
text that would sit in the same crate as tested code and be read as equally
finished. That reasoning holds. The premise behind it did not.

`cargo check` needs the *target's standard library*, not a linker or a
platform SDK. `rustup target add x86_64-pc-windows-msvc aarch64-apple-darwin`
puts the real `windows` and `objc2` crates -- genuine WinRT metadata, genuine
Objective-C class definitions -- in front of the type checker on a Linux
host. So both backends are now written and both compile against the
frameworks they call.

Compile-checking earned its place immediately. The Windows backend was
written against `ToastNotificationManager::CreateToastNotifier(&HSTRING)`,
which does not exist: the AUMID overload is `CreateToastNotifierWithId`.
Nothing short of a compiler holding the real metadata would have caught
that, and it would have shipped looking entirely correct.

Because a cfg-gated module can silently compile to nothing -- leaving a
green check that proves only that the module was skipped -- each backend was
verified to be genuinely reachable by injecting a type error and confirming
the target build failed. Both macOS and Windows were checked this way, then
restored.

Two platform decisions worth recording.

macOS uses NSUserNotification, not UNUserNotificationCenter, and that is a
deliberate downgrade to a deprecated API. `UNUserNotificationCenter.current()`
raises an Objective-C exception when the process has no bundle identifier;
that unwinds through Rust frames and aborts. A plain `cargo run` host has no
bundle, so the modern API would crash the caller instead of reporting
unavailable -- which is worse than deprecated. The nil check on
`defaultUserNotificationCenter` is there for the same reason: msg_send on nil
returns zero rather than crashing, so every later call would silently do
nothing, which is precisely the failure this crate exists to remove.

Windows requires the host to supply an AppUserModelID, because a library
cannot invent one. It comes from an MSIX manifest or a Start Menu shortcut,
and a fabricated id produces a notifier that constructs happily and then
fails at Show. `set_app_user_model_id` is therefore public, a no-op off
Windows so portable hosts call it unconditionally, and `is_available()` is
false with a reason naming exactly what is missing until it is called. Toast
payloads are XML, so text is escaped -- the same bug class as the Telegram
MarkdownV2 escaping p2p-intel needed before it dropped Telegram, and a
merchant nickname containing `&` is not hypothetical.

The support table now has two columns, "compiles" and "executed", because
they are different claims. All four backends compile; only Linux has posted
a notification. iOS remains unavailable by design: it needs UN* with a
bundle and an entitlement, which is an app-packaging concern rather than
something this crate can satisfy.

Off Windows the module defines no entry points at all rather than stubs.
Clippy was right to call them dead: sys/mod.rs dispatches elsewhere, so they
existed only to satisfy a symmetry nothing needs.

CI gains a step that clippies all four cross-targets with -D warnings, so a
cfg-gated backend cannot rot unnoticed -- which is exactly how the
CreateToastNotifier mistake would have survived.

51 tests, clippy clean on five targets, p2p-intel still at 225.
2026-09-01 18:54:57 +00:00
e16d1d1d44 feat(robius-notification): a zero-dependency D-Bus notifier, and p2p-intel's last limit closes on Linux
Some checks failed
p2p-intel / engine (push) Has been cancelled
p2p-intel / notifications (push) Has been cancelled
p2p-intel / coverage (push) Has been cancelled
p2p-intel / makepad-app (push) Has been cancelled
p2p-intel / exchange-tab (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (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
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
nigig-map / test (push) Has been cancelled
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.
2026-09-01 18:43:07 +00:00
8bf62e2644 feat(p2p-intel): the exchange tab becomes spread intelligence, with rails, drift capture, and three of four limits closed
Some checks failed
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (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
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
nigig-map / test (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
p2p-intel / engine (push) Has been cancelled
p2p-intel / coverage (push) Has been cancelled
p2p-intel / makepad-app (push) Has been cancelled
p2p-intel / exchange-tab (push) Has been cancelled
The exchange tab in nigig-mpesa and nigig-pay was a swap mock-up -- Sell/Buy
cards showing hardcoded ETH and AAVE at $38,409.24, wired to nothing -- above
six rate labels, each built from the *first* cached advert for its exchange:

    Binance P2P: USDT BUY @ 129.92 KES  min 1000 max 200000  via BANK

That is a price, not an opportunity. It never compared the two sides of the
book, said nothing about whether the counterparty could be dealt with, and
ignored what it costs to move the money. Against the live KES book it would
have shown a 134.60 advert from a merchant with three completed trades.

Both pages now run the p2p-intel analyzer over the adverts the app already
caches. No new endpoint, no new traffic, api.rs untouched -- the data was
always there, nothing was being asked of it. The two files are
byte-identical and CI now fails if they drift apart, which they already had
once: data.rs differs between the apps by 188 lines of tests one copy has
and the other does not.

**Rails, because a spread alone is a lie.** 29 bps on a 10,000 KES trade is
29 KES of gross margin; M-Pesa Send Money costs 55 KES a leg, 110 round
trip. The trade is deeply negative and the spread says nothing about it.
rail.rs prices every route and ranks by what survives. M-Pesa fees come from
the published Safaricom bands in robius_ussd::mpesa_bands -- real tariff
rows, not estimates, and flat rather than percentage, which is exactly why
the same spread is ruinous at 500 KES and fine at 200,000. Both legs are
charged. An amount outside the tariff reports OutOfRange and an
unconfigured bank reports Unknown; neither reports zero, because zero is a
claim and it is the wrong one. Bank tariffs ship unconfigured for the same
reason: every bank differs and there is no table to default to.

**API drift capture.** These endpoints are internal and undocumented. When
one changes the symptom is an empty panel -- indistinguishable from a quiet
market -- and the response that broke it is gone by the time anyone looks.
Every parse failure is now recorded with the payload excerpt that caused it
and copyable as a plain-text report. Deduplicated, because a 30-second poll
against a changed endpoint fails 120 times an hour and 120 identical rows is
a log nobody reads; the excerpt is excluded from the equality check, since
two responses differing only in advert ids are the same drift. The exported
header states that it carries response excerpts only and never a request,
credential or account number -- the type can only be constructed from a
response body, so that is enforced rather than promised. Cutting the excerpt
is done on character boundaries: Binance really returns names like
BennyBoss and a byte slice would panic mid-character.

The repo's other clipboard code (nigig-build's crdt_widget.rs) answers a
Hit::TextCopy, which is the query-driven path the platform uses for Ctrl+C
on a focused widget. Right for a text editor, wrong for a button exporting a
report the user never selected, so this uses cx.copy_to_clipboard and
confirms in the UI -- a copy button with no feedback is one people press
three times.

On the four limits, the honest scoreboard is two closed, one usefully
sidestepped, one open. ADR 0035 records why, because rounding all four up to
"addressed" would have been the easy write-up and the wrong one.

The poll timer and the host clock are closed outright. cx.start_interval
delivers the tick as an ordinary UI-thread event, so there is no runtime and
nothing to join at shutdown, and the interval reads through
effective_poll_seconds so the fifteen-second floor still applies -- a config
file cannot be used to hammer a rate-limited endpoint, which a test pins by
name. Staleness is now shown, because a price from four minutes ago is not a
price. One ambiguity is recorded rather than hidden: last_scan_ms == 0 is the
"never scanned" sentinel, so a scan whose timestamp genuinely is 0 reads as
never. That only happens when the host clock is broken, which is exactly
when the UI should not claim the data is current.

The headless-backend limit is sidestepped, and the distinction matters
enough to write down. Xvfb is a real X11 server that draws into memory, so
the app gets the display it insists on and tools/test-p2p-app-smoke.sh runs
the real binary end to end. That earned its place immediately: it caught
`Row = <View> { ... }`, which is not valid in this fork's script language
and which cargo build is entirely silent about, because script_mod! is
parsed at *runtime* -- a broken widget tree compiles perfectly and then
renders nothing. The gate greps for [E] in the log for that reason; the
process exits 0. I verified it fails by reintroducing the bad syntax. What
it still cannot do is drive widgets: makepad_test's Selector::id(..).click()
needs the harness to own the event loop, so the six #[ignore]d interaction
tests stay ignored.

OS notifications stay open. There is no robius-notification crate beside
robius-sms and robius-ussd, and Makepad exposes none on any target; building
one means NotificationCompat on Android, UNUserNotification on iOS and a
D-Bus call on Linux. What shipped is the seam plus one implementation named
UnavailableNotifications -- named for what it is, because a DefaultNotifier
that silently discarded every alert would read like a working feature at
every call site. is_delivering() returns false and the UI shows the reason,
so the user is told that alerts stop with the window instead of assuming
they are covered. A no-op reporting success is the exact "declared versus
delivered" failure ADR 0017 exists to prevent.

223 tests, coverage 97.03% with fifteen per-file floors -- seven of them new
and all measured, after I noticed an earlier edit had silently failed to
apply and the new files were being counted in the total but gated by
nothing. CI gains the Xvfb smoke job and an exchange-tab job that builds
both host apps and diffs their pages.
2026-09-01 16:07:43 +00:00
fefde1ecca refactor(p2p-intel): Makepad's HTTP stack, in-app alerts, and micro_serde -- serde and reqwest are gone
Some checks failed
p2p-intel / engine (push) Has been cancelled
p2p-intel / coverage (push) Has been cancelled
p2p-intel / makepad-app (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Three changes that turn out to be one change: the app now uses the
platform's own facilities instead of carrying its own. Networking moves to
Cx::http_request, alerting moves into the UI, and deserialisation moves to
makepad_micro_serde. The default dependency graph drops from serde +
serde_derive + serde_json + toml + reqwest + rustls + tokio + hyper to
**twenty-five crates total**, none of which is any of those.

Networking follows nigig-mpesa/src/pages/exchange/api.rs: build an
HttpRequest, hand it to Cx::http_request keyed by a LiveId, match the reply
in handle_network_responses. There is no HTTP client, no TLS stack and no
async runtime in this workspace any more. That is not only leanness -- on
Android and iOS the platform stack is the only one that works without
shipping a second TLS implementation, so the `live` feature that gated
reqwest has been deleted rather than made default.

Correlating replies is where the real trap was. A scan of five markets puts
ten requests in flight and the replies come back in whatever order the
network gives them, so the LiveId has to say which market and which side.
RequestKey encodes both plus a generation counter and round-trips through a
u64 with the high bit set, so a LiveId Makepad derived from a name is never
decoded as a scan reply, and a late reply from a previous round is dropped
instead of folded into fresh data. Six tests cover the codec, including the
one that matters most: the two sides of one market must not share an id, or
the second reply overwrites the first and every spread is measured against
itself.

The transport allowlist is lifted from the nigig-mpesa review, which reached
the same conclusion I would have: Makepad exposes no certificate pinning --
its only TLS control is set_ignore_ssl_cert, which weakens verification --
so what is enforceable at this layer is that only HTTPS to p2p.binance.com
can be dialled at all. The tests cover the two ways a naive check leaks: the
lookalike host p2p.binance.com.evil.example, which passes any starts_with
test, and the userinfo smuggle https://p2p.binance.com@evil.example/, which
resolves to evil.example while reading as Binance.

Telegram is gone, as asked, and the app alerts itself: a banner, an unread
badge on the status line, an Alerts tab holding the history, and a chime.
Removing it removes a bot token from the threat model entirely -- a token in
a config file is a bot anyone who reads the file can drive -- and removes a
second network dependency from a tool already gated on one endpoint. One
test now records the *absence* of a bug rather than its fix: Twin_traders00
is a real merchant from the live capture, and its underscore previously had
to be escaped or Telegram rejected the whole message with a 400 and
delivered nothing. Rendering in our own UI deletes that failure mode, and
the test asserts the name appears unescaped.

The trade-off is stated in the README rather than glossed: an in-app alert
only reaches you while the app is running. No OS notification is raised, so
a minimised window is a missed alert.

The chime is synthesised rather than bundled -- a two-note rising blip
generated at the device's sample rate, which is a few dozen lines instead of
an audio asset shipped on three platforms, and which can therefore be
tested. It is, and the tests found the bugs you would expect from writing
audio: a freshly rendered chime starts *finished* so opening the output does
not announce itself at startup, both note edges fade so neither clicks, the
tail pads with silence rather than replaying whatever the buffer last held,
and a nonsense sample rate falls back instead of panicking. Rising rather
than falling because a falling interval reads as a dismissal and this is an
invitation to act.

The micro_serde migration surfaced two behaviours that differ from serde and
both bit before they were understood.

**micro_serde is strict by default.** deserialize_json errors on the first
key it does not model. Binance sends about forty fields per advert and we
model eight, so the strict parser cannot read the response at all -- and for
a config file it means an older build cannot open a file written by a newer
one. Everything uses deserialize_json_lenient, and a test pins that the
strict call *would* have failed, because the two differ by one word and the
strict one looks more correct.

**There is no #[serde(default)].** Optional config entries are modelled as
Option<T> on a Raw* struct and resolved into the real Config by hand. A few
more lines in exchange for two fewer dependency trees. config.toml became
config.json for the same arithmetic: micro_serde has no TOML reader, and
toml depends on serde, so a single config file would have dragged all of
serde back in through the back door.

Also worth recording: DeJsonErr implements Debug but not Display, and Debug
is the variant carrying line and column, so every error path formats it with
{e:?} deliberately rather than by accident.

CI gains a check that fails the build if reqwest, tokio, rustls, hyper,
serde, serde_derive, serde_json or toml reappears in the default graph. I
verified it fails by adding serde back to p2p-core and watching it match
serde_derive, then reverted. A gate that cannot fail is decoration.

136 tests in the default feature set and 36 more with --features ui,
including the RequestKey codec, which cannot be measured by the coverage job
because it lives behind the feature that needs Makepad. Coverage 96.92% with
ten per-file floors, up from eight -- chime.rs at 100% and client.rs at
95.69% are new. The desktop binary was built and linked to prove the app
half is real.
2026-09-01 09:37:20 +00:00
eaeebd3910 feat(p2p-intel): Binance P2P spread intelligence, shaped by what the live book actually contains
Some checks failed
p2p-intel / engine (push) Has been cancelled
p2p-intel / coverage (push) Has been cancelled
p2p-intel / makepad-app (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (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
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
nigig-map / test (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
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.
2026-09-01 09:13:43 +00:00
a5ba719d8d perf(cad): add measured sub-pixel LOD to the 2D path -- Phase 5
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
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Phase 4 reduced the number of tessellation calls, but it left the geometry volume unchanged: a zoomed-out 2,000-part plan still queued a full outline for every visible part. At the measured 200 m site view a 1 m part is only about 2.7 logical pixels across, so the remaining geometry was not resolvable detail.

Add a pure lod policy that projects plane extents into logical pixels, keeps selected and hovered parts full detail, and conservatively falls back to a full outline for malformed state. The renderer and CadViewport::frame_budget call the same policy. Ordinary sub-pixel parts use one bounded 2x2 filled marker, while FrameBudget reports full outlines, markers, strokes and fills separately.

Also fold the remaining 2D 1.2 margins into render_budget::VIEW_MARGIN, extend the structural benchmark and coverage harness, correct the phase documentation, and explicitly leave 3D mesh LOD deferred until a real GPU/window measurement justifies a second geometry policy.
2026-08-26 15:40:02 +00:00
a2b05c56c9 feat(makepad-table): opt-in capabilities feature, and raise the matrix_client defect
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
makepad-table / model (push) Has been cancelled
makepad-table / widget (push) Has been cancelled
makepad-table / hygiene (push) Has been cancelled
The two caveats from the dependency investigation.

## The capabilities feature

Camera and location attachments are now available behind
`features = ["capabilities"]`, which pulls `nigig-uikit` and supplies
`UikitAttachmentProvider`.

Measured: 89 crates by default, 275 with the feature on. That cost is real
and it is inherent, not packaging waste. `camera_widget` imports
`send_geocode_request` and `request_map_tile` from `nigig-core`, both of
which call `spawn_async` — the shared Tokio runtime — and the first makes an
HTTPS call to Nominatim. A camera that geocodes needs an async runtime and an
HTTP client; there is no lighter honest version.

It is affordable because it is opt-in, and because any app enabling it
already depends on `nigig-core`, so that app's own tree grows by nothing.

Everything touching `nigig-uikit` is in one module, so the boundary is a file
rather than `#[cfg]` scattered through the widget. The provider holds no
widgets of its own: the host owns the `CameraWidget` already in its tree and
this asks it to open, because a provider that instantiated a second camera
would fight the first for the device.

A second request while one is outstanding is refused rather than overwriting.
The table turns that refusal into `AttachmentUnavailable`, so the user is
told the camera is busy instead of watching their first request vanish.

File picking is deliberately declined here — `robius-file-picker` already
ships unconditionally and costs nothing, and two paths for one job is one too
many.

Two CI gates, both verified to fail when they should: the opt-in build must
keep compiling, and the default build must pull none of `tokio`, `reqwest`,
`hyper`, `clap`, `csv`, `image`, `nigig-uikit` or `nigig-core`. The second
checks the resolved `cargo tree` rather than the manifest, because feature
unification can switch an optional dependency on from a sibling crate.

Tests 99 default, 105 with the feature. Both clippy-clean.

## The matrix_client defect

Raised in REVIEWS/MATRIX_CLIENT_FEATURE_GATE.md rather than fixed. It is not
my crate, nothing depends on the broken combination, and a blind fix could
change behaviour someone relies on.

`matrix_client` declares `native = ["dep:tokio", "dep:reqwest",
"dep:rusqlite"]` but its source gates on `#[cfg(not(target_arch =
"wasm32"))]`. Two switches for the same modules, so on a native target with
the feature off the modules compile and their dependencies do not — 19
errors, 26 ungated uses across 7 files. There is no CI job for the crate,
which is why it rotted unnoticed.

The note corrects an overstatement I made while arguing for the trait hook.
I said fixing this would unblock wasm. It would not: `matrix_client` already
builds clean for wasm32 with `--no-default-features`, and `nigig-core` has 8
wasm errors of its own (`crate::platform::spawn` missing) that have nothing
to do with it. The only broken combination is native-target-with-feature-off,
which nothing builds.

I also said earlier that `matrix_client` was heavy — it is a 7-dependency
local crate, not matrix-sdk. That was wrong and it inflated the case for the
trait hook; the note records the measured numbers instead.
2026-08-18 18:03:26 +00:00
9989043a37 ci: gate the coverage that was already measured and unenforced (Phase 0)
Some checks failed
repo hygiene / hygiene (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
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
Phase 0 of REVIEWS/REPO_COVERAGE_100_PLAN.md, and the reason it is
Phase 0: no new tests, no new measurement, just ratchets on numbers that
were already good and already decaying-capable.

**spreadsheet** — `tools/test-spreadsheet-coverage.sh` has had a 96
floor for the engine and another for the UI controllers, and no CI job
has ever run it. New `.forgejo/workflows/spreadsheet.yml`, two jobs:

  engine-coverage          98.83% of lines (floor 96)
  ui-controller-coverage   98.85% of lines (floor 96)

Split in two because the halves cost very differently. The engine is
pure Rust and finishes in about three minutes; the UI half has to build
Makepad's Linux backend to link a test binary. One job would hide an
engine regression behind a ten-minute build.

**CAD widget layer** — `cad-widget-coverage` in nigig-build.yml,
deliberately REPORT-ONLY. It sits at 13.25% of 10,637 lines with six
files at exactly zero, and a floor there would read as a blessing
rather than a debt. What the job buys is that the number is printed on
every push instead of being rediscovered in six months. The first real
input test should set a floor behind it.

Also corrects the plan. It claimed the doc workspace module was
ungated; it is not — nigig-build.yml has run doc-workspace-coverage
since before the plan was written. I had surveyed by grepping workflow
files for the word "coverage" and attributed nigig-build's coverage
jobs to CAD alone. I nearly committed a duplicate workflow on the
strength of it. The census table was right; the prose under it was not,
and the correction is in the file.

One thing checked and deliberately NOT changed: the spreadsheet script
appears to skip its UI half when the native packages are absent. It
does not. `makepad-native-libs.sh --check` returns 1, the script runs
under `set -e`, and it aborts. What misled me was reading `$?` after
piping the script into `tail` — which reports tail's status, not the
script's. The same class of mistake this repository's CI comments warn
about; no fix was needed and none was made.

Verified by running each job's exact command line:
  COVERAGE_TARGET=engine ./tools/test-spreadsheet-coverage.sh   rc=0
  COVERAGE_TARGET=ui     ./tools/test-spreadsheet-coverage.sh   floors met
  ./tools/test-cad-widget-coverage.sh                           13.25%, rc=0
2026-08-18 10:20:22 +00:00
3928063392 ci(email): raise the domain floor; record the finance-email path
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
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
email.yml: FLOOR 225 -> 230. The review doc records the email-to-finance
sharing and notes the chat/Matrix path remains unbuilt (matrix_client has
login+sync only).
2026-08-18 10:07:54 +00:00
nigig-ci
6e784fffee fix(ci): the sample_thread gate was inverted under pipefail
Some checks failed
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
The 'Development sample data must not reach the UI' gate ran
  count=$(grep ... | wc -l)
under set -euo pipefail. When sample_thread is correctly ABSENT from
the UI, grep returns exit 1 (no matches), pipefail propagates it, and
set -e kills the script -- so the gate reported FAIL in the GOOD state
and would have passed in the BAD state.

Add || true so a zero-match result is counted as 0 and the gate
passes, as intended. Verified: all 11 gates now pass, and
sample_thread is confirmed gone from the UI crate (only in
email_store.rs, definition + tests).
2026-08-18 09:36:35 +00:00
nigig-ci
632479c964 fix(ci): email.yml has been invalid YAML for six commits
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
`python3 -c "yaml.safe_load(open('.forgejo/workflows/email.yml'))"` fails:

    mapping values are not allowed here
      in ".forgejo/workflows/email.yml", line 455, column 35

A workflow that does not parse does not fail -- it does not RUN. So every
gate in this file has been silently absent: the S2 password checks, the
multi-recipient regression check, the TLS check, the coverage floors. All
of them. The file has looked like protection while providing none.

Cause: the "Coverage floors" step was rewritten to call
tools/test-email-coverage.sh, and ten lines of the previous inline
implementation were left behind underneath the new `run:` scalar. YAML
reads the first `echo "$out" | grep -E '^test result:'` as a new mapping
key and gives up.

Broken by 3dab4a1 and still broken at 6bf138d -- six commits, every one of
which believed it was adding or tightening a gate.

Verified the removal is safe: tools/test-email-coverage.sh exists, is
executable, and enforces the floors itself (TOTAL_FLOOR=88 plus per-file
floors), so the orphaned lines were duplicating work the script already
does. Nothing was lost.

The repo's own repo-hygiene.yml WOULD have caught this -- it has an
"Every workflow file must be valid YAML" step, added precisely because
commit 8c9ccb9 once broke nigig-build.yml the same way and silently
disabled the CAD gates. I ran that step by hand against this tree and it
fails, correctly. It did not catch it because no runner is registered, so
repo-hygiene has never executed on these commits.

That is the actual lesson here and it is not about YAML: a gate that has
never run is indistinguishable from a gate that does not exist. This is
the third time in this crate's history that a check existed, looked
right, and was doing nothing.

After the fix: YAML valid, 4 jobs / 32 steps, all 12 source gates pass,
cargo check --all-targets clean on both crates, 213 email domain tests
pass, fmt clean.
2026-08-18 05:33:18 +00:00
6bf138d027 ci(email): cover the trip-report modules
Some checks failed
email.yml / ci(email): cover the trip-report modules (push) Failing after 0s
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
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Successful in 18s
doc-engine / coverage (push) Successful in 30s
doc-engine / consumer (push) Successful in 4m58s
nigig-map / test (push) Failing after 2m18s
sms / gates (push) Successful in 3s
sms / robius-sms (push) Failing after 11m46s
sms / android (push) Successful in 1m48s
sms / nigig-sms (push) Successful in 5m42s
sms / supply-chain (push) Successful in 7s
The domain test filter and floor (225) now include finance_report and
email_receipts, and test-email-coverage.sh instruments both new files.
Domain tests 216 -> 234; coverage 90.6% over 15 files. The review doc
records the new feature.
2026-08-17 12:08:19 +00:00
arena-agent
b478945c34 ci(doc): gate the doc-workspace coverage floor on every push
Some checks failed
email.yml / ci(doc): gate the doc-workspace coverage floor on every push (push) Failing after 0s
repo hygiene / hygiene (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
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
Adds the doc-workspace-coverage job to the nigig-build workflow,
mirroring the CAD gate: checkout, then
./tools/test-doc-workspace-coverage.sh, which installs its own
instrumented toolchain into a shell-trap-cleaned temp dir and fails
if the total floor (92%) or any per-file floor is not met. The script
joins the workflow's push/PR path filters next to
tools/test-cad-coverage.sh so edits to the harness itself re-run the
gate. The doc README gains the milestone section recording the
28.55% -> 96.76% line measurement, the honest exclusions (widget
layer, persistence write-path wrappers, defensive traversal guards)
and the behavior pins and defect fixes the drive surfaced.
2026-08-17 10:25:27 +00:00
b83e7122c4 feat(makepad-table): file picker, search, recents, New/Delete (Invoicer UI Phase 3)
Some checks failed
email.yml / feat(makepad-table): file picker, search, recents, New/Delete (Invoicer UI Phase 3) (push) Failing after 0s
repo hygiene / hygiene (push) Has been cancelled
makepad-table / model (push) Has been cancelled
makepad-table / widget (push) Has been cancelled
makepad-table / hygiene (push) Has been cancelled
The last open phase. The sidebar gains a search box, a filtered document
list and a recents list; the toolbar gains New Invoice/Quote/Receipt, Open,
Save As and Delete.

**The file picker is robius, not makepad's.** Makepad has an
`open_system_openfile_dialog`, and it is implemented on macOS only — the
Linux and Android backends never handle `CxOsOp::SelectFileDialog`, so the
op is queued and dropped. It compiles, it runs, the dialog never appears.
That is the worst kind of broken, so this uses `robius-file-picker`, the
same crate `nigig-build`, `nigig-pay-ui` and `nigig-sms` already depend on
at the same pinned revision, which goes through `rfd` on desktop and the
platform picker on Android. CI gates against the macOS-only call returning.

The picker's callback runs off the UI thread with no `Cx`, so it parks its
outcome in a mutex and signals; `drain_file_picker` applies it on the next
`Event::Signal`. Same shape as the SMS bulk CSV import.

Model additions, in `makepad-doc-model` so they are testable without a
window: `DocKind` with `blank()` constructors, `DocumentLibrary::create`,
`remove`, and `selection_after_remove`.

Decisions worth naming, because each has a wrong answer that looks fine:

- **A new document is empty**, not seeded from the samples. A blank invoice
  arriving with "Acme Studio LLC" on it invites someone to export it without
  noticing whose name is there. `issue_date` is blank too — there is no
  clock in that crate and a guessed date is worse than none.
- **Generated numbers cannot collide**, including with documents loaded from
  disk, and they reuse gaps left by deletions. The number becomes the
  filename: two documents called INV-1 save over each other and one is lost
  silently.
- **Delete removes the row, not the file.** Removing an entry from a list is
  not consent to delete a document off disk, and there is no undo here. The
  status line says the file is untouched.
- **Save reports "Choose where to save…", not "Saved."** The dialog being
  open is not the file being written.
- **Search filters on every keystroke**, unlike the header fields, which
  commit on Return. Every prefix of a query is a valid narrower search;
  there is no such thing as a half-typed one.
- **Searching does not move the selection.** Filtering is a view change, and
  switching the open document because a letter was typed loses the user's
  place.
- **`selection_after_remove` is separate and exhaustively tested.** Deleting
  before the selection shifts it, deleting the selection keeps the index
  unless it was last, deleting after it changes nothing, and emptying the
  library selects nothing. Every wrong answer silently shows a different
  document; one of them indexes out of range.

The document list is a fixed pool of 12 button slots rather than a
`PortalList`, because this app opens documents one at a time. The pool is
honest about its limit: anything past it renders as "+n more — narrow the
search to reach them" rather than being dropped.

Tests 79 -> 90. Six of them are the invoicer's first: `App` derives `Script`
and cannot be built outside a live `Cx`, so the sidebar's presentation logic
was extracted into four pure functions and tested there. Verified by
reintroducing six defects across the two crates — silent overflow, a
selection marker that shifts the indent, whitespace counting as a search,
colliding numbers, a selection that ignores the shift, and a `blank()` that
pre-fills.

Also fixed, all pre-existing and all now blocking the `-D warnings` gate
that has been running on these crates since the workflow was added:
`std::io::Error::new(ErrorKind::Other, _)` in two crates, a manual
`RangeInclusive::contains`, a manual `is_multiple_of`, a single-arm `match`,
and a duplicated `#[test]` attribute that was annotating one function twice
— which is why the count reads 36 rather than 37 here; no test was lost.

The sample data keeps its `12_000_00` money literals, where the last group
is the minor units and the number reads as "12,000.00" at a glance.
`inconsistent_digit_grouping` is allowed at the crate root with that
reasoning, rather than regrouping every amount into thousands and making
each one need arithmetic to check against its comment.
2026-08-17 10:01:43 +00:00
189377a3a3 ci(email): build the wasm path; document the closed §8 gaps
Some checks failed
email.yml / ci(email): build the wasm path; document the closed §8 gaps (push) Failing after 0s
repo hygiene / hygiene (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
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
nigig-map / test (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
email.yml: install the wasm32-unknown-unknown target and check the email
domain's credential-bearing wasm half (call_email_api, WasmFetchTransport,
set_email_api_url) so a browser-only breakage cannot reach main unseen.
The domain test floor ratchets 205 -> 210.

The review doc's §8 is rewritten: the TLS handshake, the wasm build, B1
and the test/clippy baselines are now executed/measured; the only entries
left are the ones that genuinely cannot run in CI (a live relay's cert, a
browser's fetch), stated with their exact reasons.
2026-08-17 09:39:04 +00:00
89ca5186c6 docs(pdf): Phase 4 is not 100% — audit it, and verify the half that is
Some checks failed
email.yml / docs(pdf): Phase 4 is not 100% — audit it, and verify the half that is (push) Failing after 0s
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
Asked whether Phase 4 was complete, I checked the tree instead of my own
commit message, and the commit message was wrong.

Three items named in the Phase 4 spec are **not** implemented, and the
status line said "complete" over them:

- **Field-value reconciliation** (`form_reconcile_test.dart`). Setting a
  value writes /V, marks the field dirty and regenerates /AP — that all
  works. What is missing is the reconciliation case: a file opened with
  /V and /AP *already disagreeing*, where the right answer depends on
  /NeedAppearances. Nothing decides that today.
- **Type1/CFF embedding.** The spec hedges with "if feasible", so this is
  a legitimate deferral rather than an oversight — but "complete" did not
  say so. `sfnt.rs` detects CFF outlines and `font.rs` reads an existing
  /FontFile3; nothing writes one. Creation is TrueType-only.
- **`repair-cmap`.** No equivalent exists.

`text_box_appearance_test.dart` *is* covered, by appearance.rs:235 — it
just does not carry that filename, which is why a grep for the dart test
names is a starting point and not an answer.

The other half of the exit criterion — "generated PDFs open cleanly in
external viewers" — had never been checked at all. The sample generator's
own doc comment admits no test in this repository can assert it. So I
ran it through implementations we share no code with, and **it passes**:

  qpdf --check           no syntax or stream encoding errors
  pdfinfo                title, author, subject, keywords, 2 pages,
                         Form: AcroForm
  pdftotext              all text, including the embedded DejaVu subset
                         and its em-dash
  qpdf --list-attachments  readme.txt, extracted by name with description
  catalogue              /Outlines /Names /EmbeddedFiles /PageLabels
                         /Dests /PageMode /ViewerPreferences /AcroForm

`tools/check-pdf-external-readers.sh` makes that repeatable, and pdf.yml
runs it. It treats a qpdf *warning* as failure, not just an error: qpdf
warns where it had to reconstruct, and reconstructing is exactly what a
stricter viewer will refuse to do. Negative-tested twice — removing the
attachment fails 3 checks, and corrupting the startxref offset makes
qpdf report "file is damaged".

Two defects that audit found:

- **The sample never exercised XMP**, so the Phase 4 feature most likely
  to be silently missing was also the one nothing looked at. Probed
  separately: `set_xmp_metadata` works, pdfinfo reports
  `Metadata Stream: yes`.
- **A `Banner` naming an unregistered font produces a structurally valid
  PDF that renders no text.** qpdf --check passes; poppler says
  `Unknown font tag 'F1'` and draws nothing. `stamp.rs` cannot register
  the font itself — fonts belong to the document, and a banner does not
  know which document it will be drawn into — so this is now documented
  on `Banner` with a worked example, and pinned by
  `a_banner_font_must_be_registered_or_the_page_lacks_the_resource`,
  which asserts on the page's /Font resources because that is the thing
  actually missing and the thing a caller can check.

The plan now records that it was wrong once, rather than quietly
correcting itself. A status line that has been overstated should show its
working.

Engine suite 952 -> 953. Phase 4's engine half is verified end to end
against third-party readers; the ui.rs interaction half is written and
still blocked on the Makepad headless backend.
2026-08-17 09:11:03 +00:00
fc0b1f287f ci(email): run the conversation-kit tests; mark Phase E complete
Some checks failed
email.yml / ci(email): run the conversation-kit tests; mark Phase E complete (push) Failing after 0s
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
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
nigig-map / test (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
email.yml: the domain test floor ratchets 190 -> 205, and the nigig-email
job runs cargo test -p nigig-uikit --lib -- conversation so an email-driven
regression in the shared kit cannot silently surface in SMS.

The review doc marks E1-E6 done and records the honest correction E5
surfaced: lettre's timeout bounds only the TCP connect, not the
greeting/command reads — the send path now bounds the whole operation.
2026-08-17 05:09:15 +00:00
arena-agent
228bc2c81f ci(doc-engine): gate the engine coverage, and note it in the doc README
Some checks failed
email.yml / ci(doc-engine): gate the engine coverage, and note it in the doc README (push) Failing after 0s
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
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
New coverage job runs tools/test-doc-engine-coverage.sh on changes to
crates/apps/doc/**, the script itself, or the workflow. A coverage
number nobody gates goes down; the floors (total plus per-file) are the
enforcement. The doc workspace README records the milestone and the two
CRDT-tolerance behaviors the new tests pin.
2026-08-17 04:33:08 +00:00
2a74c6cac4 ci(email): gate the keystore feature, cover email_bulk
Some checks failed
email.yml / ci(email): gate the keystore feature, cover email_bulk (push) Failing after 0s
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
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
nigig-map / test (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
email.yml: the feature-compile check now covers imap,keystore together.
test-email-coverage.sh instruments email_bulk.rs (91.9% line) alongside the
rest of the domain; total 89.84%, floors enforced.

The review doc records C6/C7/C1f as fully closed, with the honest caveats
unchanged (network sockets and the OS vault are compile-checked, not
runtime-verified).
2026-08-17 04:29:30 +00:00
ab17c72c55 feat(makepad-table): drag-reorder columns, and the first tests this crate has
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
email.yml / feat(makepad-table): drag-reorder columns, and the first tests this crate has (push) Failing after 0s
makepad-table / model (push) Has been cancelled
makepad-table / widget (push) Has been cancelled
makepad-table / hygiene (push) Has been cancelled
Phase 4 of the README's table, plus the test infrastructure Phases 1-3 never
had. The crate had zero tests before this; it now has 21.

Drag-reorder:

- A press on a column header no longer commits to an action. It arms a drag
  and resolves on release: travel more than 8px and it reorders, release
  without travelling and it opens the column menu as before. Without that
  ambiguity resolved, every menu open would jitter into a one-pixel drag.
  The threshold matches `TouchTracker::MOVE_THRESHOLD` so a mouse and a
  finger agree on what a drag is.
- While dragging, the carried column is tinted full-height and a 2px bar
  marks the boundary it would land on. The bar is suppressed when the drop
  is a no-op, so no bar means nothing will happen rather than a bar sitting
  misleadingly at the source edge.
- `TableAction::ColumnMoved { from, to }` fires only when the index actually
  changed, so a host persisting column order is not asked to write on every
  wobble. An open cell editor is cancelled, because it addresses a cell by
  index and the indices just moved underneath it.

`draw_drag: DrawVector` — declared, never used anywhere — is replaced by two
`DrawColor` layers. `DrawVector` is a full tessellator with path, vertex,
index and paint state; a translucent rectangle and a vertical bar do not
need any of it.

Testability, which needed a structural change rather than a test file:

`Table` derives `Script` and `Widget`, so it has no `Default` and cannot be
constructed without a live `Cx`. Nothing about it was unit-testable. The
logic worth testing does not need a widget, so it moved off it —
`ColumnGeometry` owns boundary and drop-position arithmetic, and a free
`reorder_columns` owns the move. `Table` forwards to both, and
`compute_layout` now goes through `ColumnGeometry` too, so there is one
implementation rather than two that can drift.

The 21 tests cover column geometry at even and uneven widths and at a
non-zero origin, drop-position resolution including the exact-midpoint case
and clamping outside the table, the index shift in both directions, no-op
drops, out-of-range refusal, cells travelling with their header, ragged
rows, a permutation property over repeated drags, and the Phase 3 menu's
geometry and hit-testing.

Verified by reintroducing three defects separately: removing the shift for
the removed source column fails 7 tests, dropping the no-op guard fails 1,
and moving headers without their cells fails 3.

Phase 3 was marked "scaffolds only" in the README and was in fact
substantially complete — menu state, open, hit-test, apply, and drawing all
present, with 15 row and column actions wired. Corrected to done, with its
geometry now under test.

Also adds `.forgejo/workflows/makepad-table.yml`, the first CI this tree has
had. Every step passes `--manifest-path` explicitly: the crate is excluded
from the root workspace, so `-p` from the repo root cannot reach it and
`--workspace` skips it — omitting the flag does not fail loudly, it silently
tests nothing. The workflow gates tests, clippy at `-D warnings` and fmt,
and asserts three invariants that would otherwise regress quietly: that the
exclusion still holds from both sides, that no manifest tracks a git branch
instead of pinning a revision, and that monetary fields stay integer.

Each gate was checked by breaking what it protects. The exclusion check
caught a defect in itself while being tested: a bare grep for the path also
matched the explanatory comment above the exclude list, so deleting the
entry and keeping the comment passed. It now anchors on the quoted entry.

Two pre-existing clippy warnings fixed so the new `-D warnings` gate starts
from zero.
2026-08-17 04:22:22 +00:00
1c91d6b398 ci(cad): gate the engine coverage, with per-file floors
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
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
email.yml / ci(cad): gate the engine coverage, with per-file floors (push) Failing after 0s
repo hygiene / hygiene (push) Has been cancelled
The harness measured; nothing enforced. A coverage number nobody gates
goes down.

tools/test-cad-coverage.sh now exports llvm-cov JSON and fails when the
total drops below 85% or any of the fourteen engine files drops below
its own floor. The per-file floors are the point: deleting every test in
persistence.rs moves the total by under two points, so a single number
would wave that through. Each floor sits a couple of points under
today's measurement, so refactoring does not trip it and a real loss
does.

The low floors are the honest ones. arch_pdf (72) and arch_gltf (72)
have gaps in byte-layout paths that only a real PDF or GLB consumer
reaches; arch_svg (79) and cad_scene (78) have gaps in widget-facing
helpers and defensive arms on invariants SceneBuilder already enforces;
exporters (88) cannot reach the save-dialog branch without a windowing
system. Raising those needs work, not a bigger number here.

Also in this commit, from running the script the way CI will rather than
with a warm local checkout:

  - the Makepad fetch is sparse + blobless + depth 1 over the actual
    path-dependency closure (math, csg and its six siblings,
    micro_serde, its derive, micro_proc_macro, live_id, id_macros).
    29 MB and two seconds instead of a 319 MB checkout of a repository
    that is mostly shaders, fonts and demos. Two of those crates were
    found by the run failing at manifest-read time, which is why the
    script now verifies all thirteen manifests exist before building
    instead of trusting the sparse pattern.

The new cad-engine-coverage job needs no native packages and no GPU --
makepad-math and makepad-csg are dependency-free Rust, which is the
whole reason the engine can be measured at all. It installs its own
toolchain into a temp dir and deletes everything through a shell trap:
nothing cached between runs, nothing left in the workspace.

Verified end to end with a cold run: fresh toolchain, fresh sparse
fetch, 466 tests green, total 88.75%, all floors met, environment
cleaned.
2026-08-16 22:26:42 +00:00
b87d8b0762 test(email): coverage over the full domain; IMAP feature gate in CI
Some checks failed
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
email.yml / test(email): coverage over the full domain; IMAP feature gate in CI (push) Failing after 0s
nigig-map / test (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
repo hygiene / hygiene (push) Has been cancelled
tools/test-email-coverage.sh now instruments all twelve email files
(the new pacing, credential-store, cache, session and imap modules) and
enforces per-file floors; measured 90.7% line coverage over the domain.

email.yml: the domain test filter gains imap_client::/credential_store::,
the test floor ratchets 150 -> 190, the sample-data gate is now a hard
zero (sample_thread is test-only), and a new step checks the feature-gated
IMAP transport still compiles.

The review doc marks Phase C and Phase D complete with the honest
caveats (sockets/keystore/pool-reuse are not host-verified).
2026-08-16 22:22:55 +00:00
3dab4a1fd5 test(email): coverage floors for the email domain
Some checks failed
email.yml / test(email): coverage floors for the email domain (push) Failing after 0s
repo hygiene / hygiene (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
tools/test-email-coverage.sh instruments the nigig-core email domain
and enforces a whole-domain floor (90%) plus per-file floors on the
files that harboured the bugs. It runs in an isolated temp dir and
reports over only the seven email source files, excluding Makepad's
generated code. Wired into email.yml, which also now runs mail_proxy
tests and ratchets the domain test floor to 150.

Measured 93.4% line coverage across the domain.
2026-08-16 21:49:12 +00:00
nigig-ci
c0b27d0586 feat(email): MailBackend trait and BackendKind — both backends (C1a/C1b)
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (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
You chose to support IMAP-on-device AND a server-side proxy, user
selectable. This is the seam that makes that contained rather than two
parallel apps.

Why it is cheaper than it sounds: wasm cannot open a raw TCP socket, so a
proxy always had to exist for the browser target. The second backend was
never optional -- it was implied scope nobody had named.

C1a, mail_backend.rs:

  BackendKind { ImapSmtp, ProxyApi } with three predicates that exist so
  the UI cannot get them wrong:

    is_available_on_wasm()      IMAP is raw TCP; a browser cannot open one,
                                so the chooser must not offer a dead option
    stores_reusable_password()  IMAP keeps a REUSABLE mailbox password on
                                the device. For most people that is the
                                password-reset channel for every other
                                account they own. A revocable proxy token
                                is strictly safer, and the chooser must say
                                so rather than presenting a free choice
    summary()                   the honest one-liner, asserted by test to
                                actually mention "password" / "revoke"

  BackendSettings is the PERSISTABLE half and carries no secret, exactly
  as EmailAccount does for the password (S2). BackendDraft::validate
  returns (settings, Secret) and reports every problem in one pass.

  The trait is deliberately synchronous and tiny -- kind(), is_configured(),
  describe(). Anything computable above the line (grouping, previews,
  threading) is NOT a backend concern, which is why email_store did not
  change at all. I/O stays in the free functions that already own the async
  context, so this file is host-testable with no runtime.

  ImapSmtpBackend exists with validation but no protocol client yet; that
  is C1e and nothing here claims a connection works.

C1b: EmailAccount gained `backend: BackendSettings`, #[serde(default)] so
existing persisted accounts still load. A test asserts the serialised
account -- including the backend section -- contains neither the token nor
a field named password/token.

Provider defaults now fill IMAP too, so a Gmail user still fills one
field. Outlook is special-cased: its IMAP host is outlook.office365.com,
not imap.outlook.com, so the naive smtp->imap rewrite would produce a name
that does not resolve.

New gate, negative-tested both ways: stores_reusable_password() and
is_available_on_wasm() must exist, and the persisted settings structs must
not declare password/token/secret fields.

Domain tests 99 -> 126. Test floor 95 -> 120.
2026-08-16 20:30:33 +00:00
nigig-ci
901cddc716 fix(email): abandon_send shipped as dead code; wire it and gate it (B6)
Some checks failed
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Auditing Phase B against the tree rather than against my own notes found
that abandon_send() existed in nigig-core and NOTHING called it. The user
had no way to stop waiting on a hung send. I had marked B6 "partial" for
the right reason -- lettre cannot cancel mid-transaction -- and missed
that the part I did implement was unreachable.

A control the user cannot reach is not a control. It is dead code wearing
a safety label, which is worse than an acknowledged gap because it reads
as done.

Now wired: while a send is in flight the Send button becomes "Stop
waiting". The label is deliberately not "Cancel" -- this does not stop
delivery, because once DATA is accepted the message is sent whether we
wait for the reply or not. It frees the UI and suppresses a result the
user has stopped caring about. The 20s timeout from A6 bounds the window.

New gate: abandon_send() must exist in nigig-core AND be called from the
UI. The wiring is the thing checked, not the function.

That gate was ALSO broken when first written -- it grepped for
`abandon_send()` across src/, and the comment block explaining why the
control exists mentions it by name, so unwiring the call left the gate
green. Same flaw as the B5 gate in the previous commit, found the same
way: delete the fix, watch the gate. Now excludes comment lines.

Twice in two commits I have written a gate that its own explanatory text
satisfied. Worth stating rather than quietly fixing: a gate is only
evidence if you have watched it fail.

Phase B verified closed: B1-B6 all done, 11 gates pass, 99 domain tests,
check --all-targets clean on both crates, fmt clean.
2026-08-16 20:04:41 +00:00
nigig-ci
d889cbecd4 ci(email): gate multi-recipient send, and a gate that did not work
Some checks failed
repo hygiene / hygiene (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
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
Two new gates, and one of them was broken when I first wrote it.

B1 gate: the send path must call email_send::parse_recipients, must NOT
contain a single-Mailbox parse of the whole To field, and must add every
accepted recipient. Three checks rather than one, because each failure
mode is separately reachable.

B5 gate: spawn_send_email must keep the SEND_IN_FLIGHT swap.

THE B5 GATE DID NOT WORK AS FIRST WRITTEN. It grepped the whole file for
`SEND_IN_FLIGHT.swap(true`, and the unit TESTS for the guard contain that
same string -- so deleting the guard from production code left the gate
green. I found it by negative-testing, which is the only reason I know.
Now scoped to the text before `#[cfg(test)]`.

That is worth recording rather than quietly fixing: a gate whose own test
fixtures satisfy it is indistinguishable from a gate that works, and the
only way to tell them apart is to break the thing on purpose.

Negative tests, all confirmed firing:
  remove the list parse                     -> fires
  reintroduce `let to_mbox: Mailbox = ..`   -> fires
  delete the in-flight guard                -> fires (after the fix)
and all 10 gates pass on the clean tree.

Test floor 60 -> 95 (actual 99).

Bulk page: builds through EmailSendRequest, so a partly-invalid list
reports what was dropped instead of refusing everything, and requires a
second tap before sending. The prompt quotes the recipient count and any
duplicates or rejections, so the user knows what they are confirming.
Editing the message after arming re-prompts rather than sending the old
confirmation.
2026-08-16 19:51:21 +00:00
nigig-ci
b3d562f4e2 ci(email): gate the Phase A security properties, and surface the warning
Three new gates in email.yml, each negative-tested by reverting the fix
and confirming the gate fires:

  1. SmtpConfig.password must be a Secret, AND SmtpConfig must not derive
     Serialize/Deserialize. Two separate checks, because either alone
     re-opens S2: a Secret that gets serialised is still exposed, and a
     String that never gets serialised still Debug-prints.

  2. set_email_api_url must call email_api_url_is_safe. Checks both that
     the validator exists and that the setter uses it -- a validator
     nobody calls is decoration.

  3. tls_mode_for_port must exist, and Tls::None / Tls::Opportunistic must
     not appear. Opportunistic is the dangerous one: it silently accepts a
     downgrade, which is exactly the attack starttls_relay's Tls::Required
     prevents.

Negative tests, all confirmed firing:
  password: Secret -> String              gate fires
  re-add #[derive(.., Serialize)]         gate fires
  remove the validator call               gate fires
  introduce Tls::None                     gate fires
and all 8 gates pass on the clean tree.

Domain test floor raised 38 -> 60 (actual: 68) and the filter widened to
include the secret:: module, so the new tests are actually covered by the
floor rather than sitting outside it.

Also surfaces config_warning() in the setup flow, so a from/username
mismatch is shown while the user can still fix it, rather than becoming a
silent provider rejection later.

One YAML trap worth recording: the test filter ends in `secret::`, and a
bare trailing colon makes YAML parse the line as a mapping. The run string
has to be quoted. Caught by validating the workflow before committing,
which is the only reason this is not a broken pipeline.
2026-08-16 19:22:40 +00:00
cf73ef4c1d test(pdf): assert what a file declares is delivered, and floor the coverage
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
Every serious bug in this stack has had one shape: a valid, well-typed,
empty-or-default value where the file plainly declared content. xobjects
empty for every document; acroform() dropping every field behind an
indirect reference; DCTDecode returning its own compressed bytes; a JPEG
decoder that was a stub returning black. None errored, none panicked, and
the tests asserted Ok, which they got.

Coverage would not have caught any of them. Measured when each shipped:
page.rs 92.4%, form.rs 93.6%, content.rs 89.2%, xref.rs 95.2%. The buggy
lines ran; nobody checked what they produced.

So: a property test that walks the raw object graph of every corpus
fixture, counts what the file declares, and requires the API to deliver
it - fonts, xobjects, graphics states, colour spaces, form fields,
filters, MediaBox. It reimplements the resolution rule independently of
page.rs on purpose; a test that asks the code under test what to expect
agrees with the bug.

It failed the day it was written, on a shape the corpus had never
contained. Every fixture wrote /Resources inline, and all six extractors
read it with dict.get_dict("Resources") - which returns None for an
indirect reference and never consulted /Parent. A page with
"/Resources 5 0 R", the commonest shape in real PDFs, reported no fonts,
no xobjects, no graphics states and no colour spaces. Same for a page
inheriting resources from its /Pages node. Empty, not wrong, so nothing
failed.

Fixed by resolving /Resources once in PdfPage::from_obj through a helper
implementing the full inheritance rule (32000-1 Table 30), and passing
the resolved dictionary down. Indirect /MediaBox entries resolve too.
Six resources/ fixtures cover the shapes that were missing.

Mutation-checked: reverting inheritance kills 5 tests, the sub-dict
reference 3, indirect MediaBox 2, and removing the depth bound hangs.
One mutation survived - a visited-set guarding a /Parent cycle, which
the depth bound already handles - so it was deleted rather than left as
untested defence with a reassuring comment.

tools/test-pdf-coverage.sh enforces a floor instead of printing a number,
with per-file floors as well as a total: image.rs could fall from 33% to
5% and move the total by under a point. All three failure modes verified
to fail. It caught a bug in itself first - its ignore regex matched its
own work directory and reported a confident TOTAL 0.00%.

.gitattributes marks *.pdf binary. An xref entry must be exactly 20 bytes
(7.5.4), so with a one-digit generation field it ends in a space, and
git diff --check was reporting unfixable "trailing whitespace" on every
fixture in the corpus.

TEST_TARGET=pdf: 695 passed, 0 failed (was 680). Coverage 83.42%.
ADR 0017 records the four mutations so they can be repeated by hand.
2026-08-16 19:04:57 +00:00
nigig-ci
7751e96c54 ci(email): give nigig-email a CI workflow, and fix two bugs it caught
(Phase 0.3, 0.6)

nigig-email had no CI of any kind. That is how a binary with unbalanced
braces reached main and stayed there -- `cargo check -p nigig-email`
failed while `--lib` passed, so the library was fine and the BINARY had
never compiled once. It is also how four unused dependencies survived.

Four jobs:

  gates          4 source scans, no toolchain, fail fast
  email-domain   the 38 pure tests in nigig-core + a floor
  nigig-email    check --all-targets, test, fmt, clippy ratchet
  supply-chain   unused deps, lockfile, whitespace

`--all-targets` is deliberate in the check step: `--lib` alone passed for
the entire time main.rs was syntactically invalid, which is precisely the
failure this job exists to prevent.

Phase 0.6: fmt is a HARD gate here, not report-only. The crate already
formats clean so there is no pre-existing drift to grandfather in --
unlike sms.yml and nigig-map.yml, which inherited hundreds of diffs and
had to settle for reporting.

WRITING THE GATES FOUND TWO REAL BUGS, both in bulk.rs:

  B3 -- `port_t.parse().unwrap_or(587)` was still live. A typo'd port like
  "465x" silently became 587, and because the port selects the transport
  (465 implicit TLS vs 587 STARTTLS) that silently changed the security
  posture with no message. Now routed through AccountDraft::validate,
  which is unit tested in nigig-core and returns
  AccountError::PortInvalid.

  B2 -- the handler read five TextInputs and built an SmtpConfig on EVERY
  action event: ten heap allocations per keystroke, per scroll, per timer
  tick from any widget in the app, for a struct only read on click. It
  also captured whatever the fields happened to hold when an unrelated
  action fired. Now read on click.

I also got a baseline wrong and corrected it. I set the clippy ratchet to
2, having seen two `unexpected_cfgs` warnings for native_activity from
the app_main! macro. Measuring with the same dedupe the script uses gives
0 -- those two attribute to the bin target and are filtered by the
package_id check. A baseline above the real count is not a harmless
margin: the script fails when n < BASELINE precisely so slack cannot hide
a regression.

Every gate negative-tested:
  password field on EmailAccount        -> fails
  unwrap_or(587) in non-comment code    -> fails
  a new clippy warning                  -> fails (0 -> 2)
  test floor raised above actual        -> fails (38 < 99)
  and all pass on the clean tree.

Two of my own regexes were too strict on the first run and are fixed
here: the port gate matched the comments that document the old behaviour,
and the sample-data gate counted the `use` import as a call site. A gate
that trips on its own rationale is a gate nobody keeps.

Verified: check --all-targets clean; 41 tests pass; fmt clean;
clippy 0 at baseline 0.
2026-08-16 18:35:39 +00:00
nigig-ci
964fd5d4ef build(email): drop three unused dependencies, and gate the platform one
(Phase 0.5)

nigig-email declared four dependencies its source never mentions:

    serde            0 references in src/
    serde_json       0
    robius-location  0
    chrono           1  <- KEPT, see below

robius-location is the same defect SMS Phase B removed from nigig-build,
nigig-core and nigig-uikit: it drags polkit/gio/glib into the dependency
graph, which is where RUSTSEC-2024-0370, RUSTSEC-2024-0429 and an
LGPL-2.1 distribution question come from -- for code that is never
called.

A CI gate already exists to stop that regressing ("The removed platform
deps must not come back"), but its manifest list covered only three
crates and nigig-email was not one of them. Added it, so this cannot come
back the way it did here.

Correction to the assessment: it listed chrono as unused. That was true
when written and is no longer -- inbox.rs::format_thread_time uses it for
list-row timestamps. Kept, with a comment saying why, so the next person
auditing this file does not delete it and break the build.

Gate negative-tested: appending robius-location back to the manifest
produces
  ERROR: crates/apps/nigig-email/Cargo.toml declares robius-location
         but never uses it
and removing it passes again.

Verified: cargo check -p nigig-email --all-targets -> 0 errors;
41 tests still pass (38 nigig-core email_*, 3 nigig-email).
2026-08-16 18:35:39 +00:00
nigig-ci
422a0e3388 docs(ci): all jobs green; note that this is a starting line
All checks were successful
repo hygiene / hygiene (push) Successful in 3s
cad-module was the last red job and now passes. Board updated.

Also records that pdf.yml/fuzz reporting "skipped" is correct -- it is
gated on schedule || workflow_dispatch -- so nobody spends time
investigating it as a failure.

The caveat stays prominent: several jobs are green because their gate
is deliberately loose (the nigig-map unit-test ratchet sits at 9 real
failures, and its fmt/clippy steps are report-only). Those are listed
under Known-not-gated so a full green board is not mistaken for a
healthy codebase.
2026-08-04 06:57:21 +00:00
nigig-ci
e89dea347a ci: name the nigig-build formatting gate for what it actually checks
All checks were successful
nigig-build (CAD) / supply-chain (push) Successful in 2m58s
nigig-build (CAD) / cad-module (push) Successful in 7s
nigig-build (CAD) / full-crate-check (push) Successful in 4m32s
repo hygiene / hygiene (push) Successful in 4s
The step was called "Formatting (CAD module)" but runs
`cargo fmt -p nigig-build`, which is the entire crate. Of the 1,559
diffs it reported on its first real run, the three worst files were
doc/widgets/doc_widget.rs, doc/tests.rs and project_management/mod.rs
-- none of them CAD. Anyone debugging the red job was pointed at the
wrong directory.
2026-08-04 06:47:16 +00:00
nigig-ci
afe7c518b3 docs(ci): nigig-map is green; record what is deliberately not gated
All checks were successful
repo hygiene / hygiene (push) Successful in 3s
16 of 17 jobs now pass. Updates the board and adds two sections:

- Known-not-gated: the nigig-map unit-test ratchet (9 real logic
  failures), the three test/bench targets that do not compile, and the
  report-only fmt/clippy steps. Written down so nobody reads a green
  tick as "this crate is healthy".

- Writing a ratchet step: the `bash -e` trap that made this workflow
  fail at exactly its own baseline, and the `|| status=$?` fix. Cheap
  to record, expensive to rediscover.

Also corrects the cad-module note: `cargo fmt -p nigig-build` is 1,559
diffs across 89 files spanning doc, project_management and
cost_estimator, not just CAD.
2026-08-04 05:22:14 +00:00
nigig-ci
833181faec ci(map): fix the ratchet aborting before it could evaluate anything
All checks were successful
nigig-map / test (push) Successful in 5m41s
repo hygiene / hygiene (push) Successful in 4s
First real run of nigig-map.yml reported failure at 530 passed /
9 failed -- exactly the baseline it was supposed to allow.

The step ran `out="$(cargo test ...)"` under the runner's `-e` shell.
cargo test exits 101 while any test fails, and a failing command
substitution in a plain assignment aborts the step immediately, so
neither the parse nor the comparison ever executed. The `set -o
pipefail` I had added made it worse, not better.

`|| status=$?` puts the assignment inside a tested compound command,
which -e exempts, so the script keeps control and decides for itself.

Verified against the same `bash -e` the runner uses:
  at baseline      530 passed / 9 failed  -> exit 0, "OK"
  regressed        527 passed / 12 failed -> exit 1, "12 failing ...
                                             baseline is 9"
  restored         530 passed / 9 failed  -> exit 0

My bug, introduced in de698b1. The rest of that workflow was sound:
the same run proved checkout, native deps, the pinned-toolchain
install and the build gate all pass, which is the first time this
crate has ever built in CI.
2026-08-04 05:15:00 +00:00
nigig-ci
de698b1a64 ci(map): make the workflow runnable, and cover the code it now guards
Some checks failed
nigig-map / test (push) Failing after 2m37s
repo hygiene / hygiene (push) Successful in 4s
nigig-map.yml has never executed a single step. It used
actions/setup-rust@v1, which does not exist on data.forgejo.org, so
every run died in "Set up job" with "repository not found" and
cancelled all seven steps -- the same class of defect as
android-actions/setup-android in sms.yml. Replaced with the inline
rustup install already used by pay-domain.yml.

That action also requested `toolchain: stable`, contradicting the
1.97.1 pin in rust-toolchain.toml. The replacement reads the channel
out of rust-toolchain.toml, so CI and developers use one compiler.

Added the native GL/wayland dependencies; Makepad does not build
without them.

Gates, scoped to what is honestly true today now that the crate
compiles:

  - Build is a hard gate. This is the regression that matters: until
    the previous commit the crate did not compile at all.

  - Unit tests are a RATCHET at 9, not a hard gate. 535 unit tests
    existed and had never run; 526 pass and 9 fail on real logic
    (4 mvt_parser, 1 overpass_parser, 4 sprite classification). Failing
    the build on those would mean a permanently red job that everyone
    learns to ignore. The ratchet fails the moment a tenth appears.

  - `cargo test` with no filter is NOT used: two of the four test
    targets and the criterion bench do not compile (tests/ui.rs imports
    makepad_widgets::makepad_test; tests/makepad_visual_tests.rs and
    benches/tile_decode_bench.rs import pub(crate) modules, and
    criterion is not a declared dev-dependency). Separate defects.

  - fmt and clippy report without gating, matching doc-engine.yml and
    sms.yml. rustfmt could not parse view.rs while the crate was broken
    so it skipped all of src/; there are now 392 visible pre-existing
    diffs and 132 clippy warnings. A step that always fails is worse
    than no step.

Also added four unit tests for center_lat() and meters_per_pixel().
Both were introduced in the compile fix and had zero coverage: I
verified that by regressing center_lat() by +1.0 degree and watching
the ratchet stay green at 9. It now fails at 12. The tests round-trip
the projection across eight latitudes, pin the equator to zero, check
hemisphere sign, and assert the ground scale ratio between 0 and 60
degrees is cos(60) = 0.5 -- the position puck's accuracy circle is
sized from that, so an inversion would be wrong by 2x at Nordic
latitudes.

Ratchet negative-tested both ways: perturbing lon_lat_to_normalized
takes it 9 -> 12 and fails; at HEAD it reports 530 passed, 9 failed
and passes.
2026-08-04 05:06:48 +00:00
nigig-ci
ea7788673d docs(ci): record the repo-hygiene scheduling bug and the current board
All checks were successful
repo hygiene / hygiene (push) Successful in 4s
Adds the fourth defect the first real runs exposed -- the mapping form
of `on:` not being scheduled on this instance -- and a table of the
latest result for all 17 jobs, so "is CI green" has an answer that is
not someone's memory.

14 pass. The two failures, cad-module formatting and nigig-map, are
pre-existing source problems rather than CI plumbing.
2026-08-02 10:38:06 +00:00
nigig-ci
4d627496d2 ci: use the list form of on: so repo-hygiene actually runs
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
repo-hygiene.yml is the one workflow with no path filter. Its whole
purpose is to run on every commit, because the other six are scoped
with `paths:` and a commit touching only unfiltered files otherwise
gets no checks at all. The file's own header comment explains this,
citing commit 8c9ccb9, which pushed 30 conflict-marker lines into two
workflow files and silently disabled the CAD gates.

It has never run. Not once. Of the first 47 task records after a runner
was registered, every other workflow appears and this one does not,
across pushes that touched .forgejo/, tools/, crates/ and Cargo.lock.

The cause is the mapping-with-null-values form:

    on:
      push:
      pull_request:

Valid YAML, both keys parse as None, and it is the spelling GitHub
documents for "all branches". This instance does not schedule it. The
list form does.

So the workflow that exists to catch silently-disabled checks was
itself a silently-disabled check.
2026-08-02 10:09:28 +00:00
nigig-ci
89f7c1df2a docs(ci): how to register a runner, and what the first runs found
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
Not in the repo root -- this lives next to the workflows it describes.

Covers registering a forgejo-runner against gitdab (Gitea 1.22),
the ubuntu-latest label every job depends on, the docker-vs-host
tradeoff, and how to read job logs given that this instance's REST
API 404s on the logs endpoint.

The important section is action resolution: Forgejo resolves `uses:`
against data.forgejo.org with no github.com fallback, and a missing
action fails the job in "Set up job" before any step runs -- which
reads like an infrastructure blip rather than a config error. Records
which actions currently resolve and which do not.

Also records the four defects the first real runs exposed, three now
fixed, so the next person understands why these workflows look the way
they do.
2026-08-02 09:58:08 +00:00
nigig-ci
bda0124992 ci(pay): pass --config to cargo-deny's check subcommand, not the binary
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
repo hygiene / hygiene (push) Has been cancelled
The dependency audit step has never audited anything. cargo-deny 0.18.6
exits 2 immediately:

    error: unexpected argument '--config' found
    tip: 'check --config' exists

--config belongs to the `check` subcommand, and its path resolves
relative to the manifest rather than the working directory, so it also
has to be absolute. nigig-build.yml already gets both right; this
invocation predates that fix.

This step is the last one that runs in isolated-payment-tests, so its
failure also skipped the three gates behind it:
  - Payment crates must not depend on Makepad
  - Domain and storage must not reach the platform SDK
  - Mock gateway must not compile into a release build

Verified with cargo-deny 0.18.6 against all three crates:
  nigig-pay-domain    advisories ok, bans ok, licenses ok, sources ok
  nigig-pay-storage   advisories ok, bans ok, licenses ok, sources ok
  nigig-pay-platform  advisories ok, bans ok, licenses ok, sources ok

Found by running the workflow on a real runner for the first time.
2026-08-02 09:42:46 +00:00
nigig-ci
892471c73f ci: commit tools/*.sh executable, and gate the mode
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
Payment domain, storage, platform and UI / isolated-payment-tests (push) Failing after 2m19s
Payment domain, storage, platform and UI / payment-ui-tests (push) Has been cancelled
Five of the six scripts under tools/ were committed mode 100644. Every
one of them is invoked with a leading ./ from pay-domain.yml or
pdf.yml, so those steps could only ever fail:

    ./tools/makepad-native-libs.sh: Permission denied
    ./tools/test-mpesa-store-clean.sh: Permission denied

Both are real failures from run 349, the first time a runner existed to
execute pay-domain.yml at all. They fail at the job's first substantive
step, so payment-ui-tests did no work whatsoever and
isolated-payment-tests skipped its last seven gates -- including the
dependency audit, the "payment crates must not depend on Makepad"
check, and the mock-gateway-in-release guard.

The mode is a property of the index, so a local chmod that is never
staged does not fix it. Marked all five executable with
`git update-index --chmod=+x` and added a hygiene gate that fails if any
tracked tools/*.sh is not 100755.

repo-hygiene.yml is the right home: it has no path filter, needs no
toolchain, and already exists to validate CI configuration itself.

Gate negative-tested: reverting one script to 100644 fails it with
"tools/makepad-native-libs.sh is mode 100644, expected 100755";
restoring the bit passes.
2026-08-02 09:28:52 +00:00
1d3e6ab72a fix(pay): correlate USSD callbacks to the payment that asked for them
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
repo hygiene / hygiene (push) Has been cancelled
doc-engine / consumer (push) Successful in 3m56s
Payment domain, storage, platform and UI / payment-ui-tests (push) Failing after 4s
doc-engine / engine (push) Successful in 15s
nigig-map / test (push) Failing after 2s
Payment domain, storage, platform and UI / isolated-payment-tests (push) Failing after 1m58s
sms / gates (push) Successful in 3s
sms / robius-sms (push) Successful in 22s
sms / android (push) Successful in 21s
sms / nigig-sms (push) Successful in 4m6s
sms / supply-chain (push) Successful in 4s
R2.1. Review items 2.7 and 5.3.

## SessionRegistry was built in Phase 5 and never wired

The pump still read:

    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 dispatched then abandoned
with events still queued; payment B starts; the pump drains A's ResultText
and SessionEnded and applies both to B. An abandoned payment settles the one
that replaced it.

## Now

- Dispatch claims the single in-flight slot. The USSD backend returns no
  session handle, so the intent id is the correlation id — enough, because
  the registry only has to tell this payment from the previous one.
- Every event is admitted against the live operation before it can touch an
  intent. Foreign and stale events are logged and dropped.
- Terminal events are de-duplicated; progress chatter still repeats freely.
  ussd_duplicate_key mirrors ProviderSignal::duplicate_key in the platform
  crate, and a test pins the two together.
- All six terminal and teardown paths retire the session id, so a late
  duplicate cannot revive a closed operation.

6 tests, including the abandoned-payment scenario by name. Verified by
removing the close call: that test goes red. CI gate asserts the pump still
admits, dispatch still claims, and at least six paths still close — matching
the method rather than a receiver literal, because rustfmt wraps the call.

## What this does not do

The pump still lives in PayFlowHandler, which still owns the pending-store
writes and the bulk queue. Moving *ownership* to PaymentCoordinator changes
who cancels on teardown and who observes an out-of-order callback, which is
what ADR 0007's device matrix exists to check. That is now tracked as R2.1b.

The correlation defect — the one that could settle the wrong payment — is
closed, and it did not need a device. I had previously filed the whole of
R2.1 as device-blocked; that was too coarse.

## Validation

  nigig-pay-ui 78 (was 72) / nigig-mpesa 20                        pass
  domain 148 / storage 41 / platform 64 / mpesa 29                 pass
  clippy -p nigig-pay-ui --no-deps -D warnings                     0 errors
  builds: pay-ui, pay, mpesa, core; default and --no-default       pass
  correlation injection: abandoned-session test fails without it   pass
  pin-capture guard                                                pass
2026-08-02 09:21:45 +00:00
Arena Agent
015cf44422 fix(cad): remove the reachable panics; gate unwrap/expect at 5
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
repo hygiene / hygiene (push) Has been cancelled
Phase 6, scoped to what is provable rather than a blanket -D warnings.

Measured the CAD module first: ~200 clippy warnings, but the panic
family -- the part the plan actually cared about, copying the pay
crates' ratchet -- was only 8: 2 unwrap, 6 expect, 0 panic!. Three were
real, five are genuine constructor invariants.

Fixed:

- code_editor.rs x2. `lazy_init_session(); self.session.as_mut().unwrap()`
  in both draw_walk and handle_event. Correct today, but the guarantee
  lived across a function boundary the compiler cannot see, so an
  unwrap sat on a widget draw path waiting for a third caller to forget
  the prologue -- and a panic there kills the editor with unsaved work
  in it. Added `editor_and_session()`, which splits the borrow and
  returns Option, so both sites take an early return instead.

  I first tried folding init into `get_or_insert_with`. That silently
  dropped the `keep_cursor_in_view = Once` side effect, which only
  happens on the create path. Caught it by grepping for the field rather
  than trusting the refactor; reverted.

- cad_scene.rs x1. MeshCache::get_or_build did
  `.write().expect("mesh cache poisoned")` while every other method on
  the type already degraded with `if let Ok(..)`. Reachable: the export
  path calls get_or_build on a spawned thread, so one panicking worker
  poisoned the lock and the next draw took the UI thread down with it.
  The cache is pure derived data -- every entry rebuilds from its node
  -- so a poisoned lock now costs memoisation, not correctness. The mesh
  is built before the lock is taken, and the double-check still prefers
  a racing thread's entry so Arc::ptr_eq comparisons stay consistent.

Left alone, with reasons: 4 x cad_scene "default material/layer always
exists" (SceneBuilder::new inserts both; verified) and 1 x arch_gltf
serde_json::to_vec over a Value built in that file.

New gate: "No new unwrap/expect in CAD production code", allowlist of 5.
A bare count drifts upward quietly and a blanket ban just gets
#[allow]-ed, so the count is pinned and each exemption is named in the
comment.

The gate skips #[cfg(test)] by BRACE DEPTH rather than stopping at the
first one. That matters: arch_gltf.rs has production code after two test
modules, so the existing panicking-macro gate's "stop at first
#[cfg(test)]" awk cannot see line 850 at all. My first attempt used the
same awk idiom and reported 4 of 5 -- I only noticed because the number
disagreed with clippy. Verified the older macro gate is not currently
hiding anything, but it is hiding it by luck.

Both negative tests pass: an unwrap added to viewport.rs is caught, and
one added to arch_gltf.rs *after* its test modules -- the exact blind
spot -- is also caught, named with file and line.

13 gates now, all green. 761 lib + 154 integration tests pass.
2026-08-02 09:20:12 +00:00
nigig-ci
015462b386 ci(sms): install the Android SDK inline instead of a nonexistent action
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
sms / gates (push) Successful in 4s
sms / robius-sms (push) Successful in 23s
sms / android (push) Successful in 34s
sms / nigig-sms (push) Successful in 4m10s
sms / supply-chain (push) Successful in 4s
A runner was registered against this repo for the first time, so the
workflows in .forgejo/ finally executed instead of only ever being run
by hand. The android job failed immediately:

    Unable to clone https://data.forgejo.org/android-actions/setup-android
    refs/heads/v3: repository not found: Not found.

android-actions/setup-android does not exist on data.forgejo.org, and
Forgejo does not fall back to github.com for action resolution. The
failure happens in "Set up job", before any step runs, which cancels
all seven remaining steps. The job reported failure without compiling a
single line -- so the Android gate, the only job in this file that sees
the ~600 lines of JNI under #[cfg(target_os = "android")], has never
checked anything.

Replaced with an inline cmdline-tools install, which is the same
sequence used to verify these crates by hand and depends only on
actions/checkout and actions/setup-java -- both of which do resolve.

Verified on the same runner in this run: gates, robius-sms (48 tests),
nigig-sms (46 tests, floor gate, clippy ratchet) and supply-chain all
pass. nigig-map.yml has the identical defect with actions/setup-rust@v1
and is left alone here.
2026-08-02 09:09:57 +00:00
5800beb552 fix(pay): close the R1 gaps against the completion standard
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
Payment domain, storage, platform and UI / isolated-payment-tests (push) Failing after 2m2s
Payment domain, storage, platform and UI / payment-ui-tests (push) Failing after 3s
You are right that my first pass at R1 fell short. It deferred an item on a
judgement call, and it fixed three defects without regression tests naming
them. Four gaps, all closed here.

## 1. S8 was deferred; it is now done as far as the platform allows

I skipped certificate pinning as "wasted work if the endpoints get dropped".
That was my call to make about effort, not an external blocker.

Investigated properly: Makepad's HttpRequest exposes no pinning API. Its
only TLS control is set_ignore_ssl_cert, which weakens verification. Pinning
is not implementable at this layer without patching the platform crate.

What *is* enforceable is the property pinning mostly buys — that 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: lookalike hosts (api.coingecko.com.evil.example), embedded
credentials (https://evil@real/), explicit ports, plain HTTP, malformed
URLs, and an assertion that TLS is never disabled. Verified by disabling the
allowlist: 3 tests fail.

## 2. The 13-digit phone defect had no test naming it

I fixed it and moved on. It now has a regression test quoting the original
duplicated branches, plus a property test that normalisation output is
either empty or exactly a valid 10-digit 07/01 number — no third outcome.

## 3. The fee-policy UI wiring was untested

The domain guard had 11 tests; the wiring that connects it to the pay sheet
had none, so nothing proved the sheet actually consults it. Four tests now
cover the shipped policy: it identifies the bundled tariff, refuses once
stale, still quotes while current, and keeps "unknown band" distinct from
"stale table".

## 4. The exchange client had no tests at all

It does now, via the transport module above.

## A test that failed against itself

tls_verification_is_never_disabled_in_this_module asserts the module never
calls set_ignore_ssl_cert — and the literal in the assertion put the string
in the file, so it failed on first run. The needle is now assembled at
runtime. Recorded because it is exactly the kind of thing that gets
"fixed" by deleting the test.

## Completion standard, now written into the plan

A phase is done when: no item is deferred on a judgement call; no capability
is removed to satisfy a review item; defects found while implementing are
fixed in the same phase even if absent from the review; every fix carries a
test that fails without it; and CI enforces it.

## Validation

  domain 148 / storage 41 / platform 64 / mpesa 29                pass
  nigig-pay-ui 72 (was 66) / nigig-mpesa 20                       pass
  clippy -p nigig-pay-ui --no-deps -D warnings                    0 errors
  builds: pay-ui, pay, mpesa, core                                pass
  allowlist injection: 3 tests fail when disabled                 pass
  pin-capture guard                                               pass

Pre-existing and untouched: `cargo test -p nigig-pay --lib` fails to build
on clean HEAD (ClassifiedTransaction not in scope in transact.rs). Verified
by stashing. The transport tests are exercised through the nigig-mpesa copy.
2026-08-02 08:52:32 +00:00
nigig-ci
25f32f7870 test(sms): build a real test suite (Phase G)
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
repo hygiene / hygiene (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
nigig-map / test (push) Failing after 1s
sms / gates (push) Successful in 3s
sms / robius-sms (push) Successful in 23s
sms / android (push) Failing after 54s
sms / nigig-sms (push) Successful in 4m12s
sms / supply-chain (push) Successful in 6s
50 tests -> 102, and the two that were there at the start of this work
are deleted.

Where this started: robius-sms had ZERO tests, and nigig-sms had two --
bulk_sub_tab_default_is_contacts and bulk_sub_tab_variants_distinct.
Both asserted a derived Default and a derived PartialEq. Neither
mentioned SMS. Neither could fail short of the compiler breaking. That
is the defect that produced every other defect in this plan: nothing
could prove a change was safe, so nothing was ever deleted and every
bug survived contact with review.

Property tests (proptest, new dev-dependency)

  Seven over truncate_preview, format_timestamp, badge_text, and five
  more over segment_count, the rate limiter and ScheduleRequest.

  These are the ones that matter, because the hand-written cases in this
  repo all encode a bug someone had ALREADY found. proptest searches the
  space instead. I verified that by reinstating the original byte-slicing
  truncate_preview and confirming
  prop_truncate_preview_survives_mixed_scripts and
  prop_truncate_preview_respects_the_char_limit both fail against it --
  they would have caught A3 before it shipped.

  prop_rate_limiter_respects_capacity models the window independently
  and asserts the invariant across random clock sequences, rather than
  re-implementing the limiter's own arithmetic in the assertion.

Integration tests (2 new files, public API only)

  robius-sms/tests/sms_pipeline.rs and nigig-core/tests/sms_store.rs go
  through the public surface the application actually uses. The unit
  tests inside src/ can see private helpers; these cannot, which is the
  point -- they catch a refactor that keeps every unit test green while
  breaking the caller-visible contract.

  Two of them are privacy canaries. e1_message_bodies_are_never_persisted
  and e1_no_body_text_reaches_the_serialised_store fail if anyone removes
  #[serde(skip)] from OfflineSmsMessage.body. Verified by removing it:
  both fail, the other six pass. Nothing else in the tree would have
  noticed the inbox silently going back to plaintext on disk.

Named regression tests

  One per defect, named for it -- c1_*, d1_*, d3_*, e1_*, e7_*, a4_*,
  c3_*, c7_* -- so a future reader goes from a failing test straight to
  the bug it guards rather than to a git archaeology session.

New coverage for logic that had none

  - build_timeline_items / build_filtered_timeline_items: date-divider
    placement and the message indices the draw loop uses to index
    conv_data.messages. An off-by-one there renders the wrong body in
    the wrong bubble; it had no test at all.
  - kind_to_offline / kind_from_offline round-trip: the only thing
    stopping a cached Sent message reappearing as Inbox after a restart,
    which would flip the bubble to the wrong side of the screen.
  - normalize_number: what C1 groups on, across five formatting variants
    plus short codes and alphanumeric senders.

MessageKind::from_android_type / to_android_type were hoisted out of
sys/android/inbox.rs onto the type, the same way ScheduleRequest::validate
was in A4, so the provider mapping is testable off-device. An
unrecognised TYPE value is preserved verbatim in Unknown rather than
defaulted, and there is a property test asserting the round trip is
total over every i32.

CI: a test-count FLOOR at 100. A floor rather than a ratchet -- unlike
the clippy count, there is no reason to ever want this number to fall.

Deliberately NOT faked: the JNI cursor loop, the keystore round-trip and
broadcast delivery still need an emulator. A mock returning what I expect
would test my expectations, not Android. Those remain called out in the
Phase A and E commit messages.

Verified: 11/11 checks. 48 robius-sms + 46 nigig-sms + 8 sms_store = 102.
clippy -D warnings clean on host and aarch64-linux-android; nigig-sms
ratchet holds at 32 (my first draft added an orphaned `use super::*`,
caught by the ratchet and removed rather than baselined).
2026-08-02 08:43:16 +00:00
a264f53eb7 feat(pay): complete phase R1 of the remaining-work plan
Some checks failed
repo hygiene / hygiene (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
All four R1 items. Two of them uncovered defects that were not in the
review, and R1.4's corpus found a live bug.

## R1.1 versioned fee policy (U9)

The band table is a static "effective Jan 2024" snapshot. When Safaricom
revises a tariff, nothing notices: the old number is quoted and the user
authorises a total they are not charged.

FeePolicy attaches provenance and a 400-day trust horizon. Past it,
fee_for returns FeeError::PolicyOutOfDate rather than a number, and the
sheet refuses to quote exactly as it already does for an unknown band —
"no band for this amount" and "our table is old" stay distinguishable
because they need different messages. Verified by disabling the check:
4 tests fail. Domain tests 137 -> 148.

## R1.2 quality gate for nigig-pay-ui (Q2)

Correcting my own earlier count: 9 of the 10 unwraps were in tests. The one
production case, on the dispatch path inside the biometric branch, is now 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 with --no-deps 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 more issues, one 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 on as a recipient. The duplication was hiding it.

## R1.3 exchange API (S7/S8/S10)

The client forged origin/referer for api2.bybit.com and p2p.binance.com,
impersonating those exchanges' own web clients against internal endpoints.
Removed from both copies (nigig-pay and nigig-mpesa — item A5 again), along
with the framework-identifying User-Agent. CI rejects either regrowing.

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.

Not done, deliberately: certificate pinning. Pinning an endpoint the
product may drop is wasted work, and whether to keep these endpoints is a
product call recorded in the plan.

## R1.4 adversarial CSV corpus (7.5)

parse_csv turns an untrusted file into a payment list. Corpus covers empty
input, injection-shaped fields, overflow, NUL, RTL override, full-width
digits, a 5,000-row file and malformed numbers. The bar is not "parses
correctly" but "never silently produces a payment nobody intended".
Verified it can fail. UI tests 61 -> 66.

## Validation

  domain 148 / storage 41 / platform 64 / mpesa 29 / pay-ui 66   pass
  clippy -p nigig-pay-ui --no-deps -D warnings                    0 errors
  builds: pay-ui, pay, mpesa, core; default and --no-default      pass
  pin-capture guard                                               pass
  fee-policy injection: 4 tests fail with the check removed       pass
  corpus injection: catches a fabricating normaliser              pass
2026-08-02 08:33:16 +00:00
nigig-ci
1670ddf49c refactor(sms): delete the dead code and the duplication (Phase F)
Some checks failed
doc-engine / engine (push) Has been cancelled
doc-engine / consumer (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
repo hygiene / hygiene (push) Has been cancelled
nigig-map / test (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
Net -596 lines. No behaviour change except F10, which replaces a label
that was lying.

F2 -- four copies of one stub backend.

  apple.rs, linux.rs and windows.rs were BYTE-IDENTICAL 66-line files,
  and unsupported.rs was the same again. That duplication is what let
  them drift: Phase C3 had to fix `Error::Unknown` in exactly one of the
  four, because only one had it wrong.

  Collapsed into sys/stub.rs, which each platform module invokes. 268
  lines become 35 plus one shared definition. The module is cfg'd out on
  Android, which has a real implementation and would otherwise report
  the macro as unused under -D warnings.

F3 -- TWO dead compose implementations.

  SmsComposePage (189 lines) was registered in the VM and instantiated
  nowhere. Separately, the FAB and its compose overlay were left in the
  DSL as `visible: false` with a comment saying "FAB removed: SMS
  compose/inbox navigation now lives in SmsActionBar" -- but 102 lines
  of DSL and 53 lines of handler stayed behind, wired to a button no
  user can reach.

  Deleted both, and send_reply() with them: it existed only to serve the
  unreachable overlay. Compose navigation is SmsActionBar's, as the
  comment already said.

F4 -- a whole second contact subsystem, unreachable.

  sms_screen.rs carried its own CONTACTS_CACHE, contacts_loaded(),
  load_contacts_into_cache(), display_name_for_number(),
  normalize_number(), try_load_contacts() and a
  contacts_load_attempted field. Nothing called any of it -- the live
  implementation is in conversations_list.rs.

  Worth noting the dead copy was also the WRONG one: its
  display_name_for_number did an O(n) linear scan of the whole phone
  book per lookup, where the live version is O(1) because
  cache_contact_number inserts under both the raw and normalised key.

F5 -- the page tree was written out twice.

  sms_bulk_page, sms_schedule_page and sms_more_page were each declared
  under Desktop AND under Mobile, byte-identical apart from indentation.
  Any change to a page header had to be made in both places or the
  layouts silently diverged. Now three named widgets plus a shared
  SmsPageHeader, referenced from both variants.

F8 -- serde, serde_json and robius-location were declared by nigig-sms
  and referenced nowhere in its sources.

F9 -- was_scrolling was read twice per frame from the same portal list;
  the copy in handle_event was bound and never used.

F10 -- the character counter was a hardcoded lie.

  The old compose page rendered "0 / 160 characters" and never updated
  it. It died with F3, but the bulk composer -- where the money actually
  goes -- had no cost indication at all. It now shows live segment count
  as you type, using segment_count() from Phase A5, because segments are
  the billing unit and "160" is only right for GSM-7: one emoji forces
  UCS-2 and drops the limit to 70.

  This is the only user-visible change in the commit.

F1 and F7 were already done, in Phase A (shared cursor.rs) and Phase D1
(I/O out of draw_walk).

The deletions orphaned eight imports, which are also removed. Together
that takes the nigig-sms clippy ratchet from 49 to 32 -- these were not
suppressed, the code they reported on is gone.

Verified: 10/10 checks. clippy -D warnings clean on host AND
aarch64-linux-android, 28 robius-sms tests, 22 nigig-sms tests,
nigig-build still builds, metadata --locked clean.
2026-08-02 08:21:07 +00:00