Zero Alpha's first three apps were built to be thrown away.
The product is a shared household ledger with two equal seats. Where income enters is accounting metadata, not authority. Both people can act independently, see important things together, and change the rules together. A Personal Pocket can keep the merchant, note, and receipt private while still accounting for the amount in the shared household record.
Those are product promises, but each one depends on behavior deep inside CloudKit. Apple's sharing model has an owner. Its conflict detection operates on records, not household agreements. Its shared database doesn't offer per-field access control. A clean interface could conceal all three problems right up until they hurt the person who trusted it.
So I froze production persistence and sync work. Before the real app could store a transaction, three disposable harnesses had to answer whether the platform could support the product I had described. Each harness ran on two physical iPhones with two real Apple accounts. Every operation, error, conflict, and local record landed in an append-only log that could be exported and inspected later.
The record had to be the proof.
A platform owner inside a product with equal seats
Spike 1 tested the most dangerous claim first. Zero Alpha promised no owner, but CloudKit requires one account to create the shared zone and invite the other. I needed to know what that asymmetry allowed the owner to do, what the participant retained afterward, and whether a local copy could make stranding survivable.
The first result had nothing to do with ownership. The new CloudKit container returned HTTP 500 for every request during its first 23 minutes. Apple's status page was clean, both accounts were available, and the requests reached the service. Then the container started accepting writes.
That outage changed the first-run requirement immediately. A new user can do everything correctly and still meet a dead backend, so the app can't treat initial sync failure as evidence that setup failed or the household doesn't exist.
The harness found a second false signal during the same delay. It logged that a share had been created even though the share never saved. CloudKit reported the failure in the returned per-record results rather than as a thrown error. A success callback wasn't evidence of success. The harness had to inspect the records the service returned.
Once the container healed, the baseline worked. The owner seeded three records, each person added one, and both phones fetched the same five-record household. Then I revoked the participant.
Her next fetch succeeded.
It returned zero zones and zero records, with no error and no denial. The shared household had simply disappeared. Worse, the harness's naive local mirror accepted that empty response as truth and erased its own copy. The platform had stranded one seat silently, and my local implementation had helped it finish the job.
I changed the mirror during the run so an empty server response could never shrink local history. That version survived revocation, share deletion, and deletion of the entire zone. After the zone was deleted, both phones still held the same five record IDs locally. The owner received an explicit zoneNotFound error. The participant received silence again.
Re-inviting the participant restored the same zone with the same record IDs. Leaving and returning behaved like membership changing around a durable household record, provided the devices had preserved that record themselves.
That killed the absolute claim. CloudKit has an owner, and Zero Alpha can't honestly say otherwise. What survived was more precise: equal product roles, plus a complete local copy that no remote action can shrink. Export must always work from local data. The synced store is a projection, not the household's only record. When local and remote state diverge, the app has to say so.
One limit remains. Destroying a real Apple account wasn't a responsible test, and signing out after the zone was already gone would've added little information. Permanent account loss remains untested. That unknown is one reason the local guarantee is mandatory.
Conflicts the server could see, and conflicts it couldn't
Spike 2 reused the first harness's CloudKit container. The 23-minute provisioning outage was already sunk cost, so the second app replaced the first on both phones and carried its conclusions forward: empty fetches couldn't erase the mirror, unpushed edits stayed above fetched server data, and conflicts were captured before anyone resolved them.
The harness created six races between the two devices.
CloudKit handled stale saves well under its strict policy. When both people categorized the same transaction differently, the second save was rejected and returned three complete versions: mine, theirs, and the common ancestor. Neither person became the default winner. The interface could show both choices and record the resolution.
When the two phones changed different fields on the same transaction, CloudKit still reported a conflict because its unit is the whole record. The three versions contained enough information for the app to identify the independent changes and merge them. Category and amount both survived, then converged on both phones.
Deletion behaved differently.
In one scenario, an edit reached a record that had already been deleted. The save failed visibly, and the edited local copy remained available for export. In the reverse order, the owner's newer edit saved first and the participant's stale deletion arrived 13 seconds later. The deletion succeeded with no conflict and erased the server record. CloudKit's strict save policy protects saves. It doesn't protect deletes.
That one result settled a product rule. Domain actions don't delete records. Voiding a transaction is a new saved state with history, so it participates in conflict detection. True deletion belongs only in the explicit destruction flows, with its authority and consequences handled by the product rather than implied by sync.
Two other races showed where platform conflict handling ends. A household rule changed on one phone while a transaction changed on the other. Both saves succeeded. A month closed on one phone while a new entry landed from the other six seconds later. Both saves succeeded again. CloudKit saw separate records; it couldn't see that the household considered them one decision.
Those results moved agreement binding and monthly close from documentation into application logic. A transaction has to bind to the agreement version that governed it. Closing a month needs a state machine with stored statement versions and invalidation rules. The database can't infer either invariant from timing.
The harness also exposed one bug in its own fetch logic. It used full refetches and ignored CloudKit's deletion stream, so a record that disappeared inside a nonempty fetch remained as a ghost locally. The production sync layer has to consume tombstones from change tokens and surface divergence per record, not only when the whole remote store vanishes.
Spike 2 didn't prove that sync would be simple. It established the exact places where the platform tells the truth, where the app can merge safely, and where household semantics have to take over.
Privacy with no switch to get wrong
The Personal Pocket creates a narrow privacy requirement. Both people see that the money moved and how it was allocated. Only the spender sees the merchant, note, and receipt.
Filtering those fields out of the other person's interface wouldn't be enough. If private detail reached the shared database, it could escape through logs, exports, edit history, search indexes, or any later feature that copied the record. The other account's device had to receive no private detail in the first place.
CloudKit can't apply permissions to individual fields inside a shared record. Spike 3 documented that limit with a deliberate canary: every shared envelope carried an encrypted field containing its merchant name. If encryption created a privacy boundary between participants, the other phone wouldn't be able to read it.
It could. Both accounts read the encrypted canary in the shared zone. Its content then appeared in the reader's log and export, reproducing the entire contamination path the architecture review had warned about. Encryption at rest wasn't a boundary between people who both possessed the zone keys.
The working design split one transaction across two stores. The shared zone received an envelope containing the amount, pocket owner, and an opaque reference. The spender's private CloudKit database received the merchant and note. Both phones saw both amounts immediately. Each attached detail only to its own transaction.
Then each phone probed the other person's detail reference through every CloudKit path it could address. Every attempt was denied. The stronger finding was structural: CloudKit exposes no API for one account to target another account's private database. There's no access-control list to configure incorrectly because the path doesn't exist.
A final cross-side export check found no private detail crossing stores outside the canary that had been planted to demonstrate the failure. A random merchant-name collision forced the check to match opaque references rather than strings, which made the verification stronger.
The transaction path passed. The claim still has a boundary. Receipts, edit events, search indexes, notifications, and any future derived record must follow the same split before pocket privacy is proven across the production system.
Two receipts were enough to reject the first extraction logic
The first three spikes gated the persistence architecture. Spike 4 gates receipt capture, so it could continue after production work began. Its harness scans real paper, proposes a merchant, date, and total, then asks for a strict grade: correct, close, or miss. If a value would need retyping, it isn't correct.
Only two receipts existed on the first day, far short of the sample needed for a verdict. They still found four defects in the starting heuristics.
A drive-through receipt included a handwritten tip. The harness proposed the correct tip-inclusive charge, but its diagnostic log showed that it had arrived there by accident. OCR separated the Total label from its value, so the keyword pass found nothing. The fallback selected the largest amount, which happened to be the final card charge.
The grocery tape made the same fallback look reckless. It proposed the store's annual-savings marketing number as the transaction total and chose a slogan as the merchant name.
Four changes were already justified without pretending that two receipts established an accuracy rate. Labels and values need to be joined spatially by row. The largest amount is an unsafe fallback on grocery tapes. Payment lines are better evidence for the household's actual charge, including tips. Merchant extraction needs to favor the largest text near the top, then pass through later normalization.
The OCR spike now accumulates receipts through normal household use. Its correction flow will be chosen from measured accuracy by field and paper class, not from the first two scans. CSV import remains a separate fifth spike, waiting on exports from the banks this household actually uses.
The harnesses changed interaction design too. Across the spike apps, an accidental role selection required a reinstall, a paper-class tag became unreachable after grading, and one tag landed on the wrong receipt with no way to fix it. Three disposable interfaces found the same rule: any state a person can enter with one tap needs a one-tap way back out.
Spikes 1 through 3 closed on August 27, about eight hours after the project's first code. The gate on production persistence and sync lifted that afternoon. The real project started with the token contract, documented data invariants, and the plan and statement arithmetic running as pure functions under test.
Its first screen didn't pretend any of that work had produced financial insight. Under the two-square mark, it showed an empty ledger and one sentence: “No activity recorded yet.”
