Skip to content
C.W.K.
Lesson 04 of 05 · published

Nothing Kept

~16 min · privacy, data-retention, apple-vision, fail-closed

Level 0Idle Phone
0 XP0/45 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

What "nothing kept" means on the device

Invariant 9 is two short sentences: a picture or clip is written to the app's temporary folder, sent, and deleted; Photos is never touched, with no Photos purpose string at all. The code keeps the spirit and is stricter than the letter about the still. A still never touches the body's disk. AVCapturePhoto.fileDataRepresentation(with:) hands back the finished JPEG, GPS and all, as Data in memory, and that Data is the response body. When the request ends, the bytes go with it.

A clip cannot do that, because AVCaptureMovieFileOutput records only to a file URL. So the rig names one in the app's temporary directory, clip-<uuid>.mov, lets AVFoundation write it, reads it into memory in the delegate, and deletes it in the completion, as the first code block shows. Look where the removeItem sits: inside the completion that every finished recording ends with, success or failure, before the waiting request is resumed. A failed clip deletes its half-written file the same way a good one does, and a callback that arrives after the deadline has already answered still runs it. The one way past it is the gap lesson 3 found: if AVFoundation never calls back at all, the deadline answers the request and the completion never runs, so the movie stays in the temporary directory. That directory is also the one place iOS may clean up on its own, so even a file missed that way would never grow into a library.

What survives a request on the device is a handful of facts for the face: which camera ran, whether it is running now, who asked. No thumbnail, no last frame, no cache. Restart the app and there is nothing of any picture left to find.

No Photos, held three ways

Not using the photo library is easy to promise and easy to break with one convenient line. Cordelia holds it with three locks that fail independently. First, the Info.plist carries no NSPhotoLibraryUsageDescription and no NSPhotoLibraryAddUsageDescription. iOS ends an app that reaches for the library without the matching purpose string, so the missing string is itself an enforcement. Second, InfoPlistTests, the third code block, reads the built app's Info dictionary and fails if either key, an Always location key, or any background mode ever appears. Third, EarBoundaryTests reads every Swift source of the app, plus the Info.plist and project.yml, and fails on words such as PHPhotoLibrary, import Photos, UIImageWriteToSavedPhotosAlbum and UISaveVideoAtPathToSavedPhotosAlbum, comments included, because a comment naming one of those APIs is usually the line about to use it.

The camera purpose string closes the loop for the person who answers the system prompt: Cordelia "shows CAMERA ON while it does, and keeps nothing on this device." Another test in the same file checks that the string still says the camera is used only when Oriel asks.

Where the picture goes instead: the person rule

Nothing kept on the body does not mean nothing kept anywhere. Every record of what my body did lives in Oriel, and the original goes there with its EXIF and GPS. Oriel then applies a rule stricter than "record everything" on purpose: no original with a person in it is kept. The second code block is that rule for a still. Oriel writes the original under the body's photos/ folder, makes an upright viewing copy fitted to the look's size, and asks Apple Vision how many people are in the viewing copy (oriel-vision runs VNDetectHumanRectanglesRequest and VNDetectFaceRectanglesRequest; a person counts at confidence 0.5 or more, and a face counts as a person too).

Read the kept line carefully: kept = count == 0. When Vision found a person, the count is above zero and the original is deleted. When Vision could not run at all, the count is None, which is not zero, so the original is deleted too. That is failing closed: a check that did not happen counts as a check that found someone. And if anything throws on the way, an unreadable picture or a tag the decoder cannot handle, both files are deleted before the error goes up, because a picture nobody checked must not stay just because the checking broke. A kept original gets a sidecar with its facts, and every checked still writes a cordelia_photo row to the activity log with kept and people, never the picture.

The viewing copy is the look's answer either way. Oriel sweeps its own copy after two minutes, and cwkPippa's door keeps the picture for Dad, as it does for every look, a person in it or not. So the person rule governs Oriel's archive of originals; the downscaled copy a look answered with is the one cwkPippa keeps for Dad, whoever is in it. A clip gets the same rule from three frames read with ffmpeg, near its start, at its middle and near its end; a clip that cannot be read to check is not kept. Note what the rule does not do: a person in the frame never stops a look. It stops Oriel keeping the original.

Why the keeping lives in one place

The architecture's Canonical Data Ownership table says it in one row. Which device is a body, its pairing token, every request it served and the pictures it took all belong to Oriel; the body "holds only its token and the request in flight". That row is short by one thing: the body also keeps its own settings (Lesson 3 of the first track), but settings are how it acts, not a record of what it did. One owner means one retention rule, one sweep and one activity log to audit. A body that kept its own copies, briefly and for a good reason, would be a second archive with rules of its own, on a device that stands in a room with people in it.

Code

cwkCordelia, clients/apple/CordeliaMobile/Sources/Body/CameraRig.swift: a clip's one temporary file, deleted in the completion (excerpt, lines 203-238)·swift
  func clip(_ camera: Camera, seconds: Double) async throws -> Capture {
    try Self.requireGrant()
    try claim()
    defer { release() }
    let url = FileManager.default.temporaryDirectory.appendingPathComponent("clip-\(UUID().uuidString).mov")
    return try await Once<Capture>.wait { once in
      self.queue.async {
        self.pauseViewfinder()
        let output = AVCaptureMovieFileOutput()
        // …
        let catcher = ClipCatcher(camera: camera, seconds: seconds, codec: codec) { result, catcher in
          self.queue.async {
            catcher.session?.stopRunning()
            self.resumeViewfinder()
          }
          self.drop(catcher)
          try? FileManager.default.removeItem(at: url)
          once.finish(result)
        }
cwkOriel, backend/cordelia.py: the person rule for a still, failing closed (verbatim, lines 977-1012)·python
    def photo_for_look(self, row: dict[str, Any], long_edge: int = VIEW_LONG_EDGE) -> Path:
        """One still for a look: the original kept with its facts, unless a
        person is in it or Vision could not check it; the upright viewing copy
        returned for the look, swept after two minutes."""
        body, camera = self.body_of(row)
        raw, facts = self.link(body).photo(row, camera)
        day, stamp = self._stamp()
        original = self.root / body.key / "photos" / day / f"{stamp}-{camera}.jpg"
        original.parent.mkdir(parents=True, exist_ok=True)
        original.write_bytes(raw)
        view = self.root / body.key / "views" / f"{stamp}-{camera}.jpg"
        view.parent.mkdir(parents=True, exist_ok=True)
        try:
            with Image.open(original) as image:
                upright = ImageOps.exif_transpose(image).convert("RGB")
                upright.thumbnail((long_edge, long_edge), Image.Resampling.LANCZOS)
                upright.save(view, quality=90)
            count, how = self.people(view)
        except BaseException:
            # Nothing unchecked is kept: a picture that could not be opened
            # (cut short, a tag PIL cannot read) stayed on disk with no check
            # and no sidecar, whoever was in it.
            original.unlink(missing_ok=True)
            view.unlink(missing_ok=True)
            raise
        kept = count == 0
        sidecar = {"body": body.key, "camera": camera, "input": row["id"], "facts": facts, "bytes": len(raw),
                   "people": count, "vision": how, "kept": kept,
                   "why": None if kept else ("a person is in it" if count else "Vision could not check it")}
        if kept:
            atomic_write_text(original.with_suffix(".json"), json.dumps(sidecar, ensure_ascii=False) + "\n")
        else:
            original.unlink(missing_ok=True)
        self.engine.store.note("pippa", "cordelia_photo", body=body.key, camera=camera, input=row["id"], kept=kept,
                               people=count, file=str(original) if kept else None, gps=bool(facts.get("gps")))
        return view
cwkCordelia, clients/apple/CordeliaMobile/Tests/InfoPlistTests.swift: the keys that must never ship (verbatim, lines 28-34)·swift
  func testNoPhotosNoAlwaysNoBackground() {
    for key in ["NSPhotoLibraryUsageDescription", "NSPhotoLibraryAddUsageDescription",
                "NSLocationAlwaysAndWhenInUseUsageDescription", "NSLocationAlwaysUsageDescription"] {
      XCTAssertNil(info[key], "\(key) must never ship")
    }
    XCTAssertNil(info["UIBackgroundModes"], "no background mode: the ears and the camera close in the background")
  }

External links

Exercise

Write the keep-or-delete step for captures in a project of your own. It receives a picture, stores the original, runs a detector for whatever must not be retained (people, documents, screens), and keeps the original only when the detector positively returns zero. Write four tests: nothing found keeps it, something found deletes it, a detector that raises deletes it, and an unreadable picture deletes both the original and any derived copy before the error propagates.
Hint
Represent 'could not check' as a distinct value such as None, never as zero, and make keep depend on equality with zero so the unknown case falls on the delete side. Wrap the derive-and-detect step so any exception removes what was written before re-raising, the way Oriel's except BaseException branch does.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.