89 KiB
CLAUDE.md — JQC iOS App Developer Reference
Audience: AI assistants and developers working on the JanitorialQC iPad app. Purpose: Authoritative reference for architecture, conventions, constraints, and decisions. Last reviewed: June 2026 (Phase 19 complete — server selection, standalone issue creation, multi-photo evidence, facility deduplication, RetryablePhotoView, photo URL /static/ prefix + Phase 25 GPS capture at submit time + active-template filter + area relationship fix + read-only inspection detail view) Companion: See the web backend's
CLAUDE.mdfor API contract, server-side rules, and migration chain.
Table of Contents
- Project Overview
- Tech Stack
- Repository Layout
- App Entry Point & Lifecycle
- Authentication
- SwiftData Models
- Offline-First Architecture
- Sync Engine (SyncManager)
- API Client (APIClient)
- Navigation & View Hierarchy
- Inspection Workflow
- Form Field Rendering
- Issue Flagging Workflow
- Standalone Issue Creation
- Re-inspection Workflow
- Inspection History
- Photo Handling
- Score Calculation
- Background Sync
- Settings & Cache Management
- Server Selection
- Known Constraints & Hard Rules
- Xcode 26 Specific Issues
- Change Philosophy
1. Project Overview
JanitorialQC Inspector is a native iPadOS app used by inspectors to conduct facility quality-control inspections. Its defining characteristic is offline-first operation: every action writes to local SwiftData storage first; the server is a secondary destination reached asynchronously when connectivity allows.
Core capabilities:
- Login/logout via JWT against the JQC web backend API
- Server selection — inspector can choose between jqc (primary) and jqc1 (secondary) server at login or in Settings
- Reference data sync — facilities, areas, and inspection templates pulled from server
- Inspection execution — dynamic form rendering driven by server-side template schemas
- Issue flagging — severity-tagged issues with optional photos, attached to inspections
- Standalone issue creation — inspector can create issues directly from the Issues page without an active inspection, with contract → facility cascade picker
- Re-inspection — linked follow-up inspections with parent-form pre-fill
- Outbox queue — completed inspections and issues submitted to server automatically when online
- Inspection history — server-side read-only history for completed inspections
- Issues list — assigned/reported issues pulled from server + device-created issues
- Issue detail — shows local photos (pending) or server photos (synced) via
RetryablePhotoView - Facilities browser — read-only view of synced facilities and their areas
- Push notifications — local notifications via 60-second polling
2. Tech Stack
| Layer | Technology |
|---|---|
| Language | Swift 5.10+ |
| UI | SwiftUI. iPad is the primary target; iPhone (compact width) is supported — see rules 76–77. All four orientations. TARGETED_DEVICE_FAMILY = "1,2", so the app installs on iPhone and must stay usable there |
| Local storage | SwiftData (iOS 17+ required) |
| Networking | URLSession async/await |
| Connectivity detection | NWPathMonitor (Network.framework) |
| Token storage | iOS Keychain (Security.framework) |
| Photo capture | UIImagePickerController (camera), PHPickerViewController (library) |
| Signature capture | PencilKit (PKCanvasView) |
| Background tasks | BGTaskScheduler / BGProcessingTask |
| Server config | UserDefaults (not Keychain — not a secret) |
| Min deployment | iOS 17.0 |
| Xcode | 26.4.1 (Xcode 26 beta — see §23 for critical constraints) |
3. Repository Layout
JanitorialQC/
├── JanitorialQCApp.swift # @main — SwiftData container, BGTask registration
├── ContentView.swift # Auth gate + startup lifecycle (.task{})
│
├── Auth/
│ ├── AuthManager.swift # @MainActor ObservableObject — login/logout/restore
│ └── KeychainHelper.swift # Security.framework wrapper (nonisolated)
│
├── API/
│ ├── APIClient.swift # actor — URLSession, JWT inject, 401-retry, photo upload
│ │ # submitIssue() sends photo_path + result_photos (rule 85)
│ └── APIModels.swift # All Codable/Sendable response DTOs
│ # APIAssignedIssue has photoPath + mobilePhotoPaths + resultPhotos
│
├── Sync/
│ └── SyncManager.swift # @MainActor ObservableObject — NWPathMonitor, outbox queue
│ # pullReferenceData() deduplicates facilities by serverId
│ # pullAssignedIssues() reads photo_path + mobile_photo_paths only
│
├── Models/ # SwiftData @Model classes — NO other .swift files here
│ ├── LocalFacility.swift # @Attribute(.unique) serverId; projectId/projectName for contracts
│ ├── LocalArea.swift
│ ├── LocalTemplate.swift
│ ├── LocalInspection.swift
│ ├── LocalIssue.swift # photoLocalPathsJSON + photoServerPathsJSON (both JSON-encoded)
│ ├── PendingPhoto.swift
│ └── SyncQueueEntry.swift
│
├── Views/
│ ├── Auth/
│ │ └── LoginView.swift # Server picker (segmented) above login form
│ ├── Dashboard/
│ │ ├── DashboardView.swift # Sidebar + all detail views
│ │ │ # MyInspectionsView owns + button (not sidebar)
│ │ │ # IssuesListView owns + button → StandaloneIssueView
│ │ │ # RetryablePhotoView — AsyncImage with tap-to-retry
│ │ │ # IssueDetailView — pending shows local, synced shows server
│ │ ├── StartInspectionView.swift # Contract → Facility → Area cascade pickers
│ │ ├── ExecuteInspectionView.swift
│ │ ├── FlagIssueView.swift
│ │ └── FormFieldView.swift
│ └── Inspection/
│ └── InspectionHistoryView.swift # Uses RetryablePhotoView for server photos
│
└── Utils/
└── Constants.swift # ServerConfig (UserDefaults), ServerOption enum, Keychain keys
# Constants.baseURL is GONE — use ServerConfig.current everywhere
Critical: This project uses PBXFileSystemSynchronizedRootGroup (Xcode 26 folder sync). Xcode automatically compiles every .swift file in the folder tree. There is no explicit file list. A stray duplicate causes "Multiple commands produce" build errors.
4. App Entry Point & Lifecycle
JanitorialQCApp.swift
@main struct. Sets only SyncManager.shared.modelContext in the container callback. No async calls from there.
ContentView.swift
Startup sequence in .task {}:
.task {
await AuthManager.shared.restoreSession() // 1. auth first
SyncManager.shared.startMonitoring() // 2. monitor after auth resolves
if SyncManager.shared.isOnline && AuthManager.shared.isAuthenticated {
await SyncManager.shared.triggerSync() // 3. sync only if authenticated
}
}
The order is mandatory. startMonitoring() must not be called before restoreSession() completes — NWPathMonitor fires immediately, triggering triggerSync() before tokens exist.
5. Authentication
AuthManager
@MainActor class AuthManager: ObservableObject — singleton via AuthManager.shared.
Session restore: Validates stored token via GET /api/v1/auth/me. On notAuthenticated → clears Keychain. On other errors (network) → restores from Keychain and sets authenticated (offline launch).
Logout: Calls POST /api/v1/auth/logout best-effort, clears Keychain, sets isAuthenticated = false.
KeychainHelper
nonisolated static methods. All keys use kSecAttrAccessibleAfterFirstUnlock.
Stored keys (all prefixed com.jqc.): accessToken, refreshToken, userId, userRole, username, displayName.
6. SwiftData Models
Model Container Registration
LocalFacility.self, LocalArea.self, LocalTemplate.self,
LocalInspection.self, LocalIssue.self, LocalScheduledInspection.self,
PendingPhoto.self, SyncQueueEntry.self
SwiftData lightweight migration: New Bool fields require = false default — without it the app crashes on launch. New non-optional model properties need inline defaults too; new optional properties (e.g. the LocalIssue handler fields) are nil-safe. Exception: a @Attribute(.unique) key must have NO default (rule 63).
Model Reference
| Model | Role | Key fields |
|---|---|---|
LocalFacility |
Read-only cached facility reference | serverId (@Attribute(.unique)), name, projectId, projectName, areas (cascade) |
LocalArea |
Read-only cached area reference | serverId, facilityServerId, name, areaType |
LocalTemplate |
Cached template + raw JSON schema | serverId, formSchemaJSON, formSchema (computed) |
LocalInspection |
Inspector-authored inspection record | localId (UUID, unique), serverId, status, syncStatus, formDataJSON, followUpRequired, parentLocalId, parentServerId, scheduledInspectionServerId (Int?, inline default — links the submission to the schedule it fulfils), submitLatitude (Double?), submitLongitude (Double?) |
LocalIssue |
Issue record | localId (UUID, unique), serverId, inspectionLocalId ("" for standalone/server-pulled), facilityServerId, severity, syncStatus, photoLocalPathsJSON, photoServerPathsJSON, handler fields (handlerType, handlerLabel, facilityHandler*, vendor* — all optional, synced from server, inspector-editable) |
LocalScheduledInspection |
Read-only cached scheduled/recurring assignment (phase36) | serverId (@Attribute(.unique), no default — rule 63), facilityServerId, facilityName, templateServerId, templateName, inspectorId, frequency, frequencyLabel, dueDateString (sort key), isOverdue, nextDue (computed), parentInspectionServerId (Int?, phase45 — set when the schedule is a planned follow-up; becomes the run's parentServerId). Pulled by pullScheduledInspections(); init(from:)/update(from:) like LocalFacility |
LocalFollowUpRequest |
Read-only cached follow-up request raised on the web (July 2026) | serverId (@Attribute(.unique), no default — rule 63; this is the flagged parent inspection's id and the parentServerId the re-inspection links to), facilityServerId, facilityName, templateServerId, templateName, overallScore, inspectionDateString (sort key), followUpNote, note (computed, trimmed/nil-ed), inspectedOn (computed, parses the yyyy-MM-dd prefix only — see the file comment), fulfilledLocally (= false, rule 71), parentFormDataJSON (= "{}", the parent's answers cached for re-inspection prefill — rule 79), parentFormData (computed). Pulled by pullFollowUpRequests() |
PendingPhoto |
Photo awaiting upload | localId, localFilePath, serverPath, uploadStatus, uploadRetryCount (Int = 0, rule 83 — the row stays "pending" until it hits 5), entityType ("issue" or "inspection"), fieldId |
SyncQueueEntry |
Outbox entry (informational) | entityType, localId, syncStatus, payloadJSON |
LocalInspection Status Flow
"draft" → "completed" → "synced"
→ "failed" (after 5 retries)
status = inspector workflow state. syncStatus = server submission state ("pending" | "synced" | "failed").
7. Offline-First Architecture
Inspector action → SwiftData write (always succeeds immediately)
↓
syncStatus = "pending"
↓
NWPathMonitor detects connectivity
↓
SyncManager.triggerSync()
1. Upload pending photos (processPhotoQueue)
2. Submit completed inspections (processInspectionQueue)
3. Submit pending issues (processIssueQueue)
4. Pull fresh reference data (pullReferenceData)
5. Pull + reconcile assigned issues (pullAssignedIssues)
6. Poll server notifications (pollNotifications)
8. Sync Engine (SyncManager)
@MainActor class SyncManager: ObservableObject — singleton via SyncManager.shared.
triggerSync() guard
guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated else { return }
triggerSync() — processing order
-
processPhotoQueue— uploads allPendingPhotowithuploadStatus == "pending". On success: propagatesserverPathtoLocalInspection.formData[fieldId](inspection image fields) or appends toLocalIssue.photoServerPaths(issue photos). Fetch-all + filter in Swift — no#Predicate. -
processInspectionQueue— submits completed inspections when allpendingPhotosare settled. -
processIssueQueue— guards against submitting when parent inspectionsyncStatus == "failed", and waits for the issue's own photos to settle (same rule as inspections — see rule 83).submitIssue()sends every evidence photo in the one create request (rule 85). After successful submit: setssyncStatus = "synced"and clearsphotoLocalPaths = []only when every photo uploaded (rule 84). -
pullReferenceData— fetches facilities, areas, templates. Deduplicates facility response byidusingseenFacilityIds = Set<Int>()before upserting — prevents duplicate buildings in pickers when server returns same facility ID multiple times.Prunes facilities the server no longer returns (Aug 2026).
/api/v1/facilitiesis already scoped server-side, but cached rows were never removed, so a facility survived locally after the inspector's contract was unassigned, after it was deactivated, or after a different user signed in on the same iPad. Every picker derives its contract list from these rows (StartInspectionView.contracts,IssuesView.contractsboth map overLocalFacility), so one stale facility kept a whole contract in the Start Inspection picker forever — which is how this surfaced. Templates already had this prune; facilities were the gap.Two rules in the prune:
- Deletion only runs after both requests succeeded, so a failed sync can never empty the cache (it throws first).
- A facility still referenced by unsynced local work (a
LocalInspectionorLocalIssuewithsyncStatus != "synced") is kept but markedisActive = falseinstead of deleted.ExecuteInspectionView/MyInspectionsViewresolve the facility name byserverIdand the issue detail view has nofacilityNameCachefallback, so deleting it would turn an in-progress draft into "Unknown Facility". The row is pruned on a later sync once that work has been submitted, andupdate(from:)flipsisActiveback to true if the facility returns to scope.
Every picker must therefore filter on
isActive— both views exposeavailableFacilitiesfor this and derivecontracts/filteredFacilitiesfrom it, never from the raw@Query. A retained out-of-scope row is for display only; offering it would let an inspector start work the server then rejects. -
pullAssignedIssues— fetchesGET /api/v1/issues. Mergesapi.photoPath+api.mobilePhotoPathsintophotoServerPaths. Does NOT includeapi.resultPhotos— resolution photos are web-only. Deletion pass runs always (not short-circuited on empty response). -
pullFollowUpRequests— fetchesGET /api/v1/inspections?follow_up_required=true. UpsertsLocalFollowUpRequestbyserverId, deletes rows the server no longer returns, and mirrorsfollowUpRequired/followUpNoteonto the matchingLocalInspectionso the history badge agrees with the card. Best-effort — never blocks the pipeline. Runs afterprocessInspectionQueue, so the section clears on the same sync that submits the re-inspection. -
pollNotifications— fetches new notifications sincelastNotificationFetchcursor.
Server-pulled issue identification
Records inserted by pullAssignedIssues are identified by: syncStatus == "synced" AND inspectionLocalId == "". These are the only records safe to delete during reconciliation.
purgeSessionScopedData() — on identity change
Replaces clearServerPulledData(), which deleted LocalIssue only and was called
from the wrong place. See rule 88.
Trigger is a change of SessionScope — the (server, userId) pair the database is
scoped to — not logout. AuthManager.reconcileSessionScope() compares the incoming
session against the recorded scope on every login() and restoreSession(), and purges
only when they differ.
| Model | Kept |
|---|---|
LocalFacility / LocalArea / LocalTemplate / LocalScheduledInspection / LocalFollowUpRequest |
Nothing — pure caches, re-pulled on the next sync |
LocalIssue |
Nothing — the model has no author field, so an unsent issue cannot be attributed and must not be submitted under a different inspector's name |
LocalInspection |
Only rows whose inspectorUserId matches the incoming user, and only when the server is unchanged |
PendingPhoto |
Only rows belonging to a kept inspection; the JPEGs of the rest are deleted from disk too |
A plain logout still purges nothing: the same inspector signing back into the same server keeps their cache and stays usable offline. That was always the right call — the defect was that nothing checked whether the next sign-in was the same person.
SessionScope.stored == nil adopts the existing data rather than purging. A fresh
install and an upgrade from a build without the marker are indistinguishable, and guessing
"purge" would delete an in-progress draft belonging to the person signing in right then.
Every identity change after that first login is covered.
Notification polling
startPollTask()creates aTaskwithTask.sleep(nanoseconds: 60_000_000_000)loop.Timer.scheduledTimeris banned — insideTask { @MainActor },RunLoop.current ≠ RunLoop.main; timer fires never.resetNotificationPoller()cancels task + clears cursor. Call on logout.
Fetch pattern — CRITICAL for Xcode 26
Never use #Predicate in SyncManager. Use fetch-all + filter in Swift. Always parenthesise try? before ??:
// CORRECT
let all = (try? context.fetch(FetchDescriptor<LocalIssue>())) ?? []
let x = all.filter { ... }
Role gates — Constants.Roles (Aug 2026)
Never write role == "inspector" in a view. external_inspector ("Customer Inspector" — an inspector employed by the customer) has the same powers as our own inspector and the API scopes it identically, so a literal equality check locks that account out of actions the server would happily accept. It fails silently: no error, no 403 to debug — the control simply is not drawn.
That is exactly what happened to Update Status, Handled By and Start Follow-up, which were three separate hand-written lists in two files:
| Site | Was | Now |
|---|---|---|
IssuesView.canUpdateStatus |
admin | director | inspector |
Constants.Roles.issueActors |
IssuesView.canEditHandler |
admin | director | inspector | project_manager |
same |
InspectionHistoryView.canStartFollowUp |
admin | director | inspector | project_manager |
same |
Constants.Roles in Utils/Constants.swift is the single definition, mirroring User.INSPECTOR_ROLES / User.is_inspector on the server (server rule 87):
inspectorRoles={inspector, external_inspector}— test membership, never==.issueActors={admin, director, project_manager} ∪ inspectorRoles— a subset of the API's_ALLOWED_ROLESfor these endpoints, so every role it admits is one the server accepts.auditoris deliberately excluded (read-only in the app).
The server stays the authority and additionally enforces facility scope; these gates only decide whether to draw the control.
9. API Client (APIClient)
actor APIClient — singleton via APIClient.shared. All methods are async throws.
All server URLs built as: ServerConfig.current + endpoint — Constants.baseURL no longer exists.
JSON decoding
decoder.keyDecodingStrategy = .convertFromSnakeCase — snake_case server fields map to camelCase automatically. Server POST body keys are snake_case (photo_path, result_photos, facility_id, etc.).
decode() reads the envelope header before the payload. _EnvelopeMeta (ok + error) is decoded first; only if ok is true is the payload decoded strictly via _EnvelopePayload<T>. This separates "the server reported a failure" from "the server succeeded and we could not read it" — previously data was decoded with try?, so any schema drift produced data == nil and surfaced as serverError("Unknown server error"), sending every investigation to the backend for what was a client-side contract mismatch. Failures now report the offending field (missing field 'x' in APIFoo.bar) via describe(_:as:), because DecodingError.localizedDescription is always the useless "data couldn't be read" string.
Token refresh is coalesced (refreshTask). Being an actor is not sufficient: refreshAccessToken() suspends at await, releasing the actor, so two requests 401-ing at once each POSTed /auth/refresh with the same refresh token. The server rotates on the first, so the second presented a spent token, failed, and signed the user out mid-sync — reachable because pollNotifications and registerDevice run alongside triggerSync. Concurrent callers now await one shared Task.
Key methods
| Method | Endpoint | Notes |
|---|---|---|
request<T> |
Any | Generic; 401 auto-refresh once (refresh is coalesced — see above) |
post<T> |
Any | POST convenience |
uploadPhoto |
POST /api/v1/photos/upload |
Multipart form-data; entity_type="issue" → uploads/issue_photos/ |
submitInspection |
POST /api/v1/inspections |
Sanitises local:// paths |
submitIssue |
POST /api/v1/issues |
Sends photo_path = first server photo only |
updateIssuePhotos |
PATCH /api/v1/issues/<id>/photos |
Recovery only — called from pushLateIssuePhotoIfNeeded() for a photo that succeeded after its issue was already created. The normal path sends every evidence photo inside submitIssue's create request (rule 85); do not call this from it. Merges idempotently into mobile_photo_paths. |
fetchAssignedIssues |
GET /api/v1/issues |
Returns issues assigned to OR reported by current user |
fetchIssueDetail |
GET /api/v1/issues/<id> |
Fetches current status |
updateIssueStatus |
PATCH /api/v1/issues/<id>/status |
Inspector updates status |
fetchNotifications |
GET /api/v1/notifications |
Optional since: Date cursor |
markNotificationsRead |
PATCH /api/v1/notifications/mark-read |
Marks IDs read on server |
fetchScheduledInspections |
GET /api/v1/scheduled-inspections |
Active scheduled assignments; inspector-scoped server-side. Pulled into LocalScheduledInspection |
createScheduledFollowUp |
POST /api/v1/scheduled-inspections/follow-up |
Plans a follow-up re-inspection for a later date (phase45). Body is only parent_inspection_id + due_date (yyyy-MM-dd) + optional notes — the server derives facility/template/assignee from the parent. Idempotent: a retry re-dates the existing active follow-up. Inspector-writable (deliberate divergence — the web is @project_manager_required). Online-only; see rule 80 |
fetchFollowUpRequests |
GET /api/v1/inspections?follow_up_required=true&limit=200 |
Outstanding follow-up requests; same response shape as fetchInspectionHistory. Limit is the endpoint max on purpose — a follow-up raised on a months-old inspection must still appear. Pulled into LocalFollowUpRequest. See rule 78 for what the server-side filter must mean |
updateIssueHandler |
PATCH /api/v1/issues/<id>/handler |
Sets "Handled By". Body ["handler_type": …] + optional snake_case detail keys (rule 65). Inspector-writable (server scopes by facility) |
APIAssignedIssue fields
let photoPath: String? // primary evidence photo
let mobilePhotoPaths: [String] // extra evidence photos from iPad (mobile_photo_paths)
let resultPhotos: [String] // resolution photos — NOT stored in photoServerPaths on iPad
// Phase A:
let resultNotes: String? // resolution notes from web staff
let verifiedAt: String? // ISO 8601 — when fix was verified
let verificationNote: String? // verifier note
let reportedByName: String? // reporter display_name
// Phase E:
let areaName: String? // area the issue was flagged in
let assignedToName: String? // assigned user display_name
// Handler ("Handled By", July 2026):
let handlerType: String? // "internal" | "facility" | "vendor"
let handlerLabel: String? // human-readable label
let facilityHandlerName: String?, facilityHandlerContact: String?, facilityHandlerNotes: String?
let vendorName: String?, vendorContact: String?, vendorNotes: String?
SyncManager.pullAssignedIssues merges photoPath + mobilePhotoPaths into photoServerPaths. resultPhotos is decoded but intentionally excluded from photoServerPaths — resolution photos are web-only. All Phase A/E and handler optional fields are persisted to LocalIssue on both insert and update paths; refreshStatusFromServer() also refreshes the handler fields from APIIssueDetail (skipped while the inspector is mid-edit). The same handler fields exist on APIIssueDetail.
10. Navigation & View Hierarchy
JanitorialQCApp
└── ContentView (auth gate + startup lifecycle)
├── LoginView (unauthenticated)
│ └── Server picker (segmented: jqc Primary / jqc1 Secondary)
└── DashboardView (authenticated)
├── Sidebar (NavigationSplitView — no selection: binding)
│ ├── Dashboard → DashboardStatsView [default landing — KPI tiles]
│ ├── My Inspections → MyInspectionsView [+ button here, NOT in sidebar]
│ ├── Issues → IssuesListView [+ button → StandaloneIssueView; resolved excluded]
│ ├── Facilities → FacilitiesListView
│ ├── Pending Sync → SyncStatusView
│ ├── History → InspectionHistoryView
│ ├── Notifications → NotificationsView [red badge when unreadCount > 0]
│ └── Settings → SettingsView [server picker + logout alert]
└── (sidebar has NO + button)
NavigationSplitView constraint: init(selection:content:) unavailable on iPadOS 17. Use @State var selectedTab: SidebarTab with Button handlers. Never add a selection: binding.
Compact width takes a different tree entirely. That Button-driven sidebar cannot navigate once the split view collapses (rule 76), so DashboardView.body branches on horizontalSizeClass: regularBody is the NavigationSplitView above, compactBody is a NavigationStack whose rows are NavigationLinks. Both share sidebarRowLabel(_:tinted:) and detailRoot(for:) — the latter returns destination content without a NavigationStack wrapper so the call site can supply one (iPad) or push it (iPhone). Add new destinations to sidebarTabs + both helpers, never to one body only.
+ button placement: The new inspection + button lives on MyInspectionsView (not the sidebar) so it remains accessible when the sidebar is collapsed. The new issue + button lives on IssuesListView.
11. Inspection Workflow
1. Start (StartInspectionView)
Contract → Facility cascade pickers (same as StandaloneIssueView). onChange(of: selectedProjectId) guards against resetting pre-filled facility — checks facilityBelongsToContract before clearing selectedFacilityId.
2. Execute (ExecuteInspectionView)
- Renders 12-column grid form. Auto-saves every 30 seconds.
- Submit: computes score, sets
status = "completed",syncStatus = "pending", callsclearParentFollowUpFlag(), triggers sync. - Shows success banner 2.5 seconds then dismisses.
GPS capture (Phase 25): InspectionLocationManager (thin CLLocationManager wrapper defined at the bottom of ExecuteInspectionView.swift) begins acquiring a fix the moment the submit confirm dialog appears. On successful fix, inspection.submitLatitude and inspection.submitLongitude are set before the inspection is marked completed. GPS is best-effort — nil on permission denial or location failure; submission still proceeds normally.
APIClient.submitInspection sends submit_latitude / submit_longitude only when non-nil. The PATCH endpoint does not accept GPS fields — creation-time (POST) capture only. The server displays a Google Maps embed in inspections/view.html for admin/director when both fields are present.
Scheduled inspections (phase36, July 2026)
ScheduledInspectionsView.swift holds ScheduledRow + ScheduledInspectionsCard. The card renders on the Dashboard (DashboardStatsView); MyInspectionsView renders its own inline "Scheduled" List section reusing ScheduledRow. Both query LocalScheduledInspection (sorted by dueDateString), self-hide when empty, and tap-to-Start opens StartInspectionView(preFillTemplateId:preFillFacilityId:preFillScheduleId:). Data is pulled read-only by pullScheduledInspections() (see rules 63–64 for the model + cover pitfalls hit while building it).
Fulfilling the schedule (July 2026 fix). preFillScheduleId is the schedule's serverId; startInspection() copies it onto LocalInspection.scheduledInspectionServerId, and submitInspection() sends it as scheduled_inspection_id. The server then fulfils the schedule (one-time → deactivated, recurring → rolled forward) in the same commit as the inspection.
Without it — the original bug — the schedule was never fulfilled: the banner stayed on the inspector's Dashboard and My Inspections, it stayed on the web dashboard for admin/director, and the web inspection list showed no "Scheduled" badge. Both Start call sites must pass preFillScheduleId (ScheduledInspectionsCard and the MyInspectionsView inline section); re-inspection launches correctly leave it nil.
No SyncManager change was needed: pullScheduledInspections() already runs after processInspectionQueue() in the same triggerSync() pass and deletes rows the server no longer returns, so the section clears on the same sync that submits the inspection.
Second fix (July 2026) — the + path and the vanishing cover. The above only covers inspections started from a Scheduled row. The iPad's + button reaches the identical form with the facility and template hand-picked, and that path leaves scheduledInspectionServerId nil — the server stores scheduled_inspection_id = NULL, never calls _fulfill_schedule(), and the schedule stays Active on the web. On the web this cannot happen: scheduled_inspections.start is the only way in. ExecuteInspectionView.resolveAndFulfillSchedule() (called from submitInspection(), before context.save()) restores parity — see rule 69 for the match criteria and why the cached row is deleted rather than rolled forward.
Correction (July 2026). That first cut deleted the cached row at submit. It shipped, and recurring schedules then stopped picking up their new due date — see rule 71. resolveAndFulfillSchedule() now sets fulfilledLocally instead, and both scheduled lists filter on it. The cover-ownership work below still stands: pullScheduledInspections() continues to delete rows for one-time schedules, so consumers must still hold value snapshots rather than the model.
Flagging that row at submit time exposed a second problem: ScheduledInspectionsCard self-hid the moment its @Query emptied, tearing down the .fullScreenCover it owned — with the inspector's form inside it. Cover ownership therefore moved to the parents (DashboardStatsView's ScrollView, MyInspectionsView's Group) and the cover item became the value type ScheduledStartTarget. See rule 68.
Instructions (July 2026). The manager-authored text on a schedule is labelled "Instructions" everywhere the user sees it, but remains notes on the wire, in APIScheduledInspection, and in LocalScheduledInspection. LocalScheduledInspection.instructions is a computed accessor over notes that trims and nils-out blank text — no stored property, so no schema change and no migration. Three surfaces: a one-line preview on ScheduledRow, a full Section at the top of StartInspectionView (passed in as preFillScheduleInstructions via ScheduledStartTarget.instructions), and a collapsible banner above the form in ExecuteInspectionView (looked up from SwiftData, so it also works on the draft-resume path where no parameter is threaded). See rule 70.
Inspection-start presentation (July 2026)
All start flows are full-screen for consistency (rule 66): draft-resume (dashboard) → .fullScreenCover → ExecuteInspectionView(isModallyPresented: true) with a leading Close; scheduled / new (+) / re-inspection → .fullScreenCover → StartInspectionView (its own Cancel). Pushed presentations (My Inspections row → ExecuteInspectionView) keep isModallyPresented = false and rely on the nav back button.
Leaving after submit — ExecuteInspectionView.onFinished. StartInspectionView pushes the form onto the NavigationStack inside its own cover, so dismiss() there only pops: the inspector finished an inspection and landed back on the "New Inspection" form that started it, with Cancel as the only way out. StartInspectionView passes its own dismiss as onFinished so the whole cover closes. Left nil everywhere popping is correct — the My Inspections row (pushed onto the list's stack) and the dashboard Resume banner (this view is the cover root).
"Handled By" (issue handler, phase35 → mobile July 2026)
IssueDetailView (IssuesView.swift) shows a "Handled By" section: current handler label + detail, and — for admin/director/PM and the assigned inspector — an inline editor (segmented internal/facility/vendor + name/contact/notes) that PATCHes via updateIssueHandler and mirrors the result onto LocalIssue. The inspector-writable path is a deliberate divergence from the web form (web CLAUDE.md rule 78).
3. Draft management
Swipe-left delete (confirmation required). Deletes draft + PendingPhoto records + local photo files + associated LocalIssue records. Only status == "draft" inspections may be deleted.
12. Form Field Rendering
GridFormView — 12-column absolute-position grid
| Constant | Value |
|---|---|
totalColumns |
12 |
cellGap |
8 pt |
rowGap |
4 pt |
cellAspect |
52/72 |
cardPadding |
16 pt |
Field ID rule (CRITICAL)
Form schema IDs arrive as Int in [String: Any]. Always cast:
let fid: String
if let s = field["id"] as? String { fid = s }
else if let n = field["id"] as? Int { fid = String(n) }
else { continue }
Never use Optional.map on field["id"] — produces "Optional(5)" instead of "5".
13. Issue Flagging Workflow
FlagIssueView — presented as sheet from ExecuteInspectionView. Sets inspectionLocalId = inspection.localId, areaServerId = inspection.areaServerId, and appends to inspection.localIssues. Creates PendingPhoto with entityType = "issue". areaServerId is sent as area_id in submitIssue().
Issue sync guard: If parent inspection.syncStatus == "failed", issue is immediately marked "failed" — prevents orphaned server records.
14. Standalone Issue Creation
StandaloneIssueView — presented as sheet from IssuesListView via + button.
- Contract picker →
Pickerwith.navigationLinkstyle.onChangeresetsselectedFacilityIdunless the current facility already belongs to the new contract. - Facility picker — gated: only appears after a contract is selected. Shows only facilities for the selected
projectId. - Severity segmented picker + description + up to 5 photos (camera + library).
- On submit: creates
LocalIssuewithinspectionLocalId = ""(same as server-pulled issues).processIssueQueuepicks it up and submits — the parent guard (parent?.syncStatus == "failed") evaluates tofalsefor""inspectionLocalIdso it proceeds normally.
Contract/facility data source: @Query(sort: \LocalFacility.name) — same cached reference data as StartInspectionView. contracts computed var deduplicates by projectId using var seen = Set<Int>(). filteredFacilities deduplicates by serverId using the same pattern.
15. Re-inspection Workflow
Trigger
CompletedInspectionView shows orange banner with Start Re-inspection when followUpRequired == true. Opens StartInspectionView with preFillTemplateId, preFillFacilityId, parentServerId, parentLocalId.
The Follow-up Requested card / section (July 2026) is the second trigger, and reaches the same view with parentServerId plus preFillFollowUpNote and preFillParentFormDataJSON.
History detail (HistoryDetailView) is the third and fourth: a Re-inspect Now toolbar button (immediate, passing this response's own answers as preFillParentFormDataJSON — history is served from the API, so the parent is usually not local) and Schedule Follow-up, which plans it for a later date via createScheduledFollowUp. The scheduled row then starts as a linked re-inspection because it carries parentInspectionServerId. See rule 80.
Parent pre-fill — two sources
StartInspectionView.startInspection() copies the parent's answers forward, excluding rating, pass_fail, image, signature so every scoreable item is re-evaluated fresh and the parent's photos stay with the parent. This mirrors the web's inspections.execute prefill.
resolvedParentFormData() resolves the source in order:
- The local
LocalInspectionwith a matchingserverId— theCompletedInspectionViewpath, where the inspector just finished it on this iPad. Used only when it actually holds values, so an empty local shell can't shadow source 2. preFillParentFormDataJSON— snapshotted from the server ontoLocalFollowUpRequest.parentFormDataJSONat pull time. This is the follow-up-request path. See rule 79.
followUpRequired clearing — three points
- Immediately on Submit in
ExecuteInspectionView.submitInspection()viaclearParentFollowUpFlag(). - After sync in
SyncManager.processInspectionQueue. - On the server when PATCH/POST arrives.
16. Inspection History
InspectionHistoryView — online-only. Fetches GET /api/v1/inspections?status=completed. Pagination: limit 30, offset-based. Uses RetryablePhotoView for server photos.
17. Photo Handling
Capture
UIImagePickerController (camera) or PHPickerViewController (library, no permission required iOS 16+).
Local storage
Documents/JQC/Photos/<UUID>.jpg at JPEG quality 0.8.
PendingPhoto lifecycle
Created (uploadStatus="pending", uploadRetryCount=0)
↓ SyncManager.processPhotoQueue()
├─ success → uploadStatus="uploaded", serverPath set
│ ↓ Parent record updated (attachServerPath)
│ Inspection image fields: LocalInspection.formData[fieldId] = serverPath
│ Issues: LocalIssue.photoServerPaths.append(serverPath)
└─ error → uploadRetryCount += 1, STAYS "pending" (retried next sync)
└─ only at maxPhotoUploadAttempts (5) → uploadStatus="failed"
"failed" is terminal and means "every attempt was used", not "one error happened" — see rule 83. SyncStatusView → Retry Failed Items resets these back to "pending"; it is the only thing that does.
Multi-photo issue submission sequence
1. processPhotoQueue: uploads all N photos → appends each serverPath to issue.photoServerPaths
(a shared local file is uploaded ONCE; every PendingPhoto row
pointing at it gets the same serverPath — rule 86)
2. processIssueQueue: waits until all N photos are "uploaded" or "failed" ← rule 83
submitIssue(issue) → photo_path = photoServerPaths[0]
result_photos = the rest ← rule 85
(server stores these in mobile_photo_paths)
issue.photoLocalPaths = [] ONLY if all N uploaded ← rule 84
One request, not two. There is no post-create PATCH on the normal path — see rule 85. Two related calls are NOT exceptions to that:
pushLateIssuePhotoIfNeeded()fires only whenissue.serverIdis already set, i.e. a photo recovered after the issue was created (rule 83's residual case).PATCH /issues/<id>/result_photosisIssueDetailViewattaching resolution photos, which genuinely are added after the fact.
Photo display in IssueDetailView
Gated on syncStatus:
syncStatus != "synced"(pending/failed): ShowsphotoLocalPathssection only — local files from disk viaUIImage(contentsOfFile:).photoServerPathsmay be partially populated from mid-sync uploads; hiding it prevents a mix of working and broken images.syncStatus == "synced": ShowsphotoServerPathssection only viaRetryablePhotoView.photoLocalPathshas been cleared byprocessIssueQueue.
Server photo URL construction
All server photo URLs: ServerConfig.current + "/static/" + relativePath
The server stores photos under app/static/uploads/ and serves them via Flask's static handler at /static/. The /static/ prefix is required — omitting it returns 404.
RetryablePhotoView
struct RetryablePhotoView: View defined in DashboardView.swift, used by both IssueDetailView and InspectionHistoryView.
- Holds
@State private var reloadToken = UUID(). AsyncImagecarries.id(reloadToken)— toggling the UUID forces SwiftUI to destroy and recreate theAsyncImage, triggering a fresh network request.- On
.failure: shows "Photo unavailable" label + Retry button that setsreloadToken = UUID(). AsyncImagehas no built-in retry. Once it enters.failure(transient network error, SSL hiccup) it stays there for the view's lifetime.RetryablePhotoViewis the fix.- Uses
transaction: Transaction(animation: .easeIn)for fade-in on success.
SF Symbol availability
Only use symbols available on iOS 17+. Known unavailable symbols:
photo.slash— BANNED, does not exist on all iOS 17 devices. Useexclamationmark.triangle.photo.badge.exclamationmark— introduced iOS 16 but not universally available. Useexclamationmark.triangle.
local:// sentinel
While a photo is pending upload, the inspection form field value is "local://<path>". Before submitInspection sends formData, these are replaced with "". A local:// value reaching the server is a malformed path.
18. Score Calculation
LocalInspection.computeScore(fromSchema:) mirrors Python's _compute_score_from_form().
Scoreable: rating, checkbox, radio, pass_fail.
| Type | Rule |
|---|---|
rating |
0 = unanswered → excluded. Each answered rating: value / 5 of 1.0. The denominator is a flat 5, never the field's max — _compute_score_from_form() hardcodes it, so anything reading max disagrees with the score the server stores |
checkbox |
"true" = pass |
radio |
Pass: pass, yes, ok, good, acceptable, compliant (case-insensitive) |
pass_fail |
Same keywords. Empty = unanswered → excluded |
Returns nil if no scoreable fields or all unanswered.
Two implementations must agree. ExecuteInspectionView.liveScore recomputes the same thing from in-memory formValues to drive the toolbar badge as the inspector fills the form. It read field["max"] ?? 5 for the rating denominator while computeScore hardcoded 5, so any template with max != 5 showed one percentage in the toolbar and submitted another. Change both together, and check _compute_score_from_form() in app/routes/inspections.py — it is the authority.
19. Background Sync
Registers BGProcessingTask with identifier com.jqc.sync. Info.plist requirement: BGTaskSchedulerPermittedIdentifiers must contain com.jqc.sync. Project uses GENERATE_INFOPLIST_FILE = YES — no physical Info.plist in source tree.
Requirements: requiresNetworkConnectivity = true, requiresExternalPower = false.
20. Settings & Cache Management
SettingsView exposes:
- Account info — username, role (read-only).
- Sync Now — triggers
triggerSync(); disabled when offline or syncing. - Clear Reference Cache — deletes
LocalFacility,LocalArea,LocalTemplateonly. Never touchesLocalInspection,LocalIssue,PendingPhoto. TriggerspullReferenceData()if online. - Server picker — see §21.
- Log Out — calls
resetNotificationPoller()+auth.logout(). Purges nothing: the cache stays so the same inspector can work offline after signing back in. A different inspector signing in is handled at login byreconcileSessionScope()(rule 88). - App version + current server URL (from
ServerConfig.current).
21. Server Selection
Architecture
Server is selected at runtime from two options. Selection persists to UserDefaults (not Keychain — not a secret).
enum ServerOption: String, CaseIterable, Sendable {
case primary = "https://jqc.ltservicesinc.com" // default
case secondary = "https://jqc1.ltservicesinc.com"
}
enum ServerConfig {
static var current: String { /* reads UserDefaults */ }
@MainActor static func select(_ option: ServerOption)
@MainActor static var selectedOption: ServerOption
}
Constants.baseURL no longer exists. All code that previously read Constants.baseURL now reads ServerConfig.current.
Login page
Segmented picker above the credential fields. onChange calls ServerConfig.select(newValue) immediately. The very next Sign In tap hits the selected server — no restart required when changing at login.
Settings page
Segmented picker in a "Server" section. onChange snaps the picker back to the current saved server, stores intent in pendingServer, and shows Alert("Switch Server?").
Alert actions:
- Switch & Log Out (destructive):
ServerConfig.select(chosen)→purgeSessionScopedData(keepingUserId: nil, sameServer: false)→SessionScope.clear()→resetNotificationPoller()→auth.logout(). Erases everything, unsynced work included — the alert says so. Previously this clearedLocalIssuealone and leftLocalInspectionrows holding the other server's facility/template ids. - Cancel: clears
pendingServer, picker stays on original.
Why logout is required on server switch: serverId values are server-specific. A LocalIssue with serverId = 48 from jqc.ltservicesinc.com has no meaning on jqc1.ltservicesinc.com. Keeping stale records causes "Issue not found" errors on every status fetch/update.
Scope boundary
There is no partial boundary any more. serverId is not the only server-specific value —
facilityServerId, templateServerId, areaServerId and parentServerId all name rows in
one particular database, and inspector facility scope differs per user on top of that. So a
scope change purges wholesale rather than filtering (rule 88); the only thing carried across
is the incoming user's own unsent LocalInspection rows, and only when the server is
unchanged. See §8, purgeSessionScopedData().
22. Known Constraints & Hard Rules
| # | Rule | Rationale |
|---|---|---|
| 1 | import Combine required in files using @Published |
Swift 5.9+ does not auto-import Combine |
| 2 | No selection: binding on NavigationSplitView |
init(selection:content:) unavailable on iPadOS 17 |
| 3 | No #Predicate anywhere in SyncManager |
Xcode 26 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor causes LocalInspection is ambiguous cascade |
| 4 | Sequential await in pullReferenceData() |
async let causes Swift 6 actor-isolation warnings on Decodable |
| 5 | PencilKit requires explicit framework linkage | Add PencilKit.framework under Target → Frameworks |
| 6 | Free Apple ID provisioning expires every 7 days | Rebuild with ⌘R while iPad is connected |
| 7 | kSecAttrAccessibleAfterFirstUnlock for all Keychain items |
Tokens readable after reboot for BGTaskScheduler |
| 8 | New Bool model fields require = false default |
SwiftData lightweight migration crashes without default |
| 9 | Field IDs in formData are always String keys | Server encodes as Int; cast via as? String then as? Int → String(n). Never Optional.map |
| 10 | Strip local:// paths from formData before submitInspection |
JSONSerialization silently drops non-serialisable values |
| 11 | Do not submit an issue when parent inspection syncStatus == "failed" |
Creates orphaned server records |
| 12 | Photo-before-inspection ordering in sync | processPhotoQueue runs before processInspectionQueue |
| 13 | com.jqc.sync must be in BGTaskSchedulerPermittedIdentifiers |
Silently ignored if absent |
| 14 | refreshAccessToken() uses a local JSONDecoder, not self.decoder |
Actor-isolation error in Swift 6 |
| 15 | clearParentFollowUpFlag() fallback-2 is ambiguous |
Matching by template+facility ambiguous with multiple follow-ups |
| 16 | SyncQueueEntry model registered but not actively written |
Future use; syncStatus on models is the active queue |
| 17 | All server URLs read from ServerConfig.current |
Constants.baseURL no longer exists. Every endpoint: ServerConfig.current + path. Never hardcode a server URL. |
| 18 | Photo JPEG compression is 0.8 | Do not raise above 0.85 without testing 50 MB server limit |
| 19 | clearCache() in Settings never deletes inspections or issues |
Only LocalFacility, LocalArea, LocalTemplate are safe to purge |
| 20 | AuthManager.restoreSession() falls back to Keychain on non-auth errors |
Allows offline launch |
| 21 | startMonitoring() must be called AFTER restoreSession() completes |
NWPathMonitor fires immediately; 401 loop leaves isLoading stuck |
| 22 | triggerSync() guards on AuthManager.shared.isAuthenticated |
Prevents sync before auth established |
| 23 | Do NOT place model files in non-Model folders | Xcode 26 folder sync compiles every .swift; duplicate causes build error |
| 24 | No physical Info.plist when GENERATE_INFOPLIST_FILE = YES |
"Multiple commands produce Info" build error |
| 25 | Always parenthesise try? before ?? |
try? fetch(...) ?? [] parses as try? (fetch() ?? []) — nonsensical |
| 26 | Delete Xcode's default Item.swift immediately after project creation |
Compiles silently via folder sync, conflicts with real models |
| 27 | Timer.scheduledTimer must NOT be used for periodic work in SyncManager |
RunLoop.current ≠ RunLoop.main inside Task { @MainActor }; timer fires never |
| 28 | UNUserNotificationCenterDelegate must be set for foreground notifications |
Without it, iOS drops local notifications while app is active |
| 29 | All Decodable & Sendable structs need nonisolated init(from:) |
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor taints synthesised Decodable inits |
| 30 | pullAssignedIssues deletion pass must NOT be short-circuited on empty response |
If server returns zero issues, deletion pass must still run |
| 31 | Server-pulled issues: syncStatus == "synced" + inspectionLocalId == "" |
Only these are safe to delete during reconciliation. Standalone device-created issues have inspectionLocalId == "" too but serverId == nil until synced — they survive. |
| 32 | StartInspectionView.onChange(of: selectedProjectId) guards pre-filled facility |
Check facilityBelongsToContract before clearing selectedFacilityId |
| 33 | photoServerPaths = photo_path + mobile_photo_paths only |
result_photos (resolution photos) is intentionally excluded from photoServerPaths on iPad |
| 34 | IssueDetailView photo display is gated on syncStatus |
Pending: show photoLocalPaths only. Synced: show photoServerPaths only. Never both simultaneously — mixed state causes "Photo unavailable" for mid-upload server paths |
| 35 | SyncManager.isoFormatter is the only date formatter — never allocate per-call |
DateFormatter init is expensive; nonisolated static let with en_US_POSIX locale |
| 36 | uploadPhoto(retrying:) — pass retrying: true on recursive retry |
Prevents double token refresh on 401 during retry |
| 37 | LocalIssue.inspection must declare explicit @Relationship inverse |
@Relationship(deleteRule: .nullify, inverse: \LocalInspection.localIssues) prevents implicit inverse ambiguity |
| 38 | APIUser.displayName uses fullName when non-empty |
fullName.isEmpty ? username : fullName — mirrors User.display_name server-side |
| 39 | Server photo URLs include /static/ prefix |
Server stores at app/static/uploads/; Flask serves at /static/uploads/. URL = ServerConfig.current + "/static/" + relativePath. Missing /static/ returns 404. |
| 40 | Use RetryablePhotoView for all server photo loads |
AsyncImage has no retry — once in .failure it stays there for the view's lifetime. RetryablePhotoView allows tap-to-retry by toggling .id(reloadToken). |
| 41 | Only use SF Symbols available on iOS 17 | photo.slash and photo.badge.exclamationmark are absent on some devices. Use exclamationmark.triangle for all photo-error states. |
| 42 | clearServerPulledData() boundary is serverId != nil |
Superseded by rule 88. The function is gone; purgeSessionScopedData() replaces it and no longer filters by serverId at all. The history is still worth knowing: the boundary was widened twice (from syncStatus == "synced" && inspectionLocalId == "" to serverId != nil) and was wrong both times, because the problem was never which issues to delete — it was that issues are not the only server-scoped model, and logout is not the moment that matters. |
| 43 | processIssueQueue clears photoLocalPaths after successful submit |
Prevents IssueDetailView from rendering a duplicate "local photos" section alongside the server photos section for synced issues. |
| 44 | StandaloneIssueView uses inspectionLocalId = "" |
Same pattern as server-pulled issues. processIssueQueue's parent-inspection guard evaluates parent?.syncStatus == "failed" → false for "", so standalone issues submit normally. |
| 45 | Facility lists deduplicate by serverId at both storage and display layers |
Storage: pullReferenceData() deduplicates server response before upsert. Display: filteredFacilities in both StartInspectionView and StandaloneIssueView uses filter { seen.insert($0.serverId).inserted }. |
| 46 | LocalIssue Phase A–E fields all use nil-default optionals |
String?, Date? optionals default to nil. SwiftData lightweight migration supports nil-default optional properties without a migration plan. |
| 47 | IssueDetailView comments block must be inside List { } |
.navigationTitle and .task must chain on the List view. Placing the if sync.isOnline { } comments block outside List causes Instance member ‘navigationTitle’ cannot be used on type ‘View’ build error. |
| 48 | IssuesListView filters resolved in Swift, not via #Predicate |
@Query returns allIssues; computed var issues filters $0.issueStatus != "resolved". #Predicate with string literals on LocalIssue is unreliable under Xcode 26 SWIFT_DEFAULT_ACTOR_ISOLATION (rule 3). |
| 49 | SyncManager.fetchDashboardStats() is best-effort — never blocks pipeline |
Called last in triggerSync(). Failure leaves dashboardStats nil; UI shows placeholder. Never propagates errors. |
| 50 | NotificationsView.markNotificationsViewed() on both .onAppear and sidebar tap |
Sidebar tap calls sync.markNotificationsViewed() inline to clear the badge immediately without waiting for navigation. |
| 51 | FlagIssueView.submitIssue() must copy inspection.areaServerId to issue.areaServerId |
Without this, the server cannot link the issue to the correct area. APIClient.submitIssue sends area_id only when issue.areaServerId is non-nil. |
| 52 | LocalIssue.swift zip delivery must include all Phase A–E fields |
When packaging, copy from the working-tree file and verify every field with grep before zipping. Partial field sets cause SyncManager build failures. The recurring hotfix pattern traces to this: each phase patched the working tree but the prior phase’s file was not in working tree. Fix: always cp back immediately after creating a phase file. |
| 53 | submitLatitude/submitLongitude on LocalInspection are Double? optionals |
SwiftData lightweight migration supports nil-default optionals without a migration plan. APIClient.submitInspection sends them only when non-nil via if let lat = inspection.submitLatitude. Never make them non-optional — GPS is best-effort and must not block submission on permission denial or hardware failure. |
| 54 | InspectionLocationManager.startUpdating() called when confirm dialog appears, not at view load |
Starting too early wastes battery. The confirm dialog provides a natural ~1–2 second window before the user taps Confirm, giving the manager time to acquire a fix. GPS captured into LocalInspection immediately before marking status = "completed". |
| 55 | StartInspectionView must filter templates in Swift, not via @Query predicate |
@Query(sort: \LocalTemplate.name) fetches all into allTemplates; computed var templates filters { $0.isActive }. #Predicate with isActive is unreliable under Xcode 26 rule 3. |
| 56 | LocalTemplate.isActive must have inline default = true |
Added in this session. SwiftData lightweight migration requires all new Bool fields to carry an inline default (rule 8). GET /api/v1/templates now only returns active templates; pullReferenceData deletes cached templates not in the server response so inactive ones never appear in pickers even offline. |
| 57 | upsertAreas must set newArea.facility = facility at insert time |
SwiftData relationship wiring requires the inverse to be explicitly assigned. Without newArea.facility = facility, LocalFacility.areas is always empty and the area picker shows nothing. The LocalFacility object (from facilityMap or just inserted) is passed into upsertAreas as a parameter. Also re-wires on update: if ex.facility == nil { ex.facility = facility }. |
| 58 | ReadOnlyGridFormView uses rowView (GeometryReader + ZStack), NOT a ZStack canvas or LazyVGrid |
ZStack canvas: gaps from unanswered rows because y-offsets are absolute. LazyVGrid: ignores col position, flows items sequentially. Correct approach: group fields by original row into RowGroups, render each group as a GeometryReader that divides width by 12 to get colW, positions each field with .offset(x: colW * (col-1)) and .frame(width: colW * colSpan). VStack(spacing: 3) between rows. Row height fixed at 36pt (section headers 28pt). |
| 59 | Read-only inspection detail: only answered fields are shown — filtering is 5-pass | Pass 1: collect answeredIds (rating > 0, or non-empty value). Pass 2: collect visibleLabelIds (labels immediately before an answered field). Pass 3: collect visibleSectionIds (sections with at least one answered field after them). Pass 4: group all schema fields by original row. Pass 5: for each row group, emit only visible fields; skip rows with no visible content. |
| 60 | ReadOnlyGridFormView rows advance by 1 regardless of original rowSpan |
The web renders every field with grid-row: N / span 1. The read-only view collapses all rowSpans to 1 — no field occupies more than one row of vertical space. |
| 62 | Sort schema by (row, col) before grouping fields into row buckets |
The form editor stores fields in creation/drag order, NOT row-numeric order. Section fields have their own row numbers but may appear anywhere in the JSON array. Any code that groups fields by row and attaches section headers must first sort by (f["row"], f["col"]) — exactly like the web's sorted(key=lambda f: (f['row'], f['col'])). Without this, sections attach to the wrong rows and appear displaced or missing. Sites that use absolute (col, row) pixel offsets (e.g. GridFormView ZStack, canvasHeight()) are unaffected — sort order only matters when grouping by row for sequential rendering. |
| 63 | @Attribute(.unique) must NOT carry an inline default value | A .unique key with a default (@Attribute(.unique) var serverId: Int = 0) stops the @Model macro from emitting a clean PersistentModel conformance. Symptom is misleading: the .modelContainer(for: [ … ]) array literal fails to type-check and Xcode reports "Cannot find '' in scope" on the other schema elements, not the offending one. Declare the unique key with no default (@Attribute(.unique) var serverId: Int) and set it in init, exactly like LocalFacility/LocalArea. (Bit us adding LocalScheduledInspection, July 2026.) |
| 64 | Attach .fullScreenCover / .sheet to a stable view, NEVER to a Section | A Section inside a List is recycled, so a presentation modifier attached to it silently never fires. Attach the cover to the enclosing List/ScrollView/VStack/Group root instead — and that root must also outlive the data that drives the presentation (see rule 68). The dashboard scheduled cover lives on DashboardStatsView's ScrollView; MyInspectionsView puts the scheduled "Start" cover on its enclosing Group. |
| 65 | CodingKeys stay plain camelCase (decoder uses .convertFromSnakeCase); request-body keys are raw snake_case | The shared JSONDecoder sets keyDecodingStrategy = .convertFromSnakeCase, converting JSON facility_handler_name → facilityHandlerName before matching — so CodingKeys must be bare camelCase; adding an explicit = "facility_handler_name" raw value double-converts and breaks decode. Conversely PATCH/POST bodies are [String: Any] encoded with JSONSerialization (no key strategy), so body keys must be the literal snake_case the server reads ("handler_type", "vendor_name", …). |
| 66 | Modal (fullScreenCover root) views need an explicit Close/Cancel; pushed views get the nav back button for free | ExecuteInspectionView takes isModallyPresented and shows a leading Close only when true (draft-resume from the dashboard is the root of its NavigationStack, no back button). All inspection-start flows now use .fullScreenCover for a consistent full-screen form: draft-resume → ExecuteInspectionView(isModallyPresented: true); scheduled/new/re-inspection → StartInspectionView (its own .cancellationAction Cancel). .onDisappear/auto-save preserves work, so Close is always safe. |
| 67 | Render server photos through ServerConfig.mediaURL(absolute:path:), never by hand-building current + "/static/" + path | After the R2 migration the server returns absolute display URLs (presigned R2, or absolute-static on the local backend): photo_urls/result_photo_urls on issues (APIAssignedIssue/APIIssueDetail → LocalIssue.photoServerUrls/resultPhotoServerUrls, parallel to the path arrays), and form_media {fieldId: url} on inspection detail (APIInspectionSummary.mediaURLByPath, injected into the read-only grid via the \.mediaURLByPath environment for PhotoThumbnailView). The resolver prefers the absolute URL and falls back to /static/ for older servers. Presigned URLs expire (24h) — always render from the freshest pull/detail fetch; don't persist a URL and reuse it days later. |
| 68 | A .fullScreenCover owner must outlive the rows that trigger it; carry a value snapshot, not the @Model object | ScheduledInspectionsCard self-hides on scheduled.isEmpty, and submitting the last scheduled inspection empties that @Query while the cover is still on screen — the card disappears and takes its cover (and the inspector's form) with it. Fix: the card is presentational and reports taps via onStart; DashboardStatsView owns the cover on its always-present ScrollView, MyInspectionsView on its Group. The cover item is ScheduledStartTarget (three plain Ints), never LocalScheduledInspection — reading a deleted PersistentModel traps. |
| 69 | ExecuteInspectionView.resolveAndFulfillSchedule() links the submission to its schedule and invalidates the cached row — it never computes the next due date | Two entry points reach the identical form (Scheduled row → prefilled; + → hand-picked), but only the first sets scheduledInspectionServerId, so a +-started inspection lands with scheduled_inspection_id = NULL and the schedule stays Active on the web. The fallback matches an active LocalScheduledInspection on facility + template, gated to dueDateString <= today, assignee nil-or-self, earliest due first, and skipped for re-inspections. Roll-forward stays server-side: the local model has no phase43 recurrence detail (weekdays / month_mode / day_of_month / nth_week / nth_weekday), so the row is flagged fulfilledLocally, never deleted (see rule 71), and pullScheduledInspections() writes the authoritative next_due_date via update(from:) — which also self-heals a submission that never lands. |
| 70 | Snapshot schedule instructions into @State in onAppear — never read them from SwiftData during body | ExecuteInspectionView shows the schedule's instructions above the form, but resolveAndFulfillSchedule() deletes that LocalScheduledInspection the instant Submit is tapped and the view stays up for another 2.5 s showing the success banner. A computed lookup would re-read a deleted PersistentModel in that window and trap. loadScheduleInstructions() copies the String once, at appear. Same reasoning as ScheduledStartTarget (rule 68): once the fulfilment path can delete a cached row mid-flow, every consumer must hold a value, not the model. |
| 71 | Never delete a cached row the server is going to send again — flag it | The first cut of resolveAndFulfillSchedule() deleted the LocalScheduledInspection at submit. One-time schedules were fine (the server deactivates them and never returns them again), but every recurring schedule is returned again on its next occurrence, so each completion became delete-then-reinsert against an @Attribute(.unique) serverId — and the reinserted row did not reliably carry the rolled-forward date. A daily schedule kept showing today's date after being completed. Fix: fulfilledLocally: Bool = false hides the row locally; init(from:)/update(from:) clear it, so the pull remains the only thing that ever writes a cached schedule's dates. Deletion of schedule rows now happens in exactly one place — the "server no longer returns it" branch of pullScheduledInspections(). |
| 72 | A toolbar Label needs .labelStyle(.titleAndIcon) or SwiftUI renders it icon-only | IssuesView's New Issue button was written as Label("New Issue", systemImage: "plus") and still appeared on the iPad as a bare "+" — SwiftUI decides toolbar label styling itself and drops the title. Having the text in the source is not enough; state the style explicitly. Both creation entry points (MyInspectionsView → New Inspection, IssuesView → New Issue) now pin .titleAndIcon alongside .borderedProminent. |
| 73 | Both background-sync Info.plist keys must live in Info.plist itself — INFOPLIST_KEY_* cannot express them | BGTaskSchedulerPermittedIdentifiers and UIBackgroundModes are both arrays. INFOPLIST_KEY_* build settings only merge Xcode's recognised key list and only as strings, so INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers = com.jqc.sync never produced a valid entry — it sat inert in the pbxproj while BGTaskScheduler.submit() failed with .notPermitted under a try?. Adding UIBackgroundModes then made App Store Connect check, and the upload was rejected with error 90771. Both keys now live in JanitorialQC/Info.plist as arrays and the build setting is deleted from both configurations. Do not reintroduce it: a build setting overwrites the file's value at merge time. Keep the identifier string in sync with BGTaskScheduler.register / BGProcessingTaskRequest in JanitorialQCApp. Verify a build before uploading: plutil -p <built .app>/Info.plist \| grep -A2 BGTask must show an array. |
| 74 | Two different background mechanisms — do not confuse them | BGProcessingTask (JanitorialQCApp) asks iOS to wake us later: opportunistic, typically charging + Wi-Fi + idle, not a heartbeat. beginBackgroundTask (SyncManager.beginSyncBackgroundTask) asks iOS not to suspend us right now: ~30 s, covers submit-then-lock. The expiration handler must end the assertion or iOS terminates the app, and it needs MainActor.assumeIsolated because the closure is nonisolated under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor. |
| 75 | A background launch has no ContentView and may have no ModelContainer | restoreSession() runs from ContentView.task{} and the container comes from the .modelContainer scene modifier — neither happens on a cold BGTaskScheduler launch, so triggerSync()'s isAuthenticated / modelContext guards silently no-op. handleBackgroundSync() now restores the session itself and logs-and-returns when there is no context. Background sync therefore covers the suspended-but-resident case; cold relaunch needs the container hoisted out of the scene modifier. |
| 76 | A collapsed NavigationSplitView shows only its sidebar — Button-driven rows navigate nowhere on iPhone | On compact width the split view collapses to a stack rooted at the sidebar, and the detail: column is presented only when something pushes it. Rule 2 forbids a selection: binding, so the rows are plain Buttons that mutate @State — and a state change alone cannot push the detail column. The app installs on iPhone (TARGETED_DEVICE_FAMILY = "1,2"), so every inspector on a phone got a list where tapping highlighted the row and opened nothing: Dashboard, Inspections, Issues, Settings were all unreachable. Verified in the simulator: setting selectedTab programmatically still rendered only the sidebar. DashboardView now branches on horizontalSizeClass and gives compact width a real NavigationStack with NavigationLink rows. Never "fix" this by adding a selection: binding — that breaks iPadOS 17 (rule 2). |
| 77 | The 12-column form grid is unusable below ~600 pt — reflow to one field per line, don't shrink it | GridFormView positions cells absolutely from cellW = (W - 32 - 88) / 12. At 375 pt (iPhone SE/6/7/8) that is a 21 pt column and a 15 pt row, so an ordinary 6x2 field renders ~167x35 pt — less than the label needs. Cells are deliberately unclipped (matching the web's overflow: visible), so the excess draws on top of the row below and the form becomes an unreadable pile of overlapping controls. Below GridFormView.minGridWidth (600 pt, keeping a column at >=40 pt) the view switches to stackedLayout: fields sorted by (row, col) (rule 62), one per line, full width, natural height. Widgets with no intrinsic height (textarea, signature, table, image) get floors from stackedMinH or they collapse to nothing. In the stacked branch the card must be a .background modifier, not a ZStack sibling — as a sibling the flexible RoundedRectangle competes with the VStack for the container's size and the card ends up shorter than its own content, cutting off the last fields. ReadOnlyGridFormView (history detail) has the same 12-column assumption and the same compact branch, keyed off horizontalSizeClass. |
| 78 | "Outstanding follow-up" is three conditions, not one — follow_up_required alone is not the definition | Every web surface (inspections.list / reports status_filter == 'follow_up', stats.pending_followups) means flagged AND status == 'completed' AND ~follow_ups.any(). The reason the third clause exists: the web execute route never clears follow_up_required on the parent — it only stops listing the parent once a child re-inspection exists. (The mobile POST path does clear the parent flag, app/api/inspections.py, so only web-completed re-inspections leave a stale flag.) The first cut of the API's ?follow_up_required=true filter matched the flag alone, which would have returned follow-ups already satisfied on the web — and on the iPad those rows are undismissable: pullFollowUpRequests() keeps receiving them, update(from:) deliberately resets fulfilledLocally = false (the server is authoritative), so FOLLOW-UP REQUESTED would never clear and the only way out is a duplicate re-inspection. Fixed server-side so one definition serves every client. Never re-narrow this filter to the bare flag, and never "fix" a stuck row on the client — fulfilledLocally is a display flag, not state. |
| 79 | A re-inspection's parent is usually NOT on the device — prefill must fall back to the cached snapshot, and never prefill without the template schema | startInspection()'s prefill originally matched only a local LocalInspection by serverId. That works for CompletedInspectionView (the inspector just finished it here) but not for a follow-up raised on the web: that parent synced long ago and is routinely absent (reinstall, second iPad, follow-up raised weeks later — the same premise LocalFollowUpRequest exists for). The lookup found nothing, the whole block silently no-opped, and the form opened blank where the web pre-fills it. Fix: LocalFollowUpRequest.parentFormDataJSON caches the parent's answers at pull time — free, because GET /api/v1/inspections already returns form_data on every row via _inspection_payload, so there is no extra request and prefill works offline. Store formDataRaw.mapValues(\.anyValue), not formValues: the latter joins arrays into "a, b", which would be written back as one bogus string. Second trap, only reachable once prefill actually runs: the exclude set is derived from the template schema, so an unresolved schema (?? []) yields no exclusions and copies everything — including the parent's image paths, attaching its photos as this inspection's evidence. Guard on !schema.isEmpty and copy nothing instead. |
| 80 | "Schedule Follow-up" is a server-side plan — it is the one action in the app that cannot work offline, and its link must survive the client forgetting it | History detail (HistoryDetailView) carries three toolbar actions: Re-inspect Now (immediate, opens the linked re-inspection), Schedule Follow-up (deferred), and the existing email button. Starting an inspection writes locally and syncs later, but scheduling writes a ScheduledInspection row that only the server can create — there is no local record to queue, so the button is .disabled(!sync.isOnline) and failures report inline instead of dismissing as though they worked. Do not "fix" this by faking a local schedule: pullScheduledInspections() deletes any row the server doesn't return, so it would vanish on the next sync. The link itself is scheduled_inspections.parent_inspection_id (phase45): both start paths inherit it onto the inspection (ScheduledStartTarget.parentServerId on iPad, the web's scheduled_inspections.start), and the API's create-inspection endpoint re-derives it from the schedule when the client sends none — a belt-and-braces step that matters because an older build or a resumed draft would otherwise submit a plain inspection and leave the parent flagged forever. Creation is inspector-writable, a deliberate divergence from the web's @project_manager_required, and the endpoint is deliberately narrow: it takes only a parent + date and derives facility/template/assignee, so a follow-up can only ever target the thing it follows up on. |
| 81 | Never commit a local-dev override — repointing ServerOption.primary at localhost took production login down | July 2026: primary was changed from https://jqc.ltservicesinc.com to http://127.0.0.1:5055 for local API work, with NSAllowsArbitraryLoads=true added to Info.plist for cleartext. Both shipped in dac7e6c, so the "Primary" entry in the server picker dialled a developer laptop and every inspector failed to log in — only the untouched secondary worked. Symptom in the device log is unmistakable and is not an auth problem: NSErrorFailingURLStringKey=http://127.0.0.1:5055/... with Connection refused [61]. Revert a dev override in the same session that adds it; being aware of it is not a safeguard. Prefer an override that cannot be committed — a debug-only scheme argument, an xcconfig, or #if DEBUG — over editing this shared production constant. One thing that saved us: ServerConfig.current validates the stored UserDefaults string through ServerOption(rawValue:) and falls back to .primary, so a stale localhost selection self-heals on update — keep that round-trip validation. ATS exceptions must be scoped to the host (NSExceptionDomains for 127.0.0.1/localhost); NSAllowsArbitraryLoads disables TLS validation for the production servers too and is an App Store review trigger. |
| 82 | A shared static read from APIClient (or any nonisolated context) must be declared nonisolated | SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor makes every type implicitly @MainActor, including a bare constants-holder enum. APIClient is an actor, so reading such a static from it warns "Main actor-isolated static property 'X' can not be referenced from a nonisolated context" — and that becomes a hard error under the Swift 6 language mode, so it will block a toolchain move. PhotoCaptureFormat.iso8601 hit this from the two captured_at multipart call sites. Mark the enclosing enum nonisolated, matching Constants, ServerConfig, PhotoCaptureFormat and SyncManager.isoFormatter (rule 35). Do not reach for nonisolated(unsafe) (used nowhere here — it hides the problem) or allocate a formatter per call (rule 35 exists because that cost is real on the upload/sync paths). Foundation formatters are thread-safe for formatting, so one shared instance is correct. |
| 83 | A photo upload error is TRANSIENT — leave the row "pending". "failed" means every attempt was used, and nothing else may set it | Aug 2026, the lost-photo defect. processPhotoQueue marked uploadStatus = "failed" on the first error; processInspectionQueue's photosReady accepted "failed" as settled and submitted anyway; APIClient.submitInspection rewrote the surviving local:// value to ""; the inspection was then marked synced forever. And nothing anywhere ever moved a row off "failed" — SyncStatusView.retryAllFailed() reset LocalInspection/LocalIssue only. One dropped connection therefore destroyed an evidence photo permanently and silently, with the sync reported as successful. Now: uploadRetryCount increments and the row stays "pending" until SyncManager.maxPhotoUploadAttempts (5), so the next sync retries it and the parent keeps waiting. The cost is that a completed inspection can sit in the outbox for a few sync cycles while a photo retries — that is the correct trade; submitting first is what caused the loss. retryAllFailed() now resets photos too, and is the only escape hatch from terminal "failed". PhotoDiagnosticView exists to size the damage already done and must stay read-only. |
| 84 | Clear LocalIssue.photoLocalPaths only when EVERY photo reached the server | The clear was unconditional after a successful submit, so a partial upload left the JPEGs on disk with nothing referencing them — invisible to IssueDetailView and to PhotoDiagnosticView alike. Keeping them costs a duplicate photo section in the detail view at worst (rule 34's cosmetic concern); dropping them costs the evidence. processIssueQueue now guards on issuePhotos.allSatisfy { $0.uploadStatus == "uploaded" }. |
| 85 | Send an issue's evidence photos IN the create request — never in a follow-up call after it is marked "synced" | submitIssue() sent photo_path only, then processIssueQueue fired PATCH /issues/<id>/photos for the rest with try? await. By then syncStatus == "synced", so processIssueQueue never revisited the issue: one failed PATCH silently cost every photo after the first, and the loss became invisible on device too once pullAssignedIssues overwrote photoServerPaths with the server's copy. The split was never necessary — POST /api/v1/issues already accepts result_photos and stores it in mobile_photo_paths (app/api/issues.py, create_issue), and processPhotoQueue fully populates photoServerPaths before processIssueQueue runs, so the extras were always known at create time. submitIssue() now sends photo_path + result_photos together: attachment is atomic with creation, there is no synced-but-unattached window to reconcile, and mobile_local_id idempotency covers retrying the whole request. The generalisation holds beyond photos — if a second call is needed after a record is marked synced, either fold it into the first or persist the debt; try? there means silent permanent loss. |
| 86 | Two PendingPhoto rows sharing a local file must both receive the uploaded serverPath | The de-dup pass marked the duplicates "uploaded" without ever setting serverPath, so the same image attached to two form fields submitted the second field blank. processPhotoQueue now uploads once and settles every row from a localFilePath -> serverPath map (a row whose twin failed stays "pending" so both retry together). Uploading once still matters independently: two uploads of one file yield two server filenames and duplicate the photo in the evidence and the PDF. |
| 87 | cleanupOrphanedPhotos() sweeps JQC/Photos only, references EVERY surviving local:// path, and never deletes a file younger than 7 days | It pointed at Documents/JQCPhotos, which no writer has ever used — contentsOfDirectory failed, the guard returned, and it silently deleted nothing for its entire life while photos accumulated. Correcting the path is only safe alongside rule 83, and only with the reference set widened: a local:// sentinel surviving on a submitted inspection means that photo never reached the server, so the file is the only copy left and is exactly what PhotoDiagnosticView reports as recoverable — the old draft-only filter would have deleted it. JQC/ResultPhotos is deliberately not swept: those files are staged in IssueDetailView's @State with no database row, so nothing can prove one is unused. The 7-day age floor covers that flow and the window between writing a JPEG and saving the record that points at it. |
| 88 | Local data is scoped to a (server, userId) pair — purge on an identity CHANGE, never on logout | Two defects, one cause. (a) Logout deliberately kept the cache so the same inspector could work offline after signing back in — correct — but nothing checked that the next sign-in was the same inspector. pullAssignedIssues' reconciliation only deletes rows with inspectionLocalId == "", so device-authored synced issues survived indefinitely and a different inspector on the same iPad simply inherited them. (b) The server switch cleared LocalIssue alone, leaving LocalInspection rows carrying facilityServerId/templateServerId values that name different rows on the server being switched to — ready to be submitted against it. SessionScope (UserDefaults, not Keychain — it must outlive KeychainHelper.clearAll()) records the pair; AuthManager.reconcileSessionScope() compares on every login()/restoreSession() and calls SyncManager.purgeSessionScopedData() only on a mismatch, before isAuthenticated flips so no view ever renders the previous user's data. LocalInspection is the only model with an author (inspectorUserId), so it is the only one whose unsent rows can be handed back; LocalIssue has none, and submitting one under a different inspector's credentials would put a false name on a QC record. A nil marker adopts the existing data rather than purging — fresh install and pre-marker upgrade are indistinguishable, and guessing wrong would delete the signing-in user's own draft. |
| 89 | Never raise a second alert from inside the first one's button action | Both alerts hang off the same view, so the new presentation is discarded while the first is still tearing down. ExecuteInspectionView's Submit set showNoGPSAlert = true from inside the confirm alert's action, and the warning simply never appeared — tapping Submit without a GPS fix did nothing at all: no alert, no submission, no feedback. Park the intent in a @State flag and act on it from onChange(of:) when the first alert's binding flips false, with a short hop so the dismissal animation has finished. Applies to .sheet/.confirmationDialog chained onto one view too. |
| 90 | date form fields are "yyyy-MM-dd", and an unanswered one must render as unanswered | Two defects in one widget, both in CellDatePicker and DateFieldView. (a) They stored ISO8601DateFormatter().string(...) — a full 2026-08-18T14:30:00Z timestamp — into a field the web writes with <input type="date"> and both the read-only grid and the PDF print verbatim. FormDateFormat (UTC + POSIX, yyyy-MM-dd) is now the single definition, and parses a leading date out of legacy timestamp values. (b) A DatePicker bound to an empty value still displays TODAY, so the field looked answered — but the setter only fires on a change, so selecting the already-shown date wrote nothing and missingRequiredFields() reported it missing with a date visible on screen. An explicit "Set date" affordance replaces the picker while the value is empty, plus an × to return to unanswered. |
23. Xcode 26 Specific Issues
This project was created with Xcode 26.4.1. See previous entries in §22 (rules 3, 4, 23, 25, 29) for the specific workarounds.
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
Every type and function is implicitly @MainActor. Effects on SwiftData FetchDescriptor and #Predicate: see §8 and rule 3.
Solutions applied:
- Fetch-all + filter in Swift — no
#Predicatewith captured variables. - All
try? context.fetch(...)parenthesised before??. - Chained optional patterns split into two
letstatements. - All API model structs declare
nonisolated init(from decoder: any Decoder)explicitly.
Do NOT remove SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor from build settings unless you audit every file for the resulting isolation changes.
PBXFileSystemSynchronizedRootGroup (Folder Sync)
Every .swift file in the project folder is compiled automatically. Stray files cause "Multiple commands produce" errors. Deleting from Finder is sufficient — no project navigator change needed.
Derived Data corruption
After "Multiple commands produce" errors: delete derived data manually:
rm -rf ~/Library/Developer/Xcode/DerivedData/<ProjectName>-<hash>
Do not rely on Product → Clean Build Folder alone.
24. Change Philosophy
- Read the actual file before editing. Never rely on earlier context — a prior edit invalidates it.
- Trace the full data path. View → SwiftData write → SyncManager → APIClient → server response. Identify the exact layer.
- Root cause, not symptom. State the root cause explicitly before proposing a fix.
- Smallest possible change. Do not restructure, rename, or reformat surrounding code.
- Never remove functionality unless explicitly directed.
- SwiftData schema changes need defaults. All new
Boolfields:= false. - Test offline and online. Every sync-related fix must be verified in airplane mode.
- Verify file placement. Xcode 26 folder sync means a file in the wrong subfolder compiles as a duplicate.
- When errors persist unchanged across multiple fix attempts, the file is not being picked up. Ask the user to paste the current file content.
- Update this document at the end of any session that introduces a new constraint, model field, sync rule, or architectural decision.