H2O Professional Services · field ops suite

Build status & where we go next

Everything below was verified by running it, not by reading the code. The three checklists are the working surface: every item names its files and an observable acceptance check, so any session — on any model — can pick the top unchecked item and execute it without re-investigating. Tick items as they land; rate the directions; both are saved in this browser.

7 / 7Android modules building
7 / 7Xcode schemes building
$78,384revenue completed Aug 1–11 (corrected basis)
73checklist items to go-live
178 / 178audit claims verified, 0 rejected

Nothing on this page fetches live — figures are baked in at edit time. Revenue and counts as of 2026-08-12. Standalone page built 2026-08-19.

Where it runs

TargetStateEvidence
ST Bridge running systemd user unit on the laptop (10.0.0.41:8420 — the office-demo bridge) plus launchd on the mini (g4); production tenant 4155131542, read-only, raw passthrough off. Board and performance both answer in ~0.7s. Recovers from SIGKILL.
Android running All 7 modules green. Verified live on a phone emulator and a Pixel Tablet against the production tenant.
iOS / watchOS held All 7 schemes build against Xcode 26.6 on the Mac mini. Dispatch board verified running in the Simulator. Deliberately parked behind Android — see the parity ledger.
Physical devices in the field H2O Command installs OTA from h2oinventoryreports.info/install (SSO-gated, g19); ran on a real phone 2026-08-12. Live data still needs the shop Wi-Fi (debug build → laptop bridge 10.0.0.41:8420). iOS still needs an Apple Developer team for signing (g12).

The checklists — feature, visual, go-live

Built by fourteen agents: three persona walkthroughs (owner, dispatcher, technician), two visual audits, an easy-mode designer, a release/compliance investigator, four platform researchers — then three adversarial verifiers re-derived 178 claims against the code and the live tenant and rejected none. Each item carries its files and an acceptance check. Working a checklist: tick what ships, use Copy next unchecked to hand the top remaining item to a Claude session as a ready-made prompt. The executor stamps landed on the item in the file when it ships — a fresh browser derives its ticks from those stamps; a manual tick or untick in this browser still wins.

One app per platform — LOCKED 2026-08-11, in progress. The six satellite Android entry modules total 817 lines of shell; every real screen already lives in shared core-ui, and the Command hub (app/MainActivity.kt, iOS CommandContentView.swift) already contains all five surfaces as tabs and overlays. Consolidating means promoting Command to be the app and letting a role decide what it shows — technician boots into My Day, office into the board, admin into Overview — with the role riding the device-identity picker plus one read-only bridge route (GET /api/roles serving a roles.json the admin edits on the Mac mini). It collapses the distribution problem from 14 signed binaries to 4 (one phone + one watch app per platform; the widget and Live Activity ride inside), divides every store review, keystore and version bump by seven, and dissolves the iOS constraint that a watch app must ship inside a phone app. The seven icon marks survive as the in-app tab iconography, optional Android activity-alias home-screen doors, and iOS alternate icons by role. Stated honestly: role-shaping is UX, not security — until the bridge's bearer-token door lands, any device can call any read endpoint. The go-live checklist below is written assuming the consolidated shape.

Ground rules — every copied executor prompt carries these; a proposal that violates one is worthless.

  • The bridge is read-only by construction — non-GET → 405 before any handler runs. ServiceTitan writes belong on field-api.php, journalled via X-H2O-User; a feature needing a write names that dependency (f28).
  • No signed-in identity exists in the mobile suite — nothing knows who holds the phone until f28's Google SSO lands; the device claim is a picker, not an identity.
  • ServiceTitan silently lies — technicianId on appointments, date filters on dispatch/assignments, startsOnOrBefore: 200 with wrong data. customers ids= caps near 50 then bare-400s. Diff every new filter against its unfiltered call.
  • This tenant prices services only — materials and equipment carry zero in every money field; unitOfMeasure is null on every record.
  • 8 of 29 customers have no address — the ceiling on anything map-shaped.
  • Nearly everything is dispatched at booking — unassigned work is rare; don't design for a backlog that doesn't exist.
  • Roadmap hygiene — ids are append-only; the executor stamps landed in the file when done (ticks derive from it); deploy with docs/deploy-roadmap.sh and verify the served page.

Live data vs sample

The suite's original problem was screens quietly showing invented data. This is where that stands.

ScreenAndroidiOSBacked by
Dispatch boardlivelive/api/dispatch/board — 19 appointments, 10 technicians, customer names and addresses
Technician performancelivelive/api/performance — revenue, jobs worked, WIP, memberships, conversion
Pricebook · Live tablivelive783 priced services across 5 pages
Pricebook · other 4 tabssamplesampleNothing to back them: materials and equipment have zero priced records in this tenant
Dispatch listlivepartialAndroid: real day per technician, multi-column on tablet with the shared detail rail. iOS still on assignment-derived counts
Dispatch maplivepartialAndroid plots 21 of 29 from real coordinates, fits the service area with outliers one tap away, full-bleed on tablet. iOS still geocodes technician home addresses
Admin dashboard tiles6 of 8 livesampleAndroid: board + performance. Parts Scanned and Lowest Margin have no ServiceTitan source and are marked per tile
Admin activity feedlivesample/api/activity — assignments, sold estimates and completed jobs merged newest-first over 7 days
Home-screen widgetlivesampleAndroid: next job on the board, with the count remaining — tappable, 15-min WorkManager refresh, honest failure card (f6). iOS: widget and Live Activity are built, on sample data with policy .never — the remaining work is a feed, not a build-out
My Day / Field WatchlivesampleAndroid: My Day renders the claimed technician's real board day (f1); Field Watch is live on Wear OS 3 (f8). iOS: still the 4 invented customers — the personal day stays fiction there until the parity pass

iOS parity ledger

What Android has that iOS does not, as of now. This is the cost of letting Android run ahead — it is recoverable in one catch-up pass, but it grows every time Android ships alone.

BehaviorEffort to portNote
Hold-to-peeksmallSwiftUI has a direct equivalent — onLongPressGesture(pressing:) gives press-and-release for free
iPad three-pane layoutlargeThe real work. Same shape, but SwiftUI layout, not Compose
Queue stripsmallPart of the three-pane pass
Map on real coordinatesmediumBridge side is done — the payload carries lat/long. iOS map still geocodes technician home addresses and fails most of them
List as the real schedulesmallSame board data, a different row layout
Map fixesn/aAndroid-specific: the clipping bug and the Compose line-height fix have no iOS counterpart

Directions

Rate each one — several have since started (the checklists above are the execution truth); ratings stay in this browser.

Enhancement guide

Produced by eleven agents reading this codebase against the live tenant: five domain surveys, an adversarial feasibility pass over each, then synthesis. Every entry cites a file, an endpoint or a ServiceTitan field. Proposals that could have been written without reading the code were rejected.

Android · iOS the apps H2OSTBridge GET only · 405 otherwise ServiceTitan production tenant field API (web stack) journalled · X-H2O-User reads reads writes writes
Every read the suite makes travels the solid path. No write can: the bridge rejects non-GET before any handler runs, so anything that changes ServiceTitan goes the dashed way — and as of 2026-08-11 that path is known to EXIST: field-api.php on the mini fronts ~30 routes on the web stack’s own st-bridge, including status, notes, the full estimate write family, payments/pay-links, photo attachments, the on-my-way text and membership sales, each stamped with the signed-in X-H2O-User. The mobile suite’s write work is CONNECTING (Google SSO from the apps), not building.

Sequencing. Build the four Level-1 prerequisites first, in one pass, because every later entry reads through them: the local board day (three live Admin tiles are wrong for four hours every evening and no week or watcher feature is correct without it), `active: 'any'` on the technicians roster (one parameter that recovers $236,377.64 of misattributed revenue and is inherited by every money screen after it), the jobs-by-ids plus locations-by-ids join in the board handler (it fixes three wrong addresses and two unmappable stops today and is the same plumbing My Day, close-out and the job-field join all need), and the pricebook mapper carrying ServiceTitan's SKU id (without it the cart has no stable key and four estimating entries cannot be built at all). In parallel, delete every piece of sample data that produces a confirming animation or a plausible number on failure — the phone's fake assign dialog, samplePerformance on the money screen, sampleTechnicians on dispatch — since those are the defects that actively mislead rather than merely underserve. Then Level 2 spends those joins on new capability: the job record on the board, My Day off the live payload, technician GPS, the revenue and open-estimate screens, with the platform hygiene items (client drift, the bridge's front door, the filter probe) sequenced alongside because each one prevents a class of the bugs this list is full of. Level 3 is the structural work in a fixed order — device identity, then the durable on-disk layer, then the outbox — because an outbox built before My Day is live would queue records against job ids ServiceTitan has never heard of, and everything genuinely requiring a write stays parked behind the web stack's field API and a real X-H2O-User, which no amount of mobile work can supply.

Give the board a local day, and make its chr… unblocks 5 Send the truck to the job's location, not th… unblocks 5 Carry the SKU id unblocks 5 Pass active unblocks 5 Device-scoped technician unblocks 6 An offline read layer with an 'as of' stamp unblocks 4
What each prerequisite unblocks. Five sit in this codebase; the amber one does not — it is the web stack’s field API, and nothing that writes to ServiceTitan can ship until it exists.

Level 1 corrections and unlocks: small changes that make existing live screens tell the truth, and the four mappers everything else reads through

SGive the board a local day, and make its chrome describe that daydispatch / platform

One S-effort sweep: add a timezone offset to /api/dispatch/board and slice on it, drive the pills and chips from the `counts` the bridge already returns, give Hold its own color, drop Canceled from stop counts and pins, and stop AdminScreen fetching the board with a null date.

Why: After 20:00 Eastern the bridge's UTC default (server.js:284) rolls the day over, so three of the six tiles the log calls live — Jobs Today, Jobs Completed, Technicians Active — read tomorrow's 5 appointments / 9 technicians instead of today's 18 / 10, every evening. The phone StatPills and tablet CountChips are worse: they come from fetchTechnicians(), the undated last-100-assignments call, so they report 11 technicians (including ZZ Test Tech, whose own ServiceTitan memo reads "IT test technician… Not a person") no matter which day is on screen, while server.js:334-339 already ships the right numbers in `counts` and the Kotlin DispatchBoard has no field for them. And slicing the day on a UTC string prefix drags three Aug-9 evening jobs onto the 2026-08-10 board, stretching the axis to firstHour 7 / lastHour 24 — seventeen 76dp columns for what is really an eleven-column day.

First step: Add `tz`/`utcOffset` to /api/dispatch/board; widen appointmentsOn()'s startsOnOrAfter to day−1 (server.js:205-212) so positive offsets keep their early-local-morning stops, and apply the offset in dayOf() at :448. Add `counts` to the Kotlin and Swift DispatchBoard and feed the pills from it — de-duplicating appointment ids first, since 08-10 has 29 technician×appointment rows for 18 distinct appointments and 56015562 alone sits on four technicians. `counts` has no `done` field, so derive it from the deduped rows or add it server-side. Change AdminScreen.kt:163 to fetchDispatchBoard(LocalDate.now().toString()) and leave /api/performance on UTC month boundaries — a board day is a local day, a report window is not.

None. Prerequisite for the week spine, the board-change watcher and every per-day count in Admin.
SOne parameter recovers 27% of misattributed revenuereporting

pageAll on /settings/v2/.../technicians at server.js:486 passes `{}`, which defaults to active-only and returns 11 people; passing `active: 'any'` returns 26 and takes unresolved job sellers from 178 to 0.

Why: creditFor() (server.js:509-521) honours soldById only when nameById has it, so every job sold by an inactive technician silently falls through to whoever worked it — the opposite of the rule technicians are told, "if you sell it, it's your job". Measured across 2026: 178 of the 649 jobs that name a seller, carrying $236,377.64 of completed revenue, are credited to the wrong person, and the two ids responsible — 51464644 Wayne Gross and 52181060 Kristian Burnopp — are technicians marked active:false, not employees. Performance, the Admin revenue tile, Avg Job Value and the Revenue and Discount screens below all inherit that error from one line.

First step: Change the pageAll options at server.js:486 to `{ active: 'any' }`, re-run /api/performance?from=2026-01-01 and confirm attribution.unattributed drops to 0 for jobs. Then carry `active` on each row, because the roster now contains ex-employees who must read as ex.

None. Must land before Revenue Breakdown and Discount Exposure inherit creditFor.
MSend the truck to the job's location, not the customer's billing addressfieldtech / dispatch

slim() resolves address and coordinates from customerDetailsFor(a.customerId); the work happens at job.locationId, and adding a jobs-by-ids → locations-by-ids join to the board handler fixes three wrong addresses and two missing map pins on today's board alone.

Why: Job 56036296 (EPro Repairs LLC) bills to 1574 Villena Drive, Myrtle Beach SC and works at 9030 West Broad Street, Richmond — that is the "one Myrtle Beach appointment against twenty around Richmond" SETUP-LINUX.md records, the outlier that forced the map's ~130km service-area fit and its "Show all" escape hatch. 56015561 bills to 2807 N Parham Rd and works at 3911 Ludlow Road; 55974108 bills to 4326 Chamberlayne Ave B and works at 3609 Chamberlayne. Two of those have null coordinates on the customer record and real ones on the location (37.5239541/-77.3582466 and 37.5830817/-77.4466145), so this shrinks the cannot-be-mapped count as well as the wrong-address count. Map, list, tablet rail, hold-to-peek and any navigation handoff all inherit the error today.

First step: In the board handler collect the jobIds, fetch /jpm/v2/.../jobs?ids= chunked at 50, dedupe locationIds (today's 17 jobs resolve to 16 locations — 56091592 and 56093645 share 56094413), fetch /crm/v2/.../locations?ids= chunked at 50 (a 50-id call returns 200), and take street/lat/lng from the location with the customer record as fallback. Keep the customer's NAME: location 54098501 is named "Chuck E Cheese" while the customer is EPro Repairs LLC. No client change — the JSON field names stay put. Do not delete the ~130km fit or "Show all": Petersburg (37.175) and Prince George (37.252) are genuine spread, so re-measure the fit after the addresses are right.

None. Prerequisite for the job-field join, My Day and close-out — it is the same ids= plumbing.
SCarry the SKU id, and show the work scope instead of printing the name twiceestimating

One mapper change per platform: put ServiceTitan's `id` on PricebookItem, and build the subtitle from the description's first steps instead of from `code`.

Why: `subtitle = code.ifBlank { … }` at STBridgeClient.kt:390 has a branch that has never executed — code is blank on 0 of 783 priced services — while 261 of 783 have code equal to displayName modulo punctuation, so a third of the Live tab renders the title and then the title again in gray. 782 of 783 carry a real step-by-step scope: SKU 23988762 "Pull toilet and reset" reads "Turn off water / Disconnect supply line / Remove closet bolts / Remove toilet / Clean up old wax ring and caulk / Install new wax ring…", which is exactly what a technician reads out and a customer wants to see. Separately PricebookItem (PricebookScreen.kt:35) carries no ServiceTitan identifier at all, so the cart's structural-equality key is all it has and nothing downstream can join to estimate lines or supply a sku.id.

First step: Add `id` to PricebookItem and to both mappers. Split the RAW description on /<br\s*\/?>/ FIRST and only then stripHtml each step — stripHtml at STBridgeClient.kt:404 replaces every tag, including <br/>, with a space and destroys the boundaries. Take two or three steps for the subtitle and put the full list behind the row. The iOS mapping is H2ODispatch/STBridgeClient.swift:323, not PricebookView.swift, which only passes $0.subtitle through at line 92.

None. Prerequisite for quantity/discount, pricebook insight, Good-Better-Best, quote-from-history and the estimate write.
SFail visibly, never fictionallyplatform

Delete sample data from every network failure path, render the bridge's actual error, and add a Connection panel over /health.

Why: DispatchScreen.kt:128 falls back to sampleTechnicians on any exception and TechnicianPerformanceScreen.kt:120 falls back to samplePerformance — "Marcus Ibe $1,850", "Priya Shah $740", three people who do not work here, on the money screen. It is already labelled (line 131, gold, 11sp), so the defect is not a missing caption: it is that a money screen invents named people at all. Meanwhile both clients discard the bridge's error body — STBridgeClient.kt:123 raises RuntimeException("ST bridge returned ${responseCode}") without ever reading conn.errorStream, and Swift drops `data` — while forward() at server.js:149-155 already returns {error, body} with ServiceTitan's own words (60 customer ids yields the ids-cap 400 verbatim). Unreachable host, ST auth failure, the ids cap and a 405 all collapse into one string, which the log says costs an afternoon to trace.

First step: Delete samplePerformance and sampleTechnicians from the failure paths and render an empty state. Then split client error handling into three branches, not one: IOException out of getResponseCode() ("no route to the bridge at <BASE_URL>"), an HTTP status with an errorStream body, and a status with a null errorStream. Add a Connection panel calling /health, which already reports tenant, environment, readOnly and rawPassthrough and has zero callers on either platform.

Landed 2026-08-11 (Admin Overview): the half of this on the manager's home screen is done. AdminScreen fetched its six sources sequentially with the ~10s AR walk last in the chain, so on a cold open every still-loading tile read "sample" and the still-loading feed read "could not reach the ST bridge" — a healthy bridge looked broken for ~30s. Now the sources fetch concurrently (coroutineScope + launch; each tile goes live the instant its own data lands, cold fill ~2–3s), a new AdminStat.isLoading is distinct from isSample (a dim "loading…" wins over the gold "sample", and loading tiles are excluded from the "N of 8 live" count), and Recent Activity is three-way — live / loading / settled-failure — so "could not reach the ST bridge" shows only on a real, settled failure. Verified on-device: 6 of 8 tiles live in ~3s, the only "sample" left is Parts Scanned, which has no ServiceTitan source. Remaining: the same three-branch split on the DispatchScreen / TechnicianPerformance failure paths and the iOS mirror; the /health Connection panel.

None. The L3 offline layer later upgrades the empty state to a stamped cached one.
SDelete the assign dialog that dispatches nothingdispatch

Replace sampleUnscheduledJobs and its local-state assign dialog on the phone (and the identical array in DispatchView.swift) with board.unassigned, the data the tablet QueueStrip already renders correctly.

Why: The phone ships three invented customers — Wendy Cho, Green Valley HOA, Marcus Feld — under a heading reading "Unscheduled — tap to assign", and the dialog appears to work: the job moves onto a technician and leaves the queue. Nothing leaves the device. That is the most dangerous sample data in the suite, because unlike a gray pill it produces a confirming animation for a dispatch ServiceTitan has never heard of. The real queue is not trivial: 2026-08-05 is 25 appointments, 15 assigned, 10 unassigned.

First step: Delete DispatchScreen.kt:97-101 and the dialog at :360-394, render board.unassigned in that slot and open the detail sheet on tap. Design the card for the real shape first: all 10 unassigned rows on 08-05 are the same customer, Red Oak Apartments, with null latitude/longitude and null customerAddress, so it must read well with no address and ten near-identical titles. If you want "waiting since", add createdOn to slim() at server.js:291-312 — it is dropped today.

None — this is read-only and needs no identity. Only the assign action it deliberately does not ship needs the web stack's field API.
SRepair the two broken metrics on the technician cardreporting

Drop Memberships Sold, divide Job Average by billed jobs instead of all jobs, and suppress the test-technician row by id.

Why: server.js:558 credits a membership only if soldById is on the technician roster; all three memberships this tenant created in 2026 carry soldById 58, which is h2oprollc (Owner), so no field technician has ever sold one and a quarter of every card has read 0 for eleven people for seven months — which trains managers to stop reading the card. Job Average (TechnicianPerformanceScreen.kt:47) and Avg Job Value (AdminScreen.kt:68-70) divide by a count that includes jobs billing nothing: August is 69 completed jobs and $67,863.34 of which 12 bill $0 and 9 are flagged noCharge, and across 2026 it is 271 of 1,573. The tile's $730 is attributedRevenue / 93 jobsWorked while revenue per billed job is $1,191 — two different denominators, neither labelled.

First step: Emit `billedJobs` and `noChargeJobs` per row on /api/performance on the same share-counting basis as jobCount so the tile and the card agree, divide by billedJobs, label which denominator each surface uses, and put "No-charge jobs" in the slot memberships vacates — a number this business actually generates. Suppress technicianId 56034638 explicitly, not by an all-zero heuristic: it currently carries workInProgress 1 and conversionRate 0.

None, but the numbers only become correct after `active: 'any'`.
SName the customer on assignments, name the technician on completionsreporting

Resolve assignment jobIds through a chunked jobs?ids= call and add a `customer` field to completion events, so the feed stops printing job numbers and stops putting a customer in the technician slot.

Why: A live pull returns 100 events of which 56 are assignments reading "Omar Ayala assigned to job #56015561". The design note at server.js:623-625 already says it — "Job #56101061 is not activity anyone can read" — and that fix was applied to completions and never to assignments, which are the majority of the stream. Completions have the mirror bug: server.js:671-681 puts the customer name in the `who` slot that AdminScreen.kt:388 renders as the person, so a completion row shows a customer where a technician belongs.

First step: Do not widen the existing job fetch — createdOnOrAfter ANDs with completedOnOrAfter rather than unioning (78 and 69 separately, 55 together), and even a proper union covers only 80 of the 105 distinct jobIds on the 200-assignment page. Chunk those jobIds through ids= the way customerDetailsFor already chunks customers at 50, for 105 of 105. Name the technician from the assignment page already fetched at server.js:634, which resolves 68 of 69 completions at no extra request, and keep the customer as a separate field so a row can read "Zach Fox completed — Colleen Morse, $2,800.08".

None.
SPut the dollar value on the Open Estimates tilereporting

One reduce beside the existing filter at server.js:610-612 adds openEstimatesValue to totals, at zero extra ServiceTitan requests.

Why: The tile reads "109". Those 109 estimates opened in August are worth $447,836.02 — more than six times the $67,863 of completed revenue the tile beside it is celebrating — and `subtotal` is already on every element of the array being counted, fetched and discarded. For a business whose largest revenue job type is literally "Approved Estimate" ($982,812 of $1.74M in 2026), a count without a value is the least informative rendering of the most important number on the dashboard.

First step: Add the reduce at server.js:612, parse it at STBridgeClient.kt:325 and the Swift equivalent, and make the value the headline with the count as the subtitle. Label it "opened this month", because it is a window count and not the 2,002-strong standing backlog.

None. Prerequisite for the L3 pipeline screen.
MSearch, and the categories this tenant actually hasestimating

Add a text field over displayName, code and description, and replace the four sample chips with the categories ServiceTitan returns on these very records.

Why: 783 live services render into one alphabetical LazyColumn and PricebookScreen.kt has no text field anywhere. After Hours is not a cosmetic tab: AH Water Line Spot Repair is $500 against $425 for the daytime version, AH Drain Line Spot Repair $575 against $445.03, plus a $450 After Hours Hazardous Dispatch Fee and a $350 Holiday Dispatch Fee. A technician on a Saturday call has to already know the "AH" prefix exists and then scroll 783 alphabetical rows, so the likely outcome is quoting the weekday rate. "After Hours/Emergency Fee" is the most-quoted fee in the business — 229 quoted, 185 sold, 81%.

First step: Replace PricebookScreen.kt:43-49 with All / Products & Services (761) / After Hours (7) / Holiday Hours (6) / Membership (2) / Customs (1), read from services[].categories[], and keep All as the default so the 6 priced services carrying no categories at all are not silently hidden. Say plainly in the change that this deletes the sample pricebook the non-Live tabs render — that is the right call, but it changes what those tabs are.

None; reads best after the description/subtitle fix, since search covers description text.
MQuantity, and the discount line this shop uses more than any SKUestimating

Give cart lines a quantity stepper keyed on the ServiceTitan SKU id, and add a free-entry discount line that the price>0 guard currently filters out of existence.

Why: The cart is a mutableStateListOf toggled by cart.contains(item), so an item is in or out and quantity does not exist. Over the full corpus (4,279 estimates, 9,241 line items) 538 lines carry qty != 1, and they are service lines — materials are $0 across this tenant, so the quantity case is services, not parts. And SKU 23988614 "Discount" is the single most-used SKU in the business: 567 lines across 552 estimates, 565 of them negative, netting -$340,007.71. It carries price 0, so STBridgeClient.kt:381's `price <= 0.0` guard drops it before it reaches the app — a guard written to filter unpriced catalog stubs, for which a $0 discount SKU is a false positive. A technician building a proposal literally cannot do the most common thing this shop does on an estimate.

First step: Land the SKU id first, key cart lines on it, then add the stepper. Special-case 23988614 past the price guard with a free-entry amount, since ServiceTitan holds 0 for it and real line totals span a wide range. State the window on any figure you display — the 2026-YTD subset is 6,289 lines / 423 discount lines / -$273,969, and quoting one window's numbers as another's is how these figures drifted in the first place.

SKU id on PricebookItem.
SRender the office's instructions as instructionsfieldtech

Decode HTML entities on specialInstructions and stop truncating it at three lines, then render one row per line in the technician's job detail.

Why: This is the only channel the office has to tell a technician what to do on a specific job, and today it is truncated, entity-mangled and absent from the app the technician carries. Appointment 56037232 carries "Take before picture of disconnected ice machine line.\nReconnect line.\nTake after picture of connected ice machine line." — a three-step checklist — while DispatchTimeline.kt:405 draws it as a single "⚠ $it" Text with maxLines=3, putting the third step exactly on the ellipsis boundary in the peek card. Appointment 56094421 prints "&lt;3" literally because nothing decodes entities.

First step: Entity-decode in the bridge and lift maxLines on the dispatcher's peek — both ship independently of My Day. Note these values are plain text with entities, not tagged HTML (stripHtml is used only at STBridgeClient.kt:390 for pricebook descriptions), so decoding is the fix and tag-stripping is defensive extra. Split on newlines dropping blanks — appointment 56093766 has two paragraphs separated by a blank line — and offer a checkbox without imposing one: 56037232 is the only imperative-per-line value on the board today and four of the eight instruction values are the single word "Prep day!".

The checklist rows land with My Day; the entity decode and the truncation fix do not and should go first.
MClose-out driven by the job, not by a hardcoded rulefieldtech

Prefill the payment from the real invoice balance, suppress charging when that balance is zero, and gate the signature on the job type's invoiceSignaturesRequired instead of always.

Why: MyDayScreen.kt:417 hardcodes `enabled = job.signatureBytes != null` with the caption "Collect a signature before charging the customer", while invoiceSignaturesRequired genuinely varies by job type (50296265 "Potential Leak Detection" is true). Two of today's seventeen jobs, 56036296 and 56093645, are noCharge:true and would still be offered a Charge button. And PaymentCollectionScreen.kt:56 starts from an initialAmount that MyDayScreen.kt:201 always passes as job.paymentAmount = 0.0, so the technician hand-types the number while job.invoiceId points at invoice 56101064: total 9503.77, balance 9503.77, nine real line items. Three ways to close a job out wrong, on a business where the money has to reconcile.

First step: Gate and prefill on invoice.balance — one condition that covers no-charge and already-paid alike — and keep job.noCharge only for the label wording, preferring the job's flag over the job type's, since 56036296's type is ordinarily chargeable and the no-charge status is a per-job decision. The bridge can serve neither input today: /api/invoices forwards only windowParams with no ids (server.js:402-405) and there is no job-types route at all, so fold both into the job join rather than adding per-screen round trips.

The job/location join for the data, and the My Day rebuild for the screen it lives on.

Level 2 new capability on data already arriving: joins, screens and platform hygiene

MJoin the job record to the appointment: why the visit exists, how urgent, which visitdispatch

Extend the board's jobs-by-ids join with summary, priority, jobStatus, appointmentCount, businessUnitId and recallForId, and surface them as a priority marker, the complaint text in peek and rail, a visit count, and a business-unit filter.

Why: The board tells a dispatcher who, where and when, and nothing about what — and every one of those blanks is one request away. 95 of 96 August jobs carry a populated summary; job 56094420's reads "He had work done approximately 2 weeks ago. The pipe that was repaired is now leaking", which is the entire reason that visit exists and is invisible on the board. 11 of 96 jobs are priority High or Urgent and render identically to the other 85, so a dispatcher deciding which of two 13:00 stops to move has no signal at all. 10 of 96 need two or more appointments. And the tenant runs two active business units split 48/48 with no way to look at one — the natural cut between service and install work on a board this dense.

First step: Reuse the chunked ids= call from the location join and make ids= the primary path, not a window: across the 08-05/10/11/12/13/14 boards there are 48 distinct jobIds and 3 (55862084, 55866069, 55801797) are absent from a createdOnOrAfter=2026-08-01 set because a July job can be worked in August — 55866069 is Colleen Morse, visible on three technician rows on 08-10. Treat the window only as a warm cache and assume the customers ids= cap of 50 until diffed. Resolve type names from /jpm/v2/.../job-types (62 types, one request, 1776/1776 jobs covered). Render appointmentCount as "5 visits on this job", not "visit 3 of 5" — the appointment's index within its job is on no record the board carries; and present a job reading InProgress with a Done appointment as "visit 1 of 2 done", not as a contradiction.

The location join (same ids= plumbing, same handler).
LMy Day, rebuilt on the live board and the job summaryfieldtech

Replace the four invented customers duplicated across MyDayScreen.kt:64, MyDayView.swift:54, wear/MainActivity.kt:55 and the widget fallback with an /api/day route joining board, jobs, job-types and invoice for the selected technician.

Why: The technician's primary screen is the only major surface in the suite still entirely fictional, while the dispatcher's board beside it runs on real data. A technician arrives knowing nothing about the job; ServiceTitan has already written down what is wrong at every one of today's 17 stops — one summary reads "-toilet leaking at base / -loose faucet in bathroom / -need water heater flushed". Everything the screen needs already reaches the phone: fetchDispatchBoard() returns customer, address, coordinates, start/end, arrival window, specialInstructions and status per appointment.

First step: Build /api/day?date=&technicianId= that filters the bridge's OWN appointment→assignment→technician join — never forward technicianId to ServiceTitan's appointments endpoint, which server.js:196-199 documents returning 200 with the full unfiltered list. Strip-and-decode HTML summaries once in the bridge, not twice in Kotlin and Swift (STBridgeClient.kt:404 strips tags but decodes no entities, so &nbsp; survives it). Take priority from the job record, not the job type. And before real named customers land on that screen, disable or visibly mark the photo, signature and payment paths hanging off it — otherwise this reproduces exactly the confirming-animation failure the fake assign dialog was deleted for.

The job join; the device-identity picker supplies who the day belongs to; the write-shaped capture flows must be gated first.
MPlot where the technicians actually are, stamped with the age of each fixdispatch

ServiceTitan returns location.latitude, location.longitude and location.coordinatesUpdatedOn on every technician record and both parsers throw them away; draw them as a second, technician-colored pin class carrying the age of the fix.

Why: This is the largest piece of live data already arriving in the apps and going completely unused. /api/technicians is called on every DispatchScreen load and 10 of 11 records come back with a real position: Bobby Micalizzi's last fix sits 20 metres from Lucas Dooley, his own 16:00 stop; Daniel Perkins' sits 0 metres from Colleen Morse, his. That is genuinely where the crews are. The log records that the map used to geocode technician home addresses, failed on 8 of 11, and concluded a home address answers the wrong question — correct, but the right answer was in the same response the whole time, and iOS still does the failed thing at DispatchMapView.swift:75-96, one CLGeocoder call per technician with a throttle sleep.

First step: Parse the three fields in RawTechnician (STBridgeClient.kt:137-144) and STBridgeClient.swift:74, and plumb technicians into DispatchMapScreen.kt, which today takes only `board`. Do not hang location off the existing Technician model — it is produced by fetchTechnicians(), the undated last-100-assignments call the board is shedding, and the map would re-acquire the dependency the board just dropped. Age every pin: fixes ranged from 4 minutes (Trevor Stewart) to 9h22m (Daniel Perkins) in a single sample, so desaturate and spell out anything over an hour. On Android this revives the dead Technician.pinColor / techPalette; on iOS (DispatchMapView.swift:125) it replaces a working pin source.

None.
MRevenue Breakdown goes live — by job type and business unitreporting

New /api/revenue?from&to grouping completed revenue by job type and business unit, replacing the sample screen two live tiles point at, and deleting Lowest Margin Job rather than leaving it marked sample forever.

Why: The Admin dashboard shows a live $67,863 month-to-date Revenue tile which, when tapped, opens a screen reading "$4,280 — Estimated revenue — today — Sample data". A live number linking to a fake number that contradicts it is worse than no link at all. And the breakdown the business needs is not the sample one: "Approved Estimate" is $982,812 of 2026's $1.74M (57%) at $3,548 a job while plain "Estimate" jobs average $433, and the single revenue figure silently blends Residential - Plumbing (1,052 invoices) with Commercial - Plumbing (742) — different businesses with different economics. This is also the taxonomy the pricebook could never provide, since ServiceTitan's own pricebook categories are one bucket of 197 of 200. Margin genuinely is not computable here: cost and totalCost are "0.0000000000" on all 3,380 invoice line items of 2026.

First step: Resolve names from the lookup endpoints — /jpm/v2/.../job-types returns 62 named types in one request and resolves 1,776 of 1,776 jobTypeIds, and /settings/v2/.../business-units returns the units by id. Do not derive the taxonomy from invoices: that costs ~7.1s paging 1,801 invoices and can only ever know the 52 types that happened to produce one. Keep invoices for money.

`active: 'any'` — this screen inherits creditFor.
MSurface the open estimates — the shop's follow-up list, and the four sitting on the job you are standing inestimating / fieldtech

One bridge change (server.js:412-415 forwards only windowParams; add jobId and an open/status path) feeds two screens: an aged, valued follow-up worklist in Admin, and a panel on the technician's job showing that job's open estimates with their line-item procedures.

Why: There are 2,002 open estimates worth $6,947,716 and nothing in the suite can list one. The tenant's own close-lag settles what to show: median created-to-sold is 0.4 hours and 86% close within 24 hours, so the 0-7 day bucket leads — 108 estimates, $433,750, 45 distinct customers, resolvable in a single 50-id call in 0.29s, all with addresses — and the 91-365 day bucket (1,066 estimates, $3.7M) belongs in a collapsed stale section rather than at the top pretending to be actionable. On the technician side, job 56101061 carries four open estimates worth about $20,791 — "NCB 240/110A" $18,849.36 down to "Pull and reset of hallway toilet" $279.79 — and the one person who could close them sees none of it, being offered instead ProposalBuilderScreen.kt:29's invented $150/$430 ladder.

First step: Verify ?jobId= by diffing against unfiltered (it returns exactly job 56101061's five estimateIds against a call that otherwise reaches 2024). Parse status as an object, {value:0, name:'Open'}, not a string. Render the procedure text from item.description, NOT sku.description — the sku object has no description field and would render an empty panel on every line — splitting on <br/>, <br /> and <div> variants and hiding the panel when the description is empty or merely echoes the name. Recover the writer through jobId→assignment for the 0-30 day buckets only: assignmentsForJobs() (server.js:455) has a 120-day floor and a 25-page cap, so label the stale buckets unattributed rather than shipping blank name columns. Fix the two tile bugs as two different bugs: Android's Admin tile reads month-to-date openEstimates (109) against a 2,002 backlog and needs renaming or repointing, while AdminView.swift:44's hardcoded "5" is iOS sample data that needs wiring.

None, though the technician-side panel lands naturally with My Day. Marking an estimate sold is a write and belongs on the field API.
MFlag the stops nobody can physically reachdispatch

Subtract end(N) from start(N+1) per technician and compare it against the great-circle distance, flagging overlaps, gaps that cannot cover the distance, and coordinates far from the day's cluster.

Why: This is a tight Richmond service area and the board currently draws physically impossible days without comment. On today's live board Kevin Jaworski is at EPro Repairs LLC in Myrtle Beach until 12:00Z and at Colleen Morse in Richmond at 13:00Z — 442km in a 60-minute gap; Bobby Micalizzi has the same pair at 444km. Daniel Perkins has two customers 4.9km apart with blocks overlapping 13:00-15:00. On 2026-08-05 "TCG Services" geocodes to Kansas and "Facility Mate" to Southfield, Michigan, and the map's service-area fit quietly buries both in an "outside this area" count while "Show all" zooms the working day out to half the United States. Lane packing at DispatchTimeline.kt:105-114 already detects the overlap and only labels it "N at once" — including 56091593 and 56093646, two different jobs for the same customer at the same 00:30-02:30, a duplicate booking reading as an ordinary double.

First step: Compute both checks from the board payload already parsed — no new data — and label the outlier "far from today's cluster", never "address may be wrong": a 2-degree-from-median rule flags EPro Repairs (3.9 degrees off) alongside the Kansas and Michigan geocodes, and EPro is a real appointment two technicians actually worked. Geometry cannot separate a long-haul from a bad geocode; leave the judgement to the dispatcher. Ship gaps and distances first — only the "can you still make your next window from where you are" extension needs technician GPS. Run it after the location join, since three of today's addresses are currently the wrong ones.

Technician GPS pins, for the live-position extension only. The first two-thirds ship independently.
MClose rate and real sold-price range on every Live pricebook rowestimating

A cached /api/pricebook/insight aggregate over the full estimates corpus returning, per sku.id, times quoted, times on a sold estimate, and the min/median/max unit rate actually charged.

Why: The Live tab presents 783 prices as if they were equally real. Measured: "Cut concrete and chip up" closes 25% (80 quoted / 20 sold), "Small Drain Cable" 65%, "15 Minute Diagnostic" 82%. The median sold unitRate equals the book price on 344 of the 457 SKUs that have both — genuinely reassuring and worth showing — while 71 diverge by more than 20% and Non-Specific Repair has been written anywhere from $0 to $26,729.52. Ranking by quote frequency also fixes the browse problem outright: the top 10 SKUs cover 30% of all quoted lines.

First step: Do not build it as a pass-through. /api/performance windows estimates by createdOnOrAfter (month-to-date, roughly one page), while per-SKU history needs the whole corpus — measured at 9 pages of 500, 4,279 estimates, 8.9 seconds — which cannot sit inline on a Live-tab load. Recompute periodically and cache server-side. 213 SKUs are quoted 8 or more times and 210 of those resolve to a live priced service, so the join is sound once PricebookItem carries an id.

SKU id on PricebookItem.
MCredits and discounts given — the honest replacement for the margin metricreporting

Add discountGiven per technician to /api/performance and a most-discounted-jobs block to the Revenue screen, selecting on negative line totals rather than on the unused discountTotal field.

Why: This business gave away $165,430.12 in 2026 on "Discount" lines alone — 8.89% of gross invoicing, worst single job $13,500 — and no screen anywhere shows a cent of it. Because per-job costing is genuinely absent from this tenant (every cost field is zero), discount is the only real profitability lever the data can see, and it is the one number that would change behavior if a technician saw their own figure next to their revenue.

First step: Select on Number(item.total) < 0, not on skuName === "Discount": 2026 carries 27 further negative SKUs worth roughly $27k more, including "Approved Estimate Credited" -$5,498.78, "10% discount Nest" -$4,878.28, "Relevate 10% Vendor Fee" -$3,366.58 and "Military Discount" -$14.84. Label the metric "credits and discounts given". invoice.discountTotal is "0.00" on all 1,801 invoices of 2026, so that field is a dead end. Attribution needs no new rule: invoice line technicianId is null on 3,379 of 3,380 items, so credits attribute through the job via creditFor() unchanged.

Revenue Breakdown (same route and screen) and `active: 'any'`.
MAccounts receivable: what is owed, and what was never even sentreporting

New /api/receivables over all invoices with balance > 0, grouped by age and by sentStatus, with an Admin tile and a worklist sorted by balance.

Why: Roughly $390,392 is outstanding across 226 open invoices, of which about $78,363 across 49 invoices carries sentStatus "NotSent" — the customer has never been billed at all, the oldest open since 2025-05-12 (Dorothy Brag, $3,558.00). Nothing in the entire mobile suite reads the balance field even though every invoice already returns it. The largest single debt, $32,293.79 from Chris Eklund, has neither dueDate nor invoiceDate, so it appears in no aging report ServiceTitan itself can produce.

First step: Do not inherit windowParams — AR is a balance question, not a period one; scoping to 2026 understates the never-billed problem by half. Age with a createdOn fallback or bucket explicitly as undated: 11 of the 111 in-window open invoices carry neither date and are 48% of that money, including the two largest debts. Group by customer and business unit — assignedTo is null on 1,801 of 1,801 invoices. Paging everything costs about 21 seconds against Android's 20s readTimeout at STBridgeClient.kt:300, so cache the aggregate server-side or paginate the worklist, and diff any server-side balance filter before trusting it.

None. Sending an invoice or taking payment is a write and belongs on the field API; this route does not touch it.
MThree hand-written bridge clients, two of them wrong: pay down the drift and lock the contractplatform

Repoint the Wear module at the board, add the three fields iOS decodes away, and check in captured JSON fixtures with a decode test per platform so the drift stops recurring.

Why: wear/MainActivity.kt:53-67 counts non-Done rows among the 100 most-recently-modified assignments in the tenant and labels the result "28 active jobs" under a green "Live from ServiceTitan" badge — the documented assignments-ignore-dates trap surviving in the one module that never received the fix (assignedOn reaches back to 2026-07-29, while the real board is 18 appointments across 10 technicians). Its "technicians in the field" number is a separate bug: it is activeTechIds.size off /api/technicians, a headcount of everyone employed including ZZ Test Tech, not of anyone working. On iOS, BoardAppointment (STBridgeClient.swift:142-159) declares no latitude/longitude, so coordinates the bridge sends on every appointment are decoded and thrown away — which is why DispatchMapView still geocodes home addresses with a 400ms sleep each, 4.4s for the roster, the exact approach Android deleted after it failed on 8 of 11. LivePerformance.Totals decodes only attributedRevenue and there is no fetchActivity at all, so AdminView is 0 of 8 live against Android's 6 plus a live feed.

First step: Repoint Wear at /api/dispatch/board with an explicit local date and fix each number for the right reason. Add the missing Swift decodes and rewrite DispatchMapView to plot appointments with the median-fit rule already in DispatchMapScreen.kt. The fixtures and the Kotlin decode test can be checked in from here — no module declares a src/test source set today — but the Swift half needs an XCTest target added to H2OTechnicianApp.xcodeproj, which is an Xcode edit on the Mac mini, not a file drop.

None. iOS work batches with the next Mac mini pass.
MPut a door on the bridge, and close the unchunked customers routeplatform

A shared-token check in the same app.use that already 405s non-GET, one journal line per request, /health left open, and the public /api/customers route stopped from forwarding more than 50 ids.

Why: Verified against the LAN address with no credential of any kind: GET /api/customers returns 200 customer records in one page — name, street, city/state/zip, coordinates, balance, doNotMail, doNotService — and GET /api/technicians returns 64,562 bytes including every technician's loginName, permissions[] (38-116 named entries), licenseType, accountLocked, payrollProfileId and bio. app.listen at server.js:733 binds every interface and this host is also on WireGuard, so the exposure is not one SSID, and server.js logs nothing per request, so there is no record of who read what. Read-only is not the same as not-sensitive. Alongside it a real bug: server.js:349 forwards `ids` verbatim while customerDetailsFor five lines above chunks at 50 because of the undocumented cap — 50 ids returns 200, 60 returns a bare 400.

First step: Add the token check and the journal line (ip, method, path, id count, status, ms), and sell it honestly: a token shipped through BuildConfig or Info.plist is extractable from the APK and sniffable on the cleartext HTTP the debug config permits, so it keeps an unauthenticated scanner out and gives the journal a subject — it becomes a credential only once the bridge is behind Caddy on HTTPS. For customers, prefer rejecting an ids list over 50 with a 400 that says why over stitching chunks, since stitched chunks cannot honestly carry ServiceTitan's page/pageSize/hasMore/totalCount envelope and the bridge's internal callers already chunk. Note the ids fix closes a latent trap rather than a live outage: /api/customers and /health have zero callers in either app today.

None. The token presumes the HTTPS move for release builds, which already forbid cleartext.
MTest the two things that silently lieplatform

An `npm run probe` that diffs every ServiceTitan filter the bridge depends on against its unfiltered call, plus a fixture test pinning the attribution invariants in creditFor and /api/performance.

Why: There is not one test in the repository — no src/test, no androidTest, no XCTest target, and package.json declares only "start". Every ServiceTitan defect this project has hit produced numbers that looked right, so nothing goes red: technicianId on appointments, date filters on dispatch/assignments and startsOnOrBefore on appointments all return 200 with wrong data, each found by hand, and the performance endpoint once read 79 of 95 jobs unattributed with every conversion rate at 100%. That is precisely the class of bug a diff-based probe catches and code review does not.

First step: Make the assertions per-filter rather than generic — "filtered differs from unfiltered" is wrong for startsOnOrAfter, which is honoured. For technicianId and the assignments date parameters, assert the filtered response is byte-identical to the unfiltered one, because that is what makes it a lie, and fail loudly when it stops being true, since that is the day the local re-filters become dead code nobody notices. For startsOnOrBefore, assert the raw upstream still contains out-of-day appointments while appointmentsOn's output does not. Assert 50 ids passes and that no code path can emit more. Pin invariants, not figures: attributedRevenue + unattributedRevenue equals the sum of completed job totals within a cent per contributing row, sum(jobCount) >= completed jobs, and conversionRate === null and never 0 when estimatesTotal is 0. Keep the probe out of CI — it hits the production tenant.

None.
MBoard-change watcher: unfreeze the widget and say when today movesplatform

A 15-minute WorkManager worker that fetches the local-day board, diffs it against the previous snapshot, calls updateAll(), and raises a local notification for appointments added, removed, retimed or reassigned.

Why: h2o_field_widget_info.xml sets updatePeriodMillis="0" and grepping the whole Android tree for updateAll, WorkManager, AlarmManager, PeriodicWork or BOOT_COMPLETED returns zero hits, so provideGlance runs when the widget is placed and effectively never again — the "next job" card freezes at whatever the board said that morning and is still showing it at 4pm. Nothing else polls either; every screen is a one-shot LaunchedEffect(Unit). This business dispatches nearly everything at booking time, so the operationally significant event is a same-day change — work added after the truck left, or a reassignment — and today that reaches nobody.

First step: Add androidx.work (no module depends on it today) and pass java.time.LocalDate.now().toString() explicitly: a worker firing after 8pm Eastern with no date would diff tomorrow's board against today's snapshot and report the whole day as changed. Reuse JobStatusNotifier.kt's existing channel, POST_NOTIFICATIONS flow and ongoing-notification pattern. Ship the widget refresh everywhere and the notifications only in the dispatch/admin modules: without identity every device would be told about every technician's change, which is right for a dispatcher and pure noise on eleven technician phones. No FCM — per-person push needs a device-token registry, which is a write.

The board's local-day fix should land first. It does not need identity, despite what per-person push would.
LGood / Better / Best rebuilt as additive scopesestimating

Delete the hardcoded +$0/+$150/+$430 tier surcharges and make a tier a set of cart lines, each tier carrying its own total, line list and customer-facing summary.

Why: Every number in the current tier model is invented and nothing in ServiceTitan can back any of it: warranty.duration is 0 on all 783 priced services, zero services match "maintenance", addOnPrice is 0 on all 783, and memberPrice is unpopulated — 0 on 780 and equal to price on the other 3. So "+ Priority scheduling & 1-yr warranty, $150" is a promise the business has no priced product for. What this shop actually does is additive and visible in today's data: job 56104260, created 2026-08-10, is Good $2,991.84 (4 lines, sold) / Good $3,321.84 (3) / Better $3,699.49 (4) / Best $4,280.40 (6); job 55780164 is Good $830.58 (2) / Better $1,045.12 (2) / Best $2,061.44 (4), Best sold. And it works: 1,076 jobs carry multiple same-day estimates and close at 75.8% against 69.3% for single-estimate jobs.

First step: Lead the copy with the multi-estimate evidence, not with the GBB naming — explicit Good/Better/Best appears on only 30 estimates across 13 jobs, all in July and August 2026, so it is a new and growing practice rather than the historical norm. Use ServiceTitan's `summary` (populated on 3,533 of 4,279 estimates) as the per-tier scope paragraph, and either drop the fabricated RECOMMENDED badge or let the technician choose which tier carries it.

The quantity/discount cart, which in turn needs the SKU id.
MArrival communication that stops short of pretending to writedispatch

A per-customer contacts route feeding two pure client-side intents — ACTION_DIAL and an SMS composer prefilled with the customer name and the arrival window already in the payload — labelled plainly as not logged.

Why: This is the one part of dispatch with nothing behind it at all. The board knows the promised arrival window, and it is a real distinct promise on this tenant rather than a copy of the block: 18 of 29 rows on 2026-08-10 have a window different from their scheduled times. It knows the customer, the address and the status, and it cannot reach anybody, because customerDetailsFor reads only name, address and coordinates and the ServiceTitan customer object carries no phone number at all. Two constraints belong in the UI rather than in the design: isConfirmed is false on all 121 appointments in the sampled fortnight, so the "Confirmed" chip already sitting in three files is unreachable and a confirmation-call list built on it would flag every appointment; and recording that a customer was told belongs on the field API with X-H2O-User.

First step: Use the per-customer sub-resource only: /crm/v2/tenant/4155131542/contacts returns 403 UnauthorizedRequest, "belongs to a feature that isn't available in your account", so the tenant-wide export is not an option. It has no ids= batch, so it is one round trip per customer — about 15 on 2026-08-10 — needing its own bridge route with a bounded fan-out, or lazy resolution on tap in the detail rail. Carry type, doNotService and balance through from the customer record to warn or suppress, and use the contact's phoneSettings.doNotText as the SMS suppressor, not the customer's doNotMail, which is about mail.

The dial and text half is read-only and needs nothing. Logging the contact needs the field API and an identity.
MBefore/after photos: camera capture, labelled, stored on purposefieldtech

Replace the single transient photoUri and the gallery picker with TakePicture into app-scoped storage, a labelled list of photos, and an explicit per-photo state of Saved on this phone / Queued / Sent.

Why: MyDayScreen.kt:58 holds exactly one Uri per job and the button at :330 says "Replace Photo", while the office's own instruction on appointment 56037232 asks for two — "Take before picture… Take after picture…" — so the data model cannot hold what one real job today required. Line 145 uses PickVisualMedia, which opens the gallery, so the technician must leave the app, shoot, come back and hunt for the file. And nothing survives: a transient content URI dies with the process and there is no upload anywhere. The business is asking for photographic proof in writing on live appointments.

First step: Ship capture plus durable local storage and the "on this phone only" state, and treat the upload drain as a separate item gated on confirming the endpoint exists — nothing in this repo shows the web stack's field API exposing a job-attachment write; SETUP-LINUX.md:526-527 and server.js:100-102 only say writes belong there, and H2O-CLAUDE-HANDOFF.md:99 documents X-H2O-User as a Caddy forward_auth role header. Default the labels to Before/After and treat instruction-matching as a bonus: 56037232 is the only one of today's 17 jobs with a photo instruction.

My Day on real jobs, and the durable on-disk layer — a photo queue built before durable storage exists reintroduces the defect being fixed.

Level 3 structural: identity, durability, and the objects the product does not yet model

MDevice-scoped technician: the smallest honest identity the suite can haveplatform / fieldtech / dispatch

A first-run "This device belongs to…" picker off the roster, persisted locally, shown permanently in the app bar, changed in one tap, and applied by filtering the board client-side — a device profile claim, explicitly not a sign-in.

Why: The absence of identity blocks the largest class of work in the suite, and the suite currently pays for it in fabricated data rather than in missing features: MyDayScreen renders four hardcoded jobs with working status toggles, photo, signature and payment paths; the watch shows the same four; the widget is deliberately worded "next on the board" (H2OFieldGlanceWidget.kt:27-33) because personalising it would be a claim the data cannot support — the right call under the constraint, and a worse product than the data supports. One stored technicianId turns My Day into a real day, the widget into "your next job", the watch into one person's stops, Performance into "my numbers", and the route-feasibility alert into "you cannot make your 16:00 window from where you are" — at zero extra requests, since DispatchBoard.technicians[].technicianId is already decoded on both platforms.

First step: Persist in the existing h2o_command_prefs bucket (DirectoryScreen.kt:57) and @AppStorage on iOS. Source the picker so the fake colleague never appears: all 11 roster rows are active:true and roleId is not the discriminator (ZZ Test Tech is roleId 9 with isManagedTech false, 63 permissions), so filter on the loginName shape — ZZ Test Tech's is "Tech_4155131542_2171" while all ten real people carry @h2oprofessionalservices.com emails — or source from the board's technician rows. Never send technicianId to ServiceTitan's appointments filter (server.js:196-199). Enforce structurally, not by convention, that the stored id never enters a request header, the same way server.js:136-143 makes read-only a property of the code rather than a rule people remember. And note appointments are genuinely shared — 56015562 "Prep day!" sits on four technicians at once — so the same job legitimately appears on four My Days and no filtered count is a company total.

None to build. It does not authenticate, does not attribute, and cannot supply X-H2O-User; a real login still needs the web stack or ServiceTitan's user OAuth flow.
LAn offline read layer with an 'as of' stamp, and a bridge projection to shrink what it storesplatform / fieldtech

A disk cache keyed by endpoint plus params, written on every success and read on every failure, an explicit live → cached-with-age → "bridge unreachable" ladder, allowBackup="false", and a server-side projection on /api/technicians.

Why: There is no persistence anywhere beyond two SharedPreferences strings for pinned favorites, and every screen is LaunchedEffect(Unit) into remember — not rememberSaveable — so a rotation, a low-memory kill or a long background re-fetches everything, and with no signal there is nothing at all. This is a field app for a business whose work happens in basements and crawlspaces ("leak in crawl underneath trailer", job 56126916) on drives with LTE holes, and today a lost connection does not degrade the app, it replaces the schedule with people who do not exist. Sample data must stop being a network fallback and become a build-time demo mode. It is also the precondition for any write outbox. And allowBackup="true" is set in six AndroidManifest.xml files — app, fieldtech, dispatch, admin, wear, wearcommand — with no dataExtractionRules: the moment this cache holds customer names, addresses, coordinates and balances, Android Auto Backup copies them to the technician's personal Google Drive, off-device and unrevokable.

First step: Turn allowBackup off and put the cache in app-internal storage before there is anything to lose. Prefetch today's and tomorrow's board on launch, and make every cache-served screen state "as of 4:12 PM" rather than presenting stale data as current — the suite's existing live-vs-sample honesty rule applied to time instead of provenance. Alongside it, project /api/technicians in the bridge: server.js:692-695 forwards ServiceTitan's full 46-field record including permissions[], payroll, burdenRate, commissionRate and bio while the clients read five, so one server-side map cuts 64,562 bytes by an order of magnitude for every caller on both platforms and stops shipping payroll data to a phone at all.

The failure-honesty entry — this upgrades its empty state into a stamped cached one.
MAttribution completeness: the sellers who are not techniciansreporting

Add /api/employees as a second, lower-priority name source in nameById and carry `role` and `active` on every performance row, changing the report from "technicians" into "people who earned credit".

Why: Once `active: 'any'` fixes the jobs half, what remains is estimates and memberships. 54 of the 1,202 sold estimates that name a seller still resolve to nobody — Chandler Powers 6017, Jonathan Mullins 5889, Andrew Taylor 52017348, Tiffany Waldron 7297, Matt Bienvenu 52985959, and the employee-side Jason Walton 52181445 — and all three of 2026's memberships name soldById 58, h2oprollc (Owner). Those credits currently land on the field technician who worked the job, which is why the technician card's membership counter reads 0 for everyone and why the pipeline's value-won column would be wrong before this lands.

First step: Merge with a name-level dedupe: the same human can hold both ids — Jason Walton is technician 5895 and employee 52181445 — so a naive merge yields two rows for one person. Carry `active` per row now that inactive technicians are in the roster, so ex-employees read as ex. Say plainly in the UI that these are ServiceTitan employee records, not app sign-in: no screen gains a "my jobs" filter from this.

`active: 'any'` first — it does the jobs half and this does not.
LA schedule spine: a week strip and real utilisation against the shift windowdispatch

New /api/dispatch/week?from=&days=14 returning per-local-day appointment and unassigned counts plus per-technician booked minutes, replacing the ±1-day stepper with a strip that shows where the work is.

Why: Two things make the day-at-a-time stepper actively bad here. The forward book is thin and lumpy — 2026-08-11 has 5 appointments, 08-12 has 1, 08-13 has 4, 08-14 has 1 — so finding the day that needs attention means tapping blind through near-empty days. And the whole forward window is already fetched and discarded on every board load: appointmentsOn() asks for startsOnOrAfter with pageSize 200 and then filters to one day locally, which is exactly the trap the log records ("37 appointments of which 19 were today and the rest ran to mid-September"). Utilisation is the other half: every technician record carries shiftStart 08:00:00 and shiftEnd 18:00:00, a real 10-hour denominator nothing in the suite reads, while TodayEfficiencyScreen counts assignment rows whose modifiedOn lands today — a proxy for a proxy, built on the endpoint the log already caught lying about dates.

First step: Anchor the single fetch at `from`, not at the board day, or any strip showing days before the selected one needs a second call with an earlier startsOnOrAfter. Label the denominator honestly: shiftStart/shiftEnd is identical on all 11 records with no timezone and no per-day variation, so it is a tenant-wide 10-hour default, not a per-person roster. Compute booked minutes after the assignment join — that over-counts shared work by construction, which is correct for minutes committed per person — but derive the day-level totals on a different basis, or 18 appointments will read as 29.

The local board day.
LThe estimate pipeline as a first-class object beside dispatchreporting

New /api/pipeline?from&to and a screen alongside Dispatch: open estimates by age and value, a sold/dismissed/open funnel, and a per-technician close rate reported by value won rather than only as a ratio.

Why: $5,421,646 of estimates are open against $1,866,915 sold in 2026 — the largest number in this business by a factor of three, and it appears nowhere — while the bridge already downloads every one of those records on every performance call and uses them to compute a ratio. For a business whose top two revenue job types are literally "Approved Estimate" ($982,812) and "Estimate" ($131,695), the estimate is the unit of work and the product models only the appointment. The data also forces a design decision: sold estimates close at a median 0.3 hours and 89% inside 24 hours, so the actionable object is today's presented-but-unsold estimates, not a stale-lead list — and the $2.44M that is 90+ days old should be shown as effectively dead rather than as pipeline.

First step: Scope its own window rather than inheriting the performance pager — a full-year /api/performance already takes 13.0s against Android's 20s readTimeout at STBridgeClient.kt:300. Split by business unit straight off estimate.businessUnitName with no join at all, but suppress close-rate badges below a sample-size floor: there are four units, and Commercial - Relevate (7 estimates, 1 sold) and Commercial - Nest (3, 0 sold) would otherwise be reported as confident 14% and 0% rates.

The Open Estimates value on the tile, and the employees half of attribution — the value-won column runs through creditFor().
LQuote from what sold, not from the catalogestimating

Make the corpus of past estimates the primary quoting surface — search past estimates by SKU and by name, see the line set, the price charged, the outcome and the business unit, then clone the line set into the cart — and demote the pricebook to a lookup.

Why: The pricebook is not a catalog in this tenant, it is a graveyard of one-offs: real entries include "Drain line repair for 3330 w grace", "Electrical work for 3332 both apartments", "Phase 2 completion in-wall water lines repipe", "Phase 4 completion". 162 of the 783 priced services have never appeared on a single estimate and ServiceTitan's own categories put 856 of 893 in one bucket, so there is no taxonomy to browse and never will be. What does have structure is the quoting history: 9,241 lines across 4,279 estimates using 700 distinct SKUs, each with the price charged and the outcome. One thing this must not add: a tax line — tax is 0 on all 4,279 estimates and salesTax is 0 on every line item, despite taxable being true on 145 of 783 services, so the builder is already right to omit it.

First step: Index estimate lines by sku.id and by name behind the same cached corpus the insight aggregate pages, and state the window on every figure (the 2026-YTD subset is 6,289 lines / 621 SKUs and reads very differently). Business unit must be a technician-chosen filter over history, not an attribute the builder claims to know: with no signed-in identity and no job context the app cannot infer which unit a new quote belongs to — the same honesty rule the widget already applies.

Pricebook insight (same corpus and cache) and the SKU id on PricebookItem.
LA durable outbox, so field capture survives the phoneplatform

An append-only on-disk queue — job id, kind, payload, device-local timestamp, technicianId — surfaced as "Not yet sent — 3 items" and drained by a pluggable sender that initially does nothing but hold the queue and say so.

Why: The suite has a complete capture surface with nowhere to put anything: SignatureCaptureScreen, PaymentCollectionScreen with a real Google Pay button, the photo picker, ScannerScreen, free-text notes, and GeofenceManager, which registers a real OS geofence and flips a job to IN_PROGRESS on genuine arrival — a fact that then evaporates. Status, notes, photoUri, signatureBytes, paymentAmount and paymentCollected are mutableStateOf fields on a module-level val shared by every module depending on :core-ui: they survive navigation, die at process death, and belong to nobody. A signature collected at a customer's door is lost by swiping the app away. Either the capture surface should be removed or it should be made durable, and the outbox is the half that can be built without touching the read-only guarantee.

First step: Repoint My Day at the live board FIRST. An outbox built today would durably queue signatures, payments and arrival events against job ids that do not exist in ServiceTitan — a queue that can never be sent is worse than losing the records, because it looks like pending work. Then identity, then the offline layer, then the queue, so the eventual write path is a one-file sender swap rather than a rewrite of six screens. Keep the bridge's 405 structural (server.js:138-145); and since signature PNGs and job photos are customer records, allowBackup="false" and internal storage are not optional once the queue holds them.

My Day on live jobs, device identity, the offline layer. The sender itself needs the web stack's field API with X-H2O-User.
LThe proposal becomes a real ServiceTitan estimateestimating

Post the built proposal to the web stack's field API — name, summary, and one line per cart item with sku.id, qty and unitRate — then show the returned estimate id on the tier card.

Why: Nothing survives the screen today. signatureBytes is a `remember` inside a TierCard composed by a LazyColumn item, so it is discarded when the card scrolls out of view, and even if it survived it is never written to a file, uploaded or attached to anything. The deposit is worse than transient — ProposalBuilderScreen.kt:110 defaults to total / 2.0 while ServiceTitan's own depositAmount, depositPercent and requireDepositOnSignature are null on all 4,279 estimates, so a deposit rule is a business policy someone must decide and configure, not a value to read. This is the change that turns Pricebook → Proposal → Signature → Deposit from a demo into work product, and ServiceTitan's estimate model has no signature field at all, so the signature must ride attachments/forms or the web stack's own journal.

First step: Before any of it, three prerequisites the write itself cannot supply: a ServiceTitan sku id on PricebookItem (the cart literally cannot fill sku.id today), entry into the builder from a job context (all 4,279 estimates hang off a jobId with customerId and locationId required, while ProposalBuilderScreen.kt:59 offers only a free-text "Customer Name"), and a real X-H2O-User, since /api/performance credits an estimate to its seller first and would otherwise attribute every app-created estimate to nobody. Wire the confirmation into the open-estimates list, where a posted estimate lands as Open, rather than only echoing an id.

UNBLOCKED SERVER-SIDE (2026-08-11): POST /field/estimate, estimate-set, estimate-update and estimate-ai already exist behind field-api.php. What the mobile suite still lacks is the Google SSO session that supplies X-H2O-User — see checklist f28.

Easy Mode & the watch-everything strategy

Easy Mode is one per-device boolean (default off), consumed by existing screens as a conditional simplification — never a parallel screen set. Android: a LocalEasyMode CompositionLocal in core-ui/ui/Theme.kt backed by the existing h2o_command_prefs store. iOS: @AppStorage("h2oEasyMode") on an App Group container — the same App Group the widget feed needs anyway. The watches are permanently easy mode, no toggle: both already satisfy every principle, and a settings surface is itself a violation on a 40mm screen.

  • One decision per screen — one primary full-width button (min 56dp/pt); everything else folds into a single More row. Mechanical test: more than one primary above the fold means it is not easy mode.
  • Today only, next first — no date stepper anywhere; the first non-done item is always on top (the predicate already exists in FieldWatchContentView.swift:61).
  • Guided next action — a pinned card: “Next: 1:00 PM · Sunrise Diner — Call | Navigate | Done”. Field apps suggest the first non-done job; manager apps the largest live exception.
  • Bigger type by scale, not new layouts — 1.3× through the theme: Android a fontScale Density override at each setContent; iOS requires converting fixed fonts to relativeTo: first (all 146 are fixed today).
  • Plain words, never color alone — status renders word + icon (the icon set exists at WatchJobStatus.icon); “Unscheduled — tap to assign” becomes “Needs a technician”.
  • Hide complex and sample-only surfaces — Board/Map modes, dashboard customization, crew tagging, Simulate Arrival. Rule: renders sample data or mutates nothing real → not in easy mode.
  • The honesty badges survive — “Sample data” / “Live from ServiceTitan” labels are never simplified away.
  • Never dead-end — an empty easy screen is one sentence and one recovery button.
  • 48dp / 44pt minimum targets — the status chips at ~37dp today get heightIn(min = 48.dp).
AppEasy mode is
ToolboxA single card stack over the same job list: next job at 1.3× with three stacked buttons — Call Customer, Navigate, I’m Done (advances the stack; write stays local until the write layer). Bottom bar trims 5 tabs → My Day + More. Close-out stays reachable as one “Finish paperwork” button.
DispatchList view only — no Board/Map switch, no date stepper, status word beside its icon, one pinned header card: “X still running · Y unassigned”. The fake unscheduled cards are hidden here first, deleted everywhere soon after.
Admin / CommandThree live tiles (Jobs Today, Completed, Revenue) plus one generated sentence — “✓ 5 of 8 done · $67,930 so far this month” — and a More row. Hub trims to Overview + Dispatch + More.
WatchesAlready the easy design; the work is live data, not layout — Command Watch drops its hardcoded $4,280 for fetchPerformance, and the wear job count moves off the undated-assignments trap onto the board endpoint.
WidgetsAlready easy — one job, one count, honesty badge. The iOS one needs the App Group feed, not a redesign.
Wrist surfaceWhat ships
Complications + Smart StackExtend H2OFieldWidget from .systemSmall to .accessoryCircular (jobs left), .accessoryRectangular (next: time + customer), .accessoryInline; relevance scores spike at each appointment start so the Smart Stack rotates it up as the tech should be leaving.
Field Watch quick actionsCall Customer (openSystemURL(tel:) — hands to watch cellular or the paired phone) and Navigate (MKMapItem.openInMaps — watch turn-by-turn) under the existing status buttons; needs phone + address on WatchJob.
Command Watch = manager glanceThree live lines: today’s revenue, done/left, one exceptions line (“2 unassigned”, else “all assigned”) — the Admin easy-mode sentence, on the wrist.
Siri / App Intents“What’s my next job” — an AppIntents shortcut reading the same store as the widget; compiled into the watch target so the phrase works spoken to the wrist. Android twin: app shortcuts.
Live ActivityAlready built and started by real status changes — the fixes are lifecycle hygiene: pass a staleDate, end orphans on launch via Activity.activities. watchOS mirrors it into the Smart Stack for free.
Wear OS parityA Tile + complication data source reusing the Glance widget’s loadNext(); promote JobStatusNotifier with the OngoingActivity API so the en-route job pins to the watch face.

Accessibility debts the audits measured. The dispatch timeline is invisible to TalkBack and VoiceOver on both platforms — blocks handle input via raw gesture detectors that publish no semantics. iOS has zero accessibilityLabels and 146 fixed-size fonts, so Dynamic Type does nothing suite-wide. Measured contrast on the dark ground: amber status text 3.95:1 and the In-Progress blue 3.43:1 both fail AA at the 10–11sp sizes they render at — pair every status dot/stripe with its icon and a lifted text tint rather than re-branding. All four are checklist items with files.

Platform strategy — the vault, the exits, the bets

“Database everything we need from ServiceTitan” has a design, and it doubles as the exit ramp: the vault is the system of record for everything no other CRM can import — payment history, memberships, campaigns, attachments — which is exactly why it must exist before any switch is contemplated.

Android · iOS the apps H2OSTBridge GET only · 60s board cache ServiceTitan production tenant export feeds · continueFrom vault-pull.mjs launchd 02:30 · no listener ~/st-vault on the mini raw JSONL, append-only SQLite, rebuilt by replay chmod 700 · FileVault payroll fields stripped any future CRM CSV + GraphQL import readsreads nightly pullappendexit ramp
The vault never rides the LAN bridge: it is a standalone fetcher on the Mac mini reusing the same credential with no listener and no port. The bridge keeps serving the apps exactly as today; the dashed path only exists if a CRM switch is ever chosen.
  • Two layers, both mandatory. Append-only gzipped JSONL per entity (~/st-vault/raw/{entity}/YYYY-MM.jsonl.gz, each line wrapped with fetchedAt + endpoint) is the archive — lossless, schema-proof, replayable. SQLite (vault.sqlite, upserts guarded by modifiedOn) is a disposable index rebuilt by replaying the JSONL; corruption is repaired by deletion. Whole history ≈ 10 MB/yr gzipped, excluding attachments (metadata only, by default).
  • 22 entity streams, bootstrap order: dimensions (always active:'any' — the active-only default silently dropped 15 of 26 technicians and $236k of attribution) → customers/locations/contacts → jobs/appointments → assignments (the one unbounded -modifiedOn walk) → estimates/invoices/payments → memberships. Estimates are the crown jewels: 4,279 with 9,241 priced lines and outcomes — the company’s real price book.
  • Incremental ladder per entity: export feed with a durable continueFrom token (backfill and delta in one mechanism) → transactional modifiedOnOrAfter only after the diff-proof (this tenant has four verified silently-ignored filters; a filter’s honesty is a fact with an expiry — re-prove monthly) → -modifiedOn high-water paging. First live probe: /jpm/v2/tenant/4155131542/export/jobs?from=2026-08-01 — expect {hasMore, continueFrom, data} or a 403 that drops that entity a tier. The bridge’s sanctioned routes cannot express modifiedOnOrAfter or includeTotal, so the vault cannot be built on them.
  • Verification is the deletion detector: weekly pageSize=1&includeTotal=true per entity vs count(*); drift >0.5% triggers a re-page (ST deletes never appear in modifiedOn feeds). Quarterly: rebuild the SQLite from JSONL on a different machine.
  • The open legal question, stated plainly: ServiceTitan’s API Terms §5.2 caps caching Content at 24 hours “except to the extent expressly permitted”; the Export APIs exist precisely for bulk retrieval, which is a strong argument the exception applies to a customer archiving its own tenant — but it is an argument, not a written answer. Before the vault’s first full backfill, send the one-question email to ServiceTitan developer support / the account manager (“may we retain exported data from our own tenant indefinitely for business-continuity archival?”) and record the answer in COMPLIANCE.md. The same §5.2 is already designed into the on-phone offline layer as its 24-hour purge.
  • PII posture: payroll/compensation fields stripped from technician records before they land, chmod 700, FileVault confirmed via fdesetup status, encrypted backups only, and a written tombstone-and-rewrite deletion procedure.

CRM field if the ServiceTitan bill ever forces the question

Update, Oct 7, 2026: Jobber is now H2O’s payment provider. The iPOSgo/iPOSpays rail was never switched on (no keys). PIPE’s My Day now sends techs into Jobber to take card, cash or check (Jobber’s API cannot record a payment), or texts Jobber’s own pay link, and reads the result back as Paid in Jobber. Leadership’s Crew today card lists cash and checks to collect from techs. My Day also gained the job checklist (arrival safety check, waivers, 811 ticket, permit decision, test result), customer approval over $5,000 and customer sign-off.

Payments are already decoupled — iPOSgo is Dejavoo’s tap-to-pay app on the iPOSpays gateway, with real ISV APIs (SPIn semi-integration, recurring payments, hosted payment page, transaction reporting) — so no CRM’s payment module is load-bearing, and a weak one disqualifies nothing. All pricing verified against live vendor pages 2026-08-11; Jobber’s own pricing page contradicts its help center on included seats — the corrected number is below.

BackendAPI reality~12 seats / yrMembershipsCould our board run on it?Verdict
ServiceTitan todayREST; four verified silently-lying filters; no webhooks used; export feeds existfive figures (reported ~$300+/tech/mo)first-classit doesincumbent
JobberGraphQL, OAuth2, cost-based limits (10k points, 500/s), HMAC webhooks; no plan gate documented for custom single-account apps — verify with sales≈ $4,284 (Grow 10-user tier annual + 2×$29) — not the ~$2.5k the help center implieslossy: no membership object; recurring jobs only; auto-billing wants Jobber Payments (collides with iPOSgo)yes — one visits query replaces the 3-call join; property = work address kills the billing-address bug class; swap lives inside server.js onlylean recommend
ServiceM8REST + free ungated API keys (X-API-Key), OAuth + webhooks for add-ons — the closest shape to the bridge’s stFetch$1,788–4,188 total (priced by jobs, unlimited users; ~390–470 jobs/mo)no membership object; recurring jobs + iPOSpays recurring APImost directly of any candidate — 1:1 route re-pointing, webhooks replace polling; its iOS-only full app is void here (we ship our own apps)alt #1
Service FusionREST, OAuth2 client_credentials — auth-identical to the bridge’s ST flow; ~60 req/min; API possibly Pro-gated (verify)≈ $2,500–6,400 flat, unlimited users (quote-only)in-product service agreements; API depth unverifiedyes, through the bridge’s existing cachingalt #2
Zoho FSMREST on every edition incl. Free; the only published generous limits (25k/day)low — priced by appointment volume, not seatsMaintenance Plans on Professional; assembled, not nativeclean work-order→appointment→resource mappingalt #3
Housecall ProREST + webhooks, but MAX-plan-only, no dedicated API support≈ $8,200 (MAX + 11×$35)recurring plans, MAX-onlyplausibly, at the wrong priceAPI-taxed
WorkizREST token API — gated to custom-quote Ultimate (25+ employee tier)~$6k+ before add-onstier-lockedonly after paying for Ultimateweak
FieldEdgepartner-gated API, nothing self-servequote-onlystrong in-productonly as an approved “partner”avoid
Odoo CE + OCAXML-RPC/JSON-RPC over every model, no gate — but field service is OCA modules (AGPL), not stock Community$0 license + your own ops hoursassembled from fieldservice_recurring/_agreementyes, and you own the server — you also become the vendorown-it path

Exit ramp in one paragraph. Vault first — it is the system of record for what no importer accepts (payment history, membership contracts, campaigns, attachments) and it stamps every migrated record with its ST id via custom fields so history stays joinable. Then a 30-minute GraphiQL session on a free Jobber trial answers the two blocking unknowns (payment-record mutations; property coordinates), and a one-call sales question pins the API plan gate. The mobile suite barely notices a switch: the upstream swap happens inside H2OSTBridge/server.js while STBridgeClient.kt/.swift and all app targets keep their contract — the bridge was accidentally the best migration insurance the suite has. Jobber’s CSV importers cover clients/jobs/quotes/invoices (1,000/500-row batches); memberships re-model as recurring jobs + a member tag, with billing already solved by the iPOSpays recurring API.

SDK bets ranked — what to work in, what to kill

#BetWhat it does hereCostEffortCall
1Caller-ID vaultIncoming call shows “H2O · Margaret Dooley · Member · 2 open jobs” before anyone answers. CallKit Call Directory extension (offline label DB, reloaded nightly) + Android CallScreeningService heads-up; fed by a new /api/phone-directory built from the per-customer contacts crawl (the tenant-wide contacts endpoint 403s). Office devices first.$0Mbuild
2Freeze watcherapi.weather.gov — free, no key; zone VAZ515 verified live. “Hard freeze Tue night — burst-pipe surge likely” on Admin 48h before the phones ring. Poll 2–3 zones (service area spans Petersburg/Prince George).$0Sbuild
3OSRM routingSelf-hosted on the mini against the Virginia OSM extract; one /table call gives the whole day’s drive-time matrix — upgrades the route-feasibility flags from great-circle to real minutes, and “suggested order saves 47 min”. Needs the location join first or it optimizes to billing addresses.$0Mbuild
4DocuSeal e-signSelf-hosted (AGPL, one container). Today’s signature capture fails ESIGN/UETA outright — a transient PNG bound to no document. Proposal renders to PDF, DocuSeal seals it with hash + timestamp + audit trail; the on-glass pad stays as the input surface.$0Lbuild
5Review-link SMSZero-API version now: “Text review link” button on the payment success screen firing the tech’s own SMS composer with the Google review short link. Automation later inherits the 10DLC gate.$0Sbuild
6Voice → job notesPlatform speech first (free, on-device); whisper.cpp later for crawlspaces with no signal. Gated behind the durable outbox — transcribing into a field that dies at process death is worthless.$0Safter outbox
7Twilio en-route SMSAutomated “Zach is on the way — ETA 2:40” with the OSRM ETA. A2P 10DLC registration takes weeks — start early; the send is a journalled action for the field API, never the bridge. ~$10/mo at this volume.~$10/moMgated
8Siri / App Shortcuts“What’s my next job” — packaging of the widget’s existing loader; upgrades wording from “next on the board” to “your next job” when identity lands.$0Sbuild
9MDM fleetMosyle Business (Apple, free ≤30 devices) + Google’s Android Management API (free). Remote lock/wipe before phones carry customer data; also the sane install path. Note: SimpleMDM/Mosyle are Apple-only — the Android half needs Google’s path.~$0Sbuild
10QuickBooks mirrorNightly read-only ST↔QBO reconciliation worklist — but first confirm the books actually live in QBO; nothing in the repo says so.$0Mconfirm first
11NFC equipment tags seedKeep/finish — scanning works, nothing maps a tag to equipment. Local tag→location table now, ST installed-equipment route later; sub-dollar NTAG stickers on every tank at install.~$1/tagMfinish
12Geofence arrivals seedKeep/finish — right architecture; feed it board coordinates instead of geocoding strings, compile Simulate Arrival out of release, expire fences at shift end, route arrivals into the outbox.$0Sfinish
13Barcode scanner seedRepurpose — parts-to-bill is dead (materials carry no pricing in this tenant, locked decision). Swap the analyzer to ML Kit Text Recognition and read model/serial off rating plates: install-base age is the data behind every proactive replacement pitch.$0Srepurpose
14Fake Tap-to-Pay seedKill now — a 2.2-second timer that always reports “Payment Collected”. The confirming-animation defect class, on money, at a customer’s door. Google Pay scaffolding stays parked pending a real gateway decision.—Skill
15CarPlay / Android AutoSkip — entitlement and publishing ceremony for 12 users. The 10-line Navigate deep link plus the existing ongoing notification / Live Activity deliver 90% of the in-car value free.——skip

Decisions already locked in

  • Revenue attribution — if you sell it it's your job; otherwise whoever worked it. Revenue splits across shared jobs so the column totals to what was billed; job counts stay whole and are labelled "jobs worked".
  • Pricebook is services-only — materials and equipment carry no pricing at all in this tenant, so quoting from them would show customers a wall of $0.
  • The bridge is read-only by construction — non-GET is rejected before any handler runs. ServiceTitan writes belong on the web stack's field API, where they're journalled per technician.
  • Cancellation is not failure — a LaunchedEffect that catches every exception also catches the cancellation fired by changing the date or period. That made the board claim it could not reach the bridge while showing data from the response that arrived. All fetching effects rethrow cancellation first.
  • Report dates are UTC — so a technician west of the office and the office itself see the same numbers for "today".
  • Three ServiceTitan filters silently lie — technicianId on appointments, date filters on dispatch/assignments, and startsOnOrBefore on appointments. All return 200 with wrong data. Diff filtered against unfiltered before trusting a new one.
  • Payments stay decoupled — card capture, recurring billing and settlement live on iPOSgo/iPOSpays (Dejavoo), never in the suite. No CRM's payment module is load-bearing in any comparison, and no card number, CVV or expiry field may ever exist in these apps — that is what keeps all fourteen targets out of PCI scope.
  • ServiceTitan Content caches for 24 hours, max — API Terms §5.2. Every client-side cache carries an "as of" stamp and purges past 24h; the server-side vault's indefinite archival of the company's own tenant awaits ServiceTitan's written answer, tracked in COMPLIANCE.md.
  • Sample data is a build-time demo mode, never a network fallback — extended this session from the phone screens to the widget and both watches, where a 9sp caption was carrying the entire burden of honesty.
  • The field API exists — mobile writes CONNECT, never rebuild — field-api.php + the web stack’s st-bridge already implement status/notes/estimates/payments/photos/notify/memberships behind Google SSO with X-H2O-User journaling. The suite’s outbox drains into those routes; building a parallel write path is now the mistake to refuse.
  • One app per platform, role-shaped — locked 2026-08-11. Command is the app; a device claims a person, GET /api/roles (a file the admin edits beside the bridge) decides the surface set; technician view is the default for anyone unlisted. The six satellite launchers retire after the merge ships; the marks live on as tab iconography and optional launcher aliases.