Skip to main content

The Twelve Mobile Factors: Building Robust Apps in the AI Era

The Twelve-Factor App, rewritten for the mobile runtime. Twelve boundaries that keep an app reliable when AI makes change cheap and fast.

The Twelve Mobile Factors: Building Robust Apps in the AI Era

Introduction

I like The Twelve-Factor App. It is one of the very few methodology documents I still go back to, because it turned “build it properly” into twelve things a team can argue about in a review. Every time I reread it I want a mobile version, because half of its assumptions break the moment you ship a signed binary to someone else’s phone.

Here is the release that keeps making me want one. An AI agent helps the team finish a checkout flow in four days instead of three weeks. Clean Swift, green tests, small diff, happy team. Then production teaches us what the diff did not. A six-month-old client cannot parse the new response, a remote flag creates a state nobody tested, and an upload suspended by iOS runs twice after relaunch. The logs are full, and none of them tie a failure to a version.

Code generation got cheap. The device, the network, App Store review, and that six-month-old install did not. My answer is not to slow the team down or put a review ceremony around every generated line, it is to make the safe path the easy path to generate. That is what these twelve factors are, the version I would teach an in-house mobile team.

What Does Not Transfer From the Original

The original methodology assumes you own the runtime, inject configuration at deploy time, restart a server process at will, and scale by starting more of them.

flowchart LR
    Team[Team and AI agents] --> CI[CI and signed artifact]
    CI --> Store[Distribution and review]
    Store --> Clients[Many app versions on user devices]
    Clients <--> Services[Backend and third-party services]
    OS[OS lifecycle and energy limits] --> Clients
    Net[Intermittent networks] --> Clients

We ship a signed binary to hardware we do not control. We cannot inject production environment variables after installation, cannot assume the app keeps running, and cannot roll back every installed copy. We usually need local state, and we always have several client versions talking to one backend.

So I keep the discipline and drop the literal rules. Codebase, dependencies, config, backing services, build and release, processes, disposability, dev/prod parity, and logs all survive with a mobile reading. Port binding and scaling by process type stay on the server. I spend those slots, plus the admin-process slot, on connectivity, concurrency, and automation, because those are the things mobile teams actually operate.

The principles hold across platforms. The examples are iOS because that is where I spend my days.

The Twelve Mobile Factors

1. Trace Every Release to Its Source

Every production binary should lead back to the exact commit, resolved dependencies, toolchain, and CI job that produced it. That matters more than a literal one-repo-per-app rule. Repository topology is a team choice, traceability is a reliability requirement.

The cheapest version of this is a build-stamp that CI writes and the app reads once at launch.

// Values injected by CI into Info.plist through an .xcconfig, never edited by hand.
enum BuildInfo {
    static let commit = Bundle.main.object(forInfoDictionaryKey: "GitCommit") as? String ?? "unknown"
    static let ciBuild = Bundle.main.object(forInfoDictionaryKey: "CIBuildNumber") as? String ?? "local"
}

Then attach it to your crash reporter once at startup, so every crash names its own source. Most teams I have worked with are already on Crashlytics, and it is free, so this is a ten minute change rather than a procurement conversation.

import FirebaseCrashlytics

// Call this right after FirebaseApp.configure(), before any feature code runs.
func stampBuildProvenance() {
    Crashlytics.crashlytics().setCustomKeysAndValues([
        "git_commit": BuildInfo.commit,
        "ci_build": BuildInfo.ciBuild,
        "config_version": FeatureConfig.version,
    ])

    // Pseudonymous, rotatable, never the user's email or account id.
    Crashlytics.crashlytics().setUserID(InstallationID.current)
}

The commit and build number are fixed for the life of an app run, but the config version is not. Remote Config only changes what the app sees when you activate a fetched config, which usually happens after launch, so stamp config_version again there. Otherwise the report names the config that was active at startup rather than the one the crash ran under.

// Wherever you activate, in the same place every time.
let changed = try await RemoteConfig.remoteConfig().fetchAndActivate()
if changed == .successFetchedFromRemote {
    Crashlytics.crashlytics().setCustomValue(FeatureConfig.version, forKey: "config_version")
}

Those keys ride along on every later crash report, which turns “checkout crashes sometimes” into “checkout crashes on build 4821 with config v37”, and the dashboard filters on them so you can see whether a spike belongs to one build or one flag. Add Crashlytics.crashlytics().log("...") at the few decision points in a risky flow and reports arrive with a breadcrumb trail instead of a bare stack trace.

AI makes this more valuable, not less. When an agent can touch six files across two packages in a minute, authorship tells you very little and provenance tells you everything.

Team practice: Generate releases only in CI, protect the release branch, and retain the archive, symbols, lock file, and build log for every build that reached a user.

2. Make Dependencies and Tools Explicit

Declare dependencies through Swift Package Manager and review a version bump like a source change. Package.resolved records the exact versions the resolver picked, which is why adding a dependency is a reviewable event, but a reproducible build also depends on Xcode, the SDK, build plugins, scripts, and the CI image.

The AI-era failure mode here is small and constant. An agent suggests a library because it saves twenty lines. Twenty lines is never the real price. The price is maintenance, privacy manifests, binary size, startup time, and supply-chain exposure.

The fix that worked best for me is writing the rule where both humans and agents read it.

<!-- .claude/rules/dependencies.md -->
Approved packages: swift-collections, swift-log, Alamofire (legacy, do not extend).
Anything new needs a decision record: owner, license, privacy behaviour,
binary size delta, maintenance health, and an exit plan.
Never add a dependency to avoid code we can own in under ~100 lines.

Team practice: Give coding agents an allowlist of packages already in the project, and require a human decision record before that list grows.

3. Separate Configuration, and Stop Pretending the Client Keeps Secrets

The original guidance says store deploy config in environment variables. That works in CI and on servers, not in an installed app. Mobile config comes in three layers: build settings such as bundle identifiers, entitlements, and endpoints, which Xcode configuration files handle well, remote product config such as rollout values, and user config such as preferences and granted permissions.

Nothing inside an app bundle is a server secret. Obfuscating a key does not promote it to one. Long-lived service credentials belong behind your backend, and user tokens belong in Keychain.

Remote config needs the same care as an API. Give every value a schema, a default, an owner, and a documented fallback, then generate the accessors so nobody invents a string key inside a view.

# config-registry.yml: the only place a remote key is born.
checkout_v2_enabled:
  owner: payments
  type: bool
  default: false
  # Documents what a reader gets when a fetch fails: the last value this install
  # activated, or the compiled-in default if it has never activated one.
  fallback: last_activated_then_default
  expires: 2026-12-01             # flags are temporary or they are tech debt

upload_chunk_bytes:
  owner: media
  type: int
  default: 1048576
  range: [65536, 8388608]         # validated before it reaches feature code

config_version:
  owner: platform
  type: string
  default: "unset"                # stamped on crash reports and analytics events

It lives in the repo beside the app target, checked in and reviewed like source.

App/
├── Config/
│   ├── config-registry.yml      # the file above, the only place a key is born
│   └── generate-config.swift    # a build phase script, or an SPM build tool plugin
└── Sources/Generated/
    └── FeatureConfig.swift      # output, never hand-edited

The generator reads the registry and emits one typed accessor per entry, plus the defaults you hand to the SDK at launch. On Firebase Remote Config, which is what most teams already have, the output looks like this.

// Generated from config-registry.yml. Do not edit.
import FirebaseRemoteConfig

enum FeatureConfig {
    /// Compiled-in defaults, used until this install activates a fetched config.
    /// They are the floor, not an override, see the note under this snippet.
    static let defaults: [String: NSObject] = [
        "checkout_v2_enabled": false as NSNumber,
        "upload_chunk_bytes": 1_048_576 as NSNumber,
        "config_version": "unset" as NSString,
    ]

    static var checkoutV2Enabled: Bool {
        RemoteConfig.remoteConfig()["checkout_v2_enabled"].boolValue
    }

    static var uploadChunkBytes: Int {
        let value = RemoteConfig.remoteConfig()["upload_chunk_bytes"].numberValue.intValue
        return (65_536...8_388_608).contains(value) ? value : 1_048_576   // range from the registry
    }

    static var version: String {
        RemoteConfig.remoteConfig()["config_version"].stringValue
    }
}

// At launch, before the first fetch.
RemoteConfig.remoteConfig().setDefaults(FeatureConfig.defaults)

Be precise about what those defaults buy you. setDefaults only covers keys this install has never activated a value for, and an activated config stays cached across launches, so a failed fetch serves the last activated value rather than snapping back to the registry. Needing it to snap back is a kill switch you flip and roll out, not a job for the default table.

Feature code now reads FeatureConfig.checkoutV2Enabled, and the raw string lives in exactly two places, the registry and the Firebase console. A typo fails the build instead of silently returning false, an out-of-range dashboard value falls back before it reaches a view, and an agent has exactly one legal way to read config.

Someone still has to create the key in the console, so two small CI jobs keep the halves honest. One fails the build when an entry is past expires, the other diffs the registry against the Remote Config REST API so a dashboard-only key surfaces as a failed job rather than a surprise mid-incident.

If codegen feels like too much on day one, hand-write that same enum and keep the YAML beside it as the spec. The value is the single typed door with defaults behind it, not the generator.

One boundary is worth stating out loud. Remote config steers behaviour you already shipped, it is not a channel for shipping new functionality. Review guideline 2.5.2 expects apps to stay self-contained.

Team practice: Keep the registry in review with the feature, and delete expired flags on their expiry date rather than when someone notices.

4. Treat Service Contracts as Products

Your domain layer should not care where a service is hosted, but the client still needs an explicit contract for requests, responses, errors, retries, and compatibility. Hiding transport behind a protocol is good design. Believing you can swap vendors by changing a URL is not, because a shipped client contains compiled assumptions.

The assumption that breaks most often is that everyone is on the latest version.

flowchart LR
    A[v4.2 shipped today] --> API[POST /checkout]
    B[v3.9 last quarter] --> API
    C[v2.1 on an old device] --> API
    API --> BE[One backend, one schema]

“We released a new client” is not permission to break the old one. Additive changes are the default, and the client decodes defensively so a new server value degrades instead of throwing.

enum PaymentMethod: String, Decodable {
    case card, wallet, transfer
    case unsupported   // anything the server adds after this version shipped

    init(from decoder: Decoder) throws {
        let raw = try decoder.singleValueContainer().decode(String.self)
        self = PaymentMethod(rawValue: raw) ?? .unsupported
    }
}

Now a backend adding crypto next quarter makes an old client hide one payment option, instead of failing the whole checkout response. That single ?? .unsupported has saved me more incidents than any amount of retry logic.

Team practice: Version contracts by capability, run consumer-driven contract tests in CI, publish a client support window, and feed the API schema to your AI tools so generated networking code comes from the contract instead of a guessed JSON sample.

5. Build Once, Release Progressively

A release artifact is immutable. Compile, test, sign, and archive once in CI, and never rebuild the same marketing version from a different commit because someone tweaked a setting at the last minute.

Deployment is where mobile stops resembling a server. Phased release spreads automatic updates over seven days on a fixed schedule, 1, 2, 5, 10, 20, 50, then 100 percent of eligible users. You do not get to pick those numbers or move to an arbitrary one. Your only day-to-day levers are pausing the rollout, resuming it, and releasing to everyone immediately. Users can also install the new version manually at any point, whatever the current day says. You cannot recall a binary, so rollback is never the whole recovery plan.

flowchart LR
    Artifact[One signed artifact] --> Beta[TestFlight]
    Beta --> Phased[Phased release, fixed daily steps 1, 2, 5, 10, 20, 50, 100 percent]
    Phased -->|health gates pass| Next[Let the next day advance on schedule]
    Next --> Phased
    Phased -->|crash-free or checkout conversion drops| Stop[Pause the release]
    Stop --> Kill[Flip kill switch, serve old clients]
    Kill --> Resume[Ship the fix, then resume the same schedule]

The interesting part of that diagram is the bottom row. Server-side compatibility with older versions, a kill switch on the risky path, migrations that survive an interrupted upgrade, and a degraded but usable mode are what actually contain an incident, because they work on devices you cannot update today.

Team practice: Write the numeric pause thresholds before the rollout starts, not during the incident, and name the engineer who owns the release decision. AI can summarise the telemetry, a person owns the call.

6. Persist Valuable State, Assume Memory Disappears

The original methodology wants stateless server processes. A mobile app should be restartable, but it is rarely stateless.

Picture a user posting a video. They pick the file, type a caption, tap Post, watch the progress bar reach 40%, then switch to Messages to answer someone. Ten minutes later iOS kills the app to reclaim memory. They come back.

There are two versions of that moment. In the bad one, the app opens on a blank feed, the caption is gone, the video was never posted, and nothing on screen admits it. In the good one, it opens on the composer with the caption still in the text field and the bar picking up near 40%. Same kill by iOS, completely different product.

The difference is what the app wrote down before it died, and it is three things rather than one. Where the user was, meaning the screen and the step. What they had given you, meaning the caption and a copy of the file the app owns. And what the app promised to do for them, meaning post this video exactly once. That third one is what teams forget, because it stays invisible until it fails.

So the saved record is not a row for the uploader, it is everything needed to rebuild that screen.

struct PendingPost: Codable, Identifiable {
    enum Stage: String, Codable { case saved, uploading, published }

    let id: UUID              // also the idempotency key the server sees, so a retry is not a second post
    var caption: String       // what they typed
    var mediaFilename: String // our own copy in Application Support, stored relative, never the picker's URL
    var screen: Route         // where they were, .composer or .review
    var uploadURL: URL?       // the resumable session the server handed us, nil until it does
    var confirmedOffset: Int  // bytes the server told us it has, not bytes we handed to the socket
    var stage: Stage
}

Two details there are where the naive version quietly fails. A picker URL can be temporary or security-scoped and the container path changes between installs, so copy the bytes somewhere the app owns and store the filename. And a local bytesSent counts what you wrote, not what the server received, which is why the record keeps a confirmedOffset. Resume starts from a fact the server states.

Which library holds all this is the least interesting decision here. GRDB, SwiftData, Core Data, or a file written atomically all satisfy the factor, and teams choose between them for reasons unrelated to reliability. Put it behind a small protocol and the choice stays swappable.

protocol PostStore {
    func save(_ post: PendingPost) throws        // durable before it returns
    func unfinished() throws -> [PendingPost]    // what we still owe the user
}

The factor lives at two call sites. The first is the moment they tap Post, and the ordering is the whole point.

// Copy first, so the record never points at a URL the system can take away.
let filename = try media.importForUpload(pickedURL)   // into Application Support, returns a filename

let post = PendingPost(id: UUID(), caption: caption, mediaFilename: filename,
                       screen: .composer, uploadURL: nil, confirmedOffset: 0, stage: .uploading)
try store.save(post)        // written before a single byte leaves the device
try await uploader.send(post)

The second is launch, where the app asks what it still owes and puts the user back where they were.

for post in try store.unfinished() {
    router.restore(post.screen)   // the composer, with the caption still in it
    uploader.resume(post)         // reconciles confirmedOffset with the server, then continues
}

Inside resume, the uploader asks the server where the session stands, writes that offset into the record, and streams from there. If the session expired it opens a new one with the same post.id, which is why the idempotency key lives on the record and not on the attempt. Each advance goes back to the store, so a kill costs one chunk instead of the whole upload.

That save before the network call is the difference between “the app lost my video” and “the app finished it after I reopened it”. Swap in a GRDB implementation with a WHERE stage != 'published' query behind unfinished(), and neither the domain code nor the reasoning changes.

stateDiagram-v2
    [*] --> Saved: user taps Post, promise written
    Saved --> Uploading: sent with the post id
    Uploading --> Published: server confirms
    Uploading --> Saved: app killed, backgrounded, or offline
    Published --> [*]

Classifying state up front keeps these decisions out of the code review:

Kind of stateExample from that screenRequirement
EphemeralScroll offset, current filterRecompute or discard freely
Restorable UIWhich screen, the typed captionReopen exactly where they left off
Durable userThe post we promised to sendSurvive the app being killed or upgraded
SyncedThe same draft on their iPadNeeds an ownership and conflict policy
SecretSession token, signing keysKeychain, never a plist or UserDefaults

Team practice: In design review, ask what the user sees if iOS kills the app after each step of the flow. If the answer is unknown for any step, the design is not finished.

7. Design for Intermittent Connectivity

The network is not a pipe, it is a condition that changes under you. Wi-Fi to cellular, constrained, tunnel, and back again twenty minutes later while the app is suspended.

URLSession can wait for connectivity rather than failing immediately, which removes a whole category of pointless error states.

let config = URLSessionConfiguration.default
config.waitsForConnectivity = true             // wait for a usable path instead of failing instantly
config.timeoutIntervalForResource = 60 * 60    // budget for one resource, from start to finish
let session = URLSession(configuration: config)

// Same key on every attempt, so the backend can deduplicate instead of double-charging.
request.setValue(post.id.uuidString, forHTTPHeaderField: "Idempotency-Key")

Both lines are narrower than they look. waitsForConnectivity only covers establishing the first connection, so a drop mid-transfer still fails the task and recovery is your retry logic plus the resumable offset from factor 6. timeoutIntervalForResource budgets one resource, waiting included, which is why an hour belongs to your transfer session and never to the one behind your API calls.

Transport is the easy half. Architecture still has to answer, for each network-backed feature, what is readable offline, whether local or server data wins, how retries back off, how requests deduplicate, when the cache goes stale, how conflicts resolve, and what the UI says while a sync is pending. Seven answers, one short paragraph in the ticket, written before any code exists.

Team practice: Put offline, high-latency, and reconnect cases in the acceptance criteria. Ask AI for a state-transition table before asking it for networking code, because a table is far easier to review than fifty lines of optimistic callbacks.

8. Match the Work to the Right Execution Vehicle

The user’s video is still uploading when they swipe up to the home screen. What happens next is not your decision. iOS suspends the app within seconds and may terminate it later to reclaim memory.

So the question worth asking in design review is not “did we handle backgrounding”. It is which system service is actually carrying this work while the app is not running.

The workVehicleWhen the app goes away
Loading what is on screen right nowA Task in the view’s scopeCancelled, and that is correct
A few seconds of cleanup after backgroundingbeginBackgroundTask assertionRoughly 30 seconds, then killed
Uploading or downloading a fileBackground URLSessionKeeps transferring without the app
Deferred sync or maintenanceBGTaskSchedulerRuns later, on the system’s terms
Must happen even if the app never opensServer side, plus pushNot the client’s job at all

Almost every lifecycle bug I have debugged is a row mismatch, usually an in-memory Task for something the user was promised, or a thirty second background assertion for a 200MB upload on hotel Wi-Fi. The fix is not more lifecycle callbacks, it is moving the work down the table.

// The upload from factor 6, handed to a vehicle that outlives the app.
let config = URLSessionConfiguration.background(withIdentifier: "com.example.uploads")
config.isDiscretionary = false          // the user is watching a progress bar, do not defer this
config.sessionSendsLaunchEvents = true  // relaunch us when it finishes
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)

session.uploadTask(with: request, fromFile: post.videoFileURL).resume()

The part that changes how a team thinks is what happens next. The app can be killed, and iOS will relaunch it in the background just to deliver the result.

// AppDelegate. We may be running only because a transfer finished.
func application(_ app: UIApplication,
                 handleEventsForBackgroundURLSession identifier: String,
                 completionHandler: @escaping () -> Void) {
    backgroundCompletionHandler = completionHandler
}

func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
    // The promise recorded in factor 6 finally gets settled, with no UI in sight.
    store.settle(postID: task.taskDescription, error: error)
}

That flow is the mental model worth teaching. The upload is not something your view model is doing, it is something the system is doing on your behalf, and your job is to record the promise before handing it over and settle it whenever the system reports back, possibly into an app with no screens on it.

One nuance before someone files the bug. If the user force quits from the switcher, iOS cancels that app’s background transfers, which is why force quit deserves its own test case rather than being lumped in with system termination.

Team practice: Name the vehicle in the ticket before implementation, and test cold launch, backgrounding mid-transfer, system termination, force quit, and relaunch as separate cases. Any long-running work that cannot name its vehicle is still a hope.

9. Decide Who Owns the Work, Not Just Which Function Is Async

The original concurrency factor is about scaling server process types, which does not transfer. Mobile needs this slot for a different reason, and here is what it looks like in the wild.

The user taps Post, watches the progress bar start, then swipes back to the feed to keep scrolling. The upload dies. Nobody called cancel(), and nobody intended for it to stop.

struct ComposerView: View {
    var body: some View {
        ProgressView(value: progress)
            .task { await uploader.upload(post) }   // dies the moment this view goes away
    }
}

.task ties the work’s lifetime to the view’s lifetime. That is exactly right for loading what the screen displays, and exactly wrong for a promise you made to the user. Same three lines, opposite meaning, and no compiler will tell you which one you wrote.

So every piece of work gets a declared scope. Screen-scoped work belongs to the view and should die with it. User-scoped work belongs to something that outlives every screen, and the view only observes it.

@MainActor @Observable
final class ComposerModel {
    private(set) var state: PostState = .idle

    // The view observes progress. It does not own the upload.
    func post(_ draft: Draft) {
        Task { await PostStore.shared.enqueue(draft) }   // a handoff, not the work itself
    }
}

actor PostStore {                    // one owner for the mutable state, lives as long as the app
    static let shared = PostStore()
    private var pending: [PendingPost] = []

    func enqueue(_ draft: Draft) async { /* save the promise, then hand it to the session */ }
}

That short Task is fine, because all it does is hand work to an owner that outlives the screen. What is not fine is a Task that is the work, because then the screen is holding something the user believes the app is holding.

And when you do cancel, be clear about what it buys you. cancel() is not a stop button, it sets a flag cooperative code has to check, and it cannot unsend a request the server already accepted. Cancelling a checkout task on a double tap does not guarantee one charge, it guarantees one task you are still holding while the cancelled one may have completed on the server. A stable attempt id sent as an idempotency key collapses the duplicates, and a status check when the user returns reconciles the two. Cancellation is a hint, idempotency is the guarantee.

Team practice: In review, every task declares its scope, dies with the screen, owned by a store, or handed to the system as in factor 8. Put that rule in the repository instructions both engineers and agents read, and turn strict concurrency checking into a CI gate so it has teeth.

10. Test Representative Reality, Not Imaginary Parity

Dev and prod are never identical on mobile. The simulator does not reproduce hardware performance, radio behaviour, energy use, real StoreKit purchases, or the full termination lifecycle, which is why Apple recommends testing on physical devices. Chasing parity is wasted effort. Layered confidence is the goal.

LayerBuys you
UnitDeterministic domain behaviour
IntegrationStorage, networking, and migrations
ContractAgreement with the server schema
UICritical journeys still work end to end
Real deviceLifecycle, performance, permissions, hardware
Beta and prodThe long tail you cannot reproduce

AI can multiply test quantity in seconds, and quantity is not the objective. A hundred generated happy-path tests can still miss the one migration that eats user data.

Team practice: Keep a risk map for each critical journey, and require every generated test to name the risk it protects or the contract it pins down.

11. Observe Outcomes, With Privacy Built In

Logs on your laptop are not observability. Production needs signals across crashes, hangs, launch time, energy, network reliability, and feature outcomes. In practice most teams get there with a free stack, Crashlytics for crashes and non-fatals, Firebase Analytics for funnel outcomes, MetricKit for launch time, hangs, and energy from real devices, and unified logging for the structured, privacy-aware detail you read during an investigation.

The thing worth designing is not the vendor, it is the shape of the event. A useful signal carries the outcome plus the dimensions that let you slice it later.

let log = Logger(subsystem: "com.example.checkout", category: "submission")

log.info("""
    submit outcome=\(outcome.rawValue, privacy: .public) \
    build=\(BuildInfo.commit, privacy: .public) \
    config=\(FeatureConfig.version, privacy: .public) \
    order=\(orderID, privacy: .private(mask: .hash))
    """)

One caveat on that masked value. The hash is stable only within one run of the app, so it ties lines together inside a single log stream and is not a join key. To follow an order across launches or systems, agree a purpose-built correlation id with the server and keep the raw identifier out of the log.

The same event usually deserves a second home, because product needs the funnel and you need the detail.

import FirebaseAnalytics

Analytics.logEvent("checkout_submit", parameters: [
    "outcome": outcome.rawValue,          // success, declined, network_failure, cancelled
    "config_version": FeatureConfig.version,
    "build": BuildInfo.ciBuild,
])

Carrying the same build and config dimensions in both places is what lets you line the two stories up when the funnel dips and you need to know whether it was a release or a flag. Every event should exist to answer a question you can name, and none should carry credentials, tokens, message bodies, or personal data for convenience. That matters more now, because AI helps group and explain incidents only if you hand it production data. Minimise and scrub before that pipeline, not after.

Team practice: For each critical journey, define what success, expected failure, technical failure, cancellation, and abandonment look like as signals, and review that schema with the feature rather than during the outage.

12. Automate the Path, Keep Human Accountability

Agents, CI jobs, release scripts, and code generators are all execution systems. Give them narrow permissions, deterministic inputs, validation gates, and auditable output. An agent should absolutely run tests, regenerate a client from an approved schema, and open a pull request. It should not quietly change signing, entitlements, analytics collection, or release policy, because those changes move trust and operational risk.

The simplest enforcement I know is the file that already exists in your repo.

# CODEOWNERS: automation gets more freedom where mistakes are cheapest.
/Sources/Features/**          @mobile-team      # agent PRs welcome
/Sources/Networking/API/**    @api-owners       # generated from schema only
/Sources/Persistence/**       @mobile-lead      # migrations need a human
/fastlane/**                  @release-owners
*.entitlements                @release-owners
*.xcconfig                    @release-owners

Human review then focuses on intent instead of re-reading generated lines. Does this preserve the product contract, what new failure mode exists, can old clients and old stored data survive it, how will we see it in production, and who owns the outcome.

Team practice: Classify repository paths by risk once, wire it into CODEOWNERS and branch protection, and let the tooling remember the rule so reviewers do not have to.

The Review That Makes This Stick

Twelve factors are only useful if they change what a team does on a Tuesday. For each meaningful feature I run a short review across six boundaries, ideally before implementation and definitely before handing the work to an agent.

BoundaryThe question
ArtifactCan we trace this binary to its inputs and archive?
ContractWhich client and server versions must interoperate?
StateWhat must survive termination, retry, and upgrade?
LifecycleWhich vehicle carries this work, and who owns it?
RolloutHow do we limit blast radius and recover?
EvidenceWhich privacy-safe signals prove it works?

Six questions, ten minutes. Good context produces better generated code, and more importantly it surfaces missing product decisions while they are still cheap.

Try It With Your Team This Week

Pick one real journey, login, checkout, media upload, or offline form submission, and give everyone the same scenario:

The user starts on an older app version, loses connectivity halfway, backgrounds the app, and returns after iOS killed the app. A remote flag changed before the retry, and the backend already accepted the first request.

Thirty minutes, and every group produces a state-transition diagram, a persistence classification, the compatibility rules, the retry and idempotency behaviour, the rollout and kill-switch boundaries, and the telemetry needed to debug it at 2am. Then compare. The point is not one perfect design, it is seeing how many assumptions were living quietly inside tickets and individual heads.

Then turn what you agreed on into artifacts that outlive the meeting, repository instructions, a config registry, API schemas, test fixtures, and CI gates, each with a named owner. Those artifacts onboard new engineers and constrain AI-generated changes at the same time.

Final Thoughts

A few releases in, the numbers I would watch are change failure rate, crash-free sessions, how long it takes to name the affected version and config, how long it takes to pause a bad rollout, and how many interrupted operations recover without the user doing anything. I also watch where review time goes, because a shift from mechanical nitpicks to intent and consequences is the clearest sign the boundaries are working.

Twelve-Factor gave service teams a shared language for reliable delivery. Mobile teams deserve one grounded in their own runtime, because our system is not just the Swift in the repo. It is signed artifacts, distribution, old client versions, device storage, OS lifecycle, unreliable networks, backend contracts, remote config, telemetry, and every human and agent changing all of it.

AI does not remove any of those constraints. It just gets us to them faster.

So the job of a mobile specialist is bigger than screens and API calls now. We design the boundaries that let a team move fast without turning every release into an experiment on users. Make provenance, compatibility, durable state, lifecycle recovery, progressive delivery, and privacy-aware evidence part of the normal path, and AI becomes a force multiplier for a robust system instead of a faster source of surprises.