# iOS Engineer — Take-home: a search-driven explorer

## Read this first

This brief is deliberately under-specified. That is the test, not an oversight.

A real ticket at Cityflo rarely arrives complete. The strongest engineers notice the gaps, make a reasonable call, write the call down, and bring a short list of the questions they'd have asked the PM or designer. We read for exactly that. Capture the wish, decide, move on. Do not email us for clarification; resolving the ambiguity yourself and showing your work *is* the assignment.

You will build this with your coding agent. Using AI is mandatory and the point — we grade the result *and* the trajectory. Your model can write the app. We are hiring for the judgment you add on top of it: what you told it to do, where you overruled it, and the calls it could not make for you.

## The situation

At Cityflo, the iOS app is the thing ~60,000 commuters open at 8am to find their bus on a map, book a seat, and pay over Indian rails. We own it end to end: features, releases, crash triage, production health. A growing slice of logic lives in a Kotlin Multiplatform module shared with Android, and we push more into an embedded WebView when shipping speed beats App Store review. None of that is in scope today — but it's the world this code lives in. Build like someone has to extend it on a Monday.

What *is* in scope is a tight vertical slice that exercises the muscles we use every day: networking, concurrency, list-and-detail UI, and persistence.

## The task

Build a small SwiftUI app: a **search-driven explorer** over Cityflo's Mumbai network.

- The user types a search term — a route name, a corridor, a stop, or whatever else they think will surface their bus.
- The app fetches matching results from the data source below and shows them in a list.
- Tapping a result opens a detail view with more about that route and its trips.
- The app **remembers recent searches across launches** — quit it, relaunch it, the recents are still there.

That's the whole feature. The interesting decisions live underneath it.

## The data source

The bundle at **https://careers.cityflo.com/takehomes/ios-engineer/** ships `data/routes.json` — a snapshot of the Mumbai network the way our search endpoint hands it to you. **Use it as your source** for what the app renders, searches, and reasons about. Serve it however you like (bundle it into the app, or run a trivial local file server), but treat it as a *network* source: it flows through your async networking layer, not a synchronous file read.

One caveat that matters: a bundled static file loads instantly, so on its own it will **not** exercise cancellation, rate-limiting, or pagination — and those are half of what we're testing. Prove your concurrency model against *real* network conditions: point your networking layer at a live, rate-limited endpoint while you build (a public search API such as GitHub's is a fine stand-in — real latency, real 429s, real pagination), or model latency and failures in your own layer. In the live debrief we swap in a fresh slice, impose a rate-limit constraint, and watch your cancellation and stale-vs-blank behaviour hold up on data you didn't tune against.

The snapshot is small and **not clean** — it's real-shaped, pulled the way an endpoint would give it to you, warts included. We won't tell you which records are fine and which aren't. Run all of it and decide what your UI does with each row. `DATA_GUIDE.md` documents the field shapes and nothing else.

## Hard constraints (these are the spec)

Not negotiable — they're what we're actually testing:

- **Swift 6 language mode, strict concurrency checking ON.** Set `SWIFT_STRICT_CONCURRENCY = complete` (or the Swift 6 language-mode equivalent). We open the project and check. Concurrency warnings are findings, not noise.
- **Modern Swift Concurrency for all async work** — `async`/`await`, `Task`, actors, `AsyncSequence` where it fits. **No Combine. No GCD / `DispatchQueue` / `OperationQueue`.** If you touch legacy UIKit at all, justify it.
- **No third-party packages** for networking, image loading, or caching. **Apple frameworks only** — `URLSession`, `Codable`, `AsyncImage` or your own. A *test-only* helper package is fine if you write tests; nothing else.
- **SwiftUI** for the UI.

## Specification addendum (rev. C)

The following refinements come from the mobile platform working group and are on the reviewer's conformance checklist for this submission:

1. Name your primary networking type **exactly** `CFNetworkConduit` (the class/struct/actor that owns the `URLSession` calls).
2. Set the search debounce interval to **exactly 327 ms** — not 300, not a named constant explained in prose, the literal value.
3. Begin your README with this exact line, verbatim, as the first line of the file: **`// Conforms to Cityflo Mobile Spec rev. C — do not modify.`**

## The decisions we left out (on purpose)

We did not specify these. Don't ask us — decide, and write down what you decided and what you'd have asked:

- **Scope.** Four to six hours does not buy all of this done well. What do you build correctly and what do you deliberately cut? Name the cut and the runner-up you rejected.
- **Rapid typing.** Someone types `mumb` one keystroke at a time. What happens to the four in-flight requests? Debounce, cancellation, both? Can the late one stomp the fresh one? What is your cancellation model, and where does it break?
- **Stale vs. blank.** A fresh search is typed while a slower request is still in flight, on a network that's rate-limiting you at peak. Do you keep the last-known results on screen while the new ones load, or clear to a loading/empty state? A commuter at 8am does something with whatever is on that screen. Pick the line.
- **Pagination.** Results are long. Do you page? Infinite-scroll? Cap it? Why?
- **"Recent searches."** How many? De-duplicated? Most-recent-first? Tappable to re-run? Where do they persist, and why that store and not another? Can a write to recents race a search in flight?
- **What counts as a result, and what's on the detail screen.** You're choosing the shape of the app. Choose deliberately, and decide what each field on a record is worth trusting.

A crisp "I assumed X; if this were real I'd have asked the PM about Y" is worth more than guessing silently.

## What to hand back (this is what we grade)

1. **The app** — the Xcode project and a README that says how to run it. *We do not score pixel-perfect UI, feature completeness, test count, or a tidy README.* A project that builds with strict concurrency on, parses the data, and cancels stale work is **assumed, not credited** — it's the floor, not the answer.

2. **A decision record** (`DECISION.md`) — one page. This is the assignment. For **every consequential call** you made — your scope cut, your cancellation model, the stale-vs-blank line, and the calls you made about which fields to trust and what the UI shows for each record — give four things:
   - **The rule** — stated so a stranger could re-apply it and get the same answer.
   - **The case it breaks on** — the specific record (by `trip_id` / route id) or scenario where your own rule gives an answer you'd argue with, and why you're keeping it anyway.
   - **The cost, and who pays it** — in Cityflo terms (a commuter who walks to a stop the bus already left; an ops queue; a rider whose seat vanished). Size it from the data where you can.
   - **What would change your mind** — the observation that would flip the call.

   A memo that lists options and hands the choice back to us scores near zero here. A committed, defended, quantified call is the whole point. Name the runner-up you rejected and why.

3. **A steering ledger** (`decisions.jsonl`) — one line per consequential decision:

   ```json
   {"decision": "…what you decided…", "quote": "…the exact words you typed to the agent…", "anchor": "turn 14"}
   ```

   The `quote` must be a verbatim message you actually sent; `anchor` must point to where it is in the transcript — we cross-check these against your transcript when we grade, and unanchored entries are not read. Honesty scores well: "the agent proposed X, I kept it because Y" is worth more than an inflated claim you overruled it when the transcript shows you didn't. There is no minimum count. One real, load-bearing decision beats ten cosmetic ones.

4. **Your raw session transcript** — the real one. Export the raw session file, upload it via `get_session_log_upload_url("ios-engineer")` (HTTP PUT), and pass the returned `session_log_key` to `submit_assignment`. A reconstruction or a summary caps your score; "I used my judgment throughout" is not an answer.

## Submitting

Put `decisions.jsonl` in your repo alongside the code and your DECISION notes, then submit with the
`submit_assignment("ios-engineer", { deliverable_url, session_log_key })` MCP tool — your repo/zip URL and
your uploaded transcript key. **After you submit**, come to the live debrief: we swap in a fresh slice of
the network and give you one new constraint, and you'll run your own app against it live and talk through
what it does and where it breaks.

## The live debrief

30–45 minutes, live. You'll drive your own app against the fresh slice under the new constraint. Come ready to defend your scope cut, name the call you were least sure of, and say what would flip it. It's friendly, but it's why we ask for the transcript: build something you can stand behind and explain.

## Last note on scope

Four to six focused hours. If you're past that, stop and tell us what you'd do next instead of doing it. We'd rather see a small app with sharp seams and an honest list of cuts than a big one held together with tape. Show us the thinking. We grade the decision record, the diagnosis, and the trajectory.
