One target on the family kit
SparkMobile is one universal XcodeGen target for iPhone and iPad. Its project.yml is the source, and the .xcodeproj is generated and never kept. The floor is iOS 18, because the app uses the Swift version of Vision's text request, and the language is Swift 6. The app links two products of CwkKit, the family's Swift package, taken as a path dependency: CwkKitCore for plumbing every family app shares (the capture outbox, the family endpoints, the share inbox) and CwkKitMedia for shared views (the upload line, the phrase card, Talk with Pippa). VisionKit rides along for the live scanner. Two extensions are embedded in the bundle: SparkShare for the share sheet and SparkWidgets for the Control Center button. One build number covers the app and both extensions, because App Store Connect refuses an extension whose number differs from its host's.
The kit matters on the phone more than anywhere else in Spark. Spark's outbox is the kit's CaptureOutbox with Spark's payload inside it, and the upload line in the next lesson is the kit's too. Sibling Kit Quest teaches how a family shares code like this without letting copies drift. This lesson is about what Spark puts into it.
Kept before the network is asked
The rule is short: a capture is safe before the network hears about it. When Dad presses the shutter, AppModel.capture(photo:) does three things before any request (the second code block). It settles the capture truth: the photo's own time if it has one, otherwise the moment it was kept. It mints every id the send will need: the moment's ULID, one media id for the copy Pippa reads and one for the original, the practical turn's request id, and the outbox entry's id. Then it writes both files into the app container with file protection and commits the entry to the outbox. Only after that does it start a drain in the background.
The doc comment says it plainly: the moment is safe once this returns without throwing, and its id is already final. If the outbox commit fails, the staged files are removed again, so the phone never holds bytes that no entry points to. A capture made in airplane mode has the same identity it will have on the engine, and it completes once.
The payload is the whole contract between the shutter and the send (the first code block). Look at the done set at the bottom. The send marks each step there as it lands, and that is how a retry knows where to resume.
The order that shows Pippa soonest
A photo capture is two files: a 2048 px copy for me to read, and the full-resolution original Dad keeps. On a slow link the original can take minutes. So the send is ordered for the answer, and the archive waits:
| Step | What goes | Why here |
|---|---|---|
| 1 | The copy's upload line | Shown before any request, so the screen moves from the first second |
| 2 | The moment record | The phone's ids and capture truth; the entry's id is the client request id |
| 3 | The copy's bytes | Sent to the copy's own media id |
| 4 | The practical turn | The copy's finishing request: Pippa starts reading while the original waits |
| 5 | The original | The heavy file, last |
| 6 | A document's text | Only for a shared PDF or Word file: the engine reads it into the moment |
| 7 | The device copy | The engine's record and the files saved for offline use before the entry may retire |
Every step is idempotent. The moment and the files carry ids minted on the phone, the turn carries its request id, and a step already in the done set is skipped. The third code block is the test that pins this down: a first pass sends the moment and the copy, the turn answers 503, and the next pass sends only the turn and the original.
Two kinds of failure
The send sorts what goes wrong into two kinds, and the kit's OutboxFailure.classify draws the line. Most of 4xx is permanent: a malformed field or a file over the size limit is this capture's own fault, and sending it again will not help. That entry is parked as needing attention, its line offers no Retry, and Dad sees the engine's reason. Five 4xx codes are carved out as transient. 408, 425 and 429 are the server asking to be retried, in those words. 401 and 403 mean the session is wrong, not the capture, and parking entries for them would quarantine every capture in the queue for a fault none of them caused. Spark's own doc comment above outboxFailure still says a 4xx is permanent, but the kit's switch is what runs. Those five, a dropped link, and an engine that answered 503 are transient: the entry goes back to waiting and the next pass tries again. A refused capture is never retried forever, and a reachable one is never given up on.
One more detail keeps the queue moving. The kit's drain takes its list of entries when a pass starts, so a photo kept during a pass used to sit unsent until the next sync pass. Spark's drain() is coalesced: a call that arrives while a pass runs asks for one more pass, so the new capture goes right after the one in flight.