Files
JQC_iOS_App/JanitorialQC/CLAUDE.md
T

35 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: May 2026 (Hardening session — Xcode 26 build fixes, startup race condition resolved) Companion: See the web backend's CLAUDE.md for API contract, server-side rules, and migration chain.


Table of Contents

  1. Project Overview
  2. Tech Stack
  3. Repository Layout
  4. App Entry Point & Lifecycle
  5. Authentication
  6. SwiftData Models
  7. Offline-First Architecture
  8. Sync Engine (SyncManager)
  9. API Client (APIClient)
  10. Navigation & View Hierarchy
  11. Inspection Workflow
  12. Form Field Rendering
  13. Issue Flagging Workflow
  14. Re-inspection Workflow
  15. Inspection History
  16. Photo Handling
  17. Score Calculation
  18. Background Sync
  19. Settings & Cache Management
  20. Known Constraints & Hard Rules
  21. Xcode 26 Specific Issues
  22. 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
  • 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
  • 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 — local read-only list of all issues flagged on this device
  • Facilities browser — read-only view of synced facilities and their areas

2. Tech Stack

Layer Technology
Language Swift 5.10+
UI SwiftUI (iPad-only, all four orientations)
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
Min deployment iOS 17.0
Xcode 26.4.1 (Xcode 26 beta — see §21 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
│   └── APIModels.swift          # All Codable/Sendable response DTOs
│
├── Sync/
│   └── SyncManager.swift        # @MainActor ObservableObject — NWPathMonitor, outbox queue
│
├── Models/                      # SwiftData @Model classes — NO other .swift files here
│   ├── LocalFacility.swift
│   ├── LocalArea.swift
│   ├── LocalTemplate.swift
│   ├── LocalInspection.swift    # ← ONLY definition of LocalInspection — never duplicate
│   ├── LocalIssue.swift
│   ├── PendingPhoto.swift
│   └── SyncQueueEntry.swift
│
├── Views/
│   ├── Auth/
│   │   └── LoginView.swift      # ← Auth/ folder must contain ONLY auth files — no models
│   ├── Dashboard/
│   │   ├── DashboardView.swift
│   │   ├── StartInspectionView.swift
│   │   ├── ExecuteInspectionView.swift
│   │   ├── FlagIssueView.swift
│   │   └── FormFieldView.swift
│   └── Inspection/
│       └── InspectionHistoryView.swift
│
└── Utils/
    └── Constants.swift

Critical: This project uses PBXFileSystemSynchronizedRootGroup (Xcode 16+ folder sync). Xcode automatically compiles every .swift file in the folder tree. There is no explicit file list. A stray duplicate (e.g. a model file accidentally placed in the wrong folder) will cause "Multiple commands produce" build errors. Always verify file locations after any copy/paste operation.


4. App Entry Point & Lifecycle

JanitorialQCApp.swift

@main struct. Responsibilities:

  1. Creates the SwiftData ModelContainer for all seven model types.
  2. In the container success callback: sets only SyncManager.shared.modelContext. Nothing else — no async calls, no session restore.
  3. Registers the com.jqc.sync BGProcessingTask identifier.

Critical: The modelContainer callback runs on a background thread. Do NOT call restoreSession() or startMonitoring() from inside this callback. Doing so causes a race condition where NWPathMonitor fires triggerSync() before auth tokens are loaded, producing a 401 loop that leaves isLoading stuck at true and the app frozen on the splash screen.

ContentView.swift

Auth gate and startup lifecycle owner. The .task {} modifier owns the startup sequence:

.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 because NWPathMonitor fires immediately on network availability, triggering triggerSync() before tokens are in Keychain.

Background transitions

DashboardView observes .scenePhase and calls scheduleBackgroundSync() every time the app moves to .background.


5. Authentication

AuthManager

@MainActor class AuthManager: ObservableObject — singleton via AuthManager.shared.

Property Purpose
isAuthenticated Master gate — drives ContentView routing
isLoading Shows spinner during network calls
errorMessage Shown inline on LoginView
currentUserId/Username/Role/DisplayName User identity persisted to Keychain

Session restore flow (restoreSession):

  1. If no access token in Keychain → isAuthenticated = false immediately (no network call).
  2. Calls GET /api/v1/auth/me to validate the stored token.
  3. On notAuthenticated error → clears Keychain, sets unauthenticated.
  4. On any other error (network timeout, server 500) → restores user identity from Keychain and sets authenticated. This allows offline launch.

Logout flow: Calls POST /api/v1/auth/logout with the refresh token (best-effort), then clears all Keychain keys and sets isAuthenticated = false.

KeychainHelper

nonisolated static methods wrapping Security.framework. All keys use kSecAttrAccessibleAfterFirstUnlock so tokens are readable for background sync after device reboot.

Stored keys (all prefixed com.jqc.):

Key constant Value stored
accessToken JWT Bearer token
refreshToken Opaque 64-char hex refresh token
userId User ID as string
userRole Role string (e.g. inspector)
username Login username
displayName Display name

6. SwiftData Models

Model Container Registration

All models are registered in JanitorialQCApp in this order:

LocalFacility.self, LocalArea.self, LocalTemplate.self,
LocalInspection.self, LocalIssue.self, PendingPhoto.self, SyncQueueEntry.self

SwiftData lightweight migration: Adding a new Bool property to any model requires a default value (e.g. var followUpRequired: Bool = false) — without a default the app crashes on launch after the model change.

Model Reference

Model Role Key fields
LocalFacility Read-only cached facility reference serverId, name, address, 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
LocalIssue Issue flagged during inspection localId (UUID, unique), serverId, inspectionLocalId, facilityServerId, severity, syncStatus
PendingPhoto Photo awaiting upload localId, localFilePath, serverPath, uploadStatus, entityType, fieldId
SyncQueueEntry Outbox entry (currently 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"). They are separate fields.

formData Storage

LocalInspection.formData is a computed property that JSON-serialises to/from formDataJSON: String. Keys are always strings (field IDs stringified). Values are Any (String, Int, Bool, Array, Dict). Do not store UIImage or any non-JSON-serialisable type in formData.


7. Offline-First Architecture

The app follows the outbox pattern:

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)

The UI never blocks on network. Every screen is driven by local SwiftData queries.


8. Sync Engine (SyncManager)

@MainActor class SyncManager: ObservableObject — singleton via SyncManager.shared.

Published state

Property Purpose
isOnline True when NWPathMonitor reports .satisfied
isSyncing True during active sync cycle
lastSyncAt Date of last completed sync
syncError Last error string (shown in Settings and Pending Sync views)
pendingCount Count of unsynced inspections + issues (shown as badge)

triggerSync() guard

triggerSync() guards on three conditions before doing any work:

guard isOnline, let context = modelContext, AuthManager.shared.isAuthenticated else { return }

The isAuthenticated guard is critical. NWPathMonitor fires immediately on connectivity, including during app startup before restoreSession() completes. Without this guard, triggerSync() runs with no valid token, hits a 401, attempts token refresh, fails with notAuthenticated, and the error propagates up through the .task{} startup chain — leaving isLoading stuck at true.

triggerSync() — processing order

  1. processPhotoQueue — upload all PendingPhoto with uploadStatus == "pending". On success, propagates serverPath to the parent LocalInspection.formData (image fields) or LocalIssue.photoServerPath. Uses fetch-all + filter in Swift — no #Predicate.
  2. processInspectionQueue — submits completed inspections only when all pendingPhotos are settled. Clears followUpRequired on parent after sync.
  3. processIssueQueue — guards against submitting when parent inspection syncStatus == "failed" (prevents orphaned server records). Uses fetch-all + filter in Swift.
  4. pullReferenceData — fetches facilities, areas, and templates. Sequential await calls — not async let.

Fetch pattern — CRITICAL for Xcode 26

Never use #Predicate anywhere in SyncManager. Under Xcode 26 with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, #Predicate with a captured String variable inside a @MainActor async function causes LocalInspection is ambiguous for type lookup — a cascade of compiler errors.

Never use the chained optional pattern:

// WRONG — compiler cannot infer T in this chained expression
let x = (try? context.fetch(FetchDescriptor<LocalInspection>()))?.filter { ... } ?? []

// CORRECT — split into two statements
let all = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []
let x   = all.filter { ... }

Always parenthesise try? before ??:

// WRONG — operator precedence: try? binds looser than ??
// parses as: try? (context.fetch(...) ?? []) — nonsensical, fetch() is not Optional
let all = try? context.fetch(FetchDescriptor<LocalInspection>()) ?? []

// CORRECT
let all = (try? context.fetch(FetchDescriptor<LocalInspection>())) ?? []

Retry / failure

  • Each item increments syncRetryCount on every failure.
  • At 5 retries: syncStatus = "failed". The item stays in SwiftData but is never retried automatically.
  • syncError is cleared at the start of each triggerSync() call.

9. API Client (APIClient)

actor APIClient — singleton via APIClient.shared. All methods are async throws.

Generic request pipeline

request(endpoint, method, body, retrying) async throws -> T
  1. Builds URL from Constants.baseURL + endpoint.
  2. Injects Bearer token from Keychain.
  3. Performs URLSession.data(for:).
  4. On HTTP 401 and retrying == false: calls refreshAccessToken() once, retries. If refresh fails → APIError.notAuthenticated.
  5. Decodes via _Envelope<T> (wraps ok: Bool, data: T?, error: String?).

Key methods

Method Endpoint Notes
request<T> Any Generic GET/POST
post<T> Any POST convenience
uploadPhoto POST /api/v1/photos/upload Multipart form-data, manual boundary
submitInspection POST /api/v1/inspections Sanitises local:// photo paths before send
submitIssue POST /api/v1/issues Sends inspection_id only if inspection.serverId is non-nil
fetchInspectionHistory GET /api/v1/inspections Paginated, returns InspectionHistoryResponseData

Token refresh

refreshAccessToken() uses a local JSONDecoder (not self.decoder) to avoid Swift 6 actor-isolation errors. It stores both new tokens in Keychain before returning.

JSON decoding

decoder.keyDecodingStrategy = .convertFromSnakeCase — all snake_case server fields map to camelCase Swift properties automatically.

JSONValue / AnyDecodable

AnyDecodable is a typealias for JSONValue, a typed enum replacing Any to achieve full Sendable conformance. Use .anyValue to bridge to Any where legacy code expects it.


10. Navigation & View Hierarchy

JanitorialQCApp
└── ContentView (auth gate + startup lifecycle)
    ├── LoginView                        (unauthenticated)
    └── DashboardView                    (authenticated)
        ├── Sidebar (NavigationSplitView — no selection: binding)
        │   ├── My Inspections  → MyInspectionsView
        │   ├── Issues          → IssuesListView
        │   ├── Facilities      → FacilitiesListView
        │   ├── Pending Sync    → SyncStatusView
        │   ├── History         → InspectionHistoryView
        │   └── Settings        → SettingsView
        └── + button → StartInspectionView (sheet)
                           └── ExecuteInspectionView (navigation push)
                                   └── FlagIssueView (sheet)

NavigationSplitView constraint: init(selection:content:) is unavailable on iPadOS 17. Navigation is driven by @State var selectedTab: SidebarTab with manual Button handlers. Never add a selection: binding.


11. Inspection Workflow

1. Start (StartInspectionView)

  • Inspector picks template (required), facility (required), area (optional).
  • Tapping Start creates a LocalInspection in SwiftData immediately (status = "draft", syncStatus = "pending") and navigates to ExecuteInspectionView.
  • For re-inspections: template and facility are pre-filled; non-scoring fields from the parent are copied into formData (rating, pass_fail, image, signature fields are always blank).

2. Execute (ExecuteInspectionView)

  • Renders the template's formSchema as a 12-column CSS-grid-equivalent layout via GridFormView.
  • Auto-saves every 30 seconds to SwiftData.
  • Saves on onDisappear.
  • "Flag an Issue" button opens FlagIssueView as a sheet.
  • "Save Draft" force-saves with a brief spinner feedback.
  • "Submit Inspection" shows a confirmation alert, then:
    1. Persists final formData to SwiftData.
    2. Computes overallScore via computeScore(fromSchema:).
    3. Sets status = "completed", syncStatus = "pending", completedAt = Date().
    4. Calls clearParentFollowUpFlag() immediately (badge clears on device before sync).
    5. Triggers SyncManager.triggerSync() in the background if online.
    6. Shows a success banner for 2.5 seconds then dismisses.

3. Draft management

  • Drafts appear in My Inspections with a blue "Draft" badge.
  • Swipe-left on a draft reveals a Delete action (confirmation required). Deletion removes the draft, all its PendingPhoto records, local photo files, and associated LocalIssue records.
  • Only status == "draft" inspections may be deleted.

Status badge map

status syncStatus Badge label Badge colour
draft any Draft Blue
completed pending Pending Sync Orange
completed synced Completed Green
failed any Sync Failed Red

12. Form Field Rendering

GridFormView (primary renderer in ExecuteInspectionView)

Uses a 12-column absolute-position grid matching the web app's CSS grid exactly:

Constant Value Source
totalColumns 12 Web editor COLS=12
cellGap 8 pt Web CSS col-gap: 8px
rowGap 4 pt Web CSS row-gap: 4px
cellAspect 52/72 Web editor CELL_H/CELL_W
cardPadding 16 pt Card inset

Cell position is computed from col, row, colSpan, rowSpan attributes in the field schema. Container width is measured via a PreferenceKey pattern (zero-height overlay with GeometryReader) — works correctly through rotations and split-screen resizing.

Supported field types

Type SwiftUI renderer
text, email TextField
textarea TextEditor
number TextField + .decimalPad
date DatePicker (date only)
checkbox Toggle
checkbox_group Custom multi-select buttons (CellCheckboxGroup)
radio Custom radio buttons (CellRadioGroup)
select Menu dropdown (CellSelect)
rating Custom star row (CellRatingStars) — tap same star to clear
pass_fail Capsule pill buttons (CellPassFail) — tap selected to deselect
signature PKCanvasView (SignatureFieldView) — requires PencilKit framework
image CompactImageFieldView (grid) / ImageFieldView (standalone)
table TableFieldView — horizontal scroll, editable cells
section, label Display-only Text

Field ID rule (CRITICAL)

Form schema IDs from the server are integers in JSON (e.g. "id": 5). After JSONSerialization, they arrive as Int in [String: Any] dictionaries. formData keys are always String. Field IDs must be resolved via:

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"] — it produces "Optional(5)" instead of "5", causing all formData lookups to silently miss and all scores to return 0.


13. Issue Flagging Workflow

FlagIssueView is presented as a sheet from ExecuteInspectionView.

  1. Inspector selects severity (segmented control: low / medium / high / critical).
  2. Enters description (required).
  3. Optionally attaches a photo.
  4. Tapping Submit:
    • Creates LocalIssue with facilityServerId from the parent inspection.
    • Appends the issue to inspection.localIssues.
    • If a photo was taken: creates a PendingPhoto with entityType = "issue".
    • Saves to SwiftData.
    • Triggers SyncManager.triggerSync() if online.
  5. Area picker is absent — facility is derived directly from the inspection context.

Issue sync guard

If inspection.syncStatus == "failed" when processIssueQueue runs, the issue is immediately marked "failed" with message "Parent inspection failed to sync — issue cannot be submitted." This prevents orphaned server records with no inspection_id.


14. Re-inspection Workflow

Trigger

On CompletedInspectionView, if inspection.followUpRequired == true, an orange banner is shown with a Start Re-inspection button. This opens StartInspectionView with preFillTemplateId, preFillFacilityId, parentServerId, and parentLocalId pre-set.

Parent form pre-fill

StartInspectionView.startInspection() copies non-scoring fields from the parent's formData into the new inspection. Excluded field types: rating, pass_fail, image, signature — these must always be re-evaluated fresh.

followUpRequired clearing

Cleared at three points to ensure the badge disappears regardless of timing:

  1. Immediately on Submit in ExecuteInspectionView.submitInspection() via clearParentFollowUpFlag().
  2. After sync in SyncManager.processInspectionQueue using parentLocalId.
  3. On the server in api/inspections.py when the PATCH/POST arrives.

clearParentFollowUpFlag() resolution order:

  1. Match by parentLocalId (UUID, always set if re-inspection was created in this session).
  2. Fall back to parentServerId (set only after parent has synced).
  3. Last resort: match by same templateServerId + facilityServerId + followUpRequired == true. This fallback is ambiguous when multiple follow-ups are pending for the same template/facility combination.

15. Inspection History

InspectionHistoryView fetches completed inspections from the server via GET /api/v1/inspections?status=completed. It is online-only — shows ContentUnavailableView with "Offline" when !sync.isOnline.

Pagination: limit 30, offset-based. A "Load More" button appears when inspections.count < total. Pull-to-refresh resets to page 0.


16. Photo Handling

Capture

Photos are taken via UIImagePickerController (camera) or PHPickerViewController (library, no permission required for iOS 16+).

Local storage

All photos are saved to: Documents/JQC/Photos/<UUID>.jpg at JPEG quality 0.8.

PendingPhoto lifecycle

Created (uploadStatus="pending")
    ↓ SyncManager.processPhotoQueue()
Uploaded (uploadStatus="uploaded", serverPath set)
    ↓ Parent record updated
    For inspection image fields: LocalInspection.formData[fieldId] = serverPath
    For issues: LocalIssue.photoServerPath = serverPath

local:// sentinel

While a photo is pending upload, the form field value is set to "local://<path>". Before submitInspection sends formData to the server, these values are replaced with "". A local:// value that reaches the server would be stored as a malformed path.

Inspection submission gating

An inspection is not submitted until all its pendingPhotos have uploadStatus == "uploaded" or "failed". It simply continues to the next sync cycle.


17. Score Calculation

LocalInspection.computeScore(fromSchema:) mirrors Python's _compute_score_from_form() exactly.

Scoreable field types: rating, checkbox, radio, pass_fail.

Type Rule
rating Value 0 = unanswered → excluded. Each answered rating contributes value / 5 of a possible 1.0
checkbox "true" = pass, anything else = fail
radio Pass keywords: pass, yes, ok, good, acceptable, compliant (case-insensitive)
pass_fail Same pass keywords as radio. Empty string = unanswered → excluded

Returns nil if no scoreable fields or all are unanswered.

Field ID resolution: Always use the explicit cast pattern (see §12). Optional.map on Any? produces "Optional(5)" — all lookups miss, all scores return 0.


18. Background Sync

The app registers a BGProcessingTask with identifier com.jqc.sync.

Info.plist requirement: BGTaskSchedulerPermittedIdentifiers must contain com.jqc.sync. Without this entry, BGTaskScheduler.shared.register silently fails and background sync never fires.

Note on GENERATE_INFOPLIST_FILE: The project uses GENERATE_INFOPLIST_FILE = YES. Do NOT also have a physical Info.plist file on disk — having both causes "Multiple commands produce Info" build error. The file is generated at build time; there is no Info.plist in the source tree.

Scheduling: scheduleBackgroundSync() is called:

  • Every time scenePhase == .background
  • At the start of each background task handler (schedules the next run)

Requirements: requiresNetworkConnectivity = true, requiresExternalPower = false.

Tokens: Keychain access policy kSecAttrAccessibleAfterFirstUnlock ensures tokens are available when the app is woken by BGTaskScheduler after device reboot.


19. Settings & Cache Management

SettingsView exposes:

  • Account info (username, role) — read-only from AuthManager.
  • Sync Now — triggers SyncManager.triggerSync(); disabled when offline or already syncing.
  • Last sync timestamp.
  • Clear Reference Cache — deletes all LocalFacility, LocalArea, and LocalTemplate records. Never deletes LocalInspection, LocalIssue, or PendingPhoto. Triggers pullReferenceData() immediately if online.
  • Log Out — calls AuthManager.logout().
  • App version and server URL (from Constants.baseURL).

20. Known Constraints & Hard Rules

# Rule Rationale
1 import Combine required in files using @Published Swift 5.9+ does not auto-import Combine; ObservableObject without it causes build errors
2 No selection: binding on NavigationSplitView init(selection:content:) unavailable on iPadOS 17; use @State var selectedTab: SidebarTab with Button handlers
3 No #Predicate anywhere in SyncManager Under Xcode 26 SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, #Predicate with a captured String variable causes LocalInspection is ambiguous compiler cascade. Use fetch-all + filter in Swift throughout
4 Sequential await in pullReferenceData() async let causes Swift 6 actor-isolation warnings on Decodable structs; use sequential await calls
5 PencilKit requires explicit framework linkage Add PencilKit.framework under Target → Frameworks, Libraries, and Embedded Content
6 Free Apple ID provisioning expires every 7 days Rebuild with ⌘R while iPad is connected; SwiftData persists across reinstalls
7 kSecAttrAccessibleAfterFirstUnlock for all Keychain items Tokens must be readable when the app is woken by BGTaskScheduler after reboot
8 New Bool model fields require = false default SwiftData lightweight migration crashes on launch without a default value for new Bool properties
9 Field IDs in formData are always String keys Server encodes them as Int in JSON; always cast via as? String then as? Int → String(n). Never use Optional.map on field["id"]
10 Strip local:// paths from formData before submitInspection Failed photo uploads leave "local://..." in formData; JSONSerialization silently drops non-serialisable values
11 Do not submit an issue when parent inspection syncStatus == "failed" Submitting with no inspection_id creates orphaned server records
12 Photo-before-inspection ordering in sync processPhotoQueue must run before processInspectionQueue
13 com.jqc.sync must be in BGTaskSchedulerPermittedIdentifiers BGTaskScheduler silently ignores unregistered identifiers
14 refreshAccessToken() uses a local JSONDecoder, not self.decoder Accessing the actor-isolated self.decoder from a non-isolated context triggers Swift 6 isolation errors
15 clearParentFollowUpFlag() fallback-2 is ambiguous Matching by template+facility is ambiguous when multiple follow-ups are pending for the same template/facility
16 SyncQueueEntry model is registered but not actively written Included for future use; syncStatus on LocalInspection and LocalIssue is the active queue
17 Constants.baseURL is the only server URL All endpoints are Constants.baseURL + endpoint. Update this one constant for environment changes
18 Photo JPEG compression is 0.8 Do not raise above 0.85 without testing against the server's 50 MB 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 but may expose stale role/identity data
21 startMonitoring() must be called AFTER restoreSession() completes NWPathMonitor fires immediately on launch, triggering triggerSync() before tokens exist; the 401 loop leaves isLoading stuck
22 triggerSync() guards on AuthManager.shared.isAuthenticated Prevents sync from running before auth is established — covers the NWPathMonitor race and any BGTask path
23 Do NOT place model files in non-Model folders Xcode 26 folder sync compiles every .swift in the tree; a LocalInspection.swift in Auth/ causes "Multiple commands produce LocalInspection"
24 No physical Info.plist file when GENERATE_INFOPLIST_FILE = YES Having both causes "Multiple commands produce Info" build error
25 Always parenthesise try? before ?? try? context.fetch(...) ?? [] parses as try? (fetch() ?? [])fetch() is non-optional so ?? is invalid inside try?; compiler infers T = Any and cascades into build errors. Write (try? context.fetch(...)) ?? []
26 Delete Xcode's default Item.swift immediately after project creation Xcode generates Item.swift with @Model class Item when creating a new SwiftData project; it compiles silently via folder sync and conflicts with real models

21. Xcode 26 Specific Issues

This project was created with Xcode 26.4.1 (Apple's major 2026 release). Several compiler behaviours differ from Xcode 15/16 and require specific patterns.

SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor

Xcode 26 sets this build setting when creating new projects with "approachable concurrency" enabled. It makes every type and function implicitly @MainActor.

Effect on SwiftData: SwiftData's @Model macro generates nonisolated accessors internally. When FetchDescriptor<LocalInspection> is used inside a @MainActor async method, the compiler sees a conflict between @MainActor LocalInspection and nonisolated PersistentModel requirements. The error cascade is:

  • 'LocalInspection' is ambiguous for type lookup in this context
  • Generic parameter 'T' could not be inferred
  • Type 'Any' cannot conform to 'PersistentModel'
  • The compiler is unable to type-check this expression in reasonable time

Solutions applied in this codebase:

  1. All context.fetch() calls use fetch-all + filter in Swift — no #Predicate with captured variables.
  2. All try? context.fetch(...) expressions are parenthesised before ??.
  3. Chained optional patterns (try? fetch(...))?.filter { } are split into two let statements.
  4. triggerSync() guards on isAuthenticated to prevent 401 cascades during startup.

Do NOT remove SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor from build settings unless you fully audit every file for the resulting isolation changes. The patterns above are the correct workarounds.

PBXFileSystemSynchronizedRootGroup (Folder Sync)

Xcode 26 uses folder-sync mode instead of an explicit file list in project.pbxproj. Every .swift file in the project folder is compiled automatically. There is no file registry to check.

Consequences:

  • Stray files (e.g. Item.swift from the project template, accidentally duplicated model files) compile silently and cause "Multiple commands produce" errors.
  • Deleting a file from Finder is sufficient to remove it from the build — no need to remove it from the project navigator separately.
  • When diagnosing "Multiple commands produce X", run: find /path/to/project -name "*.swift" | xargs grep -l "class X" to find all definitions.

Derived Data corruption

Under Xcode 26, repeated failed builds accumulate corrupt intermediate files in derived data. After any "Multiple commands produce" error is resolved, delete derived data manually before rebuilding:

rm -rf ~/Library/Developer/Xcode/DerivedData/<ProjectName>-<hash>

The hash is visible in every error message path. Do not use Product → Clean Build Folder alone — it does not remove all intermediate files.


22. Change Philosophy

  1. Read the actual file before editing. Never rely on earlier context — a prior edit invalidates it. When a user pastes file content, that is the ground truth — not the local copy.
  2. Trace the full data path. For any bug: view → SwiftData write → SyncManager → APIClient → server response. Identify the exact layer.
  3. Root cause, not symptom. State the root cause explicitly before proposing a fix. Multiple failed attempts are always caused by treating symptoms.
  4. Smallest possible change. Do not restructure, rename, or reformat surrounding code.
  5. Never remove functionality unless explicitly directed.
  6. SwiftData schema changes need defaults. All new Bool fields: = false. Run on device and check for migration crash before shipping.
  7. Test offline and online. Every sync-related fix must be verified in airplane mode.
  8. Verify file placement. After delivering a file, confirm the user placed it at the correct path. Xcode 26 folder sync means a file in the wrong subfolder compiles as a duplicate.
  9. When errors persist unchanged across multiple fix attempts, the file is not being picked up. Ask the user to paste the current file content before making further changes.
  10. Update this document at the end of any session that introduces a new constraint, model field, sync rule, or architectural decision.