Skip to content

2026 season: year rollover, PlayaDB by default, watchOS sync, official-GeoJSON geocoder - #251

Draft
chrisballinger wants to merge 178 commits into
masterfrom
2026-updates
Draft

2026 season: year rollover, PlayaDB by default, watchOS sync, official-GeoJSON geocoder#251
chrisballinger wants to merge 178 commits into
masterfrom
2026-updates

Conversation

@chrisballinger

Copy link
Copy Markdown
Member

The 2026 season branch: year rollover, the Yap→PlayaDB migration reaching the default path, a standalone watch app with phone sync, and the geocoder moving onto BMorg's official GeoJSON.

77 commits, 251 files. Opening as a draft for review — this is the integration branch the season ships from, not a single feature.

What's in it

2026 rollover. New city geometry (Man at 40.783242,-119.207871, Ararat…Kundalini, 8287' fence), 2026 API data, regenerated tiles from BMorg's official GIS drop, iBurn2026* package renames, MARKETING_VERSION 2026.0.

Yap→PlayaDB is now the default everywhere. The five list tabs, detail, map annotations, global search and AI already ran on PlayaDB; this branch finishes the job — Visit List, Audio Tour and the map's visible-pins list are SwiftUI/PlayaDB behind useSwiftUILists, and EKEvent calendar identifiers moved out of Yap metadata into event_calendar_entries behind usePlayaDBCalendarSync. Both flags default on, legacy retained as a kill-switch.

What deliberately stays on Yap for 2026: the boot-time import (it feeds the kill-switch stack) and the favorites/notes/visit-status mirrors. The OTA→PlayaDB updater is deferred — PlayaDB stays bundle-seeded, so August data drops reach the shipping UI via app updates.

watchOS. Standalone app (map, browse, favorites, detail, compass nav) with phone↔watch sync of favorites, visit status and user map pins. Pin deletions propagate via tombstones — a snapshot exchange can't distinguish "never created here" from "deleted there" without them.

Geocoder on official data. BMorg's GeoJSON is now the source of truth in both directions. Against 512 official radial×ring intersections: 489 exact, 23 correctly naming the plaza on top, 0 wrong (the legacy geocoder got 44 wrong). Also fixes two street facts that had reached shipped artifacts — the C street is Ceiba, not Chomolungma (211× in the tile labels now), and Rod's Road was removed by BMorg for 2026.

Bug fixes worth calling out, all reachable in the default config before this branch: legacy list hearts silently dropped the PlayaDB write for events (wrong uid form); notes never mirrored to any Yap key; entering the embargo passcode didn't refresh PlayaDB observations, so unlocked locations only appeared after a relaunch; breadcrumbs only recorded after visiting Location History once per launch.

Verification

iBurn (iOS) builds clean, 0 errors / 0 warnings
iBurnWatch builds clean, 0 errors / 0 warnings
iBurnTests 202 passing
PlayaDB package 253 passing
PlayaAPI package 71 passing

Simulator pass on an existing install (the upgrade path): all six migrations apply in place; Visit List, Audio Tour and Visible Pins render correctly; favoriting an event writes a real EventKit identifier and unfavoriting removes both the event and the row.

Reviewer notes

Migration numbering looks odd on purpose. watchos-updates and the PlayaDB work both claimed v4 in parallel. GRDB matches migrations by identifier string, so pin sync was renamed to v6-pin-sync and registered last rather than making its ALTER idempotent — a clean install stays the honest test. Anyone re-testing on a simulator that ran the pre-merge watchos-updates build must delete the app first, or the recorded v4-pin-sync makes v6-pin-sync look unapplied, the ALTER re-runs, and the duplicate-column error throws out of PlayaDBImpl init and crashes the watch app at launch. Never shipped, so no real users are affected.

Submodules are pushed: BlackRockCityPlanner@8f8a932 and iBurn-Data@84a16bb (private remote).

Known follow-ups

  • OTA→PlayaDB updater (deferred by decision, not oversight)
  • iBurn-Android's assets/js/bundle.js is updated but uncommitted — it had still been the 2025 build, so that's a real fix awaiting a commit in that repo
  • poi.json still places Greeters/Airport by time+distance, landing 263'/700' from their official CPNs
  • Native Swift/Kotlin ports of the reverse geocoder (the 512-intersection sweep is the shared conformance vector)

Session docs: Docs/2026-07-25-playadb-default-yap-audit-and-migration.md, Docs/2026-07-25-watch-pins-and-map-controls.md, Docs/2026-07-25-reverse-geocoder-2026-audit.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SHCtiVbmJkQW8n4ztnTzsw

chrisballinger and others added 30 commits July 3, 2026 13:35
- YearSettings.plist: 2026, Aug 30 - Sep 7, new Man center
  (40.783242, -119.207871)
- Rename data package products iBurn2025* -> iBurn2026* (Bundle+iBurn,
  pbxproj product deps, PlayaAPI/PlayaDB test deps, BundleDataLoader)
- BRCDatabaseManager: iBurn-2026.sqlite (forces clean rebuild);
  re-arm embargo defaults key for 2026; BRCArtObject default year 2026
- PlayaGeocoder: embed data/2026 geocoder bundle.js (was still 2025,
  causing 2025 street names in reverse geocoding)
- MARKETING_VERSION 2026.0
- Tests: fixture event dates day-mapped into the 2026 festival window
  (BRCRecurringEventObject drops occurrences outside
  YearSettings.eventStart/eventEnd at import), MockServices event moved
  to 2026, PlayaDB GPS/region tests skip while location data is
  embargoed, real-data year assertions -> 2026, ObjectListViewModel
  mock provider now re-yields metadata after toggleFavorite (fixes
  broken-since-April toggle test)
- Docs: 2026 update plan + implementation log

Bump iBurn-Data submodule to 2026 data (2026-updates branch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLnBJ8fozETxFg8szgP2t9
Correctness:
- Fix FTS5 external-content sync triggers: delete/update now use the
  canonical 'delete' command with OLD values (plain DELETE corrupted the
  index on any out-of-import row change). Legacy triggers are detected
  and replaced with a one-time index rebuild on open.
- Normalize event-occurrence metadata identity: favorites/notes/viewed
  on an EventObjectOccurrence now target the parent event uid, with a
  startup migration folding legacy "<uid>_<occ>" rows into the parent.
  Fixes hearts not lighting up from the SwiftUI event list.
- observeEvents now tracks object_metadata + thumbnail_colors so
  favorite toggles and color-cache writes refresh lists and the
  favorites map layer.

Performance:
- DatabasePool (WAL) for on-disk databases: reads no longer block
  behind the seed import; DatabaseQueue retained for :memory: tests.
- Read paths are write-free: removed ensureMetadata blank-row
  prepopulation (~10k rows on first launch) from all fetch/observe
  paths; metadata is created lazily on actual writes.
- Removed unused allArt/allCamps/allEvents/allMutantVehicles/favorites
  reactive props and their 5 always-on full-table observations (incl. a
  retain cycle).
- Import rewrite: dictionary-based GPS resolution (~16k point queries
  eliminated), set-based spatial/R*Tree rebuilds, sync triggers dropped
  during bulk load; full-dataset import now ~0.3s (test suite 14s→7.5s).
- Added event_occurrences(end_time) index + EXPLAIN QUERY PLAN tests;
  image URL fetches aggregate in SQL.
- removeDuplicates() on all observations, backed by new Equatable
  conformances across models and ListRow.

162 tests passing (+9 new across FTSTriggerTests, MetadataIdentityTests,
QueryPlanTests, FilterObservationTests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLnBJ8fozETxFg8szgP2t9
- List observations now track only the metadata columns lists render
  (object_type, object_id, is_favorite, user_notes) via a shared
  listMetadataRegion; art/camp/MV observations move from auto-tracking
  to explicit regions (event_objects only for onlyWithEvents, mv_tags
  only when tag-filtered).
- Metadata writers switched to column-limited updates — GRDB's full-row
  update(db) touches every column, which made last-viewed writes look
  like favorite changes to the region tracker. Together these mean
  setLastViewed (detail-screen views) no longer re-runs any list query,
  including the 8k-row event JOIN; favorite/notes writes still re-fire.
- Schema setup now runs through DatabaseMigrator: current schema
  registered as v1-initial-schema (idempotent DDL so pre-migrator
  installs adopt cleanly); FTS/R*Tree setup, occurrence backfill, and
  the metadata fold remain open-time maintenance. Future schema changes
  become numbered migrations.

166 tests passing (+4: inverted-expectation region tests in
FilterObservationTests, new SchemaMigrationTests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLnBJ8fozETxFg8szgP2t9
Replaces the ask-first git-write policy: agents should commit once a
coherent unit of work is verified (tests + app build as applicable).
Pushing, history rewrites, pulls, squashes, merges, and rebases still
require explicit authorization.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLnBJ8fozETxFg8szgP2t9
Adds createInMemoryPlayaDB() to the PlayaDB package and a shared
PreviewPlayaDB.shared helper; PreviewArtDataProvider and
PreviewCampDataProvider no longer open extra connections to the real
Documents/PlayaDB.sqlite from SwiftUI previews.

Also enables the ui-automation workflow in .xcodebuildmcp/config.yaml
for simulator UI driving from coding agents.

Verified via simulator sanity pass of the audit changes on a fresh
install: WAL journal mode, full seed (321 art / 1201 camps / 2101
events / 4431 occurrences), zero blank metadata rows, v1 migration
recorded, day-tab switching, favorite → parent-event-uid metadata row +
live heart refresh + favorites tab, FTS matches + integrity-check on
the live database.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLnBJ8fozETxFg8szgP2t9
New project skill (.claude/skills/drive-app) capturing how to build,
launch, and drive iBurn in the iOS Simulator via XcodeBuildMCP UI
automation: session setup, the DEBUG-only SwiftUI/PlayaDB feature flag,
lazy seeding behavior, onboarding/permission-alert sequence, AX-tree
gotchas (hearts, searchable fields), and direct on-sim SQLite
verification with invariants.

references/flows.md catalogs the critical flows (onboarding, feature
flag, events browsing, favoriting end-to-end, search, map/embargo,
detail, feature flags screen) as step-by-step scripts, verified today
against the 2026 dataset.

CLAUDE.md points to the skill and establishes the maintenance rule:
flow changes update the flow doc in the same change, and drift noticed
while driving the app gets fixed in that session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLnBJ8fozETxFg8szgP2t9
SwiftUI preview snapshots race the observation stream's first async
emission, so list previews always captured the loading frame. Two
changes make both states deterministic:

- ObjectListViewModel gains an initialItems parameter (default [])
  so rows are present on the first rendered frame; app call sites are
  unchanged.
- Preview data providers take rows (default mockRows, first row
  favorited to exercise the heart); passing [] yields a stream that
  never emits, pinning the loading state for its own preview.

Adds "Art List - Loading" and "Camp List - Loading" previews. All four
states verified via Xcode RenderPreview: cells previews show 3 rows
with a filled heart on the favorited row; loading previews show the
spinner. App build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLnBJ8fozETxFg8szgP2t9
- Add .watchOS(.v10) platform to PlayaDB, PlayaAPI, iBurn-Data packages
- New iBurnWatch app target (watchOS 26, SPM-only, not embedded in iOS
  target yet so iOS/CI builds don't require the watch platform)
- Smoke screen seeds PlayaDB from bundled APIData and shows counts;
  verified on Apple Watch Ultra 3 sim: 321 art / 1201 camps
- Plan doc: Docs/2026-07-03-watchos-mvp-plan.md (MapLibre has no watchOS
  support -> custom SwiftUI Canvas map from bundled GeoJSON)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbRMBUHw4q1vcWQn2X8KX4
- New Packages/PlayaGeo (zero-dep): GeoJSON decoder, equirectangular
  projection around The Man, MapCamera (pan/zoom/rotation math),
  PlayaMapView Canvas renderer (fence/streets/plazas/toilets/markers/
  user dot + heading cone). 18 unit tests incl. real 2026 city data.
- Watch MapScreen: crown zoom, drag pan (rotation-aware), heading-up
  toggle with figure-8 calibration hint (watchOS has no system
  calibration API), follow-user + recenter; geo resources referenced
  in place from the iBurn-Data submodule.
- Verified on Apple Watch Ultra 3 sim: city renders, location
  permission flow, compass toggle. iOS app build unaffected.
- drive-app flows.md: new watchOS flow entry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbRMBUHw4q1vcWQn2X8KX4
- Root restructured to NavigationStack (verticalPage TabView fought the
  map's crown-zoom/drag-pan); Nearby + Favorites via toolbar buttons
- NearbyScreen: region-filtered art+camps sorted by GPS distance
- FavoritesScreen: watch-local favorites with distances
- DetailScreen: favorite toggle + Navigate (gated on hasLocation);
  NavigationScreen fits user+POI with live distance/bearing readout
- ContentView smoke page replaced by WatchSeeder app-level task
- Sim-verified end-to-end with injected GPS (pre-embargo bundle has
  none): 309m/590m/1.1km sort, favorite -> object_metadata camp|uid|1,
  navigate readout '309 m . 55(deg)'
- Documented PlayaDB gap: spatial_index R*Tree not maintained on UPDATE
  of gps columns (only import rebuild + insert/delete triggers)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbRMBUHw4q1vcWQn2X8KX4
Region queries went through spatial_index, which was only maintained by
import-time rebuild plus insert/delete triggers - an in-place UPDATE of
gps columns (the likely shape of the embargo location drop) left the
index stale and region queries silently missed those rows.

- New *_spatial_update triggers for art/camp/event: delete stale rows,
  conditionally reinsert keyed via the spatial_objects mapping table
- New event_occurrence_rtree_event_update trigger propagates event GPS
  changes into the denormalized occurrence R*Tree
- Trigger generation refactored into a data-driven loop (was 3x
  copy-paste); dropIndexSyncTriggers covers the new names
- Existing DBs adopt the triggers on next open (IF NOT EXISTS)
- SpatialIndexUpdateTests: gain/move/clear GPS via UPDATE, no duplicate
  rows, occurrence propagation, trigger presence. Full suite 172 green;
  iOS + watch app builds verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbRMBUHw4q1vcWQn2X8KX4
- iBurn target: dependency + 'Embed Watch Content' copy phase
- Watch target: WKCompanionAppBundleIdentifier=com.trailbehind.iBurn2010,
  WKRunsIndependentlyOfCompanionApp=YES (still standalone-capable),
  WKWatchOnly removed, version aligned to container (2026.0 / 108)
- Verified: iOS install on paired iPhone 17 Pro Max sim auto-installed
  the watch app on the paired Series 11; map renders there
- Note: iOS builds (incl. CI) now require the watchOS platform/SDK

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbRMBUHw4q1vcWQn2X8KX4
The iOS and watch seeders only imported bundled APIData when the
database had never been seeded, so devices seeded with 2025 data
never picked up the 2026 bundle. Events then all failed the default
includeExpired:false filter, leaving the Events list empty while
Art/Camps silently showed stale rows.

importFromData now records update.json's per-type timestamps as
UpdateInfo.lastUpdated, and a new PlayaDB.needsImport(bundleUpdateData:)
compares them so seeders re-import whenever the bundle ships newer
data (or a new data type like mv). PlayaAPI's UpdateInfo model gains
the missing mv field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbRMBUHw4q1vcWQn2X8KX4
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbRMBUHw4q1vcWQn2X8KX4
A real GPS fix far from BRC made the follow-camera center hundreds of
km from the city, rendering a blank map. Clamp user projection via new
PlayaMapData.pointOnPlaya (10 km radius) in MapScreen and
NavigationScreen, so off-playa fixes behave like "no location".

Also add BlackRockCity.gpx wired into the iBurnWatch scheme so Xcode
runs simulate standing at The Man for at-home testing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbRMBUHw4q1vcWQn2X8KX4
- DataUpdatesView: hoist main-actor BRCAppDelegate.shared.dataImporter
  access out of the nonisolated continuation closure
- DetailViewModel: drop redundant Task.detached around asyncReadWrite,
  which already runs on YapDatabase's own queue
- MainMapViewController: use async selectAnnotation(_:animated:)
- Art/CampListView previews: nonisolated provider class with @mainactor
  init instead of @mainactor class, fixing default-arg and override
  isolation mismatches
- NearbyListHostingController: target/selector timer instead of
  @sendable closure capturing the view controller

Clean build of the iBurn scheme now emits zero warnings; all 97
iBurnTests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- DEVELOPMENT_TEAM on iBurnWatch configs (matches the team already
  committed for the iBurn target; needed for watch device signing)
- lastKnownFileType on 2026 geojson refs, package ref comment paths,
  drop redundant group name, watch scheme format bump

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- MapScreen: double tap zooms in one level (+2 crown units = 2x,
  clamped to max zoom)
- New iBurnWatch/Assets.xcassets with 1024px AppIcon (from the iOS
  marketing icon) and AccentColor, wired into the watch target
- PRODUCT_NAME = iBurn so the installed watch app reads "iBurn"
  (CFBundleName previously fell back to the iBurnWatch target name);
  product/scheme references updated to iBurn.app

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- useSwiftUILists promoted to all builds, default true (kill-switch kept;
  Settings.bundle toggle added for Release fallback)
- Adult-event region gating restored in the SwiftUI events list + search
  (RegionStatusService + EventFilter.excludingAdultEvents, legacy parity)
- FavoriteSyncService: list hearts mirror favorites into Yap with per-
  occurrence event fan-out and EKEvent calendar refresh; fixes latent
  event-uid suffix bugs in DetailViewModel/DetailDataService sync paths
- Nearby includes events starting within 30 min (legacy parity)
- festivalDays end-inclusive: final festival day (Exodus) browsable
- Tests: EventListAdultGatingTests (8), FavoriteSyncServiceTests (11);
  full iBurnTests suite green; fresh-install sim pass verified both DBs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PlayaDB gains a v2 migration (object_metadata.favorite_updated_at — LWW
can't use updated_at since view-tracking bumps it), FavoriteSyncItem,
favoriteSyncSnapshot/applyFavoriteSync/observeFavoriteSyncState, and a
symmetric FavoritesSyncManager (WCSession applicationContext; lives in
the package so the watch target needs no pbxproj surgery). Phone wiring
in DependencyContainer mirrors incoming watch favorites into Yap
(occurrence fan-out + EKEvent refresh); watch wiring starts the manager
after seeding and live-refreshes FavoritesScreen. Same-state applies
skip writes so peers can't ping-pong; sessionWatchStateDidChange /
sessionCompanionAppInstalledDidChange re-push so a fresh install
converges (found in paired-sim E2E: pushes fail with WCError 7006
until the peer app exists).

Watch polish: FavoritesScreen surfaces load errors instead of showing
"No favorites yet"; DetailScreen lists upcoming occurrence times for
favorited events.

Verified: PlayaDB suite 189/189 (12 new merge tests), iBurnTests green,
both apps build 0 warnings, paired-sim E2E both directions (phone event
-> watch list + times; watch camp -> phone PlayaDB + decoded Yap blob).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011u8E25yJQUR3FbRr8C22nY
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011u8E25yJQUR3FbRr8C22nY
…where

Watch gains a Browse menu (map top-left): Nearby / Camps / Art /
Vehicles / Events — searchable alphabetical lists and a day-chip event
browser (single observeEventsByDayThenHour subscription, in-memory day
switching, adult events excluded off-playa like the phone). Enabled by
converting the iBurnWatch group to a filesystem-synchronized root
group, so new watch files no longer need pbxproj surgery.

Visit status joins PlayaDB (v3 migration: visit_status +
visit_status_updated_at) with setVisitStatus/fetchObjects(visitStatus:)
and per-field LWW in the favorites sync payload, so favorites and visit
status merge independently between phone and watch. Phone mirrors both
fields into Yap on incoming sync (new mirrorVisitStatus, event
fan-out); DetailDataService dual-writes with event-uid normalization.

Parity gap found during E2E and fixed: the PlayaDB-backed detail had no
VISIT STATUS cell and updateVisitStatus no-oped for non-legacy subjects
— want-to-visit was unreachable in the shipping default stack. Cell
added after USER NOTES; all subject cases now write PlayaDB + Yap.

Watch detail gains a visit-status sheet (Menu is unavailable on
watchOS); watch Favorites gains a filter sheet (Show: Favorites/Want to
Visit/Visited; Type: All/Camps/Art/Events/Vehicles).

Verified: PlayaDB suite 206/206 (17 new merge tests), iBurnTests green
(5 new mirror tests), zero-warning builds, paired-sim E2E both
directions (watch "Want to Visit" -> phone PlayaDB + Yap blob; phone
"Visited" -> watch row inserted via sync; fresh watch install migrates
v1->v3 and pulls state on activation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011u8E25yJQUR3FbRr8C22nY
Bump iBurn-Data for tiles regenerated from burningmantech/innovate-GIS-data
2026 GeoJSON (published today). Add imageMap entries for BMorg's renamed
CPNs (ESD Station 3/9 -> firstAid) and new ones (Arctica Outpost -> ice,
Recycle Camp -> recycle).

Fix Bundle.brc_cachedMbtilesURL to refresh the Application Support copy
when the bundled map.mbtiles changes - previously the cache was copied
once and existing installs never saw new tiles shipped in app updates
(remote tile updates are dead code in BRCDataImporter).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015MWYhmRLk3VDcpjZH1qhZT
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chrisballinger and others added 30 commits August 17, 2026 21:12
bmorg's image pipeline emits the literal string "processing" in
thumbnail_url until a thumbnail is generated (live for camp
a1XVI00000FN9rZ2AT in 2026, and still unresolved upstream). Strict
decoding turned it into a schemeless relative URL that could never
load and made playa-seed warn about a missing bundle thumbnail.
Salvage thumbnail_url via LenientURL on ArtImage/CampImage/
MutantVehicleImage so such values become nil.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155GAvrJAAVxbtSriVXCFLw
Verified that the seed shipped in App Store build 2026.0 (110) upgrades cleanly
to today's bundled JSON: rebuilt the shipped DB state from the release-pinned
iBurn-Data snapshot (4743806d) via the same importFromData path playa-seed uses,
seeded favorites/notes, reopened the store, and imported the current snapshot.
All counts, uid uniqueness, update_info and user-data assertions passed; the only
change a user sees is that favorites whose records left the API stop appearing.

Adds ReimportUpgradeTests, a fast synthetic A-to-B re-import suite covering
dedup, stale hard-deletion, needsImport transitions, favorite/note survival
across a reopen, and orphaned-metadata benignity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155GAvrJAAVxbtSriVXCFLw
The floating favorites button was missing from the initially-shown Map
tab on cold launch and only appeared after a tab switch.

configure(withRootViewControllers:) installs the button before the app
delegate assigns window.rootViewController, so view.bounds and
tabBar.frame are both .zero. updateFloatingButtonVisibility() read that
as `barFrame.minY (0) >= view.bounds.height (0)` -> the bar has slid off
the bottom -> isHidden = true; updateFloatingButtonPlacement() likewise
compared `0 > 0` and committed the safe-area anchor as if measured.
Normally the first layout pass corrected both invisibly, but on a slow
launch landing mid tab-bar animation no further pass arrived for the
initial tab.

Zero geometry is now "unknown", not "off screen". The hide/show math
moves into a pure FloatingActionButtonBarVisibility.isHidden(...)
alongside FloatingActionButtonPlacement: with no real geometry the frame
test is skipped and the button stays visible pending a real pass, while
isHidden/alpha (states, not measurements) still hide it. Placement
defers instead of guessing - first install takes the always-legal
safe-area anchor without recording it, so the first measurable pass
re-decides - and 7557add's out-of-hierarchy guard is untouched.
viewDidAppear adds one authoritative re-evaluation once the window is
attached. No timers.

Six new unit cases for the rule (0/0 must not hide, nil frame must not
hide, real offscreen bar hides, docked bar shows, hidden/alpha-0 hide
regardless). iBurnTests 615 green. Cold-launched on an iPhone 17 Pro Max
sim: FAB present on the first frame with no interaction, unchanged after
a tab round trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155GAvrJAAVxbtSriVXCFLw
Un-favoriting an event popped the "Reminders" PermissionScope modal every
time, and "Close" never suppressed it (it never asks EventKit, so the status
stays .notDetermined forever).

- EventStoreProviding.ensureAccess gains promptIfNeeded:; reconcile passes
  isFavorite, so removal passes stay silent — nothing can be in the calendar
  unless access existed when it was written.
- EKEventStoreProvider latches the pre-prompt to at most once per app launch,
  which also fixes the rapid favorite→unfavorite double prompt.
- Legacy parity: BRCEventObject cancelNotification: uses a non-prompting store
  accessor, and BRCDetailViewController didTapFavorite: skips the Yap calendar
  write when PlayaDB calendar sync owns the entries (new BRCCalendarSync
  ObjC bridge for the feature flag).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155GAvrJAAVxbtSriVXCFLw
`orderedByName()` emitted plain `ORDER BY name`, which SQLite resolves with
the BINARY collation — a UTF-8 code-point sort that puts every capitalized
name ahead of every lowercase one ("Zebra" before "apple"). Art, Camps, and
Mutant Vehicles all funnel through it, so all three lists read as two
interleaved alphabets.

Collate with GRDB's built-in `localizedStandardCompare` instead: Finder-style,
case- and diacritic-insensitive, and numeric-aware so "Camp 2" precedes
"Camp 10". This matches `GlobalSearchViewModel.sortedByName`, so browse lists
and search results now agree. `uid` breaks ties for stable ordering.

GRDB registers its built-in collations on every connection it opens, so this
works on databases restored from the pre-baked seed zip too; the BINARY-collated
`name` indexes can no longer serve the ORDER BY, but these result sets are a few
thousand rows sorted in memory.

Also routes the unfiltered `fetchArt`/`fetchCamps`/`fetchMutantVehicles` through
`orderedByName()`, and switches `BRCDataSorter` and `UserMapViewAdapter` title
sorts to `localizedStandardCompare`. `BRCDatabaseManager`'s Yap sort block is
left alone — changing it would require bumping several hardcoded view version
tags to force a re-sort, for surfaces that are already retired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
A NaN annotation coordinate reached MapLibre, which projects it straight
into a CALayer position: fatal CALayerInvalidGeometry in
MapViewAdapter.addAnnotations, via editMapPoint / addUserMapPoint from
the place-pin FAB (2026.0 build 109).

The source is MLNMapView.centerCoordinate, the off-playa fallback in
BRCLocations.userMapPointCoordinate: a map whose bounds are still
degenerate unprojects to NaN. Nothing downstream rejected it — the
model's guard was `_latitude == 0 || _longitude == 0`, which NaN passes.

Defense in depth:
- BRCLocations.isUsable(_:) (finite + CLLocationCoordinate2DIsValid), and
  userMapPointCoordinate falls back to the Man when the viewport isn't usable
- addUserMapPoint refuses and logs an unusable coordinate
- MapViewAdapter.addAnnotations filters unusable coordinates BEFORE
  registry.add, so the registry never holds a key for a pin the map isn't
  drawing; the overlap offset (which divides by cos(lat)) skips them too
- BRCMapPoint.coordinate sanitizes non-finite values, keeping the existing
  "either component is 0 means unset" semantics

New iBurnTests/InvalidCoordinateGuardTests covers all four layers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
Production crash in build 2026.0 (110) on GRDB.DatabasePool.reader.6:
"A transaction has been left opened at the end of a database access"
(preconditionNoUnsafeTransactionLeft, SerializedDatabase.swift:261).

Cause: cancelling an async GRDB access calls sqlite3_interrupt on that
connection, and in 7.6.1 the access could then fail to close its
transaction:

- DatabasePool.read closed its deferred transaction with a bare
  `try? db.commit()` and no ROLLBACK fallback, so an interrupted COMMIT
  left the transaction open (fixed upstream in 7.7.0).
- FTS5 leaks prepared statements, leaving the connection in a sticky
  interrupted state so even COMMIT/ROLLBACK fail with SQLITE_INTERRUPT
  (GRDB #1838/#1839, fixed in 7.9.0). This app is a heavy FTS5 user.

The assert(!db.isInsideTransaction) that would catch this is compiled
out in Release, so the outer precondition fires instead.

Bumps every GRDB pin (PlayaDB package, Xcode project/workspace SPM
resolution) to 7.11.1. No app-level workaround: shielding reads from
cancellation would make them uninterruptible, and
allowsUnsafeTransactions would leave pool readers holding stale
snapshots.

Adds TaskCancellationTransactionTests, whose FTS5 write case reproduced
the fatalError deterministically on 7.6.1 and passes on 7.11.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
Theme camp addresses release a week before gates precisely so burners can
plan before they travel — but the strict rule also required a GPS fix
inside the Burning Man region, which made that window unreachable for
exactly the people it is for. Make the pure rule tier-aware instead:

    camp: passcodeUnlocked || now >= campLocationUnlock
    art:  passcodeUnlocked || (inRegion && now >= eventStart)

The art tier keeps both halves, and it is the tier that gates bulk
placement (browse-map camp pins, camp-labels-big, camp-boundaries, the
viewport fetch, bulk event pins) since the Aug 16 split — so a rolled-
forward clock now buys camp address text and the one pin the user opened,
never the city map. The watch has no logic of its own and picks this up
through the shared LocationEmbargo.

Docs/2026-08-22-camp-tier-date-only-unlock.md records the decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
Follow-up to 102a7fd.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
art 332 -> 331, camps 1185 -> 1184, events 2884 -> 2876 records
(5791 -> 5778 occurrences), mutant vehicles 492 -> 493. All uids unique
in every feed.

The Aug 19 refresh skipped the post-fetch apply_placement.js step, so
camp pins had regressed to the address geocoder's street-intersection
points. Re-applied here: 1175 camps back on their polygon centroid, 2 on
the address geocode, 7 without GPS; camp_outlines/camp_labels 1178 ->
1175 features as three camps left the roster. Also picks up one new
thumbnail. Seeds regenerated for phone and watch (gitignored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
The camp tier went date-only today, but `.BRCEmbargoDidClear` only ever fired on
region entry and passcode entry — never on a date rollover. An app running or
suspended across 2026-08-23 00:01 PDT would have kept showing "Location
Restricted" until it was killed and relaunched, and iOS suspends apps for days.

Adds EmbargoUnlockScheduler (protocolized, injected through DependencyContainer):
it holds the last tier verdict and posts `.BRCEmbargoDidClear` only on a real
locked -> unlocked transition, re-evaluating at launch, on didBecomeActive and
significantTimeChange, and on a one-shot timer armed for the next unlock instant.
Clock is Date.present, so the mock-date scheme drives it too.

Watch equivalent: WatchEmbargo.refreshUnlockState() on scene phase .active
(watchOS suspends between glances, so no timer), and NearbyScreen re-runs its
per-tier query on .embargoDidUnlock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
Driven end-to-end on an erased iPhone 17 Pro Max (iOS 26.5) against HEAD, and
corrected where the doc no longer matched:

- §1: a stock iOS 26 install is on the `searchTab` layout (that is the
  `mapSearchLayout` default), so the bar is Map/Nearby/Events/More + Search with
  Favorites on the floating button. The old "Map/Nearby/Favorites/Events/More is
  the default" line only holds below iOS 26.
- §2: seeded counts re-measured against the Aug 22 API refresh (331/1184/2876/
  5778/493, 1574 thumbnail colors, 1177 distinct camp coordinates), migrations
  now run through v7-fts-prefix-index, and the vehicles table is `mv_objects`.
- §5: the "lowercase names sort after Z" caveat is obsolete — browse lists and
  global search both collate with localizedStandardCompare since 7c66301.
  Replaced with the interleaving that should be observed, and the numeric-aware
  `#` bucket ordering.
- §6: a concrete recipe for the EmbargoUnlockScheduler's didBecomeActive
  trigger (suspend, rewrite BRCMockDateValue in the container, `simctl launch`
  to resume the same pid) plus the two traps found while driving it: the
  in-app date picker latches `_customOverrideDate` over UserDefaults, and the
  one-shot Timer path is unreachable under a mock clock that never advances.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
Facebook report: no Airport Road on the map, and Black Rock Travel Agency
missing. Both fixed in the data submodule — the geocoder now resolves
"Airport Road" (the airport camps' published address), the two affected
camps have coordinates, and a hand-authored Airport Road line is tiled
and styled in both themes.

No app source changed: Map.bundle ships through the iBurn2026Map SwiftPM
target and PlayaGeocoder builds bundle.js straight out of the submodule.
Seed regenerated (playa-seed --fetch-media); both camps now carry GPS in
PlayaDB.

Verified: BlackRockCityPlanner npm test 17/17, iBurn scheme 0 errors /
0 warnings, iBurnTests 0 failures, PlayaDB ReimportUpgradeTests 7 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
Refresh three days before gates, plus a standing audit that validates every
camp pin against the shipped map layers.

  art                332 (+1)     art at 0,0 upstream test rows 20 -> 16
  camps             1184 (=)      with GPS 1179 (=)
  events            3412 (+536)   occurrences 6565 (+787)
  mutant vehicles    492 (-1)
  camp_outlines / camp_labels features 1175 (=)

apply_placement stats are identical to Aug 22: 1175 polygon centroid /
4 address geocode / 0 entrance / 5 without GPS.

Audit (Submodules/iBurn-Data/scripts/audit_camp_geocodes.js, new): 0 geocode
failures, 0 null island, 0 outside the trash fence, 0 pins drifted from their
camp_labels point, 0 label/outline orphans. The only five camps without GPS
publish no address at all. Black Rock Travel Agency and Flybynyte re-derived
the Airport Road point automatically.

Also fixes the "<time> & <time> <letter> Plaza" address form in the nested
BlackRockCityPlanner geocoder and rebuilds data/2026/geocoder/bundle.js.

Seed regenerated (both zips 3367 KB, byte-identical), 1 new thumbnail
committed in the submodule.

  xcodebuild -scheme iBurn                                    0 errors, 0 warnings
  swift test --package-path Packages/PlayaDB
    --filter ReimportUpgradeTests                             7 passed
    --filter NameOrderingTests                                8 passed
  npm test (BlackRockCityPlanner)                             17/17, exit 0

Docs/2026-08-27-api-refresh-and-geocode-audit.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
2026.1 is live on the App Store, so the next submission needs a new
marketing version, not just a build bump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
std::out_of_range thrown from -[MLNMapView
accessibilityElementForAnnotationWithTag:] while VoiceOver enumerates map
annotations. Fixed upstream in maplibre/maplibre-native#3926 (issue #3923),
backported to the 6.18.0 line as the distribution tag 6.18.0-patch0.

Crashlytics issue: 8a3647f99fc8a4a47c8174d15810f0b7

SPM orders 6.18.0-patch0 as a prerelease *below* 6.18.0, so the existing
upToNextMajorVersion(6.0.0) range can never select it. Switched the package
reference to an exactVersion pin; revisit when moving to 6.19+.

Both Package.resolved files now record revision
f843f76a4bb79c83f4f817a090c62f255bb52fea / 6.18.0-patch0, and the resolved
xcframework artifact URL moved to ios-v6.18.0-patch0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
BMorg asked (relayed 2026-08-28) that the staff unlock passcode stop
revealing the camp boundary polygons. Until now the passcode satisfied
every tier, so entering it drew the `camp-boundaries` layer — the
`camp_outlines` footprints for the whole city — anywhere, at any date.

The polygons now ride their own rule, with no bypass:

    inRegion && now >= eventStart

Everything else is untouched: art placement, bulk camp pins,
`camp-labels-big` and camp address text keep the passcode-inclusive
rules they had. The ask was about the footprints specifically, so the
scoping is deliberate — and since the new rule is never more permissive
than the art tier, the polygons stay a strict subset of what the rest of
the placement layers show.

Implemented as a dedicated check rather than a new tier, so no other
surface's behaviour can drift with it:

- `LocationEmbargo.canShowLocations` grew a defaulted `passcodeCanUnlock`
  parameter, and `canShowCampBoundaryPolygons(now:inRegion:)` on top of it
- `EmbargoService.canShowCampBoundaryPolygons()` /
  `MapEmbargo.allowsCampBoundaryPolygons()`
- `CampLayerVisibility` resolves the two camp layers separately:
  `camp-boundaries` off the new check, `camp-labels-big` off
  `allowsBulkCampPlacement()` as before

The `.BRCEmbargoDidClear` refresh a passcode entry posts re-resolves
visibility rather than only turning layers on, so the polygons come back
false on that path; there is a test for it. The `camp-boundaries` style
layer is the only renderer of camp footprints in either target (the watch
has no map), so nothing else needed the rule. The passcode screen now
says the outlines are the one thing it does not unlock.

Verified in the simulator with a passcode-latched install: the
pre-change binary draws footprints across the city, the post-change
binary draws none, with the map otherwise identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
Crashlytics fe741015e1cc320abea6275cd5f79a02 — 2026.1 (111), iOS 26.5:
"Attempted to scroll the collection view to an out-of-bounds item (3) when
there are only 3 items in section 0" from SwiftUI's PagingCollectionView
inside layoutSubviews.

The requested index is exactly one past the end: a stale page index validated
against a newly smaller item count. The paged TabView backing the map's nearby
card is a UICollectionView, and when the feed loses an item under a selection
that is still valid by id, the pending selection scroll carries the old index
into layout after the data source has shrunk.

- Give the TabView structural identity keyed on the page count, so a count
  change rebuilds the collection view instead of mutating it in place. Reorders
  and content refreshes (the common case) still animate normally.
- Reconcile the selection through a pure, testable
  NearbyCardViewModel.reconciledSelection(selectedID:items:previousIndex:),
  still assigned synchronously with items. A dropped selection now falls back
  to the item on the page the user was on, clamped to the last page, rather
  than snapping to the first card.
- Cover the reconciliation with unit tests, including the crashing shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
iBurn/iBurn-2026.zip still held its 2026-08-17 harvest while the bundled
APIData.bundle/update.json has been at 2026-08-27 07:34 since the Aug 27
refresh. BRCDataImporter (BRCDataImporter.m:190-208) skips a data type only
when the bundled JSON is not newer than the stored BRCUpdateInfo, so every
fresh install was re-importing art, camps and events on top of the restored
seed: a slow first launch and the code path of Crashlytics issue
efdd7241b125f5b06e18abc22ba48e6c (YapDatabaseViewPage insertRowid
EXC_BAD_ACCESS).

Cause: playa-seed writes only the two PlayaDB zips; the legacy Yap seed is a
separate hand harvest (Part C of Docs/2026-07-18-api-data-refresh.md) and was
skipped on both the Aug 22 and Aug 27 refreshes. RELEASE_CHECKLIST already
had it as its own checkbox - it just was not followed - so section 3 now spells
out that playa-seed does not cover it and records the miss.

Re-harvested per Part C (stale zip removed first). New seed: 332 art /
1184 camps / 3412 events (6900 occurrence rows) / 8419 total, integrity_check
ok, all three BRCUpdateInfo.lastUpdated matching update.json exactly. Fresh
install now shows all 8419 rows two seconds after launch with no import ramp.

The zip itself is gitignored (.gitignore:101), so only the docs land here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
Picks up fork commit fdae9892, which fixes the production EXC_BAD_ACCESS in
-[YapDatabaseViewPage insertRowid:atIndex:] during the legacy Yap first-launch
import (Crashlytics efdd7241b125f5b06e18abc22ba48e6c, iBurn 2026.1).

YapDatabaseFilteredViewTransaction computes an insertion index from the parent
view's ordering without bounds-checking it against the target group's count;
std::vector::insert then runs past end() and faults. The fork now clamps the
index (with NSAssert in Debug, YDBLogError + append-at-end in Release) at both
the general YapDatabaseViewTransaction choke point and the page itself, and
guards the filtered view's unbounded do/while search loop.

Validation: 335/335 fork tests pass, iBurn builds clean, BRCDataImportTests /
BRCDataSorterTests pass, and a fresh-install cold launch completes the boot
import with no crash and no clamp logged.

NOTE: Submodules/YapDatabase must be pushed to iBurnApp/YapDatabase before this
repo is pushed, or the pointer will dangle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
… build 113

App Store Connect measured the iPad variants of build 112 at 207 MB, over
Apple's 200 MB cellular-download limit. The media bundle was 180 MB of the
app, so that is where the fix has to come from.

Two lossless levers, applied in the iBurn-Data submodule:
- jpegtran -copy none -progressive -optimize over all 1612 JPEGs: 1559
  shrank (-7.0 MB), 43 skipped because they are PNG data with a .jpg
  extension. Pixels are bit-identical (djpeg hash-checked on 10 samples).
- 39 orphaned JPEGs deleted (-4.0 MB) — their filename stems appear in no
  2026 APIData JSON, so the app cannot reference them.

Bundle 180 -> 169 MB, so the iPad variant should land near 196 MB.

Rejected: lossy re-encode (12-16 MB but visible loss on photos users
pinch-zoom) and re-encoding the audio tour (already 48 kbps AAC).

The PlayaDB and Yap seed zips hold databases only, no images (verified with
unzip -l), and their baked thumbnail colors derive from pixels that did not
change — no seed regeneration needed.

CURRENT_PROJECT_VERSION 112 -> 113 so the next upload is re-measured;
MARKETING_VERSION stays 2026.2. RELEASE_CHECKLIST gains a variant-size
check with the jpegtran command for future years.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
Moving or deleting a user pin looked like it worked, but after a relaunch the
pin was back at its original position and deleted pins reappeared.

The reverting writer is peer sync. PeerSyncManager replays the watch's last
`receivedApplicationContext` through `applyUserMapPinSync` at every WCSession
activation — that context is persistent and survives the watch app being
uninstalled — and the merge is last-writer-wins on `modified_date`. Two local
write paths produced stamps that lose that comparison:

- Edits never advanced the stamp. `BRCUserMapPoint.toUserMapPin()` writes back
  the `modifiedDate` the object was loaded with, so a move/rename changed the
  coordinates but left `modified_date` untouched; the peer's pre-edit copy then
  won (or tied) and overwrote the edit on the next launch. It also meant moves
  were never pushed to the watch.
- Tombstones could be born stale. `deleteUserMapPin` stamped a plain `Date()`,
  while a pin's stamp can sit in the future relative to the device clock —
  `BRCUserMapPoint` stamps `Date.present`, which is the mocked date (default
  2026-09-04) whenever `BRCMockDateEnabled` is on. A tombstone older than the
  live row it retires loses to the peer's copy, resurrecting the pin.

`saveUserMapPin` now stamps every edit with `nextModifiedDate(after:)`
(`max(now, previous + 1s)`) rather than trusting the caller — no caller
legitimately sets it, since peer snapshots merge through `applyUserMapPinSync`
— and preserves the earliest `created_date`. `deleteUserMapPin` uses the same
helper, so a tombstone is always strictly newer than the row it retires;
`tombstone(_:in:)` now shares it too.

Tests: five new cases in UserMapPinSyncTests covering both reported symptoms
(including future-dated rows), plus UserMapPinPersistenceTests, which drives
UserMapViewAdapter's real save/delete paths against an on-disk PlayaDB and
asserts what lands in `user_map_pins`. UserMapPinTests'
testSaveWithSameIDReplacesExistingPin now asserts the stamp advanced — it
encoded the old semantics. The save/delete extension in UserMapViewAdapter went
from `private` to internal so tests can reach it; no behaviour change.

swift test Packages/PlayaDB: 358 passed. xcodebuild test iBurnTests: 666
passed. xcodebuild -scheme iBurn: clean.

Docs: Part E of Docs/2026-08-28-maplibre-voiceover-crash-and-boundary-passcode.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015wZu9kgXjF2PjdeMZFjUn3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant