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

Departures Are Data

~15 min · event-sourcing, jsonl, instrumentation, swift

Level 0New Tab
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

An instrument for a behavioural test

Voyager's success test is that Dad never reopens another browser. A test like that needs an instrument that notices when he does, and records it without anyone remembering to. Invariant 10 states it in three words, departures are data: when another browser comes to the front, Voyager records it; fixes are ordered by that log; a departure with no recorded reason is still a departure.

The implementation is small enough to read in one sitting, and it is a clean example of a pattern worth knowing: an append-only event file, folded into state when it is read.

Four events, one file that only grows

The log lives in departures.jsonl in Voyager's folder, one JSON object per line. There are four kinds of event:

EventWire nameWhen
departeddepartedAnother browser came to the front: an id, the time, the browser's name and bundle id, and the host Dad had in front in Voyager, if any
seenAgainseen_againThe same browser came forward again before Dad returned to Voyager
askedaskedThe question about that departure was shown
reasonreasonDad typed why

Writing is append(_:): encode the event with sorted keys and ISO 8601 dates, add a newline, seek to the end, write. Nothing is ever rewritten. A reason arriving a minute later does not edit the departed line; it becomes a line of its own.

Reading is a fold. read() decodes every line it can, and fold(_:) replays the events in file order into one Departure row each: departed creates the row, seen_again moves its last-seen time and counts one more activation, asked marks it, reason fills in Dad's words. One stay in another browser is one row, however many times he switched to it during that stay.

The line this build cannot read

The detail I like most is what happens to a line that does not decode. It is counted in unreadableLines and left exactly where it is. The test for it appends a line of a kind no build knows, from_the_future, and checks two things: the count is 1, and the file still has all six lines afterward.

That one rule buys forward compatibility for free. A newer build can add a fifth kind of event, and an older build reading the same file loses nothing: it folds what it understands and reports what it skipped. A store that "repaired" unreadable lines would destroy exactly the data it did not understand.

Principle: In an append-only log, a line you cannot read tells you about your reader. Count it and keep it.

Who counts as another browser

The watcher in the Mac shell listens for NSWorkspace.didActivateApplicationNotification. Every time an app comes to the front it asks one question: is this a browser? The first answer was any app the Mac offers for web addresses, and the log promptly filled with noise. Apps that catch an https link only to finish their own sign-in were counted as browsers. The source comment records the number: the Codex app was 274 of the first 623 departures.

The rule since 2026-10-22 is an intersection: an app counts when this Mac offers it both for a web address and for a web page on disk (UTType.html), and never Voyager itself or its verification build. The watcher asks Launch Services for both sets, with https://example.com/ as the probe address, and refreshes the answer at most once a minute. There is no list of browsers anywhere in the source. A browser installed tomorrow counts tomorrow.

The watcher also decides what a departure says about the page Dad left. It records leftFrom as the host of the front tab, never the full address. That is enough to tell which site sent him elsewhere and no more.

A second reader proves the format

The file format is a contract, and Voyager is not its only reader. scripts/doctor.sh, a read-only report of what is built, what is installed and what state Voyager holds, reads the same file with a few lines of Python and prints how many departures are recorded and how many still have no reason. It does not import any Swift. If the doctor can count the log, the log is data that outlives any one program reading it.

What the log orders

The architecture splits Dad's browsing into two halves. The family half (his apps and Pippa) is where existing browsers are worst, so Voyager works that half first, by design. The outside half (everything else on the web) is worked in the order the log gives. Even the engine decision defers to it: the reserved Chromium option comes back only if departures show Dad needs it.

The contract adds one fair exception. Opening another browser to study that browser, for example to try another browser's assistant or reproduce a Chrome-only bug in a family app, is recorded like any other departure and closed with that reason. It orders no work.

One scope note: the log is the Mac's. The iPhone and iPad shell keeps no departure log, and nothing under clients/apple/VoyagerMobile writes one. The parity contract's table of what the phone app does not do yet leaves this difference out, so it is a fact of the code, not a named gap.

Code

cwkVoyager, Sources/VoyagerCore/DepartureLog.swift: reading the file, counting what cannot be read, and folding events into rows (verbatim, lines 122-166)·swift
    public func read() throws -> Contents {
        guard FileManager.default.fileExists(atPath: url.path) else {
            return Contents(departures: [], unreadableLines: 0)
        }
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        var events: [DepartureEvent] = []
        var unreadable = 0
        for line in try String(contentsOf: url, encoding: .utf8).split(separator: "\n") {
            if let event = try? decoder.decode(DepartureEvent.self, from: Data(line.utf8)) {
                events.append(event)
            } else {
                unreadable += 1
            }
        }
        let asked = events.compactMap { event -> Date? in
            if case .asked(_, let at) = event { return at }
            return nil
        }
        return Contents(departures: Self.fold(events), unreadableLines: unreadable, lastAskedAt: asked.max())
    }

    /// Events in file order, folded into one row per departure.
    public static func fold(_ events: [DepartureEvent]) -> [Departure] {
        var order: [String] = []
        var byID: [String: Departure] = [:]
        for event in events {
            switch event {
            case .departed(let id, let at, let browser, let bundleID, let leftFrom):
                guard byID[id] == nil else { continue }
                order.append(id)
                byID[id] = Departure(
                    id: id, browser: browser, bundleID: bundleID, startedAt: at, lastSeenAt: at,
                    activations: 1, leftFrom: leftFrom, asked: false, reason: nil)
            case .seenAgain(let id, let at):
                byID[id]?.lastSeenAt = at
                byID[id]?.activations += 1
            case .asked(let id, _):
                byID[id]?.asked = true
            case .reason(let id, _, let text):
                byID[id]?.reason = text
            }
        }
        return order.compactMap { byID[$0] }
    }
cwkVoyager, Sources/VoyagerApp/DepartureWatcher.swift: asking the Mac which apps open both addresses and pages, at most once a minute (verbatim, lines 66-77)·swift
    /// The apps this Mac offers for a web address and for a web page, asked
    /// again once a minute.
    private func isBrowser(_ bundleID: String) -> Bool {
        if Date().timeIntervalSince(browsers.at) > 60, let probe = URL(string: "https://example.com/") {
            func ids(_ apps: [URL]) -> Set<String> { Set(apps.compactMap { Bundle(url: $0)?.bundleIdentifier }) }
            browsers = (Date(), DepartureTracker.browsers(
                openingAddresses: ids(NSWorkspace.shared.urlsForApplications(toOpen: probe)),
                openingPages: ids(NSWorkspace.shared.urlsForApplications(toOpen: UTType.html)),
                own: ["com.cwk.voyager", VoyagerPaths.verificationBundleIdentifier]))
        }
        return browsers.bundleIDs.contains(bundleID)
    }
cwkVoyager, scripts/doctor.sh: a second reader of the same file, counting departures and the ones with no reason (verbatim, lines 86-102)·shell
  departures="$state_root/departures.jsonl"
  if [[ -f "$departures" ]]; then
    summary=$(/usr/bin/python3 - "$departures" <<'PY' 2>/dev/null || print "UNREADABLE"
import json, sys
departed, reasons = set(), set()
for line in open(sys.argv[1]):
    try: event = json.loads(line)
    except ValueError: continue
    if event.get("kind") == "departed": departed.add(event.get("id"))
    if event.get("kind") == "reason": reasons.add(event.get("id"))
print(f"{len(departed)} recorded, {len(departed - reasons)} with no reason")
PY
)
    print "             departures: $summary"
  else
    print "             departures: none recorded"
  fi

External links

Exercise

Pick a behaviour you want your product to make unnecessary (a user switching to another tool, exporting to a spreadsheet, opening a terminal to fix something). Design an append-only event log for it: list the event kinds, their wire names and fields, the fold that turns them into rows, and what the reader does with a line it cannot decode. Write the fold as a pure function and test it with in-memory events, then write one disk test that appends an unknown event kind and checks that the file keeps every line.
Hint
Decide what one row means before you name events. Voyager's row is one stay in another browser, so a repeat activation during the stay is an event that updates the row, not a new row. Record the least that answers your question: a host, not a full URL.

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.