Skip to main content
All insights

2026-05-23DataMesh Consulting

23 May — MERX Canada and AusTender extractors land, iOS Matches refresh-cancel hotfix

Two more national portals come online — MERX (Canada's federal + provincial procurement aggregator) and AusTender (Australian federal government). Both as Step-0 extractors with full detail enrichment. On the iOS side, a hotfix went out as version 1.2 for a stubborn "Couldn't refresh" banner that some users were seeing on cold launch even though the server was returning fresh data fine. Root cause turned out to be a SwiftUI lifecycle quirk cancelling the in-flight URLSession task during AppState state churn; the fix bounded-retries on URLError.cancelled so the next attempt completes cleanly. Diagnostic instrumentation stays in this build to surface any remaining edge cases.

MERX Canada — federal + provincial aggregator

MERX is the canonical Canadian procurement aggregator. It consolidates notices from Public Services and Procurement Canada (federal) plus most provincial procurement systems behind a single search interface. The portal exposes a public listing JSON endpoint that returns notices with enough metadata to populate the Tender model fully — no detail-page round-trip needed for ~80% of notices.

For the remaining 20% (province-routed listings where the detail lives on the originating provincial site), the extractor follows the source URL and parses the provincial detail page format. Six provincial layouts are handled explicitly (Ontario, Quebec, BC, Alberta, Manitoba, Nova Scotia); others fall back to a generic OCDS-shaped parser that works for the standardised notices.

Initial scrape returned ~7,200 active notices. Country code on all of them resolves to CA with the original province code preserved in nutsCodes[] (using the ISO 3166-2:CA codes — CA-ON, CA-QC, etc., adapted to our NUTS-style storage).

AusTender — tenders.gov.au

AusTender is the Australian federal government's procurement portal. The listing is paginated HTML; per-notice detail pages follow a consistent layout that parses cleanly with cheerio.

  • Listing?ShowAll=true&pageSize=100&page=N, ordered
by close date desc.
  • Detail enrichment — agency, value, contract type,
ANZSIC industry code (mapped to nearest CPV equivalent), contact officer block.
  • Australian Government Procurement classifications
there's a category taxonomy unique to the AU federal system. Preserved as-is in cpvCodes[] alongside the CPV mapping, so AU-specific filtering still works for domestic users.

First scrape: ~2,100 active notices. Median value: AUD 180,000, weighted toward services contracts (IT, consulting, maintenance).

Both new extractors are now in the scrape-push-prod registry, so they'll join the regular cycle from the next scheduled poll.

iOS hotfix — 1.2 (build 22)

Some users — including the on-call account — were seeing the "Couldn't refresh" banner persist on the Matches tab across multiple pull-to-refreshes, with the feed showing cached tenders instead of the live top matches. The production Cloud Run logs showed every /v1/tenders/matches call returning HTTP 200 with a healthy 145 KB body. So the data was making it to the device.

Tracking it down took most of the afternoon. The thread:

1. Initial hypothesis: race condition. HomeViewModel's load() function was clearing errorMessage only at the start of an attempt, so if two loads overlapped and the failing one's catch ran after the succeeding one's completion, the banner would stick. Added a errorMessage = nil on the success path. This addressed a bug but not the one users were seeing. 2. Second hypothesis: decode failure. Synthesized Swift Codable for [Tender] is all-or-nothing — a single bad row breaks the whole array decode. Wrapped each row in a failable decoder so one bad row gets skipped rather than dropping the user to cache. Useful defensive change; not the root cause either. 3. Cold-launch test in TestFlight. After force-quit and reopen the banner appeared immediately with cached data. So load() was definitely throwing on the first attempt — but getMatches() returns 200 over the wire, and the bytes (verified with sha256 against a synthetic fetch from inside Cloud Run as the same user) decode cleanly through Swift's JSONDecoder. 4. Diagnostic build. Surfaced the actual Error value in the banner text. Result: URL #-999 cancelled. 5. Root cause: SwiftUI .task cancellation. During the cold-launch state churn — AppState.checkAuthStatus updates currentUser, isAuthenticated, hasAcceptedAIConsent, and unreadNotificationCount in close succession — the HomeView body re-evaluates multiple times. The .task modifier's underlying Task gets cancelled when the view briefly remounts, which cancels the in-flight URLSession data task, which throws URLError.cancelled into the catch path.

The fix: on URLError.cancelled, skip the user-visible banner/cache fallback and schedule one retry 400 ms later. The retry runs after the AppState churn has settled, the view is stable, and the URLSession data task completes normally. Bounded to two retries so a genuine cancellation storm still surfaces eventually.

Shipped as version 1.2 build 22 to TestFlight. Diagnostic fingerprint instrumentation stays in the build to catch any other transport-layer surprises; will be stripped before the next public App Store push once we confirm the fix is holding in the wild.

Catch-up week

This is the sixth daily update in a six-day catch-up — the public status log had been silent since 15 May while the extractor wave and the iOS submission consumed engineering attention. Going forward the cadence is back to one update per shipping day. If a day has no notable ship, no entry — we'd rather have an honest gap than fill it with cruft.

Methodology: drawn from the week ending 2026-05-23 tender corpus. Tender data sourced from public procurement portals worldwide; see our methodology for the extraction pipeline.