Commit graph

346 commits

Author SHA1 Message Date
f33e999991 perf(spreadsheet-ui): improve unicode text width estimate
Some checks are pending
nigig-build (CAD) / supply-chain (push) Waiting to run
nigig-build (CAD) / cad-module (push) Waiting to run
nigig-build (CAD) / full-crate-check (push) Waiting to run
repo hygiene / hygiene (push) Waiting to run
2026-08-02 08:53:26 +00:00
5800beb552 fix(pay): close the R1 gaps against the completion standard
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
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
3fda46b1eb fix(spreadsheet-ui): flush toolbar mutation intents per event
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
2026-08-02 08:46:05 +00:00
Arena Agent
11ef0fbf67 fix(cad): GPU buffers self-invalidate; stop re-meshing on every drag frame
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 5.4. Two defects with one root cause: part_geoms was keyed on the
raw node id, so staleness was invisible to the type and correctness rested
on seventeen scattered `part_geoms.remove(&id)` calls at the edit sites.
All seventeen are deleted; the map is now keyed on (id, ParamHash), the
same key MeshCache uses, and an entry whose hash no longer matches its
node is simply never read.

1. subdivide_selected drew the wrong geometry. It replaced each selected
   part's solid with a fresh Csg and invalidated the MESH cache, but never
   removed the part's part_geoms entry -- so the stale uploaded buffer
   stayed a hit and the viewport kept drawing the un-subdivided shape.
   An audit of every mutation path found this was the only edit site
   missing its manual eviction, which is exactly the failure mode a manual
   protocol produces.

2. ParamHash covered the transform and the material, so a pure move
   invalidated the mesh. It should not: build_mesh reads neither. The
   cached TriMesh is in the node's LOCAL space, and all four consumers --
   the draw loop, pick_part, the STL and glTF exporters -- apply the model
   matrix themselves. Dragging a part therefore re-triangulated it on
   every frame to produce byte-identical triangles, at up to 886 ns per
   extruded part per frame, per selected part. The hash now covers the
   solid parameters and nothing else.

Two existing tests asserted the old behaviour and were INVERTED, not
deleted -- they described what the code did rather than what it needed to
do:
  cache_detects_transform_edits_via_param_hash
    -> a_transform_edit_reuses_the_cached_local_space_mesh
  mesh_cache_self_invalidates_on_parameter_and_transform_edits
    -> mesh_cache_self_invalidates_on_solid_parameter_edits

New: build_mesh_output_does_not_depend_on_the_transform guards the
assumption the key now rests on -- if anyone makes build_mesh bake the
transform in, it fails and the key must grow it back. Plus
mesh_cache_hits_on_a_pure_transform_edit, mesh_cache_hits_on_a_material_edit
and an_uploaded_buffer_is_not_reused_after_the_solid_changes. Negative
test: restoring the transform to the hash makes the first two fail;
restored and green.

Deletion is still explicit (`retain` on the live id set) because it is the
one case a content hash cannot express -- there is no node left to hash.

ParamHash is pub(crate), not pub: it is how the caches agree on staleness,
not a consumer contract.

Also recorded in BENCH_BASELINE.md, not fixed here: bench_command_execute
_overhead is 227 us/cmd against a stale recorded 0.4 us. Verified
pre-existing -- 254 us on the commit before this branch, so this work
slightly improves it. CadCommandCtx::new eagerly builds a scene snapshot
that most commands never read, and every command bumps the generation, so
it is O(commands x nodes). The fix is to make that snapshot lazy; it is a
separate change and gets its own commit.

753 lib + 154 integration tests pass. Test-name list diffed, not just the
count. All 12 CI gates pass.
2026-08-02 08:44:42 +00:00
9a14c0ccad refactor(spreadsheet-ui): dispatch formula edits through intents
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
2026-08-02 08:43:50 +00:00
nigig-ci
25f32f7870 test(sms): build a real test suite (Phase G)
Some checks failed
doc-engine / engine (push) Waiting to run
doc-engine / consumer (push) Waiting to run
nigig-map / test (push) Waiting to run
sms / gates (push) Waiting to run
sms / robius-sms (push) Waiting to run
sms / android (push) Waiting to run
sms / nigig-sms (push) Waiting to run
sms / supply-chain (push) Waiting to run
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
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
79950bbba6 refactor(spreadsheet-ui): dispatch grid intents through workspace
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
2026-08-02 08:34:27 +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
Arena Agent
139f6de25a refactor(cad): one CadDocument shared by all three viewports
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
The three CadViewport instances each owned a private PartsStore, kept in
step by copying the whole list every frame (parts_snapshot /
replace_parts_snapshot). Both are now deleted. They share one
Rc<RefCell<CadDocument>>: an edit in any viewport IS the edit in all of
them, so there is nothing to reconcile.

Copying was not an implementation detail, it was the source of a class
of bugs that were individually fixable and collectively unfixable:

- Only one viewport wins a sync frame, so the loser's id allocations
  were absent from the winner's snapshot and the assignment moved the
  counter BACKWARDS. Two parts drawn in different views between syncs
  got the same NodeId -- which keys MeshCache, part_geoms and every
  command. (Bounded earlier by sharing the allocator; now moot.)
- Breaking on the first dirty viewport left later ones dirty, so the
  loser overwrote the winner's edit on the next frame.
- Each sync deep-copied a Vec<CadNode> per destination and forced a
  full scene rebuild in each: ~98.5 us/frame at 100 parts, sustained
  for the whole of a drag.

sync_parts_from_any_dirty_viewport survives but no longer moves data. It
clears every script_dirty flag in one pass, returns the reporting
viewport's script, and asks the others to repaint.

Reads go through vp.parts() -> Ref<PartsStore>, writes through
vp.parts_mut() -> RefMut. Both borrow self, so "never hold a read guard
across a write" is checked by the compiler rather than left to reviewer
discipline. Where a read loop's body needs &mut self (a draw call), the
handle is cloned first via document() + read_parts() so the guard is
tied to the local Rc instead of to self -- restoring the disjoint-field
borrows the plain struct member used to give for free.

Deleted in the same commit as their cause:
- parts_snapshot / replace_parts_snapshot (40 lines)
- split_for_command + CommandBorrows, which existed only to prove to the
  borrow checker that parts, scene_cache and command_stack were distinct
  fields. The parts list is no longer a field at all;
  with_command_ctx replaces them.
- PartsStore::as_mut_vec, the last migration escape hatch. Its two
  remaining callers only wanted to edit fields, so they use a new
  iter_mut(); structural changes go through the specific methods, which
  is what makes "the generation moved" mean something.

share_document runs from request_rebuild as well as
initialize_split_viewports: the split viewports are created lazily, and
one that missed the join would quietly edit a document nobody can see.

Selection, part_geoms, scene_cache and camera state stay per-viewport by
design -- what is highlighted is a property of a view, not the model.

Test swap, verified by diffing the full test-name list rather than the
count (both are 761): command_borrows_fields_accessible is gone -- it
constructed a struct literal and read its fields back, asserting
something the compiler already guarantees, and would have passed against
every sync bug this removes. In its place,
an_edit_through_one_document_handle_is_visible_through_the_others
asserts the actual property. Negative test: replacing the shared handles
with three independent documents makes it fail (left: 0, right: 1);
restored and green.

746 lib + 154 integration tests pass. All 12 CI gates pass.
2026-08-02 08:30:09 +00:00
59dd29d1ce feat(spreadsheet-ui): queue grid mutation intents
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
2026-08-02 08:27:51 +00:00
arena-agent
08c3eed18d feat(doc): copy and clear cell ranges through the clipboard
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
An armed table cell range now copies as tab/newline text (rows top
to bottom, cells left to right, the spreadsheet convention) through
the same copyable_selection_text the TextCopy/TextCut hits answer
with, so the long-press cell menu floats the full action set instead
of a paste-only one. Cut and Backspace/Delete clear every non-empty
spanned cell via the engine's new set_table_cells, which pops each
sub-write's compensation into one Compensation::Group — the span
un-clears in a single undo step where Backspace previously dropped
the range and edited only the caret cell. Single writes keep the
leaf compensation and empty write lists record nothing.

Grouping cell writes surfaced a real undo asymmetry: an undo after a
redo re-applied the redone cell text, because redo pushed the write's
own text as its undo compensation (inverse() only swaps the verb,
keeping the stale text) while undo's leaf special-case captured live
cell state only in one direction. undo()/redo() now resolve
cell-text inverses per compensation member against the projected
text at both transition boundaries (redo_compensation/
undo_compensation), so leaves and groups round-trip through
arbitrary cycles and the "cells stay out of groups" restriction is
gone.
2026-08-02 08:21:31 +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
2649928867 docs(pay): execution plan for the remaining review items
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
Ordered by what blocks what, not by review phase number. Three gates decide
the sequence: a human Play-policy decision, an Android device, or neither.
Work needing neither is scheduled first, because it is the only work that
can be finished rather than started.

R1 (no device, no decision):
  R1.1 versioned fee policy (U9) — the only open item that shows a user a
       wrong number during normal use. The 2024 table goes stale silently.
  R1.2 clippy gate for nigig-pay-ui (Q2)
  R1.3 exchange API hardening (S7/S8/S10) — needs an option chosen
  R1.4 adversarial corpus for the UI layer

R2 (needs a device): session ownership, Keystore provisioning, the device
matrix. Each exit criterion is a device behaviour, not a compile.

R3 (needs a decision): 5.2 the AccessibilityService policy review, and 6.6
bulk controls which depend on its outcome.

R4: programme discipline — fuzzing, SBOM, telemetry, pilot.

Corrects an earlier claim of mine. I reported "10 unwrap() in the payment
UI". Checked properly: 9 are inside #[cfg(test)] and legitimate. Exactly one
is production code, at pay_flow_handler.rs:245, on the dispatch path inside
the biometric branch. It is currently unreachable but nothing enforces that,
and it panics mid-payment if it becomes reachable. The count was wrong; the
item is still real, and smaller than stated.

Also records what R1.3 actually found: the exchange client forges
origin/referer for api2.bybit.com and p2p.binance.com, which is
impersonating those exchanges' own web clients against internal endpoints,
not merely a missing pin.

B6's stored representation is deferred on purpose, with the reasoning.
2026-08-02 08:20:40 +00:00
f3c2aa7a83 feat(spreadsheet-ui): define grid mutation intent type
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
2026-08-02 08:11:18 +00:00
23fce675de feat(pay): USSD automation on by default; containment moves to packaging
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
Option A, as requested. Plus an audit of the whole review against the code.

## The default flips

  nigig-pay-ui   default = []            (leaf stays off; see below)
  nigig-pay      default = ["demo"]
  nigig-mpesa    default = ["demo"]
  pageflipnav    default = ["native", "demo"]

`cargo run -p pageflipnav` now drives *334#, shows the PIN field and
dispatches. That is the app's primary function and it works out of the box.

A release build opts out:

  cargo build -p pageflipnav --no-default-features --features native

This was not one line. A first attempt flipped the four `default =` lines
and the opt-out still leaked: the app crates depended on nigig-pay-ui with
its own defaults, so `--no-default-features` on pageflipnav was silently
re-enabled one level down. Verified with a compile probe rather than
cargo tree, which truncates. The inner deps now carry
`default-features = false`, and the probe confirms both directions:
default -> demo ON, --no-default-features -> demo OFF.

ADR 0007's Play-policy note is untouched. The containment requirement of
review item 0.1 is not dropped — the flag exists, CI exercises both
directions, and a shipped build still cannot dispatch. What changed is
which way it points by default, so development and device testing are not
fighting it.

CI guards inverted to match: they now assert automation is on by default
*and* that the packaging opt-out still works. check-no-pin-capture.sh now
probes the packaging build, since the default legitimately captures a PIN.

## REVIEWS/IMPLEMENTATION_AUDIT.md

Every phase checked against the code, not against the tranche notes. Where
they disagreed the code won. Summary: phases 0-5 and 7 done bar 5.2 and
Keystore provisioning; phase 6 substantially done with the thread_local
session ownership outstanding; phase 8 partly.

## B7 found live while auditing

Month navigation had never been examined. Both copies of the transactions
widget still stepped months with Duration::days(31) and years with
Duration::days(366). Reproduced before touching it:

  2025-12-28 -1 month => 2025-11-27   (drifts a day)
  2026-03-30 -1 month => 2026-02-27   (drifts; repeated steps skip a month)
  2027-06-15 +1 year  => 2028-06-15   (366d wrong on a non-leap year)

Now uses checked_add_months/checked_sub_months, which clamp to the end of
the target month, and checked_add_signed on the day path. An
unrepresentable date leaves the view where it was.

Neither claimed done nor flagged open — simply never looked at. That is the
argument for auditing code rather than notes.

## Validation

  domain 137 / storage 41 / platform 64 / mpesa 29 / pay-ui 61   pass
  cargo check: pay-ui, pay, mpesa, core (default and opt-out)    pass
  demo-on-by-default probe, both directions                      pass
  no-PIN-capture guard against the packaging build               pass

Unrelated and still blocking a full APK: nigig-map fails to compile on
clean HEAD (12 errors, no field center_lat on ViewportState).
2026-08-02 08:06:43 +00:00
Arena Agent
c83f8e083f refactor(cad): commands mutate through PartsStore, not a bare Vec
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
First migration step of Phase 5.2, and it closes a real hazard rather
than only moving types around.

`CadCommandCtx` held `&mut Vec<CadNode>`, taken out of the store by
`split_for_command` via `as_mut_vec()`. Every command therefore edited
the parts list *underneath* `PartsStore`, so none of their mutations
bumped its generation. `SceneCache::scene_for` compares generations, so
the only thing preventing a permanently stale scene was each command
remembering to call `mark_dirty()` by hand -- the exact discipline-based
invalidation the generation counter was introduced to replace.

Every command happens to call it today. One omission in a new command
would have produced a scene that never rebuilds, with no failing test.

`CadCommandCtx` and `CommandBorrows` now carry `&mut PartsStore`.
move_node and update_node go through `get_mut_by_raw_id`; create,
delete and insert already used store methods once the type changed.
The generation now moves on its own and the `mark_dirty()` calls become
belt-and-braces instead of load-bearing. `CadCommandCtx::new` also
builds its scene with `scene_for`, so it shares the cache rather than
rebuilding unconditionally.

Four tests. Three exercise the real `CadCommandCtx` against a real
`PartsStore` -- the mock keeps its own `Vec` and structurally cannot
observe the generation, which is why the gap survived this long. The
fourth pins the hazard itself: a mutation that skips the generation
still needs `mark_dirty()`, demonstrated through a `#[cfg(test)]`
`as_mut_vec_without_bump` that models the old path. Negative-tested by
restoring the bypass: "a command edit must move the generation, not
rely on a manual mark_dirty()".

Scope kept deliberately narrow: commands.rs only, ~93 sites left in
viewport.rs and workspace.rs. ARCHITECTURE.md updated with what is done
and what remains.

746 lib + 154 integration, 0 failed. All twelve gates pass, benchmarks
still build and run.
2026-08-02 08:01:19 +00:00
nigig-ci
737a3e5d5d security(sms): encrypt scheduled payloads, report real send status (E3, E7)
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
repo hygiene / hygiene (push) Has been cancelled
Closes the two Phase E items I had left open and documented as open.

E3 -- scheduled message bodies were plaintext on disk.

  Pending schedules must outlive the process so SmsAlarmReceiver can
  send them when the alarm fires and so they survive a reboot, so
  recipient and body go to SharedPreferences. MODE_PRIVATE is the right
  primitive -- the file is UID-scoped -- but the contents were in the
  clear, readable by anything running as the same UID and swept into
  cloud backup by default. Same asset class as the inbox
  (THREAT_MODEL.md T-I4).

  Adds SmsScheduleCrypto: AES-256-GCM, fresh IV per value, key generated
  inside the platform AndroidKeyStore and non-exportable. An attacker
  with the prefs file but not the keystore gets ciphertext.

  Deliberately NOT androidx.security.EncryptedSharedPreferences: that is
  a Gradle dependency, and this crate compiles its Java with bare javac
  against android.jar (see build.rs), so using it would mean a Gradle
  build or a vendored jar. AndroidKeyStore and javax.crypto are both in
  android.jar and give the property that matters.

  The key is deliberately NOT user-authentication-bound: an alarm fires
  while the device may be locked and the receiver must decrypt with no
  user present. This protects against another app and against an
  extracted backup, which is the threat in scope -- not against someone
  holding an unlocked handset.

  Fails CLOSED. If the keystore is unavailable, encrypt returns null and
  schedule_sms errors rather than writing plaintext. A row that cannot
  be decrypted -- wrong key after a reinstall, tampering, or written by
  an older build -- is treated exactly like a missing row and skipped;
  sending a garbled body would be worse than not sending.

E7 -- "sent" was a guess.

  Both the sentIntent and deliveryIntent arguments were null, so nothing
  could report back. send_sms returning Ok meant "the JNI call
  returned", not that the radio accepted the message and certainly not
  that it arrived -- and the UI rendered that as a tick. A send rejected
  for no service, no SIM or a throttled radio was indistinguishable from
  a delivered one.

  Adds send_sms_tracked, which attaches real PendingIntents and returns
  a correlating token, plus SmsSentReceiver to collect the platform
  result and SendOutcome/SendReport to express it: Sent (radio accepted)
  is now a different value from Delivered (handset acknowledged), and
  failures carry the RESULT_ERROR_* code.

  Three details worth recording:
    - multipart takes ArrayList<PendingIntent>, one entry per part, so
      the intent is repeated part_count times. Passing null here, as
      before, meant no status for exactly the messages most likely to
      fail: the long ones.
    - the request code is derived from (token, kind), or the two intents
      collide and the delivery report overwrites the send report.
    - the broadcast is package-scoped and the receiver registered
      NOT_EXPORTED, so another app cannot forge a delivery report.

  If the receiver class is unavailable the send still goes out with null
  intents: losing the status report is much better than losing the
  message.

Also fixes A5 on the scheduled path. SmsAlarmReceiver still called
sendTextMessage directly, so a scheduled message over 160 GSM-7
characters -- or 70 with any emoji -- was silently truncated by the
carrier. It now divides and sends multipart, as the Rust send path has
since Phase A.

CI: two gates, both negative-tested by reverting the fix and confirming
they fail. One asserts schedule_sms never writes request.recipient or
request.body directly; the other asserts the send path still passes
sent/delivery intents in both the single-part and multipart calls.

THREAT_MODEL.md T-I4 and the delivery-confirmation row move from open to
fixed, with the residual risk stated: callers may still use the
untracked send_sms, which remains honest about meaning only "handed to
the platform".

Tests: robius-sms 25 -> 28.
Verified: 13/13 CI checks, clippy -D warnings clean on host and
aarch64-linux-android, both new Java classes javac-compile and dex.

NOT verified on a device. The keystore round-trip, the broadcast
delivery and the token correlation all need an emulator or handset;
there is still no CI runner on this repo.
2026-08-02 07:58:44 +00:00
7566374237 refactor(spreadsheet-ui): make shared model handle mandatory
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
2026-08-02 07:51:41 +00:00
9f0e133c4b fix(pay): the APK crate could not reach the demo flag at all
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
Reported: building the pageflipnav APK, the Pay sheet shows no PIN field and
reports that dispatch is unavailable, with no way to enable the *334#
automation.

That is a real defect and it is worse than the earlier feature-forwarding
gap. pageflipnav is the crate that produces the APK. It depends on nigig-pay
and nigig-mpesa but declared no `demo` feature, so:

  grep -c demo crates/pageflipnav/Cargo.toml  ->  0

The flag was unreachable from the only build that matters. Adding
`--features demo` to nigig-pay does not change what the APK contains, so
every instruction I gave for enabling the automation was useless to anyone
building the real app.

pageflipnav now forwards it:

  demo = ["nigig-pay/demo", "nigig-mpesa/demo"]

Verified with a compile probe rather than cargo tree, which truncated its
output and initially suggested the wiring had failed: a
`#[cfg(feature = "demo")] compile_error!` in nigig-pay-ui fires twice under
`cargo check -p pageflipnav --features demo` and zero times without it.

A CI guard asserts pageflipnav keeps forwarding the flag, and the README now
leads with the pageflipnav command and states plainly that building
nigig-pay alone does not affect the APK.

Unrelated: nigig-map fails to compile on clean HEAD (12 errors,
`no field center_lat on ViewportState`), so a full pageflipnav build is
currently blocked by that regardless of this change.
2026-08-02 07:51:25 +00:00
arena-agent
00b3eb6e98 feat(doc): select the whole editing context with Ctrl/Cmd+A
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
Keyboard select-all lands in the CRDT editor, mirroring makepad's
text_input. A parked cell cursor selects its whole cell text (the
in-cell character span, collapsing any armed merge range); otherwise
the selection spans the first layout glyph to the last with the
caret following the focus, so Copy, Cut, style toggles and
Backspace-over-span all treat it like a maximal Shift+Arrow
selection. Table blocks ride the range like any middle block: the
clipboard payload keeps skipping their cells while a cut drains them
through replace_block_range, so one undo restores the document.

Touch sessions in Edit mode float the platform clipboard menu on the
fresh selection (the keyboard-select-all pattern from text_input),
mirrored through clipboard_menu like the long-press request; the
cell-rect lookup it shares is factored into cursor_cell_rect. A
desktop Ctrl+A never makes a menu request, and a document without
glyphs leaves the caret put. copyable_selection_text goes
crate-visible so hosts rendering their own copy affordances read the
same payload the hits answer with.
2026-08-02 07:47:06 +00:00
Arena Agent
0d05a5e62f feat(cad): add the shared CadDocument (Phase 5.2 foundation)
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
Introduces `CadDocument` and `SharedCadDocument`
(`Rc<RefCell<CadDocument>>`) -- one owner for the parts list, its
generation counter and the id allocator -- with the semantics the
viewports will move onto.

Deliberately NOT wiring `CadViewport` onto it in this commit. That is
103 call sites (73 viewport.rs, 20 workspace.rs, 8 commands.rs), each
needing its borrow scope checked individually, and bundling it with the
type definition would produce a diff nobody can review and a bisect
target nobody can isolate. This lands the destination, proven by test;
the migration follows one file per commit.

Four tests, each negative-tested:

- an edit through one handle is visible through the other -- the whole
  point of the phase;
- the generation counter is shared, so SceneCache::scene_for cannot
  serve a stale scene to a viewport that has not synced yet;
- two separately constructed documents do NOT share, so the first two
  cannot pass for the wrong reason;
- sequential borrows are fine and overlapping ones are rejected. That
  one states the borrow discipline as an executable rule rather than a
  comment, using try_borrow_mut so it documents the failure without
  aborting.

Verified before designing, rather than assumed:

- Every `self.parts` read in viewport.rs is a short-lived expression or
  a loop, and only two loops mutate `self` at all (~6231, ~6265) -- both
  touching `selection`, a different field, so neither becomes a
  BorrowMutError.
- `split_for_command` hands out `&mut Vec<CadNode>` tied to `&mut self`,
  which is the one construct genuinely awkward under RefCell. Every
  caller already scopes it in a block and drops it before touching
  `self` again, so it maps onto a RefMut guard -- but that holds because
  of how the callers are written, not because the signature enforces it,
  and ARCHITECTURE.md now says to re-check it rather than assume it.

The migration rule -- never hold a `parts()` guard across a call that
can reach `parts_mut()` -- is recorded with the two loops to re-check
first if a BorrowMutError ever appears.

738 lib + 154 integration, 0 failed. All twelve gates pass.
2026-08-02 07:43:52 +00:00
f021bdc0f5 fix(pay): the gate message hid the way to turn automation back on
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
Reported: the *334# automation used to work and now cannot be reached, and
the status message reads as though the capability was removed.

The capability was not removed. Verified with git:

- `demo` and the USSD dispatch gate are both present in cc05abd, the
  initial commit, before any of my work.
- 67abe2b "phase zero payment containment" (2026-07-27) predates my first
  commit d0f5e74 (2026-07-29); `git merge-base --is-ancestor` confirms it.
- `git diff d0f5e74..HEAD` on pay_flow_handler.rs shows I added and removed
  no `cfg!(feature = "demo")` gate at all.
- robius-ussd, including the 534-line AccessibilityService that drives the
  menu, is untouched by my commits.

What I did get wrong is the wording. The original message was:

  "USSD dispatch disabled: compile with `--features demo` to enable"

Blunt, but it named the flag. I replaced it with "This app tracks payments —
it doesn't send them", which is accurate about the default build and says
nothing about how to change it. Read on a device, that sounds like the
feature was deleted. That is a real regression in usability and it is mine.

The message now leads with the fact that it is off *in this build* and names
the flag again, while keeping the honest framing about the PIN:

  "Automated *334# payment is off in this build. Rebuild with
   `--features demo` on Android to enable it, or send in the M-Pesa app and
   it will appear here automatically. No M-Pesa PIN was requested or
   stored. [tracker-3]"

Marker bumped to tracker-3 so the running build is identifiable.
2026-08-02 07:43:45 +00:00
ea3eee6d41 refactor(spreadsheet-ui): name model sheet selection explicitly
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
2026-08-02 07:42:13 +00:00
Arena Agent
5e2d578583 fix(project): port the mobile grid to the shared workspace model
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
origin/main (92b1daf) removed `SpreadsheetGrid.data` in favour of a
`SharedWorkspaceModel`, but did not update this caller, so nigig-build
stopped compiling: five E0609s on `grid.data`.

The desktop/mobile handover open-coded what the model now does:

- Going to mobile it read the saved workbook, deserialized it by hand,
  branched on the multi-sheet vs legacy format, and copied the active
  sheet's data into the grid. That is `WorkspaceModel::load_saved()`,
  which handles both formats.
- Going to desktop it serialized the grid's data, re-read the workbook
  from disk, replaced the active sheet, and wrote the whole thing back.
  The grid now edits the shared model in place, so `model.save()`
  preserves every sheet with no read-modify-write.

The save error was also being discarded with `let _ =` in both branches.
It is now logged: a failed handover save loses whatever the user typed
on mobile, and reporting it costs a line.

Both branches shrink to roughly a third of their previous size, because
the duplicated format-handling was the bulk of them.

734 lib + 154 integration + spreadsheet-ui pass. All twelve gates pass.
2026-08-02 07:36:55 +00:00
Arena Agent
5a4ddc993f refactor(cad): share one part-id allocator across viewports (Phase 5.2)
First piece of state to become genuinely shared rather than copied.

Last commit fixed the id allocator moving backwards during sync by
reconciling the three counters on every push. That bounded the damage
but left the cause: three viewports each owning a `next_part_id`, kept
in step by copying whole snapshots, where only one viewport can win a
frame. A counter duplicated three ways cannot be made correct that way,
only patched -- which is exactly the argument for Phase 5.2, so this
takes the step instead of adding another patch.

`PartIdAllocator` is an `Rc<Cell<u64>>` handed to all three viewports by
`CadWorkspace::share_part_id_allocator`. An id handed out in any view is
never handed out again, with no sync step involved. `Rc<Cell>` rather
than `Arc<Mutex>`: the viewports are UI-thread only, and a `u64` is
`Copy` so no borrow can outlive the call and there is no `RefCell` panic
to reason about.

Removed as a result:
- `next_part_id` from `CadViewport` and all five open-coded
  `let id = self.next_part_id; self.next_part_id += 1;` sites.
- The `next_part_id` element of `parts_snapshot` /
  `replace_parts_snapshot`. The snapshot pair is one field smaller,
  which is the direction Phase 5.2 is going.
- `reconcile_next_part_id` and its four tests, now dead. Deleting the
  workaround in the same commit that removes its cause -- leaving both
  is how this codebase accumulated two of everything.

Kept: ids arriving from outside the allocator (script rebuild, file
load, undo restore) were never issued by it, so `reserve_past_nodes`
still runs on every `replace_parts_snapshot`.

Five tests. The load-bearing one asserts two clones interleave without
collision AND that they alias the same counter; a companion asserts two
*separately constructed* allocators do not share, so the first cannot
pass for the wrong reason. Negative-tested by making `clone` deep-copy:
"ids must be globally unique" fails.

Also covered: reserving past adopted nodes, reserving never lowering the
counter, and saturating at u64::MAX rather than wrapping to 0 and
reissuing live ids.

734 lib + 154 integration, 0 failed. All twelve gates pass.
2026-08-02 07:36:55 +00:00
6f4fb8058e refactor(spreadsheet-ui): keep shared model attachment single-source
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
2026-08-02 07:34:54 +00:00
92b1daf9ba refactor(spreadsheet-ui): remove grid-owned spreadsheet data
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
2026-08-02 07:32:19 +00:00
775eec4424 fix(pay): remove PIN capture from default builds rather than hiding it (0.3)
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
Asked in review: "does the latest code have the PIN input field?" It did.

## Hiding was weaker than it looked

pin_input, form_pin and the reveal toggle were all present, and the default
build hid them at runtime in on_after_new. Three problems:

1. The DSL declared the control visible and Rust hid it afterwards, so
   anything re-applying the UI definition — a hot reload, a re-instantiated
   sheet — brought it back. Defect B11 already names this class.
2. Hidden is not absent. The TextInput stayed in the widget tree, and a
   hidden input can still be focused or filled programmatically.
3. form_pin was compiled into every build, so any path reaching it could
   populate it.

Item 0.3 asks for removal, not concealment.

## The field no longer exists without `demo`

form_pin, pin_visible, the eye-toggle handler, the text-input handler, both
PIN checks, the request construction and try_build_ussd_request are all
#[cfg(feature = "demo")]. A default build has no field to write to.

The DSL now declares visible: false on the PIN input and its reveal button,
so hidden is the default state rather than a runtime correction; demo
unhides them on init. That closes the reload path in (1).

One runtime call remains as belt-and-braces: if a hot-reloaded definition
surfaces the input, the non-demo arm wipes what was typed. It has no
form_pin to clear, because there isn't one.

## Proving absence rather than asserting it

A grep for form_pin proves nothing — it passes just as happily against a
field still present behind a runtime if. tools/check-no-pin-capture.sh is a
compile probe: it references the field outside any cfg block and requires
the default build to fail with "no field `form_pin`" while demo succeeds.
The script restores the file on every exit path.

Verified both directions: passes on current code, exits 1 when the field is
re-exposed un-gated.

## Validation

  cargo check -p {nigig-pay-ui,nigig-pay,nigig-mpesa,nigig-core}  pass
  cargo check -p {nigig-pay,nigig-mpesa} --features demo          pass
  nigig-pay-ui: cargo test --lib                                  pass (61)
  domain / storage / platform / mpesa harness                     pass
  no-PIN-capture guard: verified to fail on a re-exposed field    pass

## What 0.3 still leaves open

The Java-side KEY_PIN scrubbing was already done, so 0.3 is complete for the
default product. A demo build still captures a PIN by design — that path
exists for authorised device testing and is covered by the unresolved
Play-policy decision in ADR 0007. If that decision goes against the USSD
rail, the demo path and its PIN capture are deleted with it.
2026-08-02 07:31:44 +00:00
nigig-ci
cbe47a5f6c perf(sms): close the outstanding Phase B and D items
Some checks failed
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
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
An audit of Phase 0 through D against the tree found three tasks marked
done in prose but absent from the code. This closes them.

D5 -- bulk send still blocked the UI thread.

  The send loop ran synchronously in handle_send_bulk.
  SmsManager.sendTextMessage queues to the radio and rate-limits, so a
  200-recipient batch was a multi-minute ANR with no progress and no
  way to tell whether anything was happening. Phase E8's throttle
  bounded the worst case at 30 sends, but bounded blocking is still
  blocking -- and I said as much when deferring it.

  Now uses the worker pattern D1 established: spawn, publish progress
  through a mutex-guarded slot, SignalToUI, drain on the UI thread. The
  status line counts up ("Sending 12/200…") instead of freezing.
  BULK_SEND_IN_FLIGHT prevents two overlapping batches.

D2 -- the ContentObserver, the half I left open.

  Phase D removed the 5-second poll that re-armed itself via redraw()
  and stopped the app ever idling. That fixed the busy loop but left a
  gap I documented rather than closed: a message arriving while the app
  was open did not surface until the next Resume or manual pull.

  Adds SmsInboxObserver.java -- a ContentObserver on content://sms,
  registered with a main-Looper Handler, idempotent so onResume can call
  it freely -- compiled and dexed by the existing build.rs pipeline and
  loaded through the same in-memory dex loader as the receivers.

  onChange calls into Rust, which does two cheap things: set an atomic,
  and invoke a registered waker. The waker matters. robius-sms has no UI
  dependency and cannot call SignalToUI itself, so without it the flag
  would only be observed on the next event-loop turn that happened for
  some other reason -- which, with the poll gone, might be never while
  the app sits idle. The app registers SignalToUI::set_ui_signal, so
  this is a genuine push.

  Native binding is dynamic, not #[no_mangle], for the same reason as
  E6: the class comes from an in-memory dex and is not on the JVM's
  search path.

B0 (wider) -- 23 crates declared robius-sms and never called it.

  Phase B removed the three declarations that put RUSTSEC advisories on
  nigig-build and explicitly flagged the rest as "the same latent
  problem, sweep separately". This is that sweep: every crate with zero
  references to robius_sms in its sources loses the dependency.

  nigig-mpesa, nigig-pay and nigig-sms keep it -- they are the only real
  users. nigig-system-prefs only mentions robius-sms in its package
  description, so its manifest is untouched.

  Cargo.lock loses another 21 lines.

Verified: 13/13 CI checks. clippy -D warnings clean on host and
aarch64-linux-android; the Android build compiles, javac-builds and
dexes the new observer class. cargo deny still "advisories ok, bans ok,
licenses ok, sources ok". clippy ratchet holds at 49. Sampled four of
the 23 stripped crates plus all five I edited; all build.

Pre-existing and unrelated: nigig-map fails to compile on pristine
origin/main (12 errors in view.rs, a Script/Widget derive problem), so
pageflipnav and anything else reaching it cannot be checked here. I
touched no files under crates/apps/map.

NOT verified on a device. The observer's registration, the onChange
callback and the waker all need an emulator or handset with a live SMS
provider; this sandbox has neither, and there is still no CI runner.
2026-08-02 07:28:01 +00:00
Arena Agent
44c87d4f7b fix(cad): viewport sync reissued live part ids and undid edits
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
Two defects in sync_parts_from_any_dirty_viewport, both from the same
root cause: three viewports each own state that only one of them can win
with per frame.

1. THE ID ALLOCATOR MOVED BACKWARDS.

   Each CadViewport owns a `next_part_id` counter, and
   replace_parts_snapshot assigned the source's value directly. Only one
   viewport wins a sync frame, so the loser's allocations are absent
   from the winner's snapshot -- and its counter is then reset below
   what it has already handed out.

   Draw a part in the 2D view and one in the 3D view between two syncs:
   both allocate the same id independently, the loser's part is
   discarded, and the next part in that view reuses an id that is still
   live. NodeId is the key for MeshCache, part_geoms and every command,
   so a duplicate silently aliases two parts.

   Now reconciled via scene_holder::reconcile_next_part_id, which takes
   the max of the destination's counter, the source's, and one past the
   highest id actually in the incoming list. It cannot retreat, cannot
   collide with a live id, and saturates rather than wrapping at
   u64::MAX. Four tests, negative-tested.

2. THE LOSING VIEWPORT UNDID THE WINNER'S EDIT.

   The source loop broke on the first dirty viewport. take_script_dirty
   clears the flag as it reads it, so a viewport never visited kept its
   flag set, won the *next* frame, and pushed its own now-stale snapshot
   back over the edit that had just been applied.

   The loop now visits all three and clears every flag in one pass. The
   first dirty one still supplies the snapshot; the others' edits are
   already in it, because a sync pushes the winner's full parts list.

Modelled both interleavings against the real control flow before
changing anything.

These are worth recording as evidence for Phase 5.2 rather than just
fixed: a single counter duplicated three ways cannot be made correct by
copying, only bounded. ARCHITECTURE.md now says so at the point where
someone would otherwise reach for another patch.

730 lib + 154 integration, 0 failed. All twelve gates pass.
2026-08-02 07:27:01 +00:00
arena-agent
ac1ddfe2c2 feat(doc): float the platform clipboard menu on long-press selections
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
The touch clipboard surface is now complete: a mobile long-press
selection requests the platform clipboard menu
(cx.show_clipboard_actions, iOS/Android backends) right after the
selection lands, and its actions re-enter through the synthesized hits
the editor already answers -- Copy/Cut as TextCopy/TextCut, Paste as
TextInput (which also flows through multi-line block splitting).

- Edit mode only: View keeps the long-press for merge/highlight (its
  hits stay gated), and desktop sessions never reach the touch-driven
  frame clock, so their clipboard story is unchanged.
- has_selection follows the copyable payload exactly like the native
  menu: word selections anchor on the union of their glyph rects and
  offer Copy/Cut, while a cell range carries no payload and gets a
  paste-only menu anchored on the pressed cell's rect.
- The request is mirrored on the widget as pub clipboard_menu:
  makepad's platform op queue is crate-private, so hosts rendering
  their own menu (and tests asserting the request) read it there.

Runtime tests (real TouchUpdate + 24-frame NextFrame clock) cover the
Edit word-menu request with Copy flowing back out, the cell paste-only
menu rect matching the pressed cell with a menu Paste splicing into
the parked cell, and View mode making no request at all.
2026-08-02 07:24:37 +00:00
Arena Agent
5c8ee16cbb fix(cad): a dropped rebuild request left the spinner up forever
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
CadRebuildWorker::request discarded the channel send error:

    pub(crate) fn request(&self, request: CadRebuildRequest) {
        let _ = self.request_tx.send(request);
    }

The caller then unconditionally sets rebuild_pending = true and shows
"Computing 3D model...", and that flag is cleared in exactly one place:
drain_rebuild_results, on a result whose seq matches. So if the worker
thread is gone the request vanishes, no result can ever arrive, and the
spinner stays up for the rest of the session with every later edit
appearing to hang. No error is logged and nothing recovers.

request now returns whether the send succeeded. request_rebuild acts on
it: drops the dead worker so the next edit constructs a fresh one,
clears rebuild_pending, and says "Rebuild worker stopped; retrying on
the next edit" instead of lying about work in progress.

Test builds the worker from raw channel halves so a closed receiver can
be staged without a thread or a Cx. Negative-tested by restoring the
`let _ =`.

Also examined and found sound, recorded because the absence of a bug is
worth knowing:

- The seq protocol. I suspected a stale result could strand the pending
  flag and modelled the interleavings; it cannot. The worker only emits
  seqs the UI requested, seqs are monotonic, and drain keeps the last
  result in the channel -- so the final result always matches the
  current seq. wrapping_add on a u64 needs 2^64 rebuilds.
- Worker panic safety. catch_unwind wraps the evaluation, and both
  save_cad_state and cad_mesh_data_from_solid are inside it, so a panic
  in either is converted to an Error payload rather than killing the
  thread. The dead-worker path this commit handles is therefore
  defensive rather than routine -- but it costs three lines and the
  failure it prevents is unrecoverable without a restart.

Swept the module for other discarded sends: none remain.

726 lib + 154 integration, 0 failed. All twelve gates pass.
2026-08-02 07:21:49 +00:00
Arena Agent
92e4a2515a fix(cad): a failing undo or redo destroyed the command
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
UndoRedoStack::undo popped the entry and then ran it:

    let cmd = self.undo_stack.pop_back().ok_or(..)?;
    cmd.undo(ctx)?;              // <- early return drops `cmd`
    self.redo_stack.push(cmd);

If `cmd.undo` fails, `?` returns while the command is still a local, so
it is dropped: gone from the undo stack and never reaching the redo
stack. The user silently loses a history entry, and that edit can never
be reversed again. `redo` had the identical shape.

This is reachable, not defensive. Every command's undo bottoms out in
move_node / update_node / delete_node, each of which returns
NodeNotFound when the node is missing -- which is exactly what happens
after a script rebuild replaces the parts list. Undo an edit to a part
the script no longer produces and the entry evaporates.

Fixed by running the command while the stack still owns it (`back()` /
`last()`) and only moving it across after it succeeds. The subsequent
pop cannot fail, but is still handled with `if let` rather than an
unwrap, matching the no-panic rule this module now enforces.

Two tests, negative-tested by restoring the pop-first order: both assert
`undo_depth + redo_depth == 1` after the failure, i.e. the command is
still *somewhere*. Asserting only that undo returns Err would have
passed against the broken code -- it did return Err, while destroying
the entry. The failure message prints both depths, so a regression says
which stack lost it.

Swept the rest of the CAD module for the same pop-then-fallible-call
shape: no other instances.

725 lib + 154 integration, 0 failed. All twelve gates pass.
2026-08-02 07:16:57 +00:00
a176b212ea refactor(spreadsheet-ui): bind shared model before first grid draw
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
2026-08-02 07:16:27 +00:00
4d161f1da9 refactor(spreadsheet-ui): attach shared model before grid render
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
2026-08-02 07:13:13 +00:00
Arena Agent
cb5def965b fix(cad): exports reported success on a failed write
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
Every file export buffered its output through a BufWriter and never
flushed it. BufWriter flushes on drop and DISCARDS any error it hits
doing so, so a write that fails only when the buffer is pushed to disk --
full disk, revoked permission, a network mount going away mid-export --
returned Ok(()). The status label then said "STL: 11 parts ->
model.stl" while the file on disk was truncated.

Demonstrated before fixing, with a writer that accepts buffered writes
and fails on flush:

    without explicit flush -> Ok(())
    with explicit flush    -> Err("flush failed: disk full")

Three affected paths: PDF (exporters.rs), STL and GLB (workspace.rs).
All now go through exporters::export_to_file, which owns the BufWriter,
flushes it, and maps a failed flush to "<what>: could not write file:
<cause>". Putting the flush in one place is the point -- it was
forgotten three times independently.

The fourth BufWriter, in arch_pdf, writes into a Vec<u8> where flush
cannot fail. Left alone and excluded from the gate with that reason
recorded, rather than churned for uniformity.

Two tests pin the mechanism rather than one exporter: a small write
stays in the buffer, so write_all succeeds and only the flush can report
the failure. One asserts the drop path hides it, the other that an
explicit flush surfaces it. If BufWriter's drop behaviour ever changed,
the first test would fail and tell us the helper is no longer needed.

CI gate added and negative-tested: no BufWriter::new in the CAD module
outside the flushing helper.

Also checked, and found clean: arch_svg still escapes both XML sinks
after the Phase-4 projection rewrite, and all three cargo-deny
exemptions are still live -- removing them makes exactly those three
advisories fire, so none is a stale entry silently widening the policy.

723 lib + 154 integration, 0 failed. All twelve gates pass.
2026-08-02 07:12:23 +00:00
9be2dd8d7c refactor(spreadsheet-ui): remove obsolete data swap bridge
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
2026-08-02 07:05:43 +00:00
arena-agent
920827a440 feat(doc): split multi-line paste into blocks with one-step undo
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 / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
A pasted TextInput payload containing newlines used to land as a
single run of text with literal line feed characters. It now splits
block-per-line like a desktop editor: line 0 splices into the caret
block, middle lines become sibling blocks inheriting the caret block's
kind, and the trailing SplitBlock carries the caret block's suffix
onto the last pasted line (ab|XY pasted with "l0\nl1\nl2" yields
abl0 / l1 / l2XY). The caret parks after the last pasted atom.

The whole paste folds into one Compensation::Group (undo applies group
members in reverse, redo applies the inverse group in authored order),
so N lines retract in a single Ctrl+Z. The caret anchor may be
tombstoned (the caret a selection delete leaves behind): the new
CrdtDocument::live_offset_of resolves it to the same live-text offset
RGA splicing uses, so pasting over a deleted selection lands exactly
where a single-line paste would. CRLF payloads strip their \r.

Surfaced and fixed at the source while building this: the synthetic
block a SplitBlock opens was a text dead end -- atoms and style ops
addressed to a split op id landed in the op log but evaporated from
the projection, and the child always spliced directly behind its
parent. Split children are now first-class materialization targets:
suffix atoms re-link head-to-tail (keeping ids and inherited style
runs), child-addressed atoms splice in RGA-wise, and the child's
splice skips the parent's real-chain descendants, keeping the middle
paste lines ahead of it.

Runtime tests cover the split/suffix/caret/undo/redo cycle,
paste-over-selection as two undo steps, and the in-cell newline guard.
Engine tests cover grouped-undo chronology, tombstone anchors, empty
middle lines, CRLF, table refusal, peer convergence, and the
synthetic-child text/style/order regressions underneath.
2026-08-01 05:44:28 +00:00
User
0718743e19 feat(map): implement Phase 2 route overlay system with markers and position puck
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
nigig-map / test (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
Complete overlay rendering system ported from Makepad upstream:

## New Features
- **Route Overlay Rendering**: Route polyline with casing (9px) and fill (5.5px)
  - Traveled portion dimming (alpha 0.30) for navigation progress
  - Additive glow effect (route_glow) for visual enhancement
  - Destination dot at route end
  - Screen-space rendering with viewport clipping

- **Drop Markers**: Professional pin-shaped markers with:
  - Soft ground shadow (ellipse)
  - Triangular tail + circular head pin shape
  - White pip in center
  - 16px tap detection radius for interaction
  - Custom color support per marker

- **Position Puck**: Current location indicator with:
  - Accuracy circle (scales with zoom, 10-28% alpha)
  - Heading wedge (20px tip, shows direction)
  - White ring + blue dot (9px/6.2px)
  - Automatic viewport clipping (60px margin)

## Implementation Details
- **OverlayCamera**: Screen-space transformation system
  - norm_to_screen() converts normalized coords to screen pixels
  - Supports rotation and 2.5D tilt (for future phases)
  - Meters-per-pixel calculation for accuracy circle scaling

- **Integration**: Seamlessly integrated into NigigMapView
  - Added draw_overlay: DrawVector field to widget
  - Added overlay_state: MapOverlayState field
  - Rendering happens after tile passes, before status text
  - Zero overhead when no overlays are active (is_empty() check)

- **Helper Functions**:
  - draw_route(): Multi-pass route rendering with travel dimming
  - draw_marker(): Pin-shaped marker with shadow and highlight
  - draw_puck(): Location indicator with accuracy and heading
  - marker_at(): Hit testing for tap interaction

- **Viewport Enhancement**: Added meters_per_pixel() method
  - Calculates real-world scale for accuracy circle sizing
  - Accounts for latitude-based distortion
  - Used by position puck for realistic accuracy visualization

## Technical Architecture
- Immediate-mode rendering using DrawVector
- Per-frame geometry rebuild (route scale: few hundred points)
- Viewport clipping with margin (24px for routes, 30px for markers)
- Decimation for performance (1.5px threshold on zoom-out)
- Additive blending for glow effects (no HDR required)

## Files Changed
- crates/apps/map/src/overlay.rs (NEW, 379 lines)
- crates/apps/map/src/lib.rs (module registration)
- crates/apps/map/src/view.rs (widget integration, 15 lines added)
- crates/apps/map/src/viewport.rs (meters_per_pixel method, 18 lines)

## API Usage

## Performance
- Zero cost when no overlays (early return on is_empty())
- Efficient viewport clipping reduces overdraw
- Route decimation prevents excessive geometry at low zoom
- All rendering uses GPU-accelerated DrawVector

## Compatibility
- Backward compatible: existing map functionality unchanged
- Overlay state persists across frames (no per-frame allocation)
- Integrates with existing theme system (route colors from theme)
- Supports future 2.5D camera and heading-up rotation

## Testing Recommendations
1. Verify routes render with casing/fill at all zoom levels
2. Test traveled portion dimming updates correctly
3. Verify markers appear at correct screen positions
4. Test position puck accuracy circle scales with zoom
5. Verify heading wedge rotates correctly
6. Test viewport clipping at screen edges

Refs: Phase 2 from MAKEPAD_DEV_BRANCH_GAP_ANALYSIS.md
Built on: Phase 1 (POI icon system, commit ae23d36)
2026-08-01 05:27:00 +00:00
User
913929f07f feat(map): add 42 SVG POI icons with vector tessellation infrastructure
Some checks failed
nigig-map / test (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
Add complete vector-based POI icon system with:
- 42 OpenStreetMap-carto SVG icons (alcohol, atm, bakery, bank, bar, etc.)
- icons.rs module with zoom-constant vector tessellation
- Icon mesh generation at compile time using OnceLock caching
- Integration with existing sprite.rs classification system
- Icon name mapping for vector rendering

Key features:
- Tessellate SVG paths once at startup (cached globally)
- Icons appear from zoom level 17 (carto standard)
- Support for micro-POIs (trees, benches, recycling, etc.)
- Label color classes for semantic styling
- Fallback to existing color-based rendering

Icons included:
- Food & Drink: restaurant, cafe, bar, pub, fast_food, ice_cream, etc.
- Shopping: supermarket, bakery, butcher, clothes, florist, etc.
- Transport: parking, charging_station, bicycle
- Health: pharmacy, hospital
- Culture: museum, theatre, cinema, library, place_of_worship
- Nature: tree, park, garden
- Infrastructure: bench, waste_basket, recycling, traffic_signals

This completes Phase 1 of the map feature integration plan.
Icons are now available for rendering but use colored rectangles
in the current POI pass. Vector rendering will be added in a
follow-up phase.

Refs: Phase 1 from MAKEPAD_DEV_BRANCH_GAP_ANALYSIS.md
2026-08-01 05:13:09 +00:00
arena-agent
7e0012b866 feat(doc): undo cross-block cuts losslessly via range tombstones
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
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
ReplaceBlockRange pushed no undo compensation, so a multi-block cut or
selection delete could never be undone: the range op rewrites only the
projection (drained blocks and every atom beneath the span stay in the
op log untouched), and no op existed to make it stop applying.

Add the CancelBlockRange/RestoreBlockRange op+compensation pair,
resolved chronologically per target like every other tombstone pair
(merge splits, cell styles, blocks, text, rows, columns). Undo of a
cross-block delete tombstones the range op itself, which is lossless:
splices, middle blocks and their texts all re-materialize exactly from
the untouched log, in a single undo step. Redo clears the tombstone.

The controller also refuses spans whose endpoints do not resolve
against the projection in order, so a no-op range never strands an
entry on the undo stack.

Coverage: engine tests for the one-step undo/redo cycle (with a double
undo proving the chronological tombstone), text typed beneath a
cancelled range surviving, and unresolved-span refusal growing no undo
stack. Widget runtime test cuts across three blocks, asserts the joined
payload, then Ctrl+Z restores all three blocks and Ctrl+Shift+Z re-cuts.
2026-08-01 05:02:19 +00:00
nigig-ci
dfd3e2b360 docs(deny): correct the exemption-count attribution
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
The Phase E commit message said deny-nigig-build.toml drops to 2
exemptions. It is 3, and the drop from 5 happened in Phase B, not E:
Phase B removed the two polkit-derived advisories by dropping the unused
robius-sms dependency from three manifests, and Phase E9 removed the
dependency itself so they are now unreachable rather than unlisted.

The three that remain (lopdf, ttf-parser, atomic-polyfill) are unrelated
to SMS -- they arrive via printpdf and the embedded stack. Comment in the
config now says so, so the next reader does not go looking for an SMS
link that is not there.
2026-08-01 05:00:17 +00:00
nigig-ci
a38c41c00a security(sms): stop persisting message bodies, throttle sends (Phase E)
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
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
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
E1 -- the inbox was written to disk in plaintext.

  offline_store wrote every SMS body to
  app_data_dir/offline_store/sms_messages.json as pretty-printed JSON.
  SMS is the transport for OTPs, banking codes and M-Pesa
  confirmations, so that file was the user's complete authentication
  history sitting in app-private storage -- readable by anything running
  as the same UID, and included in backups.

  This repository already knew the answer. THREAT_MODEL.md T-I2 records
  "Raw SMS persisted to PSV file" as fixed in Phase 0, with raw_message
  omitted from save_to_disk() so it "lives in memory only". The SMS app
  then re-introduced the same defect at larger scale: the entire inbox
  rather than just M-Pesa messages, and with no retention limit until
  D6.

  OfflineSmsMessage.body is now #[serde(skip)]. Dropping the field
  rather than encrypting it is the deliberate choice: every consumer
  already reads the device provider FIRST and writes the cache second
  (nigig-sms fetch_from_device, and the mpesa and pay transaction
  pages), so the provider is the system of record and no body needs to
  survive a restart. Encryption would keep the plaintext reachable to
  anything holding the key. Not writing it removes the asset.

  Two consequences handled: sms_key() no longer hashes the body, since
  a reloaded row has an empty one and dedupe would otherwise never match
  its own cached entry and grow a duplicate per refresh; and the cached
  first paint shows a neutral placeholder rather than a blank preview
  for the instant before the provider read lands.

E9 -- the Linux backend's dependencies were pure cost.

  robius-sms declared polkit =0.17.0 and gio =0.17.0 for target_os
  = "linux". sys/linux.rs references neither: all twelve functions
  return Err(PermanentlyUnavailable). Those two crates dragged in glib
  and proc-macro-error and were the origin of RUSTSEC-2024-0370 and
  RUSTSEC-2024-0429 for every consumer of this crate.

  Deleting the block removes 340 lines from Cargo.lock. polkit, gio,
  glib and proc-macro-error no longer appear in the workspace at all,
  which also closes the LGPL-2.1 linkage question outright rather than
  routing around it as Phase B did for nigig-build alone.

E4 -- ROBIUS_SMS_BOOT_LIB was a code-injection vector.

  build.rs interpolated that environment variable straight into a Java
  string literal, which is then compiled, dexed and loaded at runtime
  with the app's full permissions. A value containing a quote closes the
  literal and injects arbitrary Java that runs on the device at boot.
  Build-time environment is not trusted input. Now validated against
  [A-Za-z0-9_]+ and the build fails loudly otherwise. Tested both ways:
  an exec payload is rejected, a legitimate name builds.

E5 -- undefined behaviour in the dex loader.

  new_direct_byte_buffer was handed RECEIVER_BYTECODE.as_ptr() as
  *mut u8 -- a &'static [u8] in .rodata cast to a mutable pointer, when
  the API is documented as taking writable memory and
  InMemoryDexClassLoader may write through it. Now copies into an owned
  allocation and leaks it, which is correct rather than lazy: the buffer
  backs a ClassLoader cached in a OnceLock for the process lifetime.

E6 -- two bindings for one native method.

  rustRestoreSchedules was both exported #[no_mangle] and registered
  dynamically via register_native_methods. Which one won was
  unspecified. Kept the dynamic one, because the class is loaded from an
  in-memory dex and is not on the JVM's search path, so symbol binding
  is not guaranteed to find it.

E8 -- no send rate limiting.

  Nothing capped send rate, and the bulk UI exists to blast a scraped
  directory. Android's practical throttle is ~30 messages per 30 minutes
  per app, past which sends are silently dropped -- so an unthrottled
  batch both overspends and fails opaquely. Adds SendRateLimiter, a pure
  token bucket taking an explicit clock so it is unit-testable without
  sleeping, wired into the bulk sender. A 200-recipient blast now stops
  at 30 and says why.

E10 -- robius-sms carried no license field, so cargo-deny needed a
  [[licenses.clarify]] override asserting one. Stated in the manifest;
  override removed.

E2 was already satisfied by D6 (retention capped at 5,000).

E7/E11 are documented rather than fixed, which is the honest status:
delivery confirmation needs real PendingIntents plumbed through
(A8 documents that Ok != delivered), and sender validation cannot be
solved client-side. Both are now rows in THREAT_MODEL.md instead of
findings in a markdown report -- along with T-I2b for E1 and T-I4 for
E3, which is NOT done: scheduled message bodies are still plaintext in
SharedPreferences.

CI: adds a gate asserting OfflineSmsMessage.body keeps #[serde(skip)].
Removing that attribute silently resumes writing plaintext and nothing
else would fail. Negative-tested.

deny-nigig-build.toml drops to 2 exemptions (from 5 before Phase B).

Tests: robius-sms 21 -> 25.
Verified: 12/12 CI jobs, clippy -D warnings clean on host and
aarch64-linux-android, cargo deny "advisories ok, bans ok, licenses ok,
sources ok", clippy ratchet holds at 49.
2026-08-01 04:58:46 +00:00
acbd956791 refactor(spreadsheet-ui): route autofill reads through shared model
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
2026-08-01 04:58:30 +00:00
Arena Agent
03dea4d14f fix(cad): remove panics from CAD production paths
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
Continued auditing the code the KeyCode fix (667f565) made reachable, and
followed the panic sites out from there.

Command::merge's default was `panic!("merge() called on a Command that
didn't override it")`. That is reachable, not defensive: any command that
overrides can_merge but forgets merge hits it. UndoRedoStack::execute
runs it on every frame of a drag, and the code five lines away already
declines to panic on a genuinely unreachable branch, with the comment
"never panic on the undo path". A CAD editor holds unsaved work -- losing
the session is far worse than the mis-merged undo entry being reported.

Now a debug_assert!: loud in development where it is actionable, and
survivable in a shipped build. Proven by test in both configurations --
the release behaviour is pinned by a test marked
`#[cfg_attr(debug_assertions, ignore)]`, since the dev build is *supposed*
to fire the assertion. Negative-tested by restoring the panic.

Also replaced three `unreachable!()`s:

- add_part_command and split_selected_part_at both did
  `position(..)` then `get(idx).unwrap_or_else(|| unreachable!())`. The
  index round-trip is what created the Option they then had to discharge
  with a panic; `find(..).cloned()` and `enumerate().find(..)` express
  the same thing without arming one.
- The DDE digit match's `_ => unreachable!()` arm is genuinely dead (the
  outer arm narrows to Key0..Key9), but a keyboard handler is not worth
  a crash to say so. It swallows the keystroke instead.

CAD production code now contains no panicking macros at all, enforced by
a new CI gate that scans each file up to its first #[cfg(test)] -- test
code may panic freely. Negative-tested.

One test-authoring note worth recording: my first version of the merge
test built five MoveNode commands all with `from: 0`, which tripped
MoveNode::execute's debug_assert that the scene's previous position
matches `from`. The assertion was right and my test was wrong; a real
drag chains each frame's `from` to the previous `to`. The corrected test
now also verifies the thing that assertion protects -- five drag frames
collapsing into one undo entry.

710 lib + 154 integration, 0 failed. All ten gates pass.
2026-08-01 04:56:02 +00:00
User
36c1da92fd fix(map): resolve frozen-vec theme errors by using pure Rust theme builders
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
nigig-map / test (push) Has been cancelled
The DSL-defined MapFillRule, MapRoadRule, MapWaterwayRule, and MapRailRule
children in style_light and style_dark were causing 86 'cannot push to frozen
vec' errors per startup. The Makepad VM freezes the MapThemeStyle object after
first evaluation, preventing re-evaluation from adding new children.

This left compiled_style_light and compiled_style_dark with no theme rules,
resulting in 0 rendered features (only brown background and labels visible).

Solution:
- Strip all DSL rule definitions from view.rs style_light/style_dark blocks
- Add default_light_theme() and default_dark_theme() pure Rust builder
  functions to style.rs that construct complete CompiledMapTheme instances
- Update rebuild_compiled_styles() to call the Rust builders instead of
  compiling from frozen DSL objects

This preserves the identical visual output (same colors, widths, sort ranks)
while eliminating the frozen-vec errors. Roads, buildings, water, railways,
and all other styled features should now render correctly.

Fixes: 86 'cannot push to frozen vec' errors per startup
Fixes: 0 rendered features on all map tiles
2026-08-01 04:55:32 +00:00
3cbd61cd31 refactor(spreadsheet-ui): route grid mutation commands through shared model
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
2026-08-01 04:51:57 +00:00
538848f527 refactor(spreadsheet-ui): route recalculation state through shared model
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
2026-08-01 04:45:49 +00:00
nigig-ci
69dd169ca6 perf(sms): get blocking I/O off the render thread (Phase D)
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
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
D1 -- the SMS read blocked the render thread.

  fetch_from_device() called robius_sms::list_messages() directly from
  draw_walk. That is a blocking cross-process ContentProvider query over
  Binder, plus ~10 JNI calls per message row, plus a full rewrite of the
  offline JSON store. On a populated inbox it is a multi-hundred-
  millisecond stall, taken on the render thread.

  robius_android_env::with_activity() calls
  attach_current_thread_permanently() and ContentResolver.query is
  thread-safe, so the read is safe off the UI thread. It now runs on a
  worker and hands back through a results queue drained on Event::Signal
  -- the same shape the contact lookup in this file already used.

D2 -- the app never idled.

  draw_walk ran a 5-second wall-clock refresh, and that refresh called
  redraw(), which scheduled the next frame, which re-entered draw_walk.
  A self-sustaining loop, running whether or not the SMS tab was even
  visible, each cycle paying D1's cost plus D3's clone.

  Refresh is now event-driven: first load, Event::Resume,
  pull-to-refresh, and the permission-granted callback. NOT yet a
  ContentObserver on content://sms -- that is the remaining half of D2
  and is called out in the code. Until it lands, a message arriving
  while the app is open appears on the next Resume or pull rather than
  within 5s. That is a deliberate trade: a bounded staleness window in
  exchange for an app that can reach idle.

D3 -- a deep clone per refresh, purely to diff.

  `let previous = self.conversations.clone()` copied every message in
  every conversation on each cycle so the result could be compared for
  equality. Replaced with a u64 digest over (count, address,
  message_count, newest date_ms). Bodies are immutable once stored, so
  that is sufficient to notice an insert, a delete or a new message --
  and the test asserts exactly those three cases.

D4 -- ~900 pointless worker kicks per second.

  start_contact_lookup_worker() was called once per visible row per
  frame (~10-15 per frame, ~900/s at 60fps). Each call took 3-4 mutex
  locks before early-returning, contending with the worker thread trying
  to write results back. Now called once, after the draw pass. The
  per-row code only enqueues.

D6 -- the offline store grew without bound.

  upsert_sms_messages re-read, re-hashed, re-sorted and rewrote the
  whole file on every call, with no cap -- while append_location() a few
  lines below has always truncated to 512. Capped at MAX_CACHED_SMS
  (5000), newest-first so truncate drops the oldest. This is a display
  cache; the device provider stays the system of record.

D7 -- O(rows x selected) per frame in the bulk list.

  get_selected_companies() returns a Vec and the draw path called
  `selected.contains(..)` once per visible row, so "Select All" over the
  Nairobi directory made every frame quadratic. Now a HashSet. Same fix
  in sync_selected_to_recipients, whose phone de-duplication was also
  O(n^2) via `phones.contains()`.

Refactor note: group_into_conversations() and conversations_digest_of()
were lifted out as free functions. ConversationsList holds a Makepad
View and is not Default, so nothing in it could be unit tested; the
logic D1/D3 depend on now can be.

CI: adds a gate rejecting blocking robius_sms provider calls inside any
draw_walk. Negative-tested by reinserting the call and confirming it
fails.

Tests: nigig-sms 19 -> 22, nigig-core +1.
Verified: clippy ratchet holds at 49, clippy -D warnings clean on host
and aarch64-linux-android, nigig-build still builds.

NOT measured. The plan asked for before/after frame timings on a 5,000
message fixture and this sandbox has no device or emulator, so the
figures above are reasoned from the code, not profiled. D5 (bulk send
still blocks the UI thread) is untouched and needs the same worker
treatment as D1.

Pre-existing and unrelated: nigig-core's pending_tx_lifecycle test fails
identically on pristine origin/main (wall-clock assumption in an M-Pesa
expiry test).
2026-08-01 04:34:22 +00:00
arena-agent
a3ff59e512 feat(doc): clipboard copy/cut/paste across blocks and table cells
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 / engine (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
The CRDT editor had no clipboard surface at all. It now answers the
platform clipboard queries exactly like makepad's TextInput:

- Hit::TextCopy / Hit::TextCut fill the response cell with the selection
  payload: the text-block selection joined with newlines, or the in-cell
  character span when the caret lives in a table. Nothing selected
  leaves the response empty, so the platform keeps the clipboard alone.
- Cut removes the payload through the shared selection-deletion path and
  paste flows through the existing TextInput insert path (replace-
  selection semantics for both blocks and cells).

Three latent production bugs uncovered and fixed at the source:

1. delete_selection's applied flag was wired to the empty replacement's
   missing trailing atom, so same-block Backspace-over-selection
   over-deleted by one char and left live anchors behind.
2. engine delete_text pushed no undo compensation, making every
   selection deletion (cut, type-over, Backspace-over) unrecoverable.
   It now pushes the symmetric RestoreText pair.
3. All six engine tombstone pairs (text, blocks, rows, columns, merge
   splits, cell style clears) resolved as union-minus-union: once a
   restore op existed, delete→undo→redo could never re-delete. They now
   resolve chronologically per target in operation order.

13 new tests: 5 clipboard runtime (payload, cut+undo, cell span,
no-payload, newline join), 2 widget regressions (over-delete,
delete-key), 6 engine (delete undo/redo, empty-replace single-step,
block/row/merge/cell-style chronology cycles). nigig-build 704 -> 711,
doc-engine 62 -> 68.
2026-08-01 04:31:06 +00:00