29 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 (Phase C complete — background sync, Issues view, Inspection History) 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
- Re-inspection Workflow
- Inspection History
- Photo Handling
- Score Calculation
- Background Sync
- Settings & Cache Management
- Known Constraints & Hard Rules
- 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 | 15+ |
3. Repository Layout
JanitorialQC/
├── JanitorialQCApp.swift # @main — SwiftData container, BGTask registration
├── ContentView.swift # Auth gate: LoginView ↔ DashboardView
│
├── 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
│ ├── LocalFacility.swift
│ ├── LocalArea.swift
│ ├── LocalTemplate.swift
│ ├── LocalInspection.swift
│ ├── LocalIssue.swift
│ ├── PendingPhoto.swift
│ └── SyncQueueEntry.swift
│
├── Views/
│ ├── Auth/
│ │ └── LoginView.swift
│ ├── Dashboard/
│ │ ├── DashboardView.swift # NavigationSplitView sidebar + all embedded views
│ │ ├── StartInspectionView.swift # Template/facility/area picker, creates LocalInspection
│ │ ├── ExecuteInspectionView.swift # Grid form renderer, draft save, submit
│ │ ├── FlagIssueView.swift # Issue creation sheet
│ │ └── FormFieldView.swift # All field-type renderers + camera/library pickers
│ └── Inspection/
│ └── InspectionHistoryView.swift # Server-side history, read-only
│
└── Utils/
└── Constants.swift # baseURL, Keychain key strings
4. App Entry Point & Lifecycle
JanitorialQCApp.swift is @main. It:
- Creates the SwiftData
ModelContainerfor all seven model types. - On container success: sets
SyncManager.shared.modelContext, callsAuthManager.shared.restoreSession(), startsNWPathMonitor, and triggers an immediate sync if online. - Registers the
com.jqc.syncBGProcessingTaskidentifier — this must match theBGTaskSchedulerPermittedIdentifiersarray inInfo.plist.
ContentView.swift is the auth gate. When AuthManager.isAuthenticated is false it shows LoginView; when true it shows DashboardView.
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):
- If no access token in Keychain → unauthenticated immediately.
- Calls
GET /api/v1/auth/meto validate the stored token. - On
notAuthenticatederror → clears Keychain, sets unauthenticated. - 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)
↓
SyncQueueEntry / 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() — processing order
processPhotoQueue— upload allPendingPhotowithuploadStatus == "pending". On success, updatesserverPathon the photo and propagates it to the parentLocalInspection.formData(for image fields) orLocalIssue.photoServerPath.processInspectionQueue— for each completed inspection withsyncStatus == "pending", submits only when allpendingPhotosare"uploaded"or"failed". After sync, clearsfollowUpRequiredon the parent if this is a re-inspection.processIssueQueue— for each issue withsyncStatus == "pending", skips and marks"failed"if the parent inspection hassyncStatus == "failed"(prevents orphaned server records). Sendsinspection_idfrominspection.serverId.pullReferenceData— fetches all facilities, their areas, and all templates (with full schema). Uses sequentialawaitcalls (notasync let) to avoid Swift 6 actor-isolation warnings onDecodablestructs.
Retry / failure
- Each item increments
syncRetryCounton every failure. - At 5 retries:
syncStatus = "failed". The item stays in SwiftData but is never retried automatically. syncErroris set to the last error for display; it is cleared at the start of eachtriggerSync()call.
#Predicate string comparison rule
Never use #Predicate with string literal comparisons across model type boundaries. Fetch all + filter in Swift instead. This avoids a SwiftData macro type-inference bug that causes runtime crashes in some Xcode versions.
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
- Builds URL from
Constants.baseURL + endpoint. - Injects Bearer token from Keychain.
- Performs
URLSession.data(for:). - On HTTP 401 and
retrying == false: callsrefreshAccessToken()once, retries. If refresh fails →APIError.notAuthenticated. - Decodes via
_Envelope<T>(wrapsok: 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)
├── 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
LocalInspectionin SwiftData immediately (status = "draft",syncStatus = "pending") and navigates toExecuteInspectionView. - 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
formSchemaas a 12-column CSS-grid-equivalent layout viaGridFormView. - Auto-saves every 30 seconds to SwiftData.
- Saves on
onDisappear. - "Flag an Issue" button opens
FlagIssueViewas a sheet. - "Save Draft" force-saves with a brief spinner feedback.
- "Submit Inspection" shows a confirmation alert, then:
- Persists final
formDatato SwiftData. - Computes
overallScoreviacomputeScore(fromSchema:). - Sets
status = "completed",syncStatus = "pending",completedAt = Date(). - Calls
clearParentFollowUpFlag()immediately (badge clears on device before sync). - Triggers
SyncManager.triggerSync()in the background if online. - Shows a success banner for 2.5 seconds then dismisses.
- Persists final
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
PendingPhotorecords, local photo files, and associatedLocalIssuerecords. - 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) — this works correctly through ScrollView 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 miss.
13. Issue Flagging Workflow
FlagIssueView is presented as a sheet from ExecuteInspectionView.
- Inspector selects severity (segmented control: low / medium / high / critical).
- Enters description (required).
- Optionally attaches a photo.
- Tapping Submit:
- Creates
LocalIssuewithfacilityServerIdfrom the parent inspection. - Appends the issue to
inspection.localIssues. - If a photo was taken: creates a
PendingPhotowithentityType = "issue". - Saves to SwiftData.
- Triggers
SyncManager.triggerSync()if online.
- Creates
- Area picker is absent — facility is derived directly from the inspection context (mirrors web
flag_issue.html).
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:
- Immediately on Submit in
ExecuteInspectionView.submitInspection()viaclearParentFollowUpFlag(). - After sync in
SyncManager.processInspectionQueueusingparentLocalId. - On the server in
api/inspections.pywhen the PATCH/POST arrives.
clearParentFollowUpFlag() resolution order:
- Match by
parentLocalId(UUID, always set if re-inspection was created in this session). - Fall back to
parentServerId(set only after parent has synced). - Last resort: match by same
templateServerId + facilityServerId + followUpRequired == true(for stale records created beforeparentLocalIdwas added). This fallback is ambiguous when multiple inspections of the same template at the same facility are pending follow-up — document as a known edge case.
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.
HistoryDetailView shows follow-up badge, parent inspection link, score, and re-inspection option. The data is APIInspectionSummary — a server-side DTO, not a SwiftData model.
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 "" (empty string). A local:// value that reaches the server would be stored as a malformed path.
Inspection submission gating
An inspection in processInspectionQueue is not submitted until all its pendingPhotos have uploadStatus == "uploaded" or "uploaded" or "failed". It simply continues to the next 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 Field ID rule). Optional.map on Any? produces "Optional(5)" and causes all lookups to silently miss.
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.
Scheduling: scheduleBackgroundSync() is called:
- On app init (via
registerBackgroundTasks) - 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, andLocalTemplaterecords. Never deletesLocalInspection,LocalIssue, orPendingPhoto. TriggerspullReferenceData()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 | #Predicate — fetch all + filter in Swift for string comparisons |
SwiftData macro type-inference bug with string literals across model type boundaries causes runtime crashes |
| 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, losing the field entirely on the server |
| 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; server path must be in formData before the inspection is submitted |
| 13 | com.jqc.sync BGTaskSchedulerPermittedIdentifiers must be in Info.plist |
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 when parentLocalId and parentServerId are both nil may clear the wrong inspection if multiple follow-ups are pending for the same template/facility combination |
| 16 | SyncQueueEntry model is registered but not actively written |
Included for future use; currently syncStatus on LocalInspection and LocalIssue serves as the outbox queue |
| 17 | Constants.baseURL is the only server URL |
All endpoints are built as Constants.baseURL + endpoint. Update this one constant for environment changes |
| 18 | Photo JPEG compression is 0.8 | Balances quality vs. upload size. 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 — inspection and issue data is the inspector's primary work product |
| 20 | AuthManager.restoreSession() falls back to Keychain on non-auth errors |
A server 500 or network timeout at launch allows offline operation but may expose stale role/identity data |
21. Change Philosophy
- Read the actual file before editing. Never rely on earlier context — a prior edit invalidates it.
- Trace the full data path. For any bug: 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. All new optional fields:= nilor a safe default. Run on device and check for migration crash before shipping. - Test offline and online. Every feature must work without connectivity. Sync-related fixes must be verified by simulating airplane mode.
- Full file contents for 1–3 file changes; deployment map for larger changesets.
- Update this document at the end of any session that introduces a new constraint, model field, sync rule, or architectural decision.