One Upload Line, Four Facts
When the family pipeline finishes, it prints an ARIANDEL_MOBILE_UPLOAD_OK line and writes the build commit. Most of us would say "it shipped" right there. The family's delivery doctrine, written into cwkPippa as an invariant, says: "Four facts, and only the last is delivery. Never collapse them in a report."
| # | Fact | How you know |
|---|---|---|
| 1 | Uploaded | The pipeline's *_UPLOAD_OK line |
| 2 | Processed | App Store Connect: processingState is VALID |
| 3 | Assigned to internal testing | App Store Connect: internalBuildState is IN_BETA_TESTING |
| 4 | Installed | The device, or App Store Connect's install count |
Each fact can be true while the next is false, and each takes a different amount of time. Upload is seconds. Processing takes Apple minutes. Group assignment follows processing. Installation waits on a person picking up an iPad. A report that says "shipped" at fact 1 is making three claims it hasn't checked.
The family made reading facts 2 to 4 cheap so nobody has to guess. testflight-facts.sh, vendored from cwkSiblingKit beside the upload gate, reads all four from the App Store Connect API with the same team key, ends with one machine line, and exits 0 only when facts 1 to 3 hold. --wait polls every 30 seconds, because, in the script's own words, someone asking "is it on the phone yet" should get the answer, not an instruction to ask again.
How Ariandel's History Reports It
Read the "Shipped" sections of Ariandel's Coop history in build order and you can watch the discipline hold:
- Build 6 (written right after upload): "Apple processing and the internal group were still pending when this row was written, and installation on Dad's iPad is a separate fact." Fact 1, and an honest blank for the rest.
- Builds 8 to 17, every row: "Apple processing is VALID and the internal group is IN_BETA_TESTING. Installation on Dad's iPad is not counted yet; it is a separate fact." Facts 1 to 3 true, fact 4 open.
- Build 18: its own row says "Not yet installed"; hours later, that morning, App Store Connect counted it as installed and a device log saw it run. The one build in the run with an install count above zero.
- Builds 19, 21, 22 and 23: uploaded, processed, in the group, and "not yet installed" when each row was written. For build 22 the answer arrived one row later: build 23's row opens with Dad trying build 22 on the iPad that afternoon and reporting what was wrong with Generate. That's fact 4 from the person holding the device, not from a count, and it sits in a row about a different build. Read the rows after a build's own row before you call its fact 4 open.
- Builds 20 and 24: no history row at all. Their build commits prove the pipeline uploaded them. Nothing in Coop says what happened next, so the record alone supports "fact 1" for build 24. Its fact 4 comes from somewhere else entirely: this track's hero picture, as the end of this lesson explains. And a build Dad opened from TestFlight had passed facts 2 and 3 on its way to him.
Fact 4 Is the Slippery One
Install count is a usage metric, and it trails real installs. The facts script's header records a measurement from 25 September: the count is not live. So the reader reports installed=yes only for a count above zero, and a zero as not_counted, never "not installed". The phone's TestFlight app stays the witness.
And even a positive count only tells you a build was installed at some point. It does not tell you which build is running. On 4 October Dad's brushes vanished. A device log read over the cable showed build 18 moving the brush library to a new folder at 09:59, and within the minute the iPad was running build 14, which only knew the old folder. At 10:18 build 18 was back and so were the brushes. Meanwhile App Store Connect showed nothing wrong: 18 was the newest build and counted as installed. Why 14 came back is still unknown.
The fix was not a better query. It was making the device say what it is. Build 19 put the build number into the gallery's title, "Ariandel · Build N", and into About as "0.1.0 (N)", both read from the bundle. The hero picture of this track is that title on Dad's iPad: the device testifying to fact 4 directly.
The Rule Behind All of This
Every CWK iOS app reaches Dad's devices through TestFlight, first build included. A cable install (Xcode Run, devicectl, development signing) is a build check: it needs the cable and an unlocked device, reaches one machine, and lasts only as long as its development provisioning profile (seven days on a free personal team, a year on a paid team like the family's). TestFlight lands over the air on every enrolled device, and each build there expires 90 days after upload, so an old build is answered with another upload, never with a cable. The doctrine exists because a session once side-installed an app on a locked phone and logged TestFlight as a future step, twelve days after the pipeline existed. Note the boundary in the brush story above: the cable was used to read a log, which is diagnosis, never delivery.
One more constraint shapes where facts can be produced at all. Uploads run only on Dad's office Mac, in a GUI login session. Over ssh, codesign fails with errSecInternalComponent: an ssh session cannot answer the login keychain's private-key prompt, so the archive dies at signing even though provisioning succeeded. It looks like a provisioning problem and is not one.