---
title: 'Cross-Platform Production Practice'
source: 'https://academia.sh/en/courses/cross-platform-production'
language: en
updated: '2026-10-21T05:02:50+00:00'
license: 'CC BY-SA 4.0'
lessons: 14
outcomes:
    - 'Builds a shared architecture without hiding platform differences'
    - 'Designs an offline operation and synchronization strategy'
    - 'Builds a mobile continuous delivery pipeline for both stores'
---

# Cross-Platform Production Practice

In production a cross-platform app keeps living when the network drops, when the device switches to power saving, when an old version stays open, or when a new version reaches only one bucket. The course measures every topic with a single question — how fully does this feature work under this condition? A feature works in full, works degraded, is deferred or is disabled; shared architecture, the design system, configuration, accessibility, offline-first design, synchronization, background limits, connectivity state, mobile continuous delivery, version management, staged rollout, forced updates, app size and monitoring all pass through the same frame.

## Shared Architecture

Shared and platform-specific layers, adapting one design language to the expectations of two platforms, multiple environments and secret values, accessibility on both platforms.

### Modular Code Sharing

Source: https://academia.sh/en/courses/cross-platform-production/modular-code-sharing

Modular code sharing splits common code into a shared module and a platform-specific layer. The lesson builds the course's operability measure and measures how a fully shared module can still be disabled on one platform.

The earlier courses asked how a cross-platform app is built: how code is shared across platforms, how the view tree is updated, who decides the width of a component. For an app in production the real question is different. The same screen opens online on one device and offline on another; on a third the background work is throttled; a fourth runs an old client version. A single app does not run under a single condition.

This course classifies every feature with one question: how well does this feature work under this condition? The first lesson builds the question and ties it to the subject of the first topic, how common code is sliced. In this course a "feature" means an app feature: something the user does, such as browsing a catalog or paying. It does not mean a value a component receives from outside.

#### The Four Operability Levels

The answer to the question is called operability, and it has four values. Throughout the course these four names do not change.

- **full:** every capability the feature needs is present under this condition; the feature works as designed.
- **degraded:** a capability is missing; the feature works immediately by doing less in its place, for example by showing cached content.
- **deferred:** a capability is missing; the feature's work is not lost, it is postponed and done when the capability returns.
- **disabled:** at least one of the missing capabilities has no fallback; the feature does not work under this condition.

In the output these names are written in ASCII: `full`, `degraded`, `deferred`, `disabled`. Disabled and deferred are not the same thing. The work of a disabled feature is not done under that condition and does not wait for later either; the work of a deferred feature stays in place.

A rule gives the operability, not a label. The feature counts the capabilities it needs, the condition counts the capabilities present at that moment, and the difference gives the missing ones. For each missing capability the feature may have defined a fallback. The rule asks three questions in order:

1. If no capability is missing, the feature is full.
2. If one of the missing capabilities has no fallback, the feature is disabled.
3. Otherwise the heaviest of the fallbacks of the missing capabilities is chosen; deferred is heavier than degraded.

These three questions live in the core as code. The core is given again in the same form in every lesson of this course; it is read here for the first time.

```javascript
// operability.mjs — classifies how fully a feature works under a condition
export const LEVELS = ["full", "degraded", "deferred", "disabled"];
const FALLBACKS = ["degraded", "deferred"];

export function feature(name, needs, fallback = {}) {
  return { name, needs, fallback };
}

export function condition(name, has) {
  return { name, has };
}

function judge(item, cond) {
  const missing = item.needs.filter((need) => !cond.has.includes(need)).sort();
  let level = "full";
  for (const need of missing) {
    const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled";
    if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given;
  }
  return { level, feature: item.name, condition: cond.name, missing };
}

export function assess(features, conditions) {
  const records = [];
  for (const cond of conditions) {
    for (const item of features) records.push(judge(item, cond));
  }
  return records;
}

export function printLevels(records) {
  for (const r of records) {
    const missing = r.missing.length === 0 ? "-" : r.missing.join(",");
    console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`);
  }
}

export function countLevels(records) {
  for (const name of LEVELS) {
    console.log(`${name}: ${records.filter((r) => r.level === name).length}`);
  }
  console.log(`affected: ${records.filter((r) => r.level !== "full").length}`);
}
```

`feature(name, needs, fallback)` builds a feature: `needs` lists the capabilities it needs, and `fallback` says what it falls back to when a capability is missing. If `fallback` is not given it is empty; none of that feature's capabilities has a fallback, and every missing capability drops it to disabled. `condition(name, has)` builds a condition; `has` holds the capabilities present under it. A capability is a string (`network`, `background`, `storage`), and the core does not interpret it; it only checks whether it is in `has`.

`assess` evaluates every feature under every condition: the condition is the outer loop, the feature the inner one. Each record carries the fields `level`, `feature`, `condition`, and `missing`. `printLevels` prints the records; `countLevels` counts the four levels in a fixed order and gives the affected total on the last line. Affected is the sum of the degraded, deferred, and disabled records: the number of records where the feature does not work as designed. Full records do not enter this number.

#### Nine Records

The scenario is a store app published on two platforms with shared code. There are three features. `Catalog` reads content from the network; when the network is missing it falls back to cached content. `Checkout` takes payment and has no fallback. `PhotoUpload` sends photos; it needs both the network and background work, and when either is missing it postpones its work. There are three conditions: `online` carries every capability, `offline` loses the network, and `low-power` keeps the network but loses background work.

```javascript
// main.mjs
import { feature, condition, assess, printLevels, countLevels } from "./operability.mjs";

const features = [
  feature("Catalog", ["network"], { network: "degraded" }),
  feature("Checkout", ["network"]),
  feature("PhotoUpload", ["network", "background"], {
    network: "deferred",
    background: "deferred",
  }),
];
const conditions = [
  condition("online", ["network", "background"]),
  condition("offline", ["background"]),
  condition("low-power", ["network"]),
];
const records = assess(features, conditions);
printLevels(records);
countLevels(records);
```

```text
full: Catalog @ online missing=-
full: Checkout @ online missing=-
full: PhotoUpload @ online missing=-
degraded: Catalog @ offline missing=network
disabled: Checkout @ offline missing=network
deferred: PhotoUpload @ offline missing=network
full: Catalog @ low-power missing=-
full: Checkout @ low-power missing=-
deferred: PhotoUpload @ low-power missing=background
full: 5
degraded: 1
deferred: 2
disabled: 1
affected: 4
```

The first three lines are the `online` condition: every capability is present and all three features are full. Under `offline` the network is missing. `Catalog` is degraded because it defined a fallback for the network, `PhotoUpload` is deferred because it postpones its work, and `Checkout @ offline` is disabled because `Checkout` has no fallback. Under `low-power` the network is present but background work is not: `Catalog` and `Checkout`, which do not need background work, are full, and `PhotoUpload` is deferred.

Of the nine records, five are full, one is degraded, two are deferred, and one is disabled; four are affected. This number is the course's opening number. Later lessons refer to it and do not change it; each lesson measures its own slice separately. The only record that gives the user nothing is `Checkout @ offline`. In three of the four affected records the feature keeps working in some form.

**SA1.** The model imitates a feature's behavior under a condition only as a set of capabilities. The rule sees the capability and the fallback, not the work itself, the user, or the time. How stale the degraded `Catalog` may show its cached content, when the deferred `PhotoUpload` will do its work, and how many times it will retry are not in the model and do not change the operability. The model builds this operability's rule; the model is not the framework itself.

#### Shared Module and Platform-Specific Layer

In a cross-platform app the code is split into two parts. The **shared module** is code written once for both platforms: business rules, the data model, flow logic. The **platform-specific layer** is code written in each platform's own language with its own API: camera, notifications, file system, biometrics. The shared module does not reach for these capabilities directly. It states what it wants as a contract, and the platform-specific layer fulfills the contract.

The line between the two parts is called the layer boundary. A well-drawn boundary means the platform-specific code stays small and narrow: the shared module says what it wants and does not know how it is fulfilled on which platform. The snippet below shows a contract and the shared feature that uses it. The snippet is not measured.

```kotlin
// Platform code: shared contract
interface PlatformLayer {
    val hasScanner: Boolean
    fun scan(onCode: (String) -> Unit)
}

class ScanFeature(private val layer: PlatformLayer) {
    fun open(onCode: (String) -> Unit): Boolean {
        if (!layer.hasScanner) return false
        layer.scan(onCode)
        return true
    }
}
```

`ScanFeature` is the same code on both platforms. It asks the layer whether the capability exists; if not, it returns `false` and nothing happens. The two fulfillments of the contract differ by platform. The Android side is in Kotlin, and the iOS side follows the Swift counterpart of the same contract.

```kotlin
// Platform code: Android layer (the scanner is not written yet)
class AndroidLayer : PlatformLayer {
    override val hasScanner = false
    override fun scan(onCode: (String) -> Unit) = Unit
}
```

```swift
// Platform code: iOS layer
final class IosLayer: PlatformLayer {
    let hasScanner = true

    func scan(onCode: @escaping (String) -> Void) {
        // sets up a camera session and passes the code it reads to onCode
    }
}
```

The model builds this distinction with one function. `platformLayer(platform)` produces a condition from a platform's layer: if the layer fulfills a capability, that capability is in the condition's `has` list; if it does not, it is not. In a real app, code produces the capability set from the state of the device and the layer; the model follows the same path.

```javascript
// layers.mjs — produces the capabilities offered by the platform-specific layer
import { condition } from "./operability.mjs";

const LAYERS = {
  ios: ["network", "storage", "scanner"],
  android: ["network", "storage"],
};

export function platformLayer(platform, written = []) {
  return condition(platform, [...LAYERS[platform], ...written]);
}
```

In the first measurement two features are evaluated against two layers. Both need only capabilities that both layers fulfill.

```javascript
// main.mjs
import { feature, assess, printLevels } from "./operability.mjs";
import { platformLayer } from "./layers.mjs";

const features = [
  feature("Catalog", ["network"], { network: "degraded" }),
  feature("Profile", ["storage"]),
];
const conditions = [platformLayer("ios"), platformLayer("android")];
printLevels(assess(features, conditions));
```

```text
full: Catalog @ ios missing=-
full: Profile @ ios missing=-
full: Catalog @ android missing=-
full: Profile @ android missing=-
```

All four records are full. This is the case where shared code does the same job on both platforms, and it is the common case. The boundary becomes visible where a layer does not fulfill a capability.

#### The Bounding Measurement

In the bounding measurement one feature is added to the list: `Scan`. The business logic of the scan feature is in the shared module and is the same code on both platforms. But using the camera requires the `scanner` capability, and that capability comes from the platform-specific layer. In the scenario the layer is written for iOS and not yet written for Android. No fallback is defined for `Scan`.

```javascript
// main.mjs
import { feature, assess, printLevels, countLevels } from "./operability.mjs";
import { platformLayer } from "./layers.mjs";

const features = [
  feature("Catalog", ["network"], { network: "degraded" }),
  feature("Scan", ["scanner"]),
];
const conditions = [platformLayer("ios"), platformLayer("android")];
const records = assess(features, conditions);
printLevels(records);
countLevels(records);
```

```text
full: Catalog @ ios missing=-
full: Scan @ ios missing=-
full: Catalog @ android missing=-
disabled: Scan @ android missing=scanner
full: 3
degraded: 0
deferred: 0
disabled: 1
affected: 1
```

The lesson's focus record is `Scan @ android`, and it comes out disabled. `Scan` is the same shared code on both platforms; on iOS it is full. On Android the `scanner` capability is missing and the feature has no fallback for that capability, so the rule reaches disabled at the second question. This measurement's affected count is one, not the four of the opening; the two measurements count different sets.

**SA2.** The model does not say that the camera cannot be used on Android. The scenario only establishes that the scanner contract is not yet fulfilled in this app's Android layer. `scanner` is the lesson's construction, and the core does not interpret it. How much work it is to write the layer is not in the model.

This result differs from the question of M32/K01. That lesson asked how code is distributed across platforms: how much is shared and how much is platform-specific. Operability does not ask that. A fully shared module can also be disabled under one platform's condition; operability classifies not where the code is written but how well it works under that condition. How much of the code is shared does not change the record, because `feature` carries no sharing ratio and the rule looks only at `needs`, `fallback`, and `has`. Whether all of `Scan`'s business logic is shared or only part of it, `Scan @ android` stays the same.

What changes a disabled record is the platform's layer. The second measurement has the Android layer write the `scanner` capability and evaluates the same feature again.

```javascript
// main.mjs
import { feature, assess, printLevels } from "./operability.mjs";
import { platformLayer } from "./layers.mjs";

const scan = [feature("Scan", ["scanner"])];
printLevels(assess(scan, [platformLayer("android")]));
printLevels(assess(scan, [platformLayer("android", ["scanner"])]));
```

```text
disabled: Scan @ android missing=scanner
full: Scan @ android missing=-
```

Not a single line in the shared module changed; only the layer fulfilled the capability, and the record returned to full. Another way out of disabled is to design a fallback. If a fallback such as entering the code by hand had been defined for `Scan`, the same record would have been degraded. Being disabled says that no fallback was designed.

#### Summary

- This lesson's operability: disabled. In the `Scan @ android` record the `scanner` capability is missing and has no fallback; even though the shared module is entirely common, the record comes out disabled.
- Operability is the output of a rule: full if no capability is missing, disabled if a missing one has no fallback, otherwise the heaviest fallback among the missing ones (deferred is heavier than degraded).
- In the opening measurement, of the nine records five were full, one degraded, two deferred, and one disabled; four were affected.
- The shared module states the contract and the platform-specific layer fulfills the capability; the layer boundary is this line.
- The sharing ratio of the code does not change the record; `Scan @ android` returned from disabled to full only when the Android layer fulfilled the `scanner` capability.

#### Next Step

In this lesson what changed by platform was a capability, and when it was missing the feature was disabled. The next lesson, `Design System Adaptation`, measures the case where what changes by platform is appearance: a single design language is adapted differently on two platforms, and a date picker works fully in both forms.

### Design System Adaptation

Source: https://academia.sh/en/courses/cross-platform-production/design-system-adaptation

Design system adaptation tunes one design language to the expectations of two platforms. The lesson measures that an adapted component works fully on both platforms and that another component stays degraded under a version condition.

In the previous lesson what changed by platform was a capability: when the layer did not fulfill the capability, the feature was disabled. In this lesson what changes by platform is appearance. A product has a single design language; its colors, spacings, corner radii, and type scales keep the product recognizable on both platforms. But iOS and Android users bring different habits from their own platforms, and a good design system adapts to this difference on purpose. The lesson first describes how an adaptation is built, then measures the operability of an adapted component.

#### Design Tokens

A design system is the set of parts that keeps a product's appearance consistent: tokens, the components built from those tokens, and guidelines on when and how to use the components. A **design token** is the named value of a design decision: `accent` is a color, `gap` is a spacing, `radius` is a corner radius. A component does not write a color or spacing into itself; it reads the token by name. The gain is that a change happens in one place: the accent color changes in the token, and every component that reads it follows.

Tokens are thought of in three layers. The base layer carries the values the product keeps the same on both platforms. The adaptation layer carries the values written over the base on one platform. The component reads the final value from the combination of the two: if a value is defined on the platform it is used, otherwise the base value. This way the adaptation stays small, like a list of exceptions, and most of the product's values still live in one place.

The snippet below shows this reading on a component. The date picker takes its form and width from the token; it does not ask which platform it runs on. The snippet is not measured.

```jsx
// Platform code: component that reads tokens
function DatePicker({ tokens, value, onChange }) {
  const Picker = tokens.picker === "wheel" ? WheelPicker : CalendarPicker;
  return (
    <Picker
      value={value}
      onChange={onChange}
      width={tokens.width}
      radius={tokens.radius}
    />
  );
}
```

There is no platform check inside the component. The token decides which form is chosen, and the platform's adaptation layer fills in the token. This is the appearance-side counterpart of the previous lesson's layer boundary: the component says what it wants and does not know how the platform fulfills it.

#### Adapting Per Platform

The reason for adapting is habit. Picking a date can be turning a wheel or opening a small calendar on iOS; on Android it is touching a day in a calendar dialog. Both users expect this on their own platform. Having the same component come out in a single form everywhere gives the product consistency but breaks the expectation the user brings from the platform. Adapting preserves the expectation and charges a price for it: the team now has to design, build, and test two forms.

This price should not be underestimated. Every adapted component is a testing multiplier: two forms on two platforms means two different widths and two different corner radii. For this reason a good design system chooses deliberately what to adapt. Components where the platform's own habit is strong, such as a date picker, are adapted; the colors and type scales that carry the brand's identity stay single.

Adaptation is not only appearance. Some components rely on a visual capability the platform offers, such as a blurred background. This capability does not exist on every platform and every version. In this case the component chooses one of two paths: it requires the capability and does not work without it, or it falls back to a plain background without it. The second path is a fallback, and the lesson measures it in the second half of the bounding measurement.

#### From Token to Condition

The model builds the adaptation in two steps. First it keeps the tokens in a helper file. Then `themeFor(platform)` produces a condition from a platform's tokens: if the picker's form is defined, `picker` is in the condition's `has` list, and if the blurred background is supported, `blur` is. The lesson's core is given again below, as in every lesson.

```javascript
// operability.mjs — classifies how fully a feature works under a condition
export const LEVELS = ["full", "degraded", "deferred", "disabled"];
const FALLBACKS = ["degraded", "deferred"];

export function feature(name, needs, fallback = {}) {
  return { name, needs, fallback };
}

export function condition(name, has) {
  return { name, has };
}

function judge(item, cond) {
  const missing = item.needs.filter((need) => !cond.has.includes(need)).sort();
  let level = "full";
  for (const need of missing) {
    const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled";
    if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given;
  }
  return { level, feature: item.name, condition: cond.name, missing };
}

export function assess(features, conditions) {
  const records = [];
  for (const cond of conditions) {
    for (const item of features) records.push(judge(item, cond));
  }
  return records;
}

export function printLevels(records) {
  for (const r of records) {
    const missing = r.missing.length === 0 ? "-" : r.missing.join(",");
    console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`);
  }
}

export function countLevels(records) {
  for (const name of LEVELS) {
    console.log(`${name}: ${records.filter((r) => r.level === name).length}`);
  }
  console.log(`affected: ${records.filter((r) => r.level !== "full").length}`);
}
```

The number of the nine-record opening measurement was given in the first lesson; this lesson does not change it and only measures its own slice. The tokens and `themeFor` live in a lesson-local helper file. There are three adaptations: iOS, Android, and an old Android that stands for the version condition.

```javascript
// tokens.mjs — the values of one design language that are adapted per platform
import { condition } from "./operability.mjs";

const BASE = { accent: "blue", gap: 8, radius: 4, blur: true };

const ADAPT = {
  ios: { picker: "wheel", radius: 10, width: 280 },
  android: { picker: "calendar", width: 320 },
  "android-legacy": { picker: "calendar", width: 320, blur: false },
};

export function tokensFor(platform) {
  return { ...BASE, ...ADAPT[platform] };
}

export function themeFor(platform, tokens = tokensFor(platform)) {
  const has = [];
  if (tokens.picker) has.push("picker");
  if (tokens.blur) has.push("blur");
  return condition(platform, has);
}
```

The first measurement prints the tokens, not the operability. The final values of the two platforms are written with sorted keys.

```javascript
// main.mjs
import { tokensFor } from "./tokens.mjs";

for (const platform of ["ios", "android"]) {
  const tokens = tokensFor(platform);
  const keys = Object.keys(tokens).sort();
  console.log(`${platform}: ${keys.map((k) => `${k}=${tokens[k]}`).join(" ")}`);
}
```

```text
ios: accent=blue blur=true gap=8 picker=wheel radius=10 width=280
android: accent=blue blur=true gap=8 picker=calendar radius=4 width=320
```

The adaptation layer carries only the difference. `accent` and `gap` come from the base on both platforms, `radius` is overridden only on iOS, and `picker` and `width` carry their own value on both platforms. All of these values are the lesson's construction and carry no unit.

**SA3.** The token values (`gap`, `radius`, `width`) are the lesson's construction; they are not the measurements of a real design system. From a token the model draws only two conclusions: whether the picker's form is defined, and whether a blurred background is supported. How the component is actually drawn and how the two forms feel to the user are not in the model. The model builds this operability's rule; the model is not the framework itself.

#### The Bounding Measurement

The lesson's focus record is `DatePicker @ android`. `DatePicker` needs only the `picker` capability. The tokens differ on the two platforms: a wheel on iOS, a calendar on Android. But both have a picker form, so the capability exists under both conditions.

```javascript
// main.mjs
import { feature, assess, printLevels } from "./operability.mjs";
import { themeFor } from "./tokens.mjs";

const features = [feature("DatePicker", ["picker"])];
const conditions = [themeFor("ios"), themeFor("android")];
printLevels(assess(features, conditions));
```

```text
full: DatePicker @ ios missing=-
full: DatePicker @ android missing=-
```

`DatePicker @ android` is full. This measurement differs from the question of M32/K03. That lesson asked who owns a component's width during the layout pass. When a design system adapts a component per platform its width can change; operability does not classify the width but whether the component does its job under that condition. On the two platforms the picker is drawn in two different widths and two different forms, and it does its job in both.

The second measurement shows directly that the rule does not see token values. On the Android adaptation the corner radius, the width, and the picker's form are changed; the records before and after are compared.

```javascript
// main.mjs
import { feature, assess } from "./operability.mjs";
import { tokensFor, themeFor } from "./tokens.mjs";

const features = [feature("DatePicker", ["picker"])];
const before = assess(features, [themeFor("android")]);
const edited = { ...tokensFor("android"), radius: 16, width: 360, picker: "list" };
const after = assess(features, [themeFor("android", edited)]);
console.log(JSON.stringify(before) === JSON.stringify(after) ? "record unchanged" : "record changed");
```

```text
record unchanged
```

Three tokens changed and the record stayed the same. The rule sees only what enters `needs`, `fallback`, and `has`; a change that does not show up in the capability set does not disturb the record. This is not a contradiction but the measure itself: a narrow or wide width may affect the user, but the model says only whether the picker does its job.

The second comparison is a component that carries a fallback. `FrostedCard` wants a blurred background; without blur it falls back to a plain background. A condition for an old Android version that does not fulfill blur is added.

```javascript
// main.mjs
import { feature, assess, printLevels } from "./operability.mjs";
import { themeFor } from "./tokens.mjs";

const features = [
  feature("DatePicker", ["picker"]),
  feature("FrostedCard", ["blur"], { blur: "degraded" }),
];
const conditions = [themeFor("ios"), themeFor("android"), themeFor("android-legacy")];
printLevels(assess(features, conditions));
```

```text
full: DatePicker @ ios missing=-
full: FrostedCard @ ios missing=-
full: DatePicker @ android missing=-
full: FrostedCard @ android missing=-
full: DatePicker @ android-legacy missing=-
degraded: FrostedCard @ android-legacy missing=blur
```

Under the old version condition `DatePicker` is still full, while `FrostedCard` is degraded: blur is missing, and the feature works immediately with a plain background. Five of the six records are full. This number is not the number of the opening 5/1/2/1 measurement; the two measurements count different sets.

**SA4.** That the old version condition does not fulfill blur is the lesson's construction. The model does not say which real version carries this capability; for this reason no version number is written in the text. How bad or good the plain background looks to the user is also not in the model.

A fallback is the design decision that turns disabled into degraded. If `FrostedCard`'s `fallback` map were empty, the same missing capability would give a disabled result. The design system writes this decision into the component: when the component cannot find the capability, it already knows what to do.

#### Summary

- This lesson's operability: full. In the `DatePicker @ android` record, even though the picker is a wheel on iOS and a calendar on Android, the `picker` capability exists under both conditions and nothing is missing.
- A design token is the named value of a design decision; the adaptation layer writes only the platform difference over the base.
- Even when the width, the corner radius, and the picker's form changed, the record stayed the same; the rule does not see a change that does not show up in the capability set.
- Under the old version condition that does not fulfill blur, `FrostedCard` came out degraded and `DatePicker` stayed full.
- Adaptation preserves the expectation and creates a testing multiplier; a fallback is the design decision that turns disabled into degraded.

#### Next Step

In this lesson the content of the token determined the capability. The next lesson, `Configuration and Environment Management`, covers the case where the capability comes from configuration: development, staging, and production environments, secrets, and the value of a flag on a first launch when remote configuration cannot be reached.

### Configuration and Environment Management

Source: https://academia.sh/en/courses/cross-platform-production/configuration-and-environment-management

Configuration and environment management lets the same app run with different values in development, staging, and production. The lesson measures that a flag opened from its default leaves the feature full.

In the previous lesson appearance was adapted per platform. In this lesson what changes is not the platform but the environment the app runs in. The same build runs on the developer's machine, in the hands of the testing team, and with a real user; in none of the three does it talk to the same server, write the same log verbosity, or find the same flags open. Configuration is the mechanism that carries this difference without touching the code. The lesson first describes the parts of configuration, then measures how well a feature works on a first launch where remote configuration cannot be reached.

#### Environments and Configuration Layers

An **environment** names the purpose for which the app runs and the backend it runs against. Three environments are common: the development environment belongs to a single developer and allows everything to be tried quickly; the staging environment resembles production but carries no real data; the production environment is the one real users use. The gain of separating environments is catching a mistake in the right place: a change is tested first in development, then in staging, and only then goes to production.

Each environment has its own **configuration**: a server address, a log verbosity, a service key. Writing these values into the code causes two problems. First, when the environment changes the code has to change, and when the code changes a tested build is no longer a tested build. Second, values such as keys enter the repository together with the code. For this reason the values are separated from the code and selected by environment during the build.

The snippet below shows this separation in a configuration file. The secret itself does not stay in the file; only its name does, and the real value is substituted from the build system's secret store. The snippet is not measured.

```yaml
# Platform code: per-environment configuration
environments:
  staging:
    api_host: api.staging.example.test
    api_key: ${API_KEY}      # substituted from the secret store at build time
    log_level: verbose
  production:
    api_host: api.example.test
    api_key: ${API_KEY}
    log_level: plain
defaults:
  search: true               # the value in effect if remote configuration does not arrive
  banner: false
```

A **secret** is a value whose exposure would lead to the misuse of a service: a key, a password, a signing key. The rule is simple and applied without exception: a secret is not embedded in the code, in the configuration file, or in the app package. Everything that enters the package can be read by anyone who opens the package. The secret stays in the build system's secret store, and the configuration carries only its name. A value that truly must not stay on the client is never sent to the client at all; it stays on the server.

#### Default Values and Remote Configuration

Some values cannot be fixed at build time, because they need to change after the user has installed the app. **Remote configuration** is the app receiving values from a server while it runs. Its most common use is the **feature flag**: a value that decides, without a code change, whether a feature is on or off. A special kind of flag is the **remote kill flag**: a flag that turns off a feature causing problems in production without waiting for a new version.

Remote configuration has a flaw: the server may be unreachable. On a first launch there may be no network, the server may temporarily not respond, the user may be in airplane mode. In this case the app must have a value. This is what the **default value** is: the value that ships inside the package and applies when nothing can be received remotely. The app resolves a value in this order: the remote value if there is one, otherwise the default.

Choosing the default is a product decision and carries risk in two directions. If the default is on, the feature works even when remote configuration does not arrive; but a feature that should be turned off stays on at first launch. If the default is off, the feature is safely off; but on first launch the user never sees the feature. The lesson measures how this choice is reflected in operability.

#### From Condition to Capability

The model turns configuration into a condition. `configFor(name, remote, defaults)` resolves the environment's flags: it takes the remote value if there is one, otherwise the default. Every open flag enters the condition's `has` list as a capability of the form `flag:<name>`. If a feature needs a flag, it asks for that capability in its `needs` list. The lesson's core is given again below, as in every lesson.

```javascript
// operability.mjs — classifies how fully a feature works under a condition
export const LEVELS = ["full", "degraded", "deferred", "disabled"];
const FALLBACKS = ["degraded", "deferred"];

export function feature(name, needs, fallback = {}) {
  return { name, needs, fallback };
}

export function condition(name, has) {
  return { name, has };
}

function judge(item, cond) {
  const missing = item.needs.filter((need) => !cond.has.includes(need)).sort();
  let level = "full";
  for (const need of missing) {
    const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled";
    if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given;
  }
  return { level, feature: item.name, condition: cond.name, missing };
}

export function assess(features, conditions) {
  const records = [];
  for (const cond of conditions) {
    for (const item of features) records.push(judge(item, cond));
  }
  return records;
}

export function printLevels(records) {
  for (const r of records) {
    const missing = r.missing.length === 0 ? "-" : r.missing.join(",");
    console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`);
  }
}

export function countLevels(records) {
  for (const name of LEVELS) {
    console.log(`${name}: ${records.filter((r) => r.level === name).length}`);
  }
  console.log(`affected: ${records.filter((r) => r.level !== "full").length}`);
}
```

The configuration helper is a lesson-local file. The environment table holds not the secret but its name.

```javascript
// config.mjs — environment table, default values, and flag resolution
import { condition } from "./operability.mjs";

export const ENVS = {
  dev: { host: "dev.example.test", logs: "verbose", secret: "ref:API_KEY_DEV" },
  staging: { host: "staging.example.test", logs: "verbose", secret: "ref:API_KEY_STG" },
  prod: { host: "api.example.test", logs: "plain", secret: "ref:API_KEY_PROD" },
};

export const DEFAULTS = { search: true, banner: false };

export function configFor(name, remote = null, defaults = DEFAULTS) {
  const flags = { ...defaults, ...(remote ?? {}) };
  const has = Object.keys(flags)
    .filter((key) => flags[key])
    .sort()
    .map((key) => `flag:${key}`);
  return condition(name, has);
}
```

The first measurement is not about operability; it prints the table of the three environments.

```javascript
// main.mjs
import { ENVS } from "./config.mjs";

for (const name of Object.keys(ENVS)) {
  const env = ENVS[name];
  console.log(`${name}: host=${env.host} logs=${env.logs} secret=${env.secret}`);
}
```

```text
dev: host=dev.example.test logs=verbose secret=ref:API_KEY_DEV
staging: host=staging.example.test logs=verbose secret=ref:API_KEY_STG
prod: host=api.example.test logs=plain secret=ref:API_KEY_PROD
```

The `secret` field is not a value but the name of a record in the secret store. The three environments point to three separate secrets, and none of their values appears in the table.

**SA5.** The field names, server addresses, and secret names are the lesson's construction, and the `example.test` domain does not reach a real server. The model does not include resolving a secret, receiving remote configuration over the network, or how a flag is defined on the server; it establishes only that a flag is on or off and that this enters the condition as a capability.

#### The Remote Kill Flag

The second measurement tests two features in the production environment in two states. `Search` is the new search feature and needs `flag:search`. `Banner` needs `flag:banner` and falls back to a plain layout when the flag is missing. In the first condition remote configuration could not be reached at all, and only the defaults apply. In the second condition remote configuration has arrived and turned the search flag off.

```javascript
// main.mjs
import { feature, assess, printLevels } from "./operability.mjs";
import { configFor } from "./config.mjs";

const features = [
  feature("Search", ["flag:search"]),
  feature("Banner", ["flag:banner"], { "flag:banner": "degraded" }),
];
const conditions = [
  configFor("prod-default"),
  configFor("prod-remote", { search: false }),
];
printLevels(assess(features, conditions));
```

```text
full: Search @ prod-default missing=-
degraded: Banner @ prod-default missing=flag:banner
disabled: Search @ prod-remote missing=flag:search
degraded: Banner @ prod-remote missing=flag:banner
```

Three things are visible. Under the default condition `Search` is full, because search is on in the defaults. `Banner` is off in the defaults; but it has a fallback, the plain layout, so it is degraded and not disabled. When the remote kill flag arrives, `Search` drops to disabled, because no fallback is defined for search. This is also the purpose of a remote kill flag: to take a feature out of service without waiting for a new version.

**SA6.** The flag names and their default values are the lesson's construction. In a real app, what the search feature shows while it is off is a separate product decision; the model knows only whether a fallback exists.

#### The Bounding Measurement

The lesson's focus record is `Search @ prod-default`: on first launch, while remote configuration cannot be reached, the new search flag comes from the default. The default is on, so the capability exists and the record is full. For comparison, a sibling of the same condition whose default is off is added.

```javascript
// main.mjs
import { feature, assess, printLevels } from "./operability.mjs";
import { configFor, DEFAULTS } from "./config.mjs";

const features = [feature("Search", ["flag:search"])];
const conditions = [
  configFor("prod-default"),
  configFor("prod-default-off", null, { ...DEFAULTS, search: false }),
];
printLevels(assess(features, conditions));
```

```text
full: Search @ prod-default missing=-
disabled: Search @ prod-default-off missing=flag:search
```

With the default on, the record is full; with it off, the record is disabled. The difference is the value itself: an open flag enters the condition as a capability, and a closed flag does not.

This result differs from the question of M31/K04. That lesson asked about the origin of a read: did the answer come from memory, from disk, from the network, or from a default. Even when the value of remote configuration comes from the default, the feature can be full; operability classifies not the source of the value but the presence of the capability. The measurement below shows this directly. The same flag comes from the default under one condition and from remote under the other, and the two records are compared.

```javascript
// main.mjs
import { feature, assess } from "./operability.mjs";
import { configFor } from "./config.mjs";

const features = [feature("Search", ["flag:search"])];
const fromDefault = assess(features, [configFor("prod")]);
const fromRemote = assess(features, [configFor("prod", { search: true })]);
console.log(JSON.stringify(fromDefault) === JSON.stringify(fromRemote) ? "record unchanged" : "record changed");
```

```text
record unchanged
```

The value came from the default under one condition and from remote under the other; the record stayed the same. The source does not enter the record. This does not mean the source is unimportant: a team has to choose the default safely and know the delay of the remote value. But these are questions about the origin of a read; operability asks the question from somewhere else: how well does this feature work under this condition?

#### Summary

- This lesson's operability: full. In the `Search @ prod-default` record, even though remote configuration cannot be reached, the search flag is on from the default and the capability exists.
- If the same flag's default were off, `Search @ prod-default-off` would come out disabled; the difference is not in the source of the value but in the presence of the capability.
- The same open value coming from the default and from remote gave the same record; operability does not put the source of the value into the record.
- When the remote kill flag arrived, `Search`, which has no fallback, dropped to disabled and `Banner`, which has a fallback, to degraded.
- A secret is not embedded in the code, in the configuration file, or in the package; the configuration carries only its name.

#### Next Step

In this lesson the capability came from a configuration value and the feature stayed full. The next lesson, `Accessibility`, covers the case where the capability drops because of the user's own setting: when a screen reader is turned on, a feature can drop to degraded without a single node changing in the view tree.

### Accessibility

Source: https://academia.sh/en/courses/cross-platform-production/accessibility

Accessibility lets an app do its job under different ways of use such as a screen reader, large text, and touch. The lesson measures that a gesture drops to a menu and works in a degraded way when a screen reader is turned on.

In the previous lesson a configuration value determined the capability. In this lesson the user's own setting determines it. One user has turned on the screen reader, another has enlarged the text size, a third navigates with a switch device instead of touching. These are not rare edge cases; they are part of the app's real conditions. **Accessibility** is the design and implementation practice that lets the app do its job under these conditions too. The lesson first describes the parts of accessibility, then measures how well a gesture works while the screen reader is on.

#### The Screen Reader and Semantic Properties

The **screen reader** is an assistive technology offered by the platform that conveys the items on the screen by voice or by touch. The user does not look at the screen; they move between items, and the screen reader says what each item is, its name, and the actions that can be taken. The screen reader does not derive this information from the picture. It reads it from the **semantic properties** the app provides: an item's label, role, state, and actions.

Semantic properties are an information layer separate from appearance. The view draws a card as an image and two texts; the semantic properties describe the same card as "Order card, the title is this, there is an action to delete". If an item has no semantic properties, the screen reader either skips it or reads a meaningless name. For this reason a button without a label, even if it looks complete, does not exist for a screen reader user.

The two snippets below show the semantic properties of the same card row on two platforms. Both give the card's title as the label and offer the delete action as a named action instead of a gesture. The snippets are not measured.

```swift
// Platform code: iOS accessibility action
struct CardRow: View {
    let card: Card
    var body: some View {
        Text(card.title)
            .accessibilityLabel(card.title)
            .accessibilityAction(named: "Delete") { remove(card) }
    }
}
```

```kotlin
// Platform code: Android accessibility action
@Composable
fun CardRow(card: Card) {
    Text(
        text = card.title,
        modifier = Modifier.semantics {
            contentDescription = card.title
            customActions = listOf(CustomAccessibilityAction("Delete") { remove(card); true })
        }
    )
}
```

The same idea is stated with two different APIs on two platforms. This shows that in accessibility too a cross-platform app splits into a shared thought and a platform-specific layer: what is said is common, how it is said belongs to the platform.

#### The Touch Target and Dynamic Type

The **touch target** is the area of an item that can be touched with a finger. A small target causes mis-taps for a user with limited motor skills and for any user on the move. Platform guidelines recommend a minimum size for targets; this size varies from platform to platform and the lesson gives no number. What matters is that even if the visual item is drawn small, its touch area can be kept in line with the guideline.

**Dynamic type** is the user enlarging the text size from the system setting. The app follows this setting: the text grows, and the layout the text sits in has to make room for it. A component drawn with a fixed width and fixed height cuts off or overflows the text at a large size. For this reason the layout is designed to be flexible, to adapt to the text. The previous lesson described adapting a component; dynamic type shows that adaptation also comes from the user's own setting.

Both settings can remove a capability a feature needs. While a screen reader is on, a free swipe gesture may not be performed reliably, because the screen reader reserves most gestures for its own navigation. At a large text size a table of fixed width does not fit in a row. The model builds these two drops as two capabilities.

#### From Setting to Condition

The model turns accessibility settings into a condition. `conditionsFor(name, settings)` starts with three capabilities: `free-gesture` is the free gesture capability, `fixed-width` is the fixed-width layout capability, and `semantics` is the presence of semantic properties. If the screen reader is on, `free-gesture` drops out of the capabilities; if large text is on, `fixed-width` drops out. The lesson's core is given again below.

```javascript
// operability.mjs — classifies how fully a feature works under a condition
export const LEVELS = ["full", "degraded", "deferred", "disabled"];
const FALLBACKS = ["degraded", "deferred"];

export function feature(name, needs, fallback = {}) {
  return { name, needs, fallback };
}

export function condition(name, has) {
  return { name, has };
}

function judge(item, cond) {
  const missing = item.needs.filter((need) => !cond.has.includes(need)).sort();
  let level = "full";
  for (const need of missing) {
    const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled";
    if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given;
  }
  return { level, feature: item.name, condition: cond.name, missing };
}

export function assess(features, conditions) {
  const records = [];
  for (const cond of conditions) {
    for (const item of features) records.push(judge(item, cond));
  }
  return records;
}

export function printLevels(records) {
  for (const r of records) {
    const missing = r.missing.length === 0 ? "-" : r.missing.join(",");
    console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`);
  }
}

export function countLevels(records) {
  for (const name of LEVELS) {
    console.log(`${name}: ${records.filter((r) => r.level === name).length}`);
  }
  console.log(`affected: ${records.filter((r) => r.level !== "full").length}`);
}
```

The helper that turns settings into a condition is a lesson-local file. It also contains a function that returns the nodes in the view tree; this function does not look at the setting.

```javascript
// settings.mjs — produces a condition and a node list from accessibility settings
import { condition } from "./operability.mjs";

export function conditionsFor(name, settings = {}) {
  const has = ["free-gesture", "fixed-width", "semantics"];
  const kept = has.filter((item) => {
    if (item === "free-gesture") return !settings.screenReader;
    if (item === "fixed-width") return !settings.largeText;
    return true;
  });
  return condition(name, kept);
}

export function nodesFor(settings = {}) {
  return ["Card", "Label", "Actions"];
}
```

The first measurement is not about operability; it prints the capabilities that the three settings leave.

```javascript
// main.mjs
import { conditionsFor } from "./settings.mjs";

const conditions = [
  conditionsFor("default"),
  conditionsFor("screen-reader", { screenReader: true }),
  conditionsFor("large-text", { largeText: true }),
];
for (const item of conditions) {
  console.log(`${item.name}: ${[...item.has].sort().join(",")}`);
}
```

```text
default: fixed-width,free-gesture,semantics
screen-reader: fixed-width,semantics
large-text: free-gesture,semantics
```

Each setting drops one capability, and `semantics` drops under none of them. Semantic properties are the app's own responsibility; a setting does not remove them.

**SA7.** How the screen reader handles a free gesture varies in detail with the platform and the setting. The model reduces this detail to two states: the gesture either can be performed or cannot. Where the fixed layout overflows at large text is also not in the model; only whether it overflows is built as a capability. This shows the operability rule; it does not replace testing with a real screen reader.

#### Two Features Under Three Conditions

The second measurement tests two features under three settings. `CardSwipe` is the gesture that deletes a card by swiping and needs `free-gesture`; its fallback is the action menu, so when the capability is missing it drops to degraded. `PriceTable` is a price table of fixed width and needs `fixed-width`; its fallback is a single-column layout.

```javascript
// main.mjs
import { feature, assess, printLevels } from "./operability.mjs";
import { conditionsFor } from "./settings.mjs";

const features = [
  feature("CardSwipe", ["free-gesture"], { "free-gesture": "degraded" }),
  feature("PriceTable", ["fixed-width"], { "fixed-width": "degraded" }),
];
const conditions = [
  conditionsFor("default"),
  conditionsFor("screen-reader", { screenReader: true }),
  conditionsFor("large-text", { largeText: true }),
];
printLevels(assess(features, conditions));
```

```text
full: CardSwipe @ default missing=-
full: PriceTable @ default missing=-
degraded: CardSwipe @ screen-reader missing=free-gesture
full: PriceTable @ screen-reader missing=-
full: CardSwipe @ large-text missing=-
degraded: PriceTable @ large-text missing=fixed-width
```

Each setting affects only the related feature. The screen reader drops `CardSwipe` to degraded and does not touch `PriceTable`; large text does the opposite. A degraded record is not a feature that does not work: the card can be deleted through the action menu, and the table can be read in a single column.

**SA8.** That these two fallbacks are an action menu and a single-column layout is the lesson's construction. Whether a fallback is actually accessible, that is, whether the menu turns out to be truly usable with a screen reader, is tested separately; the model says only that the fallback is defined.

#### The Bounding Measurement

The lesson's focus record is `CardSwipe @ screen-reader`. When the screen reader is turned on, `free-gesture` drops out of the capabilities and the record falls to degraded. In this measurement the nodes of the view tree are printed as well, and `CustomGesture`, a custom gesture with no fallback, is added to the comparison.

```javascript
// main.mjs
import { feature, assess, printLevels, countLevels } from "./operability.mjs";
import { conditionsFor, nodesFor } from "./settings.mjs";

console.log(`node: ${nodesFor({ screenReader: false }).join(",")}`);
console.log(`node: ${nodesFor({ screenReader: true }).join(",")}`);

const features = [
  feature("CardSwipe", ["free-gesture"], { "free-gesture": "degraded" }),
  feature("CustomGesture", ["free-gesture"]),
];
const conditions = [
  conditionsFor("default"),
  conditionsFor("screen-reader", { screenReader: true }),
];
const records = assess(features, conditions);
printLevels(records);
countLevels(records);
```

```text
node: Card,Label,Actions
node: Card,Label,Actions
full: CardSwipe @ default missing=-
full: CustomGesture @ default missing=-
degraded: CardSwipe @ screen-reader missing=free-gesture
disabled: CustomGesture @ screen-reader missing=free-gesture
full: 2
degraded: 1
deferred: 0
disabled: 1
affected: 2
```

The node list is the same under both settings: `Card`, `Label`, and `Actions`. When the screen reader was turned on, not a single node changed in the view tree, yet `CardSwipe` dropped from full to degraded. `CustomGesture`, having no fallback, dropped to disabled; a custom gesture without a fallback does not work at all for a screen reader user.

This result differs from the question of M32/K02. That lesson asked about the effect of an update on a node in the view tree: which node was added, changed, removed. Here no node changed, and the feature dropped all the same. Operability classifies not the shape of the tree but whether the capability exists under the condition. The capability can change while the tree stays fixed, because the capability is the work of the user's setting, not of the tree.

The affected count is two and differs from the four of the opening measurement; the two measurements count different sets.

#### Summary

- This lesson's operability: degraded. In the `CardSwipe @ screen-reader` record the `free-gesture` capability drops while the screen reader is on, and the feature keeps working through the action menu.
- When the screen reader was turned on, the nodes in the view tree stayed the same; the feature dropped to degraded independently of the nodes.
- `CustomGesture`, which has no fallback, came out disabled under the same condition; the count showed full 2, degraded 1, deferred 0, disabled 1, and affected 2.
- Large text dropped only the `fixed-width` capability; `PriceTable` dropped to degraded and `CardSwipe` stayed full.
- Semantic properties dropped under no setting; the screen reader reads the label, role, and actions from this layer.

#### Next Step

In this lesson the capability dropped because of the user's setting and the feature stayed degraded through a fallback. The first lesson of the next topic, `Offline-First Design`, covers the case where the capability drops from the network: the app keeps working with local data, and a degraded flow takes the place of a full flow.

## Offline and Synchronization

Design that makes local data the source of truth, the change queue and conflict resolution, the platform's background processing limits, user experience on an intermittent network.

### Offline-First Design

Source: https://academia.sh/en/courses/cross-platform-production/offline-first-design

Offline-first design makes local data the source of truth and uses the network only for refreshing. The lesson builds the same flow with two designs and measures how well each one works when there is no network.

The previous topic measured how shared code is sliced and how each platform carries its own layer. This topic goes down to the condition an app meets most often: no network. In a subway, in an elevator, on a plane, or outside coverage, the app still opens and the user still expects something. This lesson puts two designs for meeting that expectation side by side.

The course question stays the same: how well does this feature work under this condition? The nine records in the opening lesson came out as five **full**, one **degraded**, two **deferred**, and one **disabled**. This lesson does not change that count; it measures only its own slice.

#### Two Designs

In a **network-first** design, the screen requests data from the server on every open and shows the response that arrives. The source of truth is the server; the local store is at most a cache. With a network, this design is simple and correct. Without one, the screen has nothing to show.

In an **offline-first** design, the source of truth is local. The screen always reads from the local store. The network is not the way to read; it is only the way to refresh the local store. New records from the server are written to the local store, and the screen shows the change in the local store. When the network goes away, what the screen reads does not change; it just stops being refreshed.

This distinction is a rule, not a library choice. The same embedded database can sit under both designs. The difference is where the screen's read path passes through. The Android snippet below shows the offline-first read path. The snippet is not measured.

```kotlin
// Platform code: Android local read
class FeedRepository(private val db: SQLiteDatabase) {
    fun readPosts(): List<Post> {
        val cursor = db.query("posts", null, null, null, null, null, "published DESC")
        return cursor.use { rows ->
            generateSequence { if (rows.moveToNext()) rows.toPost() else null }.toList()
        }
    }
}
```

The screen calls the repository, not the network. Refreshing is a separate job: records from the network are written to the `posts` table, and the read path sees them on the next read. On iOS the same contract is built the same way in Swift: one layer reads from the local store, and a separate layer refreshes it from the network.

#### Building the Features

Measuring the two designs needs the course core. The core is familiar from the first lesson and is given again in the same form in every lesson; this lesson does not touch it.

```javascript
// operability.mjs — classifies how fully a feature works under a condition
export const LEVELS = ["full", "degraded", "deferred", "disabled"];
const FALLBACKS = ["degraded", "deferred"];

export function feature(name, needs, fallback = {}) {
  return { name, needs, fallback };
}

export function condition(name, has) {
  return { name, has };
}

function judge(item, cond) {
  const missing = item.needs.filter((need) => !cond.has.includes(need)).sort();
  let level = "full";
  for (const need of missing) {
    const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled";
    if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given;
  }
  return { level, feature: item.name, condition: cond.name, missing };
}

export function assess(features, conditions) {
  const records = [];
  for (const cond of conditions) {
    for (const item of features) records.push(judge(item, cond));
  }
  return records;
}

export function printLevels(records) {
  for (const r of records) {
    const missing = r.missing.length === 0 ? "-" : r.missing.join(",");
    console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`);
  }
}

export function countLevels(records) {
  for (const name of LEVELS) {
    console.log(`${name}: ${records.filter((r) => r.level === name).length}`);
  }
  console.log(`affected: ${records.filter((r) => r.level !== "full").length}`);
}
```

After the core comes the lesson's helper file. Two features are built for the flow. `FeedRemote` is the network-first flow: it needs only `network` and has no fallback. `FeedLocal` is the offline-first flow: it needs `storage` and `network`, and when the network is missing it falls back to `degraded`. The third feature, `Bookmarks`, needs only `storage`. `stateOf(link)` builds a condition from a connection state: if there is a network, the `network` capability enters the condition, and if there is storage, the `storage` capability does.

```javascript
// feeds.mjs — builds the two feed designs and the connection states
import { feature, condition } from "./operability.mjs";

export const remoteFeed = feature("FeedRemote", ["network"]);
export const localFeed = feature("FeedLocal", ["storage", "network"], { network: "degraded" });
export const bookmarks = feature("Bookmarks", ["storage"]);

export function stateOf(link) {
  const has = [];
  if (link.network) has.push("network");
  if (link.storage !== false) has.push("storage");
  return condition(link.name, has);
}
```

#### Two Designs Without a Network

The first measurement runs the two flows under two connection states. Under `online`, both the network and storage exist. Under `offline`, only storage exists.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { remoteFeed, localFeed, stateOf } from "./feeds.mjs";

const links = [
  { name: "online", network: true },
  { name: "offline", network: false },
];
printLevels(assess([remoteFeed, localFeed], links.map(stateOf)));
```

```text
full: FeedRemote @ online missing=-
full: FeedLocal @ online missing=-
disabled: FeedRemote @ offline missing=network
degraded: FeedLocal @ offline missing=network
```

With a network, both designs are **full**; the difference opens when the network goes away. `FeedRemote @ offline` is **disabled**, because the missing `network` capability has no fallback. `FeedLocal @ offline` is **degraded**, because for the same missing capability the feature has defined a fallback: it keeps working with local data. The two features carry the same gap and land on different levels because of the fallback.

**OS1.** The model builds the network as a single capability: it is either there or it is not. A real connection can be intermittent, slow, or half open; those states are handled separately in the fourth lesson. The rule sees the capability and the fallback, not how many records the local store holds, how old they are, or what the user expects. The model builds this operability's rule; the model is not the framework itself.

#### Read Path and Refresh

An operability record does not say what the screen shows. To see that, the read path of the two flows is modeled separately. The store has three local records; the server has four records, and two of them are shared with the local ones. The network-first flow reads only from the server. The offline-first flow reads from local and, if there is a network, adds the new records from the server.

```javascript
// reader.mjs — builds the post read path of the two designs
export function readFeed(design, store, link) {
  if (design === "remote") {
    return link.network ? { shown: store.remote.length, fresh: true } : { shown: 0, fresh: false };
  }
  const merged = link.network ? [...new Set([...store.local, ...store.remote])] : store.local;
  return { shown: merged.length, fresh: link.network };
}

export function printReads(label, design, store, links) {
  for (const link of links) {
    const read = readFeed(design, store, link);
    console.log(`${label} @ ${link.name}: ${read.shown} posts, fresh=${read.fresh ? "yes" : "no"}`);
  }
}
```

The measurement prints the two reads together with the operability records of the `offline` condition.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { remoteFeed, localFeed, stateOf } from "./feeds.mjs";
import { printReads } from "./reader.mjs";

const store = { local: ["p1", "p2", "p3"], remote: ["p2", "p3", "p4", "p5"] };
const links = [
  { name: "online", network: true },
  { name: "offline", network: false },
];
printReads("FeedRemote", "remote", store, links);
printReads("FeedLocal", "local", store, links);
const records = assess([remoteFeed, localFeed], links.map(stateOf));
printLevels(records.filter((r) => r.condition === "offline"));
```

```text
FeedRemote @ online: 4 posts, fresh=yes
FeedRemote @ offline: 0 posts, fresh=no
FeedLocal @ online: 5 posts, fresh=yes
FeedLocal @ offline: 3 posts, fresh=no
disabled: FeedRemote @ offline missing=network
degraded: FeedLocal @ offline missing=network
```

With a network, the offline-first flow shows five posts: the three local ones and the two new records from the server. Without a network, it keeps showing the three local posts, and freshness drops. The network-first flow shows zero posts without a network. The operability records of the two designs turn this difference in the read path into a class: zero posts is **disabled**, three posts is **degraded**.

#### Storage Is the Source of Truth

A design that makes local data the source of truth reduces dependence on the network and increases dependence on storage. When storage runs out, local reads and writes stop working too. The third measurement shows this with the `storage-full` condition: the network exists, storage does not.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { localFeed, bookmarks, stateOf } from "./feeds.mjs";

const links = [
  { name: "online", network: true },
  { name: "offline", network: false },
  { name: "storage-full", network: true, storage: false },
];
printLevels(assess([localFeed, bookmarks], links.map(stateOf)));
```

```text
full: FeedLocal @ online missing=-
full: Bookmarks @ online missing=-
degraded: FeedLocal @ offline missing=network
full: Bookmarks @ offline missing=-
disabled: FeedLocal @ storage-full missing=storage
disabled: Bookmarks @ storage-full missing=storage
```

`Bookmarks` depends only on the local store, so it is unaffected when the network goes away and it is **disabled** under `storage-full`. `FeedLocal` withstands a missing network with a fallback but cannot withstand missing storage: no fallback is defined for `storage`, so the same feature is **disabled** under `storage-full`. The offline-first design covers the network dependency with a fallback; it does not remove the dependency, it moves it to the local store.

#### The Bounding Measurement

This lesson's focus record is `FeedLocal @ offline`. The last measurement runs three features under two states, prints the records of the `offline` condition and the count, and then asks whether the screen matches the local model.

```javascript
// main.mjs
import { assess, printLevels, countLevels } from "./operability.mjs";
import { remoteFeed, localFeed, bookmarks, stateOf } from "./feeds.mjs";
import { readFeed } from "./reader.mjs";

const store = { local: ["p1", "p2", "p3"], remote: ["p2", "p3", "p4", "p5"] };
const offline = { name: "offline", network: false };
const links = [{ name: "online", network: true }, offline];
const records = assess([remoteFeed, localFeed, bookmarks], links.map(stateOf));
printLevels(records.filter((r) => r.condition === "offline"));
countLevels(records);
const shown = readFeed("local", store, offline).shown;
console.log(`screen matches local model: ${shown === store.local.length ? "yes" : "no"}`);
```

```text
disabled: FeedRemote @ offline missing=network
degraded: FeedLocal @ offline missing=network
full: Bookmarks @ offline missing=-
full: 4
degraded: 1
deferred: 0
disabled: 1
affected: 2
screen matches local model: yes
```

The first two lines carry the lesson's operability: the same flow came out **disabled** when built network-first and **degraded** when built offline-first. Of the six records, four are **full**, one is **degraded**, and one is **disabled**; two are affected.

The last line shows what operability does not classify. M30/K04 asked whether a screen matches its model. Here the screen matches the local model completely: the three posts it shows are the three posts in the local store, and there is no gap between screen and model. Yet `FeedLocal @ offline` is not **full**; it is **degraded**. Conformance and operability are separate axes. Conformance asks whether the screen shows what the model says; operability classifies how much of what the feature promises it does under that condition. A feed that receives no new content does less than it does with a network, even when it matches its model completely.

This difference also affects design decisions. For an offline-first screen, saying "it works offline" is not enough; you have to say, feature by feature, which jobs continue through a fallback and which stop.

#### Summary

- This lesson's operability: **degraded**. `FeedLocal @ offline` came out degraded because `network` is missing and a `degraded` fallback is defined; the network-first `FeedRemote` stayed **disabled** under the same condition because it has no fallback.
- In the offline-first design the source of truth is the local store; the network is not the way to read but the way to refresh the local store.
- With a network, both designs were **full**. Without one, the read path showed the difference: the network-first flow showed zero posts, the offline-first flow showed three.
- Local data covers the network dependency with a fallback but does not remove the storage dependency: under `storage-full`, `FeedLocal` is **disabled**.
- Even when the screen matches the local model completely, the feature stays **degraded**; conformance and operability are separate axes.

#### Next Step

This lesson covered only reading while there is no network: local data was shown, but the changes the user makes were never discussed. The next lesson, `Synchronization Strategies`, covers how a write made offline is put into a queue, how it is sent to the server when the network returns, and how a conflict is resolved when both sides have changed the same record. It then measures how well offline note editing works.

### Synchronization Strategies

Source: https://academia.sh/en/courses/cross-platform-production/synchronization-strategies

Synchronization strategies queue a change made offline, send it to the server when the network returns, and resolve conflicts. The lesson measures that offline note editing stays deferred and shuts down when storage is full.

The previous lesson covered only reading while there is no network. Local data was shown, but the changes the user makes were never discussed. In a real app the user writes while offline too: edits a note, completes a task, fills in a form. That write must not be lost, and it must reach the server when the network returns. This lesson splits that journey into three parts: putting the change in a queue, not applying the same change twice while sending the queue, and resolving the conflict when both sides have changed the same record.

The course question stays the same: how well does this feature work under this condition? The nine records in the opening lesson came out as five **full**, one **degraded**, two **deferred**, and one **disabled**. This lesson does not change that count; it measures only its own slice.

#### The Change Queue

A write made offline cannot go straight to the server. The app writes it to the local store, into a separate **change queue**. Each entry in the queue carries one change: which field of which record moved to which value, and the identifier of the change. The screen updates the local data immediately; the user sees their own change. The server does not know yet.

The queue must be durable. A queue kept in memory disappears when the app closes, and the work the user did offline is gone. That is why the queue is a table in the local database. The Android snippet below shows adding an entry to the queue. The snippet is not measured.

```kotlin
// Platform code: Android change queue
class ChangeQueue(private val db: SQLiteDatabase) {
    fun enqueue(change: Change) {
        val row = ContentValues().apply {
            put("change_id", change.id)
            put("note_id", change.noteId)
            put("field", change.field)
            put("value", change.value)
        }
        db.insertOrThrow("pending_changes", null, row)
    }
}
```

`change.id` is generated on the client and stays fixed until the change is sent. Why this identifier matters will be clear shortly. On iOS the same table and the same identifier rule are built in Swift; the queue carries the same contract on both platforms.

#### Measuring Notes

Measuring needs the course core. The core is familiar from the first lesson and is given again in the same form in every lesson; this lesson does not touch it.

```javascript
// operability.mjs — classifies how fully a feature works under a condition
export const LEVELS = ["full", "degraded", "deferred", "disabled"];
const FALLBACKS = ["degraded", "deferred"];

export function feature(name, needs, fallback = {}) {
  return { name, needs, fallback };
}

export function condition(name, has) {
  return { name, has };
}

function judge(item, cond) {
  const missing = item.needs.filter((need) => !cond.has.includes(need)).sort();
  let level = "full";
  for (const need of missing) {
    const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled";
    if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given;
  }
  return { level, feature: item.name, condition: cond.name, missing };
}

export function assess(features, conditions) {
  const records = [];
  for (const cond of conditions) {
    for (const item of features) records.push(judge(item, cond));
  }
  return records;
}

export function printLevels(records) {
  for (const r of records) {
    const missing = r.missing.length === 0 ? "-" : r.missing.join(",");
    console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`);
  }
}

export function countLevels(records) {
  for (const name of LEVELS) {
    console.log(`${name}: ${records.filter((r) => r.level === name).length}`);
  }
  console.log(`affected: ${records.filter((r) => r.level !== "full").length}`);
}
```

The lesson's helper file builds three features. `EditNote` is note editing and needs `storage` and `network`; when the network is missing the edit is queued, so the fallback for `network` is `deferred`. `ReadNote` reads a note and needs only `storage`. `ShareNote` shares a note and needs only `network`; it has no fallback. `stateOf(link)` builds a condition from a connection state.

```javascript
// notes.mjs — builds the note features and the connection states
import { feature, condition } from "./operability.mjs";

export const editNote = feature("EditNote", ["storage", "network"], { network: "deferred" });
export const readNote = feature("ReadNote", ["storage"]);
export const shareNote = feature("ShareNote", ["network"]);

export function stateOf(link) {
  const has = [];
  if (link.network) has.push("network");
  if (link.storage !== false) has.push("storage");
  return condition(link.name, has);
}
```

The model of the queue lives in a second helper file. `submit` looks at a record's operability and reports what happens to the change: if it is full, the change is sent; if it is deferred, it enters the queue; if it is disabled, it is rejected. The queue is an array, and its length is the unit of work.

```javascript
// sync.mjs — builds the change queue and the submission
export function submit(record, queue, change) {
  if (record.level === "deferred") {
    queue.push(change);
    return "queued";
  }
  return record.level === "full" ? "sent" : "rejected";
}

export function newServer(mode) {
  return { mode, applied: new Map(), log: [] };
}

export function flush(queue, server) {
  for (const change of queue) {
    if (server.mode === "keyed") server.applied.set(change.id, change.value);
    else server.log.push(change);
  }
  return queue.length;
}

export function stored(server) {
  return server.mode === "keyed" ? server.applied.size : server.log.length;
}
```

The first measurement makes three edits offline and one edit while connected.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { editNote, readNote, stateOf } from "./notes.mjs";
import { submit } from "./sync.mjs";

const links = [
  { name: "online", network: true },
  { name: "offline", network: false },
];
const records = assess([editNote, readNote], links.map(stateOf));
printLevels(records);
const edits = records.filter((r) => r.feature === "EditNote");
const queue = [];
console.log(`c0: ${submit(edits[0], queue, { id: "c0", value: "v-c0" })}`);
for (const id of ["c1", "c2", "c3"]) {
  console.log(`${id}: ${submit(edits[1], queue, { id, value: `v-${id}` })}`);
}
console.log(`queue: ${queue.length} changes`);
```

```text
full: EditNote @ online missing=-
full: ReadNote @ online missing=-
deferred: EditNote @ offline missing=network
full: ReadNote @ offline missing=-
c0: sent
c1: queued
c2: queued
c3: queued
queue: 3 changes
```

While connected, the edit is sent directly. Without a connection, `EditNote` is deferred: three edits enter the queue, and the queue carries three changes. Reading is **full** in both states, because it depends only on the local store.

**OS2.** The model builds the queue as an in-memory array. A real queue lives in the local database, survives the app closing and opening, and has to preserve its order. How many changes the queue carries is the unit of work in the model; how much space each change takes, how long it waits, and when it will be sent are not in the model. The model builds this operability's rule; the model is not the framework itself.

#### Idempotent Submission

When the network returns, the queue is sent to the server. The difficulty in this step is that knowing whether a submission succeeded is hard. The client sends a change, the server applies it, but the response is lost on the way. The client does not know whether the change arrived and sends the same change again. If the server applies the second copy as if it were seeing it for the first time, the change is processed twice.

The solution is to make the change **idempotent**. Every change arrives with an identifier generated on the client. If the server has already applied the identifier, it ignores the second copy. Generating the identifier on the client is essential: if the server generated it, a submission whose response was lost would be repeated with a new identifier, and the server would see two separate changes.

The measurement below sends the same queue to two servers twice. The `unkeyed` server appends every incoming change to a list. The `keyed` server stores a change under its identifier.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { editNote, readNote, stateOf } from "./notes.mjs";
import { submit, newServer, flush, stored } from "./sync.mjs";

const links = [
  { name: "offline", network: false },
  { name: "online", network: true },
];
const records = assess([editNote, readNote], links.map(stateOf));
const edits = records.filter((r) => r.feature === "EditNote");
printLevels(edits);
const queue = [];
for (const id of ["c1", "c2", "c3"]) submit(edits[0], queue, { id, value: `v-${id}` });
for (const mode of ["unkeyed", "keyed"]) {
  const server = newServer(mode);
  const sent = flush(queue, server) + flush(queue, server);
  console.log(`${mode}: ${sent} sends, ${stored(server)} records on server`);
}
```

```text
deferred: EditNote @ offline missing=network
full: EditNote @ online missing=-
unkeyed: 6 sends, 6 records on server
keyed: 6 sends, 3 records on server
```

Six sends were made to both servers: three changes, twice. The `unkeyed` server holds six records; every change was applied twice. The `keyed` server holds three records; the second send finds the same identifiers and does not change the result. Idempotence makes it safe to repeat a submission, and this is the basis of retrying in the next lessons.

#### Conflict Resolution

While the user is offline, the record on the server may have changed too; for example, the same note was edited from another device. When the network returns, two versions are in hand, and the server has to decide which one to accept. This decision is called **conflict resolution**, and there are two common strategies.

The **last writer wins** strategy compares the whole record. The version with the larger timestamp wins and the other's change is dropped. The strategy is simple, but it loses one of two edits that changed different fields, entirely. **Field-level merging** compares fields one by one. If only one side changed a field, that change is kept; if both sides changed the same field, the one with the larger timestamp wins. The measurement below changes a note's title on the server and its body on the client, and puts the two strategies side by side.

```javascript
// merge.mjs — merges two conflicting versions with two strategies
export function lastWriterWins(local, remote) {
  return local.stamp > remote.stamp ? local.fields : remote.fields;
}

export function mergeFields(base, local, remote) {
  const merged = {};
  for (const key of Object.keys(base).sort()) {
    const localChanged = local.fields[key] !== base[key];
    const remoteChanged = remote.fields[key] !== base[key];
    if (localChanged && remoteChanged) {
      merged[key] = local.stamp > remote.stamp ? local.fields[key] : remote.fields[key];
    } else {
      merged[key] = localChanged ? local.fields[key] : remote.fields[key];
    }
  }
  return merged;
}

export function joinFields(fields) {
  return Object.keys(fields).sort().map((key) => `${key}=${fields[key]}`).join(" ");
}
```

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { editNote, readNote, stateOf } from "./notes.mjs";
import { lastWriterWins, mergeFields, joinFields } from "./merge.mjs";

const links = [
  { name: "offline", network: false },
  { name: "online", network: true },
];
const records = assess([editNote, readNote], links.map(stateOf));
printLevels(records.filter((r) => r.feature === "EditNote"));
const base = { title: "List", body: "Buy milk" };
const local = { stamp: 5, fields: { title: "List", body: "Buy milk and tea" } };
const remote = { stamp: 7, fields: { title: "Shopping", body: "Buy milk" } };
console.log(`last writer wins: ${joinFields(lastWriterWins(local, remote))}`);
console.log(`field level: ${joinFields(mergeFields(base, local, remote))}`);
```

```text
deferred: EditNote @ offline missing=network
full: EditNote @ online missing=-
last writer wins: body=Buy milk title=Shopping
field level: body=Buy milk and tea title=Shopping
```

With last writer wins, the server's version wins and the client's body edit is lost. With field-level merging, both changes are kept. The operability records are the same under both strategies: `EditNote @ offline` is deferred and `EditNote @ online` is full. How the conflict is resolved does not change the record, because the rule sees the capability and the fallback, not the content of the two versions. Choosing a strategy is a data-loss decision, and it comes from what the product is willing to lose, not from the operability table.

#### The Bounding Measurement

This lesson's focus record is `EditNote @ offline`. The bounding measurement runs three features under three states. The third state is `storage-full`: the network exists, storage does not.

```javascript
// main.mjs
import { assess, printLevels, countLevels } from "./operability.mjs";
import { editNote, readNote, shareNote, stateOf } from "./notes.mjs";

const features = [editNote, readNote, shareNote];
const links = [
  { name: "online", network: true },
  { name: "offline", network: false },
  { name: "storage-full", network: true, storage: false },
];
const records = assess(features, links.map(stateOf));
printLevels(records.filter((r) => r.feature === "EditNote"));
countLevels(records);

const withServer = (mode) =>
  assess(features, [{ name: "offline", network: false, server: mode }].map(stateOf));
const same = JSON.stringify(withServer("keyed")) === JSON.stringify(withServer("unkeyed"));
console.log(`duplication is not recorded: ${same ? "yes" : "no"}`);
```

```text
full: EditNote @ online missing=-
deferred: EditNote @ offline missing=network
disabled: EditNote @ storage-full missing=storage
full: 5
degraded: 0
deferred: 1
disabled: 3
affected: 4
duplication is not recorded: yes
```

The first three lines carry the lesson's operability. The same feature, the same code: **full** with a network, **deferred** without one. The edit is not lost; it waits in the queue and is sent when the network returns. Under `storage-full` it is **disabled**, because the queue itself is also written to the local store. If there is no store to hold the deferral, there is no deferral. Of the nine records, five are **full**, one is **deferred**, and three are **disabled**; four are affected.

The last line shows how this differs from the question of M30/K05. That lesson asked whether an operation's result changes if it is repeated or reordered. Idempotent and non-idempotent submission produce the same operability record; but this is not an observation, it is the definition of the model: the `stateOf` function that builds the condition never reads the `server` field, so the two calls see the same input from the start. Operability does not classify the durability of submission; it classifies that the write was postponed. A non-idempotent server applies records twice, and that is a data error, but `EditNote @ offline` stays **deferred**.

#### Summary

- This lesson's operability: **deferred**. `EditNote @ offline` came out deferred because `network` is missing and a `deferred` fallback is defined; under the same condition `ReadNote` stayed **full** and `ShareNote` was **disabled**.
- An offline write enters a durable change queue; three edits became three queue entries.
- An identifier generated on the client makes submission idempotent: in six sends, the keyed server held three records and the unkeyed server held six.
- Last writer wins loses an edit in a conflicting record, field-level merging keeps both; the strategy does not change the operability record.
- Under `storage-full` the queue cannot be written either and `EditNote` shuts down; four of nine records are affected.

#### Next Step

In this lesson, sending the queue began when the network returned; who starts it was not asked. While the app is closed or in the background, the operating system may not run this job. The next lesson, `Background Processing Limits`, covers the background task mechanisms of the two platforms, the limits the operating system imposes, and low power mode. It then measures how well media backup works when background capability is restricted.

### Background Processing Limits

Source: https://academia.sh/en/courses/cross-platform-production/background-processing-limits

Background processing limits describe the rules the operating system sets for running work while the app is not visible. The lesson measures that media backup is deferred and live location is disabled when background capability is restricted.

In the previous lesson, changes made offline entered a queue and were to be sent when the network returned. Who starts the sending was not asked. The user may have closed the app, or the phone may be in a pocket. For an app to run work while it is not visible, the operating system has to allow it, and the operating system does not give that permission generously. This lesson explains the background processing mechanisms of the two platforms and the limits they bring, then measures how much those limits affect a feature.

The course question stays the same: how well does this feature work under this condition? The nine records in the opening lesson came out as five **full**, one **degraded**, two **deferred**, and one **disabled**. This lesson does not change that count; it measures only its own slice.

#### The Operating System Decides

Working in the background is the operating system's decision, not the app's. The app asks for a job; the operating system looks at conditions such as battery, network, the user's habits, and the load from other apps, and decides when the job runs, or even whether it runs at all. So a background job never means "run now"; it means "run at a suitable moment".

On iOS the app registers a task request, and when the system finds it suitable, it wakes the app for a short time. The app does not set the wake time. On Android the job is handed to a work manager together with the conditions it needs; the manager runs the job when the conditions are met and the device's power management allows it. With both mechanisms the app has no exact clock. The limits on duration and frequency are set by the system version and the user's settings; this lesson does not write these limits as numbers, because they change with every version and every device.

The iOS snippet below shows requesting a backup task. The snippet is not measured.

```swift
// Platform code: iOS background task
func scheduleBackup() {
    let request = BGProcessingTaskRequest(identifier: "app.media-backup")
    request.requiresNetworkConnectivity = true
    request.requiresExternalPower = false
    try? BGTaskScheduler.shared.submit(request)
}
```

On Android the same request is handed to the work manager together with its conditions. The snippet is not measured.

```kotlin
// Platform code: Android background task
fun scheduleBackup(context: Context) {
    val constraints = Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .build()
    val request = OneTimeWorkRequestBuilder<BackupWorker>()
        .setConstraints(constraints)
        .build()
    WorkManager.getInstance(context).enqueue(request)
}
```

The two snippets ask for the same thing: a backup job that runs while there is a network. Both create a registration; the job does not run right away. Whether it runs is up to the system's answer.

#### Low Power Mode

The user or the system can turn on **low power mode** to save battery. While the mode is on, the system cuts or thins out background work: refreshing stops and scheduled jobs run later. While the app is open, the network and storage are still there; what disappears is the permission to run in the background.

This lesson treats low power mode as a **condition**. A condition is the set of capabilities present at that moment: in low power mode, `network` and `storage` are present and `background` is not. Under the same condition one feature can come out full, another deferred, and a third disabled.

#### Building the Features

Measuring needs the course core. The core is familiar from the first lesson and is given again in the same form in every lesson; this lesson does not touch it.

```javascript
// operability.mjs — classifies how fully a feature works under a condition
export const LEVELS = ["full", "degraded", "deferred", "disabled"];
const FALLBACKS = ["degraded", "deferred"];

export function feature(name, needs, fallback = {}) {
  return { name, needs, fallback };
}

export function condition(name, has) {
  return { name, has };
}

function judge(item, cond) {
  const missing = item.needs.filter((need) => !cond.has.includes(need)).sort();
  let level = "full";
  for (const need of missing) {
    const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled";
    if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given;
  }
  return { level, feature: item.name, condition: cond.name, missing };
}

export function assess(features, conditions) {
  const records = [];
  for (const cond of conditions) {
    for (const item of features) records.push(judge(item, cond));
  }
  return records;
}

export function printLevels(records) {
  for (const r of records) {
    const missing = r.missing.length === 0 ? "-" : r.missing.join(",");
    console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`);
  }
}

export function countLevels(records) {
  for (const name of LEVELS) {
    console.log(`${name}: ${records.filter((r) => r.level === name).length}`);
  }
  console.log(`affected: ${records.filter((r) => r.level !== "full").length}`);
}
```

The lesson's helper file builds three features. `MediaBackup` backs up photos; it needs `network` and `background`, and when background is missing it postpones its work, so its fallback is `deferred`. `LiveLocation` shares location continuously; it needs the same two capabilities and has no fallback, because sharing a stale location is meaningless. `Catalog` is a product list and needs only `network`. `stateOf(link)` builds a condition from the connection and background state.

```javascript
// background.mjs — builds the background features and conditions
import { feature, condition } from "./operability.mjs";

export const mediaBackup = feature("MediaBackup", ["network", "background"], { background: "deferred" });
export const liveLocation = feature("LiveLocation", ["network", "background"]);
export const catalog = feature("Catalog", ["network"]);

export function stateOf(link) {
  const has = [];
  if (link.network) has.push("network");
  if (link.background) has.push("background");
  return condition(link.name, has);
}
```

The model of the scheduler is in a second helper file. `schedule` looks at a record's operability: if it is full, the job runs; if it is deferred, the job is added to the waiting list; if it is disabled, the job does not run and does not enter the waiting list. `runWindow` imitates a window given by the system: the window runs a certain number of jobs from the waiting list.

```javascript
// scheduler.mjs — builds background work according to the operating system's window
export function schedule(record, pending, job) {
  if (record.level === "deferred") {
    pending.push({ feature: record.feature, job });
    return "waiting";
  }
  return record.level === "full" ? "running" : "skipped";
}

export function runWindow(pending, slots) {
  return pending.splice(0, slots).length;
}
```

The first measurement runs backup and the catalog in two states: the app in the foreground and background restricted.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { mediaBackup, catalog, stateOf } from "./background.mjs";
import { schedule } from "./scheduler.mjs";

const states = [
  { name: "foreground", network: true, background: true },
  { name: "background-restricted", network: true, background: false },
];
const records = assess([mediaBackup, catalog], states.map(stateOf));
printLevels(records);
const pending = [];
for (const r of records.filter((x) => x.feature === "MediaBackup")) {
  console.log(`${r.condition}: ${schedule(r, pending, "photo-1")}`);
}
console.log(`waiting: ${pending.length}`);
```

```text
full: MediaBackup @ foreground missing=-
full: Catalog @ foreground missing=-
deferred: MediaBackup @ background-restricted missing=background
full: Catalog @ background-restricted missing=-
foreground: running
background-restricted: waiting
waiting: 1
```

While the app is in the foreground, backup is **full** and the job runs right away. While background is restricted, backup is **deferred** and the job enters the waiting list. `Catalog` is **full** in both states, because it depends only on the network.

**OS3.** The model builds background as a single capability: either it is there or it is not. A real system gives background work short and unpredictable windows; when the job runs, how many windows it takes to finish, and whether it ever runs are the system's decision. In the model, `runWindow` takes the size of the window as a job count chosen for the lesson; that number says nothing about the behavior of a system. The model builds this operability's rule; the model is not the framework itself.

#### Deferred Work Is Not Lost

Deferred work has not run, but it has not been lost. It stays in the waiting list and runs when the system gives a window. The second measurement puts three backup jobs in the queue under the restricted condition and gives two windows. Under the same condition `LiveLocation` asks for three location updates.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { mediaBackup, liveLocation, stateOf } from "./background.mjs";
import { schedule, runWindow } from "./scheduler.mjs";

const states = [
  { name: "background-restricted", network: true, background: false },
  { name: "foreground", network: true, background: true },
];
const records = assess([mediaBackup, liveLocation], states.map(stateOf));
const restricted = records.filter((r) => r.condition === "background-restricted");
printLevels(restricted);
const pending = [];
for (const job of ["photo-1", "photo-2", "photo-3"]) schedule(restricted[0], pending, job);
for (const job of ["location-1", "location-2", "location-3"]) schedule(restricted[1], pending, job);
console.log(`first window: ${runWindow(pending, 2)} ran, ${pending.length} waiting`);
console.log(`second window: ${runWindow(pending, 2)} ran, ${pending.length} waiting`);
```

```text
deferred: MediaBackup @ background-restricted missing=background
disabled: LiveLocation @ background-restricted missing=background
first window: 2 ran, 1 waiting
second window: 1 ran, 0 waiting
```

The three backup jobs finished in two windows: in the first, two ran and one waited; in the second, the remaining one ran. The location updates did not appear in any window, because `schedule` does not add the job of a disabled record to the waiting list. A disabled feature does not run either, but its work is not left for later; the user cannot share their location in the background, and that sharing is not done at the next opportunity.

#### Low Power Mode Is Not a Level

The third measurement puts low power mode side by side with the app-in-foreground condition. Three features run under two conditions, and only the low power records are printed.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { catalog, mediaBackup, liveLocation, stateOf } from "./background.mjs";

const states = [
  { name: "foreground", network: true, background: true },
  { name: "low-power", network: true, background: false },
];
const records = assess([catalog, mediaBackup, liveLocation], states.map(stateOf));
printLevels(records.filter((r) => r.condition === "low-power"));
```

```text
full: Catalog @ low-power missing=-
deferred: MediaBackup @ low-power missing=background
disabled: LiveLocation @ low-power missing=background
```

With low power mode on, three features fell to three different levels. Low power mode is a condition; degraded is an operability level, and the two words are not the same thing. The mode says which capability decreased; the level is decided by the fallback the feature defined for that missing capability.

#### The Bounding Measurement

This lesson's focus record is `MediaBackup @ background-restricted`. The last measurement runs three features under three conditions and prints the records of the background-restricted condition, the count, and the waiting jobs.

```javascript
// main.mjs
import { assess, printLevels, countLevels } from "./operability.mjs";
import { mediaBackup, liveLocation, catalog, stateOf } from "./background.mjs";
import { schedule } from "./scheduler.mjs";

const states = [
  { name: "foreground", network: true, background: true },
  { name: "background-restricted", network: true, background: false },
  { name: "low-power", network: true, background: false },
];
const records = assess([mediaBackup, liveLocation, catalog], states.map(stateOf));
const restricted = records.filter((r) => r.condition === "background-restricted");
printLevels(restricted);
countLevels(records);
const pending = [];
for (const r of restricted) {
  for (const job of ["job-1", "job-2", "job-3"]) schedule(r, pending, job);
}
const waiting = (name) => pending.filter((p) => p.feature === name).length;
console.log(`waiting: MediaBackup ${waiting("MediaBackup")}, LiveLocation ${waiting("LiveLocation")}`);
```

```text
deferred: MediaBackup @ background-restricted missing=background
disabled: LiveLocation @ background-restricted missing=background
full: Catalog @ background-restricted missing=-
full: 5
degraded: 0
deferred: 2
disabled: 2
affected: 4
waiting: MediaBackup 3, LiveLocation 0
```

The first line carries the lesson's operability: when background capability is restricted, backup came out **deferred**. Of the nine records, five are **full**, two are **deferred**, and two are **disabled**; four are affected. The two deferred records are two conditions of the same feature: restricted and low power.

The last line shows how this differs from the question of M12/K04. That lesson asked whether a piece ran in this request. Here neither backup nor location sharing ran in the background, but their situations are different. Deferred backup has not run and has not been lost: three jobs wait. Disabled location sharing has not run either, but it has no waiting job. Operability does not classify whether a job ran; it classifies what happens to the job when it did not run.

#### Summary

- This lesson's operability: **deferred**. `MediaBackup @ background-restricted` came out deferred because `background` is missing and a `deferred` fallback is defined; `LiveLocation` stayed **disabled** under the same condition because it has no fallback.
- Working in the background is the operating system's decision. On iOS the system wakes the app when it finds the task request suitable, and on Android the work manager runs the job when the conditions are met; the limits on duration and frequency change with the version and the settings.
- Low power mode is a condition, not an operability level: under the same mode `Catalog` came out **full**, `MediaBackup` **deferred**, and `LiveLocation` **disabled**.
- Deferred work has not run but has not been lost: three backup jobs finished in two windows. The work of a disabled feature does not enter the waiting list.
- Of the nine records, five are **full**, two are **deferred**, and two are **disabled**; four are affected.

#### Next Step

In this lesson, background capability was either there or not; the connection was always there. In reality the connection also drops, slows down, and comes back. The next lesson, `Connectivity State Management`, covers monitoring the connection, a weak connection, retry with exponential backoff, and showing the connection state to the user. It then measures how well an image gallery works on a weak connection.

### Connectivity State Management

Source: https://academia.sh/en/courses/cross-platform-production/connectivity-state-management

Connectivity state management covers monitoring the connection, adapting to a weak connection, retrying, and showing the state to the user. The lesson measures that an image gallery is degraded and messaging stays deferred on a weak connection.

In the previous lesson, background capability was either there or not; the connection was always there. In reality the connection also drops, slows down, and comes back. In a subway the signal comes and goes, in a crowded hall the connection looks present but no data flows, and coming out of a tunnel the network returns all at once. This lesson covers monitoring the connection, adapting to a weak connection, retrying a failed request, and showing the state to the user.

The course question stays the same: how well does this feature work under this condition? This lesson asks it for three features under three connection states.

#### Monitoring the Connection

Both platforms report changes in the connection to the app. The app registers a monitor, and the system calls it when the connection is established, lost, or changes quality. The iOS snippet below shows this. The snippet is not measured.

```swift
// Platform code: iOS connection monitoring
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
    let isOnline = path.status == .satisfied
    let isWeak = path.isConstrained || path.isExpensive
    connectionState.update(online: isOnline, weak: isWeak)
}
monitor.start(queue: DispatchQueue(label: "connectivity"))
```

On Android the same job is done by registering a callback on a manager obtained from a system service. The snippet is not measured.

```kotlin
// Platform code: Android connection monitoring
val manager = context.getSystemService(ConnectivityManager::class.java)
manager.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() {
    override fun onAvailable(network: Network) = connectionState.update(online = true)
    override fun onLost(network: Network) = connectionState.update(online = false)
})
```

What the monitor says is limited. The system says "a network path exists"; it does not say "a request will reach the server". A connection that is joined to a network but cannot reach the internet, that is waiting for a sign-in page, or that is very slow can look "connected" to the monitor. So the monitor is a hint, not a certainty: the success or failure of a real request has the last word.

#### Weak Connection

The two ends of connectivity, fully connected and not connected at all, are easy. The hard part is the middle: the connection exists but data flows slowly, drops often, or the user has turned on data saving. In this situation the app has two options: keep asking for large content and wait, or settle for smaller content. A good app picks the second: an image arrives at low resolution instead of high, a large list shrinks to a short one, and refresh on the user's request replaces live updates.

The model builds a weak connection as a missing capability: the network exists but `bandwidth` does not. `bandwidth` means enough data rate to receive large content in a reasonable time. When it is missing, the feature moves to its defined fallback.

#### Building the Features

Measuring needs the course core. The core is familiar from the first lesson and is given again in the same form in every lesson; this lesson does not touch it.

```javascript
// operability.mjs — classifies how fully a feature works under a condition
export const LEVELS = ["full", "degraded", "deferred", "disabled"];
const FALLBACKS = ["degraded", "deferred"];

export function feature(name, needs, fallback = {}) {
  return { name, needs, fallback };
}

export function condition(name, has) {
  return { name, has };
}

function judge(item, cond) {
  const missing = item.needs.filter((need) => !cond.has.includes(need)).sort();
  let level = "full";
  for (const need of missing) {
    const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled";
    if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given;
  }
  return { level, feature: item.name, condition: cond.name, missing };
}

export function assess(features, conditions) {
  const records = [];
  for (const cond of conditions) {
    for (const item of features) records.push(judge(item, cond));
  }
  return records;
}

export function printLevels(records) {
  for (const r of records) {
    const missing = r.missing.length === 0 ? "-" : r.missing.join(",");
    console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`);
  }
}

export function countLevels(records) {
  for (const name of LEVELS) {
    console.log(`${name}: ${records.filter((r) => r.level === name).length}`);
  }
  console.log(`affected: ${records.filter((r) => r.level !== "full").length}`);
}
```

The lesson's helper file builds three features. `Gallery` is an image gallery; it needs `network` and `bandwidth`, and when `bandwidth` is missing it falls to a low-resolution preview, so its fallback is `degraded`. It has no fallback for `network`. `Chat` is messaging; it needs the same two capabilities and queues the message when either is missing, so both of its fallbacks are `deferred`. `Settings` is the settings screen and needs only `storage`. `conditionsFor(link)` turns a connection state into a condition: `online` for a good connection, `weak-network` for a weak one, and `offline` when there is no connection.

```javascript
// connectivity.mjs — builds the connectivity features and conditions
import { feature, condition } from "./operability.mjs";

export const gallery = feature("Gallery", ["network", "bandwidth"], { bandwidth: "degraded" });
export const chat = feature("Chat", ["network", "bandwidth"], { network: "deferred", bandwidth: "deferred" });
export const settings = feature("Settings", ["storage"]);

export function conditionsFor(link) {
  if (link.quality === "good") return condition("online", ["network", "bandwidth", "storage"]);
  if (link.quality === "weak") return condition("weak-network", ["network", "storage"]);
  return condition("offline", ["storage"]);
}
```

The first measurement turns three connection states into conditions, prints the capabilities of each condition, and classifies two features under three conditions.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { gallery, settings, conditionsFor } from "./connectivity.mjs";

const links = [{ quality: "good" }, { quality: "weak" }, { quality: "none" }];
const conds = links.map(conditionsFor);
for (const c of conds) console.log(`${c.name}: ${c.has.join(",")}`);
printLevels(assess([gallery, settings], conds));
```

```text
online: network,bandwidth,storage
weak-network: network,storage
offline: storage
full: Gallery @ online missing=-
full: Settings @ online missing=-
degraded: Gallery @ weak-network missing=bandwidth
full: Settings @ weak-network missing=-
disabled: Gallery @ offline missing=bandwidth,network
full: Settings @ offline missing=-
```

On a weak connection `Gallery` is **degraded**: the network exists but `bandwidth` does not, and the gallery falls to a low-resolution preview. With no connection the same gallery is **disabled**, because the missing `network` has no fallback and the gallery keeps no cache. `Settings` depends only on the local store, so it is **full** under all three conditions.

**OS4.** The model reduces the connection to three exact states: good, weak, none. A real connection slides constantly between these three, and the monitor reports the slide with a delay. Where weakness begins is built in the model not as a threshold but as the absence of the `bandwidth` capability. The rule sees the capability and the fallback; it does not see the data rate, the lost packets, or the user's patience. The model builds this operability's rule; the model is not the framework itself.

#### Retry and Exponential Backoff

A message sent while there is no connection or while it is weak can fail. The app retries the request. The question is when to try. In **fixed-interval** retry, each attempt comes the same interval after the previous one. If the connection will return soon, it catches it at the first opportunity, but if it will not return, it wastes battery and network for nothing; and if thousands of clients load the same server at the same moment, it tires the server too.

In **exponential backoff** the wait doubles after each failed attempt and stops at an upper limit. The first attempts are fast and later ones are sparse. In real apps a small random share is also added to the wait so that thousands of clients do not try at the same moment; the model has no randomness, so it does not build this share. Being able to retry safely depends on the idempotent submission of the previous lesson: the same message arriving twice does not corrupt the result.

In the model, time is expressed not in real duration but in **turns**. A turn is a unit of order; it does not stand for a second or a minute. The function below computes, in a scenario where the connection returns at a given turn, how many attempts each strategy makes and at which turn the work gets through.

```javascript
// retry.mjs — builds retry strategies by turn count
export function attemptsUntil(strategy, returnTurn) {
  let turn = 0;
  let wait = 1;
  let attempts = 0;
  while (true) {
    attempts += 1;
    if (turn >= returnTurn) return { attempts, turn };
    turn += wait;
    if (strategy === "exponential") wait = Math.min(wait * 2, 8);
  }
}
```

The second measurement prints the record of `Chat` with no connection and the result of the two strategies. The connection returns at turn ten.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { chat, settings, conditionsFor } from "./connectivity.mjs";
import { attemptsUntil } from "./retry.mjs";

const conds = [{ quality: "none" }, { quality: "good" }].map(conditionsFor);
printLevels(assess([chat, settings], conds).filter((r) => r.feature === "Chat"));
for (const strategy of ["fixed", "exponential"]) {
  const result = attemptsUntil(strategy, 10);
  console.log(`${strategy}: ${result.attempts} attempts, got through at turn ${result.turn}`);
}
```

```text
deferred: Chat @ offline missing=bandwidth,network
full: Chat @ online missing=-
fixed: 11 attempts, got through at turn 10
exponential: 5 attempts, got through at turn 15
```

The fixed-interval strategy got the work through at turn ten, as soon as the connection returned, but it made eleven attempts. The exponential strategy made five attempts and got the work through at turn fifteen: the connection had returned, but the next attempt had not yet come. The cost of sparse attempts is delay; the cost of tight attempts is wasted attempts. Which one to choose depends on what the product cares about. In both, `Chat @ offline` is **deferred**, because the message is not lost; it is queued and sent when the connection returns.

#### Showing the State to the User

When the connection is absent or weak, showing the user a single "you are offline" notice is easy and misleading. At the same moment the gallery has fallen to low resolution, messages have entered the queue, and settings work normally. What is meaningful to the user is the state of each feature. The operability record carries this message directly: a degraded feature says "limited", a deferred feature says "queued", a disabled feature says "unavailable"; a full feature says nothing.

```javascript
// banner.mjs — derives the state to show the user from a record
const LABELS = { full: "-", degraded: "limited", deferred: "queued", disabled: "unavailable" };

export function labelFor(record) {
  return LABELS[record.level];
}

export function printBanners(records) {
  for (const r of records) console.log(`${r.feature} @ ${r.condition}: ${labelFor(r)}`);
}
```

```javascript
// main.mjs
import { assess } from "./operability.mjs";
import { gallery, chat, settings, conditionsFor } from "./connectivity.mjs";
import { printBanners } from "./banner.mjs";

const conds = [{ quality: "weak" }, { quality: "none" }].map(conditionsFor);
printBanners(assess([gallery, chat, settings], conds));
```

```text
Gallery @ weak-network: limited
Chat @ weak-network: queued
Settings @ weak-network: -
Gallery @ offline: unavailable
Chat @ offline: queued
Settings @ offline: -
```

On a weak connection and with no connection, `Settings` carries no notice. `Chat` says "queued" under both conditions: the message was not sent, but it was not lost. `Gallery` says "limited" on a weak connection and "unavailable" with no connection. The same connection state produced three different messages on three features.

#### The Bounding Measurement

This lesson's focus record is `Gallery @ weak-network`. The last measurement runs three features under three connection states and prints the weak-connection records and the count.

```javascript
// main.mjs
import { assess, printLevels, countLevels } from "./operability.mjs";
import { gallery, chat, settings, conditionsFor } from "./connectivity.mjs";

const features = [gallery, chat, settings];
const links = [{ quality: "good" }, { quality: "weak" }, { quality: "none" }];
const records = assess(features, links.map(conditionsFor));
printLevels(records.filter((r) => r.condition === "weak-network"));
countLevels(records);

const withCopies = (copies) =>
  assess(features, [{ quality: "weak", copies }].map(conditionsFor));
const same = JSON.stringify(withCopies(1)) === JSON.stringify(withCopies(3));
console.log(`copy count is not recorded: ${same ? "yes" : "no"}`);
```

```text
degraded: Gallery @ weak-network missing=bandwidth
deferred: Chat @ weak-network missing=bandwidth
full: Settings @ weak-network missing=-
full: 5
degraded: 1
deferred: 2
disabled: 1
affected: 4
copy count is not recorded: yes
```

The first line carries the lesson's operability: on a weak connection the gallery came out **degraded**. Under the same condition `Chat` is **deferred** and `Settings` is **full**. Of the nine records, five are **full**, one is **degraded**, two are **deferred**, and one is **disabled**; four are affected.

The last line shows how this differs from the question of M31/K05. That lesson asked how many copies a sent event reaches the receiver as. When the connection returns, the queued messages are delivered, and because of retries the same message can arrive more than once. Operability does not classify this: one copy and three copies produce the same record. This is not an observation, it is the definition of the model: the `conditionsFor` function that builds the condition never reads the `copies` field. Operability classifies not how many copies a delivery was, but what the feature does while the connection is weak. The message enters a queue, and that is **deferred** regardless of how many times it is sent.

#### Summary

- This lesson's operability: **degraded**. `Gallery @ weak-network` came out degraded because `bandwidth` is missing and a `degraded` fallback is defined; under the same condition `Chat` stayed **deferred** and `Settings` stayed **full**.
- Platform monitors report that a network path exists, not that a request will reach the server; the result of a real request has the last word.
- On a weak connection the network exists but `bandwidth` does not; the feature moves to its defined fallback, a low-resolution preview or a queue.
- In the same scenario the fixed-interval strategy made eleven attempts and got through at turn ten, and the exponential strategy made five attempts and got through at turn fifteen; the cost of sparse attempts is delay.
- The state is shown to the user per feature: limited, queued, unavailable.

#### Next Step

In this topic, the reason a feature stayed disabled or degraded was always a condition: network, storage, background, data rate. In production, a feature does not need a broken condition to stay disabled; the configuration of the stage can keep the feature disabled. The next lesson, `Mobile Continuous Delivery`, covers the pipeline in which the app is built, signed, and reaches the stores and test users. It then measures the operability of a feature that is enabled at the staging stage and disabled at the production stage.

## Release and Operations

Signing, build and upload automation, version numbering, staged rollout and the limits of rollback on mobile, forced updates, app size, unified monitoring from both platforms.

### Mobile Continuous Delivery

Source: https://academia.sh/en/courses/cross-platform-production/mobile-continuous-delivery

Mobile continuous delivery turns a code change into a signed build and passes it through a test distribution channel and store review. This lesson measures how well a feature works when the same signed build ships to production with a closed flag.

In the earlier lessons, the state of the device determined a feature's operability: the network was present or absent, and background work was throttled or not. This lesson moves the source of the condition from the device to the stage. Before an application reaches users, it passes through a pipeline; the pipeline compiles the build, signs it, uploads it, and distributes it through a channel. The same build can run in different stages with different feature flag values, and the value of a flag says which capability exists in that stage.

The question of the course does not change: how well does this feature work under this condition? In the opening lesson the nine records came out as five full, one degraded, two deferred, and one disabled. This lesson does not change those counts; it measures only its own slice, the condition of the build.

#### Continuous Delivery on Mobile

Continuous delivery is the practice of turning every code change into a release-ready build through the same steps and without manual intervention. On the web, the end of such a pipeline updates a server, and everyone sees the new version. On mobile, the end of the pipeline is a build, and that build waits to be installed on the user's device. Until the user accepts the update, the old version keeps running on the device.

There is one more limit. The pipeline makes the build ready for release, but the final step of release goes through the store review of the app store or the Android store, and that stage is not in the pipeline's hands. For this reason mobile pipelines have two parts: the stages the team controls before the build enters the store, and the stages that wait after it enters. The team automates the first part and plans around the second.

M12/K05 asked whether a behavior reaches production. Operability asks something else. A feature flag can be off even in a build that reaches production; operability classifies not where the build went, but how well the feature works in the build that went there.

#### Signing, Certificate, and Provisioning Profile

A build does not enter a store without being signed. Signing proves who produced the build and that it has not changed since. On iOS, the signature is made with a certificate; the build is also paired with a provisioning profile that says which application identifier, which entitlements, and which distribution type it belongs to. A certificate has a validity period, and an expired certificate prevents the next build from being signed. On Android, the signature is tied to a key; the signing identity is part of the application's identity. If the team keeps the signing key itself, losing the key cannot be undone, and a leaked key concerns not a single build but the whole release history of the application. If the store keeps the signing key, a lost upload key held by the team can be renewed by applying to the store.

The rule is the same on both platforms: signing uses a secret, and a secret is not committed to the repository. The pipeline reads the certificate, the provisioning profile, and the key from a secret store and uses them only for the duration of the signing step. If signing material stays on one developer's computer, the pipeline depends on that person, which is the very problem continuous delivery tries to solve.

#### Build, Upload, and Test Distribution

A pipeline is usually divided into three stages. The build stage produces the build from the code in a clean environment and increments the build number on every run. The signing stage signs the build. The upload stage sends the signed build to the publishing console of the store or to a test distribution channel. A test distribution channel delivers a build to selected users without passing it through full store review. On iOS, when testers from outside the team are invited, the first build goes through a separate test review; the team tries the build that will go to production first on its own devices and with test users.

The fragment below shows what these stages look like in a configuration. The fragment is not measured; the field names are examples.

```yaml
# Platform code: delivery pipeline stages
pipeline:
  - stage: build
    steps: [compile, sign]
    signing:
      certificate: from-secret-store
      profile: store-distribution
  - stage: upload
    steps: [upload-to-test-channel]
  - stage: release
    needs-approval: true
    steps: [upload-to-store, submit-for-review]
```

The `needs-approval` line in the last stage shows where the pipeline stops on purpose. If no problem appeared while the build circulated in the test channel, a person gives approval and the build is submitted to store review. Automation carries the load; the decision stays with a person.

#### Habits That Make the Pipeline Reliable

The value of a pipeline comes less from its speed than from its repeatability. A few habits protect that.

First, the build tried in the test channel and the build sent to the store must be the same. If one build is compiled for staging and a second build for production, a difference can arise between what was tried and what was published. For this reason the pipeline produces the build once and carries the same file from one stage to the next. The difference between the stages lies not in the content of the build but in the configuration of the stage.

Second, the build number never goes backward and is never the same in two builds. Stores do not accept a second upload with the same build number, and managing the number by hand is one of the places where pipelines get stuck most often. The pipeline increments the number, and a person decides only the version number. The difference between the two is the subject of the next lesson.

Third, if a stage fails, the pipeline stops and a half-finished build is not uploaded. If the signing step reports an error, the upload step does not run; only a signed and complete build reaches the test channel. The log of a failed run is the first place the team looks, and no secret should appear in that log.

Fourth, a store review rejection is part of the plan. The fix for a rejected build gets a new build number, and the pipeline runs again from the start. Automation shortens this cycle, but waiting for the store's decision is not something the team can manage.

#### Measuring the Condition of the Build

From the point of view of operability, the build the pipeline produces takes a single piece of information from the configuration of the stage: which feature flags are set and with which values. In the configuration of the staging stage, the flag of the new payment flow is on, because the team wants to try it. In the configuration of the production stage it is off, because the flow will not yet be given to users. The core below is familiar from the first lesson and is given again in the same form in every lesson; the lesson does not touch it.

```javascript
// operability.mjs — classifies how fully a feature works under a condition
export const LEVELS = ["full", "degraded", "deferred", "disabled"];
const FALLBACKS = ["degraded", "deferred"];

export function feature(name, needs, fallback = {}) {
  return { name, needs, fallback };
}

export function condition(name, has) {
  return { name, has };
}

function judge(item, cond) {
  const missing = item.needs.filter((need) => !cond.has.includes(need)).sort();
  let level = "full";
  for (const need of missing) {
    const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled";
    if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given;
  }
  return { level, feature: item.name, condition: cond.name, missing };
}

export function assess(features, conditions) {
  const records = [];
  for (const cond of conditions) {
    for (const item of features) records.push(judge(item, cond));
  }
  return records;
}

export function printLevels(records) {
  for (const r of records) {
    const missing = r.missing.length === 0 ? "-" : r.missing.join(",");
    console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`);
  }
}

export function countLevels(records) {
  for (const name of LEVELS) {
    console.log(`${name}: ${records.filter((r) => r.level === name).length}`);
  }
  console.log(`affected: ${records.filter((r) => r.level !== "full").length}`);
}
```

After the core comes the lesson's helper file. `configFor(stage)` produces a condition from a pipeline stage: the network is present in every stage, while a flag capability is added only for the stage's open flags. The capability string is written with the `flag:` prefix. There are three features. `NewCheckout` is the new payment flow and needs the network and the `flag:new-checkout` capability; the flag has no fallback. `LegacyCheckout` is the old payment flow and needs only the network. `Catalog` is the product list and also needs only the network.

```javascript
// delivery.mjs — produces a condition from a pipeline stage
import { feature, condition } from "./operability.mjs";

export function configFor(stage) {
  const flags = stage.flags.map((name) => `flag:${name}`);
  return condition(stage.name, ["network", ...flags]);
}

export const checkoutFeatures = [
  feature("NewCheckout", ["network", "flag:new-checkout"]),
  feature("LegacyCheckout", ["network"]),
  feature("Catalog", ["network"]),
];
```

**RO1.** The model builds the flag as a capability: a flag is either in the configuration of the stage or not. In a real application, the value of a flag can be embedded in the build or can come from remote configuration; the rule does not see this difference and looks only at the `has` list. The model builds this operability's rule; the model is not the framework itself.

The first measurement puts two stages side by side with the same build number. The build was compiled and signed once; the only difference between staging and production is the list of open flags. The build lines are the lesson's local output; the record lines are the `assess` output.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { configFor, checkoutFeatures } from "./delivery.mjs";

const build = { number: 42, signed: true, uploaded: true };
const stages = [
  { name: "staging", flags: ["new-checkout"] },
  { name: "production", flags: [] },
];
for (const stage of stages) {
  console.log(`build: ${build.number} stage=${stage.name} signed=${build.signed ? "yes" : "no"}`);
}
printLevels(assess(checkoutFeatures, stages.map(configFor)));
```

```text
build: 42 stage=staging signed=yes
build: 42 stage=production signed=yes
full: NewCheckout @ staging missing=-
full: LegacyCheckout @ staging missing=-
full: Catalog @ staging missing=-
disabled: NewCheckout @ production missing=flag:new-checkout
full: LegacyCheckout @ production missing=-
full: Catalog @ production missing=-
```

In staging all three features are full. In production, `NewCheckout` comes out **disabled**: the missing capability is `flag:new-checkout`, and it has no fallback defined. `LegacyCheckout` and `Catalog` stay full in the same production build. The work of the disabled feature is not deferred either; the user pays through the old flow. The old flow being full does not mean it is the new flow's fallback. They are two separate features and get separate records.

#### The Build Stays Fixed, the Flag Changes

A flag can be turned on in production, and this may require no new build. If the value of the flag is read from remote configuration, the team changes the value, and the application picks up the new value on its next read. This is one of the most valuable distinctions in mobile continuous delivery: sending a build to the store and opening a behavior to users are two separate acts. The first is subject to store review; the second is not.

The second measurement compares the same production build with the flag closed and open. The condition names differ, because two conditions cannot share a name; the build and the build number are the same.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { configFor, checkoutFeatures } from "./delivery.mjs";

const closed = { name: "production", flags: [] };
const opened = { name: "production-flag-on", flags: ["new-checkout"] };
printLevels(assess(checkoutFeatures, [closed, opened].map(configFor)));
```

```text
disabled: NewCheckout @ production missing=flag:new-checkout
full: LegacyCheckout @ production missing=-
full: Catalog @ production missing=-
full: NewCheckout @ production-flag-on missing=-
full: LegacyCheckout @ production-flag-on missing=-
full: Catalog @ production-flag-on missing=-
```

When the flag opens, `NewCheckout` moves from disabled to full; the other two features do not change at all. The rule does not see how the flag was opened: remote configuration, a new build, or a manually changed value all yield the same capability. What the rule looks at is whether the capability exists at the end of the opening.

**RO2.** The model does not show the spread of a flag's opening. In a real application, remote configuration does not reach every device at the same moment; devices read the value at different times. This distribution is the subject of the next lessons, and in this lesson the condition of a single application instance is measured.

#### The Bounding Measurement

The focus record of this lesson is `NewCheckout @ production`. The bounding measurement shows two things together. First, the focus record is full at the staging stage and disabled at the production stage, and only one of six records is affected. Second, in the model the signing and uploading of the build do not enter the record: a signed and uploaded build and a build not yet signed produce the same record under the same production condition. This is not an observation, it is the definition of the model: the `configFor` function that builds the condition never reads the `build` field.

```javascript
// main.mjs
import { assess, printLevels, countLevels } from "./operability.mjs";
import { configFor, checkoutFeatures } from "./delivery.mjs";

const stages = [
  { name: "staging", flags: ["new-checkout"] },
  { name: "production", flags: [] },
];
const records = assess(checkoutFeatures, stages.map(configFor));
printLevels(records.filter((r) => r.feature === "NewCheckout"));
countLevels(records);

const signed = { number: 42, signed: true, uploaded: true };
const unsigned = { number: 43, signed: false, uploaded: false };
const withBuild = (build) =>
  assess(checkoutFeatures, [{ name: "production", flags: [], build }].map(configFor));
const same = JSON.stringify(withBuild(signed)) === JSON.stringify(withBuild(unsigned));
console.log(`signing does not enter the record: ${same ? "yes" : "no"}`);
```

```text
full: NewCheckout @ staging missing=-
disabled: NewCheckout @ production missing=flag:new-checkout
full: 5
degraded: 0
deferred: 0
disabled: 1
affected: 1
signing does not enter the record: yes
```

The first two lines carry the lesson's operability: the same feature, the same code, the same build number; only whether the flag is in the configuration of the stage changed, and the record moved from full to disabled. The count lines say that of six records, five are full and one is disabled; affected is one.

The last line shows the limit of the model plainly. The signature, the certificate, and the upload are conditions a build must meet to enter a store, but they are not capabilities the feature needs. A build that is not signed will never enter the store; but the rule classifies the feature inside a build that has entered the store. The success of the pipeline and the working of the feature answer two separate questions. The pipeline can be green, the signature valid, the upload complete, and the user can still fail to see the new payment flow.

This distinction changes the workflow as well. When a feature is found not to work after a production release, the first place to look is not the pipeline log; it is the flag value in the configuration of the stage.

#### Summary

- This lesson's operability: disabled. `NewCheckout @ production` came out disabled because the configuration of the production stage lacked the `flag:new-checkout` capability and the flag has no fallback; in the same stage `LegacyCheckout` and `Catalog` stayed full.
- A mobile pipeline compiles, signs, and uploads the build and gives it to a test distribution channel; the last stage is store review, and it is not in the pipeline's hands.
- Signing depends on secrets such as the certificate, the provisioning profile, and the key; these values live in the pipeline's secret store, not in the repository.
- In staging three features were full and in production one was disabled; of six records, one was affected. When the flag opened, the record returned to full in the same production build.
- In the model, a signed and uploaded build and an unsigned build give the same record; this equality is not a measured finding. A build being ready does not determine operability.

#### Next Step

In this lesson the build was referred to by a single build number, and no meaning was attached to it. The next lesson, `Version Management`, covers the difference between the version number and the build number, semantic versioning, and release notes. It then ties the contract between client and server to the version, and measures how well order history works when it asks for the new contract on a server that offers only the old one.

### Version Management

Source: https://academia.sh/en/courses/cross-platform-production/version-management

Version management governs which build of an application ships under which number and how the client talks to the server. This lesson measures how well a feature works when it asks for a new contract from a server that offers only the old one.

The previous lesson referred to the build by a single build number and gave the number no meaning. In a real application a number does two jobs: it tells the user which version they are running, and it tells the team which build carries which defect. This lesson covers what the numbers are, who increments them, and how a version number relates to the server.

The question of the course stays the same: how well does this feature work under this condition? In this lesson the source of the condition is the server. The client asks for a contract, and the server either offers that contract or does not.

#### Version Number and Build Number

A mobile application has two separate numbers, and the two are often confused.

The version number is the number a person reads. It appears on the store page, it is written on the settings screen, and it is what a user says when support asks which version they are on. The team decides the version number, because the number carries a meaning: how much this release changed compared with the previous one.

The build number is the number a machine reads. The store uses it to tell apart several builds uploaded with the same version number. The fix for a rejected build may keep the same version number, but the build number has to increase. The delivery pipeline described in the previous lesson increments this number; a build number managed by hand is one of the places where pipelines get stuck most often.

The two platforms keep these two numbers in different fields. The two fragments below show only where the fields are; the fragments are not measured.

```kotlin
// Platform code: Android version fields
android {
    defaultConfig {
        versionCode = 142
        versionName = "4.2.1"
    }
}
```

```yaml
# Platform code: iOS version fields
CFBundleShortVersionString: "4.2.1"
CFBundleVersion: "142"
```

On Android, `versionName` is the version shown to the user and `versionCode` is the integer the machine sorts by. On iOS, `CFBundleShortVersionString` carries the version shown to the user and `CFBundleVersion` carries the build number. In a framework that connects to native views through a bridge and in a framework that runs its own rendering engine alike, these two fields are written to the same place in the end, the platform's own configuration file; the framework only adds a layer that feeds those files.

#### Semantic Versioning

Splitting the version number into three parts is a common scheme: major, minor, and patch. In the number `4.2.1`, the major version is four, the minor version is two, and the patch is one. Each part makes a promise.

- A patch fixes a defect without breaking existing behavior. A user who installs a patch does not need to learn anything new.
- A minor version adds a new capability without breaking existing behavior. The patch resets.
- A major version brings a change that may break existing behavior. The minor and the patch reset.

This scheme matters more on mobile than for a web service, because the old version keeps living on the user's device. The server is updated and reaches everyone at once; the client is not updated, and stays old until the user decides. The major version number is therefore not only a label; it is a signal to the team that is planning which clients the server still has to talk to.

Producing the number is mechanical and goes wrong when done by hand, so it is good to leave it to a small function. The `nextVersion` function in the lesson's helper file writes this rule.

#### Release Notes

Every release ships with release notes. The notes address two readers: the user who reads the store page and the team that watches the release. The note for the user is short and focused on behavior, such as "the payment screen was redesigned". The note for the team says which changes entered the release, which of them shipped closed behind a flag, and which server version the release requires.

The last item is neglected and costs a great deal later. If a release requires a new server contract, the notes must say so plainly, because publishing the client before the server offers that contract makes the feature work incompletely and silently. The section below measures that shortfall.

#### The Contract Between Client and Server

The client and the server talk through a contract: which fields exist, in which format, under which names. The contract also has a version, and a server can offer several versions at the same time. An old client asks for the old contract, and a new client asks for the new one.

With a breaking change, the server keeps offering the old contract for a while longer, because old clients are still in use. The opposite also happens: the new client may have been published while the server has not yet moved to the new contract. The client that enters the store and the server that goes live are not updated at the same moment; in the interval between them, the new client meets the old contract.

From the point of view of operability, the shape of the problem is clear once more. The version of the contract is a capability: `api:v2` or `api:v3`. If a feature in the client asks for the new contract and the server does not offer it, the feature runs with a missing capability. Sometimes this shortfall blocks the feature entirely; sometimes the feature shows less information using the old fields. The second is a shortfall that has a fallback.

#### Measuring the Condition of the Contract

For the measurement, take the order history. `OrderHistory` asks for the new contract, the `api:v3` capability; that contract also returns the line-item details of each order. On a server that offers only `api:v2`, the screen opens but shows the orders with the old fields, without line-item details. This is a fallback and sits at the `degraded` level. The core is familiar from the first lesson and is given again in the same form in every lesson; the lesson does not touch it.

```javascript
// operability.mjs — classifies how fully a feature works under a condition
export const LEVELS = ["full", "degraded", "deferred", "disabled"];
const FALLBACKS = ["degraded", "deferred"];

export function feature(name, needs, fallback = {}) {
  return { name, needs, fallback };
}

export function condition(name, has) {
  return { name, has };
}

function judge(item, cond) {
  const missing = item.needs.filter((need) => !cond.has.includes(need)).sort();
  let level = "full";
  for (const need of missing) {
    const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled";
    if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given;
  }
  return { level, feature: item.name, condition: cond.name, missing };
}

export function assess(features, conditions) {
  const records = [];
  for (const cond of conditions) {
    for (const item of features) records.push(judge(item, cond));
  }
  return records;
}

export function printLevels(records) {
  for (const r of records) {
    const missing = r.missing.length === 0 ? "-" : r.missing.join(",");
    console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`);
  }
}

export function countLevels(records) {
  for (const name of LEVELS) {
    console.log(`${name}: ${records.filter((r) => r.level === name).length}`);
  }
  console.log(`affected: ${records.filter((r) => r.level !== "full").length}`);
}
```

After the core comes the lesson's helper file. `conditionsFor(servers)` produces a condition from each server definition: the network is present on every server, while the contract capability comes from the version the server offers and is written with the `api:` prefix. `nextVersion(version, change)` increments a three-part number according to the kind of change. There are three features. `OrderHistory` needs the network and the `api:v3` capability; if `api:v3` is missing, it works in a degraded way. `Catalog` is the product list and `Profile` is the user profile; both need only the network.

```javascript
// versions.mjs — builds a condition from the contract a server offers, increments the version
import { feature, condition } from "./operability.mjs";

export function conditionsFor(servers) {
  return servers.map((server) => condition(server.name, ["network", `api:${server.api}`]));
}

export function nextVersion(version, change) {
  const [major, minor, patch] = version.split(".").map(Number);
  if (change === "major") return `${major + 1}.0.0`;
  if (change === "minor") return `${major}.${minor + 1}.0`;
  return `${major}.${minor}.${patch + 1}`;
}

export const historyFeatures = [
  feature("OrderHistory", ["network", "api:v3"], { "api:v3": "degraded" }),
  feature("Catalog", ["network"]),
  feature("Profile", ["network"]),
];
```

**RO3.** The model builds the contract the server offers as a single capability: a server offers either `v2` or `v3`. A real server can offer several versions together, and the client can decide which one to use through negotiation; the rule does not see this negotiation and looks only at the condition's `has` list. The model builds this operability's rule; the model is not the framework itself.

The first measurement puts two servers side by side for the same client. Three number increments are printed first; these are the lesson's local output. Then the six records follow.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { conditionsFor, nextVersion, historyFeatures } from "./versions.mjs";

let version = "4.2.0";
for (const change of ["patch", "minor", "major"]) {
  const next = nextVersion(version, change);
  console.log(`version: ${version} + ${change} = ${next}`);
  version = next;
}
const servers = [
  { name: "api-v2", api: "v2" },
  { name: "api-v3", api: "v3" },
];
printLevels(assess(historyFeatures, conditionsFor(servers)));
```

```text
version: 4.2.0 + patch = 4.2.1
version: 4.2.1 + minor = 4.3.0
version: 4.3.0 + major = 5.0.0
degraded: OrderHistory @ api-v2 missing=api:v3
full: Catalog @ api-v2 missing=-
full: Profile @ api-v2 missing=-
full: OrderHistory @ api-v3 missing=-
full: Catalog @ api-v3 missing=-
full: Profile @ api-v3 missing=-
```

The number increments show the version rule: a patch increases only the last part, a minor version resets the patch, and a major version resets both. In the records, `OrderHistory` is degraded only on the `api-v2` server; the missing capability is `api:v3`, and its fallback is defined as degraded. On the same server `Catalog` and `Profile` are full, because neither asks for a contract version. On the `api-v3` server all three features are full.

The word degraded says something concrete here: the order history opens and the orders are listed, but the line-item details are absent. Had the feature been disabled, the screen would not have opened at all. A defined fallback means the feature can offer something even with the missing capability.

#### A Patch Upgrade Does Not Change the Capability

When a user installs a patch, the version number changes. The number changing does not mean that it brought in or took away one of the capabilities the feature needs. A patch fixes a defect and does not change the contract.

The second measurement compares two client versions against the same server. The condition names include the version, because two conditions cannot share a name; the contract offered is the same in both.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { conditionsFor, historyFeatures } from "./versions.mjs";

const installs = [
  { name: "4.2.0-api-v2", api: "v2" },
  { name: "4.2.1-api-v2", api: "v2" },
];
const records = assess(historyFeatures, conditionsFor(installs));
printLevels(records);

const key = (list) => list.map((r) => `${r.level}:${r.feature}:${r.missing.join(",")}`).join("|");
const before = key(records.filter((r) => r.condition === "4.2.0-api-v2"));
const after = key(records.filter((r) => r.condition === "4.2.1-api-v2"));
console.log(`does a patch change the record: ${before === after ? "no" : "yes"}`);
```

```text
degraded: OrderHistory @ 4.2.0-api-v2 missing=api:v3
full: Catalog @ 4.2.0-api-v2 missing=-
full: Profile @ 4.2.0-api-v2 missing=-
degraded: OrderHistory @ 4.2.1-api-v2 missing=api:v3
full: Catalog @ 4.2.1-api-v2 missing=-
full: Profile @ 4.2.1-api-v2 missing=-
does a patch change the record: no
```

The two client versions give the same three results across six records, and the last line says so. The comparison leaves out the condition name, because the name is different anyway; what is compared is the levels, the features, and the missing capabilities.

**RO4.** The model assumes that a patch does not change capabilities, and this assumption rests on a promise: a patch does not break behavior. In reality, a patch can disable a fallback path or remove a capability by mistake. In that case the patch fixes one defect while introducing another, and the version number does not say so. The rule does not catch this case; what catches it is rebuilding the condition and measuring again after the patch.

#### The Bounding Measurement

The focus record of this lesson is `OrderHistory @ api-v2`. The bounding measurement shows two things together. First, the focus record is degraded on the `api-v2` server and full on the `api-v3` server, and only one of six records is affected. Second, the client's version number does not enter the record: a client whose major version has been raised gives the same record on the same old server.

```javascript
// main.mjs
import { assess, printLevels, countLevels } from "./operability.mjs";
import { conditionsFor, historyFeatures } from "./versions.mjs";

const servers = [
  { name: "api-v2", api: "v2" },
  { name: "api-v3", api: "v3" },
];
const records = assess(historyFeatures, conditionsFor(servers));
printLevels(records.filter((r) => r.feature === "OrderHistory"));
countLevels(records);

const key = (list) => list.map((r) => `${r.level}:${r.feature}:${r.missing.join(",")}`).join("|");
const oldClient = assess(historyFeatures, conditionsFor([{ name: "4.2.0-api-v2", api: "v2" }]));
const newClient = assess(historyFeatures, conditionsFor([{ name: "5.0.0-api-v2", api: "v2" }]));
console.log(`client version does not enter the record: ${key(oldClient) === key(newClient) ? "yes" : "no"}`);
```

```text
degraded: OrderHistory @ api-v2 missing=api:v3
full: OrderHistory @ api-v3 missing=-
full: 5
degraded: 1
deferred: 0
disabled: 0
affected: 1
client version does not enter the record: yes
```

The first two lines carry the lesson's operability: the same feature, the same code; only the contract the server offers changed, and the record moved from full to degraded. The count lines say that of six records, five are full and one is degraded; affected is one.

The last line shows the limit of the model. The version number is a label; what the feature needs is not the version number but the contract the server offers. A client numbered `5.0.0` is as degraded on a server that does not offer `api:v3` as a client numbered `4.2.0`. The number is therefore a hint but not proof: the team writes in the release notes which client asks for which contract, while operability is determined by the contract the server actually offers.

This distinction also determines the release order. If a client that asks for the new contract is published before the server moves to that contract, some of the users see the feature degraded. Publishing the server first and the client afterward closes that interval.

#### Summary

- This lesson's operability: degraded. `OrderHistory @ api-v2` came out degraded because the server offered only the old contract and the fallback for the `api:v3` capability is degraded; on the same server `Catalog` and `Profile` stayed full.
- The team decides the version number and the user can see it; the pipeline increments the build number, and it has to be new on every upload.
- In semantic versioning a patch fixes a defect, a minor version adds a capability and resets the patch, and a major version brings a breaking change and resets both.
- Of six records, five were full and one was degraded; affected was one. A patch upgrade did not change the record on the same server.
- A client with a raised major version gave the same record on the old server; the contract the server offers, not the version number, determines operability.

#### Next Step

In this lesson the server was a fixed condition and the client was a single build. The next lesson, `Staged Rollout and Rollback`, covers the case in which a new version reaches only a portion of the users. It compares halting the rollout, rolling back, and rolling forward with a fix, and it measures the condition of each bucket separately.

### Staged Rollout and Rollback

Source: https://academia.sh/en/courses/cross-platform-production/staged-rollout-and-rollback

A staged rollout opens a new version to only a portion of the users; if a problem appears, the rollout is halted or the feature is turned off. This lesson measures how well a feature works in each bucket the rollout has reached.

In the previous lesson we measured that if a client asking for the new contract is published before the server is ready, some of the users see the feature degraded. This lesson describes how to choose that portion deliberately and under control. A new version is not opened to all users at once; it is opened first to a small group, and if no problem appears, the group grows.

The question of the course stays the same: how well does this feature work under this condition? In this lesson the condition is which bucket of the rollout the device is in.

#### Staged Rollout

In a staged rollout, a version is published in the store but offered to only a part of the users. The store selects the part itself. On Android, the team decides which stage the rollout moves to; on iOS, the stages advance by themselves on a fixed schedule, and the team can only pause the rollout or open it to everyone. As the stage widens, the number of users who can receive the version grows; in the last stage everyone receives it.

The value of this arrangement is that it limits the effect of a defect. If the new version carries an unexpected defect, the defect first shows up in a small group. Crash reports and user feedback are watched; if things are going well, the rollout moves to the next stage. Monitoring is the subject of a separate lesson and is covered at the end of this course.

A staged rollout is not a single mechanism. The store's own staged rollout feature distributes the version to devices. A flag inside the application, on the other hand, turns the feature on and off on the devices that received the version. The two can be used together: the version reaches a portion of devices at the store level, and the feature is opened by a flag on only a portion of those.

M12/K02 described how a change reaches production. In a staged rollout, the new version reaches only some buckets; operability classifies not the number of devices reached but the operability in each bucket. How many devices the version reached is a question for the rollout plan. How well the feature works in the bucket it reached is the question for operability.

#### Bucket and Halting a Rollout

A bucket is a group into which devices are split by a stable rule. The rule derives a number from the device identifier and divides that number by the bucket count; the remainder is the device's bucket. The same device always falls into the same bucket. This stability matters: when the rollout widens, a device in the first bucket does not leave its bucket; new buckets are added to the list of open buckets.

A configuration file can hold the stages of a rollout. The fragment below shows only the shape; it is not measured.

```yaml
# Platform code: staged rollout stages
rollout:
  - stage: first
    buckets: [2]
  - stage: wider
    buckets: [2, 3]
  - stage: full
    buckets: [0, 1, 2, 3]
```

To halt a rollout means not to move to the next stage. In a halted rollout, the devices that already received the version keep using it. On Android, new devices do not receive the version; on iOS, halting stops only the automatic update, and a user who downloads the version from the store on their own can still get it. Halting largely freezes the number of newly affected users, but it does not heal those already affected.

#### Rollback and Rolling Forward

When halting is not enough, two paths remain.

Rollback returns the user to the previous behavior. On mobile, an installed version cannot be withdrawn. The store does not downgrade the application on the user's device; at most it offers the old version again, and that comes as an update with a new version number. For this reason, rollback on mobile mostly means rolling back the feature, not the version.

The tool for rolling back a feature is the remote kill flag: the feature ships behind a flag, and if a problem appears the flag is pulled and the application returns to the old behavior. If the flag is read remotely, no new version is needed for this; it is the only way to cut the effect without waiting for store review. The two fragments below show how the flag is read.

```kotlin
// Platform code: reading a remote kill flag
val enabled = config.getBoolean("new_feed_enabled")
if (enabled) showNewFeed() else showLegacyFeed()
```

```swift
// Platform code: reading a remote kill flag
let enabled = config.bool(forKey: "new_feed_enabled")
if enabled { showNewFeed() } else { showLegacyFeed() }
```

Rolling forward means publishing a new version that fixes the defect. It is not as fast as rollback, because the new version goes through store review and then waits for the user to install it. But for a feature that was not placed behind a flag, it is the only path.

The choice between the two depends on whether the feature is behind a flag. For a feature behind a flag, rollback comes first, followed by an unhurried roll forward. For a feature without a flag there is only rolling forward, and the affected users live with the problem until they install the fixed version.

#### Measuring the Condition of the Rollout

For the measurement, take the new news feed. `NewFeed` needs the network and the `flag:new-feed` capability; the flag has no fallback. `LegacyFeed` is the old feed and needs only the network. The rollout supplies the flag in open buckets and does not supply it in closed buckets. The core is familiar from the first lesson and is given again in the same form in every lesson; the lesson does not touch it.

```javascript
// operability.mjs — classifies how fully a feature works under a condition
export const LEVELS = ["full", "degraded", "deferred", "disabled"];
const FALLBACKS = ["degraded", "deferred"];

export function feature(name, needs, fallback = {}) {
  return { name, needs, fallback };
}

export function condition(name, has) {
  return { name, has };
}

function judge(item, cond) {
  const missing = item.needs.filter((need) => !cond.has.includes(need)).sort();
  let level = "full";
  for (const need of missing) {
    const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled";
    if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given;
  }
  return { level, feature: item.name, condition: cond.name, missing };
}

export function assess(features, conditions) {
  const records = [];
  for (const cond of conditions) {
    for (const item of features) records.push(judge(item, cond));
  }
  return records;
}

export function printLevels(records) {
  for (const r of records) {
    const missing = r.missing.length === 0 ? "-" : r.missing.join(",");
    console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`);
  }
}

export function countLevels(records) {
  for (const name of LEVELS) {
    console.log(`${name}: ${records.filter((r) => r.level === name).length}`);
  }
  console.log(`affected: ${records.filter((r) => r.level !== "full").length}`);
}
```

After the core comes the lesson's helper file. `bucketOf(deviceId, bucketCount)` adds up the character codes of the device identifier and divides the sum by the bucket count; the remainder is the bucket. `rolloutConditions(fleet, openBuckets, bucketCount)` produces a condition from each device: the network is present on every device, and the flag capability is added only for devices whose bucket is open.

```javascript
// rollout.mjs — splits a device into its bucket, builds conditions from open buckets
import { feature, condition } from "./operability.mjs";

export function bucketOf(deviceId, bucketCount) {
  let sum = 0;
  for (const ch of deviceId) sum += ch.charCodeAt(0);
  return sum % bucketCount;
}

export function rolloutConditions(fleet, openBuckets, bucketCount) {
  return fleet.map((deviceId) => {
    const open = openBuckets.includes(bucketOf(deviceId, bucketCount));
    return condition(deviceId, open ? ["network", "flag:new-feed"] : ["network"]);
  });
}

export const rolloutFeatures = [
  feature("NewFeed", ["network", "flag:new-feed"]),
  feature("LegacyFeed", ["network"]),
];
```

**RO5.** The model builds the rollout with a fixed list of devices and four buckets. In a real rollout the number of devices changes constantly and the bucket count is much larger; moreover, the store's staged rollout and the application's flag are two separate mechanisms, and each may use its own bucketing rule. The model shows only one rule, and it builds this operability's rule; the model is not the framework itself.

The first measurement shows the first stage of the rollout: only bucket `2` is open. The buckets of the four devices are printed first, then the eight records follow.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { bucketOf, rolloutConditions, rolloutFeatures } from "./rollout.mjs";

const fleet = ["device-a", "device-b", "device-c", "device-d"];
for (const deviceId of fleet) {
  console.log(`bucket: ${deviceId} ${bucketOf(deviceId, 4)}`);
}
printLevels(assess(rolloutFeatures, rolloutConditions(fleet, [2], 4)));
```

```text
bucket: device-a 2
bucket: device-b 3
bucket: device-c 0
bucket: device-d 1
full: NewFeed @ device-a missing=-
full: LegacyFeed @ device-a missing=-
disabled: NewFeed @ device-b missing=flag:new-feed
full: LegacyFeed @ device-b missing=-
disabled: NewFeed @ device-c missing=flag:new-feed
full: LegacyFeed @ device-c missing=-
disabled: NewFeed @ device-d missing=flag:new-feed
full: LegacyFeed @ device-d missing=-
```

The four devices fall into four separate buckets. Only `device-a` is in the open bucket, and there `NewFeed` is full. On the other three devices the flag is absent; `NewFeed` is disabled, because the flag has no defined fallback. `LegacyFeed` is full on all four devices. The user of a disabled device is not left without a feed; they see the old feed. The new feed being disabled and the old feed being full are two separate records.

#### Widening and Rollback

When the rollout widens, a new bucket is added to the list of open buckets. When it is rolled back, the list empties. The rule works the same way in both cases; the only thing that changes is the capability in the condition.

The second measurement puts two stages one after the other. First buckets `2` and `3` are opened; then the flag is pulled and no open bucket remains. Only the `NewFeed` records are printed.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { rolloutConditions, rolloutFeatures } from "./rollout.mjs";

const fleet = ["device-a", "device-b", "device-c", "device-d"];
const stages = [
  { name: "widening", open: [2, 3] },
  { name: "rollback", open: [] },
];
for (const stage of stages) {
  console.log(`stage: ${stage.name} open-buckets=${stage.open.length === 0 ? "-" : stage.open.join(",")}`);
  const records = assess(rolloutFeatures, rolloutConditions(fleet, stage.open, 4));
  printLevels(records.filter((r) => r.feature === "NewFeed"));
}
```

```text
stage: widening open-buckets=2,3
full: NewFeed @ device-a missing=-
full: NewFeed @ device-b missing=-
disabled: NewFeed @ device-c missing=flag:new-feed
disabled: NewFeed @ device-d missing=flag:new-feed
stage: rollback open-buckets=-
disabled: NewFeed @ device-a missing=flag:new-feed
disabled: NewFeed @ device-b missing=flag:new-feed
disabled: NewFeed @ device-c missing=flag:new-feed
disabled: NewFeed @ device-d missing=flag:new-feed
```

In the widening stage, `device-a` and `device-b` are in open buckets and `NewFeed` is full there; `device-c` and `device-d` are still disabled. After the rollback, all four of the four devices return to disabled. At the level of a record, pulling the flag says this: the capability the new feed needs no longer exists on any device.

Rollback is not an operability level. Rollback is work the team does; operability classifies what the condition is at the end of that work. The rule does not see who pulled the flag, when they pulled it, or why it was pulled; it sees only that the `flag:new-feed` capability is absent after it was pulled.

**RO6.** The model assumes that pulling the flag takes effect on every device instantly. In reality devices read the configuration at different times, and a device that has been offline can keep using the old value for a while longer. Rollback therefore happens not at a single moment but as a distribution. The rule does not show this distribution; it requires a separate condition to be built from the value each device read.

#### The Bounding Measurement

The focus record of this lesson is `NewFeed @ device-a`, the first device in the open bucket in the first stage of the rollout. The bounding measurement shows two things together. First, the focus record is full in the open bucket, and three of the eight records are affected. Second, the number of devices the rollout reached does not determine the focus record: when the rollout widens by one more bucket, the record of `device-a` does not change.

```javascript
// main.mjs
import { assess, printLevels, countLevels } from "./operability.mjs";
import { rolloutConditions, rolloutFeatures } from "./rollout.mjs";

const fleet = ["device-a", "device-b", "device-c", "device-d"];
const records = assess(rolloutFeatures, rolloutConditions(fleet, [2], 4));
printLevels(records.filter((r) => r.feature === "NewFeed" && r.condition === "device-a"));
countLevels(records);
const reached = records.filter((r) => r.feature === "NewFeed" && r.level === "full").length;
console.log(`reached devices: ${reached} total: ${fleet.length}`);

const wider = assess(rolloutFeatures, rolloutConditions(fleet, [2, 3], 4));
const pick = (list) => list.find((r) => r.feature === "NewFeed" && r.condition === "device-a");
const same = JSON.stringify(pick(records)) === JSON.stringify(pick(wider));
console.log(`focus record unchanged when the share changes: ${same ? "yes" : "no"}`);
```

```text
full: NewFeed @ device-a missing=-
full: 5
degraded: 0
deferred: 0
disabled: 3
affected: 3
reached devices: 1 total: 4
focus record unchanged when the share changes: yes
```

The first line carries the lesson's operability: on the device in the open bucket, the new feed is full. The count lines say that of eight records, five are full and three are disabled; affected is three. The number of devices reached is one in four.

The last two lines show the limit of the model. The number of devices reached is a number for the rollout plan; it does not say how well the feature works on each device. That the new feed is full on a device that was reached does not mean that something is broken on the devices that were not reached; there the new feed was never offered, and the old feed works fully. Even when the rollout widens and the number reached rises to two, the record of `device-a` stays the same. The share changes which devices fall under which record, not a record itself.

This distinction is used in rollout decisions. The question to ask before widening is not only whether the number of devices reached is large enough, but also whether the feature works fully in the buckets reached. Both are examined separately.

#### Summary

- This lesson's operability: full. `NewFeed @ device-a` supplies the flag capability because the device's bucket is open, and the new feed works fully; on the three devices in closed buckets the same feature stayed disabled.
- A staged rollout opens the version to buckets; halting stops the rollout from widening (on iOS, only the automatic update) but does not withdraw an installed version.
- Rollback on mobile mostly means rolling back the feature: a remote kill flag cuts the effect without waiting for store review, and for a feature without a flag only rolling forward remains.
- Of eight records, five were full and three were disabled; affected was three. When the flag was pulled, the new feed returned to disabled on all four devices.
- When the rollout widened by one bucket, the focus record did not change; operability classifies the condition in each bucket, not the number of devices reached.

#### Next Step

In this lesson the new version did not reach some users, and the old version lived without trouble. This is not always the case: some versions can no longer be supported. The next lesson, `Forced Update Strategy`, covers the minimum version, soft and forced update prompts, and the end of support for an old version. It then measures how well the payment feature works on a client below the minimum version the server requires.

### Forced Update Strategy

Source: https://academia.sh/en/courses/cross-platform-production/forced-update-strategy

A forced update strategy decides when, with which prompt, and for which reason old clients that the server no longer supports are directed to the new version. This lesson measures how well the payment feature works on a client below the minimum version.

In the previous lesson the rollout advanced in stages and the old version lived without trouble for a while longer. Not every old version can live forever. At some point the server stops offering an old contract, a security fix makes it impossible for an old version to run, or a payment provider refuses to talk to an old client. This lesson describes how to prepare for that point.

The question of the course stays the same: how well does this feature work under this condition? In this lesson the condition is the version of the client.

#### The Minimum Supported Version

Every server has a range of clients it can actually talk to. The lower bound of the range is the minimum supported version: a client below it can connect to the server, but the server does not accept some of its requests. The lower bound is set by choice. The team does not want to carry the maintenance load of offering an old contract, or cannot accept the old client for a security reason.

Setting the lower bound is a data decision, not a code decision. The team looks at the version distribution in the field: which versions are still in use, and how many users each has. Raising the lower bound without knowing the distribution means meeting some of the users with a block they were not warned about.

The lower bound is kept on the server and the client reads it remotely. If the lower bound is embedded in the application, changing it requires a new version, and the purpose of the lower bound, directing old clients, does not happen, because an old client never sees the new bound.

#### Soft Prompt and Forced Update

An update prompt takes two forms.

A soft prompt tells the user that a new version is out, and the user can postpone it. The prompt appears as a banner, a dialog, or a line on the settings screen. The purpose of a soft prompt is to steer clients that have not yet fallen below the lower bound toward the new version.

A forced update makes the application unusable and shows only the update screen. The user cannot use the application without updating. A forced update is shown only when the client is below the lower bound, that is, when the server really cannot talk to it. If it is used more broadly, it blocks the user over a version that does them no harm.

The two fragments below show how the policy sits on the server and how the prompt decision is made. The fragments are not measured.

```yaml
# Platform code: update policy
update-policy:
  hard-minimum: 5
  latest: 6
```

```swift
// Platform code: update prompt
if installed < policy.hardMinimum {
    showBlockingUpdateScreen()
} else if installed < policy.latest {
    showUpdateBanner()
}
```

The decision comes down to two comparisons. If the installed version is below the minimum version, the prompt is forced; if it is below the latest version, the prompt is soft. Versions between the two bounds keep using the application.

#### The End of Support for an Old Version

Support is not cut in a single day. The team first announces, then steers with a soft prompt, and finally turns on the forced update. Each step depends on seeing that the previous one was not effective enough. If the soft prompt has moved enough users to the new version, the forced step may not be needed.

Communicating the end of support is also work. The text of the forced screen must tell the user why they need to update; "a new version is required" is not enough, because the user reads that as marketing language. When the reason is concrete, for example "the payment provider no longer supports this version", the user sees the update as a requirement, not as an obstacle.

#### The Delay of Store Review

A forced update depends on the new version being ready in the store. At the moment the lower bound is raised, the new version must be available to users; otherwise the forced screen leaves a user who finds nothing to update in a dead end.

How long store review will take is not known in advance, and the team cannot set it. The order therefore has to be this: the new version is published in the store and offered to users first; only after that is the lower bound raised. Raising the lower bound is a remote value change and takes effect immediately. That speed is dangerous: a change made in the wrong order leaves a user with no update available on their device without a usable application.

The possibility that the review rejects the build is also part of the plan. If an urgent lower-bound increase is needed for a security reason and the new version has not yet been approved, the team is left with a choice between raising the lower bound and waiting. That choice is a risk decision, not a code decision.

#### Measuring the Condition of the Update

For the measurement, take three features. `Payments` is the payment; it needs the network and the `min:5` capability, that is, a client at the fifth major version or above. The payment provider does not accept an old client, so it has no fallback. `Catalog` is the product list; it also needs `min:5`, but it can work with the old fields on an old client, and its fallback is degraded. `Settings` is the settings and needs only the network. The core is familiar from the first lesson and is given again in the same form in every lesson; the lesson does not touch it.

```javascript
// operability.mjs — classifies how fully a feature works under a condition
export const LEVELS = ["full", "degraded", "deferred", "disabled"];
const FALLBACKS = ["degraded", "deferred"];

export function feature(name, needs, fallback = {}) {
  return { name, needs, fallback };
}

export function condition(name, has) {
  return { name, has };
}

function judge(item, cond) {
  const missing = item.needs.filter((need) => !cond.has.includes(need)).sort();
  let level = "full";
  for (const need of missing) {
    const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled";
    if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given;
  }
  return { level, feature: item.name, condition: cond.name, missing };
}

export function assess(features, conditions) {
  const records = [];
  for (const cond of conditions) {
    for (const item of features) records.push(judge(item, cond));
  }
  return records;
}

export function printLevels(records) {
  for (const r of records) {
    const missing = r.missing.length === 0 ? "-" : r.missing.join(",");
    console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`);
  }
}

export function countLevels(records) {
  for (const name of LEVELS) {
    console.log(`${name}: ${records.filter((r) => r.level === name).length}`);
  }
  console.log(`affected: ${records.filter((r) => r.level !== "full").length}`);
}
```

After the core comes the lesson's helper file. `minimumFor(client)` produces the `min:` capabilities from the client's major version: a client at the fourth version carries `min:1` through `min:4` and does not carry `min:5`. `conditionsFor(clients)` adds the network to these capabilities and builds a condition from each client. `promptFor(client, policy)` says which prompt the client will get; it is a local function of this lesson and is not an input to the rule.

```javascript
// updates.mjs — builds a condition from the client version, tells the update prompt
import { feature, condition } from "./operability.mjs";

export function minimumFor(client) {
  const levels = [];
  for (let level = 1; level <= client.major; level++) levels.push(`min:${level}`);
  return levels;
}

export function conditionsFor(clients) {
  return clients.map((client) => condition(client.name, ["network", ...minimumFor(client)]));
}

export function promptFor(client, policy) {
  if (client.major < policy.hardMinimum) return "forced";
  if (client.major < policy.latest) return "soft";
  return "none";
}

export const updateFeatures = [
  feature("Payments", ["network", "min:5"]),
  feature("Catalog", ["network", "min:5"], { "min:5": "degraded" }),
  feature("Settings", ["network"]),
];
```

**RO7.** The model reduces the minimum version to a single major version number: the client is either at the fifth major version and above, or it is not. A real lower bound can include the minor and patch versions as well, and the payment provider's request may depend on another variable, for example a compatibility table kept on the server side. The model builds this operability's rule; the model is not the framework itself.

The first measurement puts three clients side by side: clients at the fourth, fifth, and sixth major versions. The prompt each client will get is printed first, then the nine records follow. The policy defines the lower bound as five and the latest version as six.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { conditionsFor, promptFor, updateFeatures } from "./updates.mjs";

const clients = [
  { name: "client-v4", major: 4 },
  { name: "client-v5", major: 5 },
  { name: "client-v6", major: 6 },
];
const policy = { hardMinimum: 5, latest: 6 };
for (const client of clients) {
  console.log(`prompt: ${client.name} ${promptFor(client, policy)}`);
}
printLevels(assess(updateFeatures, conditionsFor(clients)));
```

```text
prompt: client-v4 forced
prompt: client-v5 soft
prompt: client-v6 none
disabled: Payments @ client-v4 missing=min:5
degraded: Catalog @ client-v4 missing=min:5
full: Settings @ client-v4 missing=-
full: Payments @ client-v5 missing=-
full: Catalog @ client-v5 missing=-
full: Settings @ client-v5 missing=-
full: Payments @ client-v6 missing=-
full: Catalog @ client-v6 missing=-
full: Settings @ client-v6 missing=-
```

The client at the fourth version gets a forced update and its payment is disabled; the missing capability is `min:5`, and the payment has no fallback. On the same client the catalog stays degraded, because the catalog has a fallback defined, while the settings are full. The client at the fifth version gets a soft prompt and all three of its features are full; the client at the sixth version gets no prompt, and it is full as well.

Look at when the forced prompt is shown: only on the client where the payment is disabled. This is not a coincidence. A forced prompt makes sense at the point where a feature no longer works on the client; showing a forced prompt when nothing is disabled blocks the user for no reason.

#### The Effect of the Update

When the user updates, the client's major version changes and the condition is rebuilt. Updating is the only way to open the disabled feature; the team cannot open the payment on the old client by changing a remote value, because what is missing is not on the server side but in the client itself.

The second measurement runs the same device before and after the update. The condition names differ, because two conditions cannot share a name. Only the payment and catalog records are printed.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { conditionsFor, updateFeatures } from "./updates.mjs";

const before = { name: "before-update", major: 4 };
const after = { name: "after-update", major: 5 };
console.log(`update: ${before.major} -> ${after.major}`);
const records = assess(updateFeatures, conditionsFor([before, after]));
printLevels(records.filter((r) => r.feature !== "Settings"));
```

```text
update: 4 -> 5
disabled: Payments @ before-update missing=min:5
degraded: Catalog @ before-update missing=min:5
full: Payments @ after-update missing=-
full: Catalog @ after-update missing=-
```

Before the update the payment is disabled and the catalog is degraded. After the update both are full. The settings record was left out by the filter because it did not change.

**RO8.** The model assumes the update is a single step and succeeds. In reality an update can stop halfway, the device may not have enough space, or the user may close the update screen and abandon the application. The rule does not see these intermediate states; it measures only the two conditions before and after the update.

#### The Bounding Measurement

The focus record of this lesson is `Payments @ client-v4`. The bounding measurement shows two things together. First, the focus record is disabled, and on the same client the catalog stays degraded and the settings stay full; two of six records are affected. Second, the severity of the update prompt does not enter the record: whether the same client gets a forced or a soft prompt, its record is the same.

```javascript
// main.mjs
import { assess, printLevels, countLevels } from "./operability.mjs";
import { conditionsFor, promptFor, updateFeatures } from "./updates.mjs";

const clients = [
  { name: "client-v4", major: 4 },
  { name: "client-v5", major: 5 },
];
const records = assess(updateFeatures, conditionsFor(clients));
printLevels(records.filter((r) => r.condition === "client-v4"));
countLevels(records);

const strict = promptFor(clients[0], { hardMinimum: 5, latest: 6 });
const lenient = promptFor(clients[0], { hardMinimum: 3, latest: 6 });
console.log(`prompt: ${strict} ${lenient}`);
const key = (list) => list.map((r) => `${r.level}:${r.feature}:${r.missing.join(",")}`).join("|");
const again = assess(updateFeatures, conditionsFor(clients));
console.log(`prompt does not enter the record: ${key(records) === key(again) ? "yes" : "no"}`);
```

```text
disabled: Payments @ client-v4 missing=min:5
degraded: Catalog @ client-v4 missing=min:5
full: Settings @ client-v4 missing=-
full: 4
degraded: 1
deferred: 0
disabled: 1
affected: 2
prompt: forced soft
prompt does not enter the record: yes
```

The first three lines carry the lesson's operability: on the same client the payment is disabled, the catalog is degraded, and the settings are full. The count lines say that of six records, four are full, one is degraded, and one is disabled; affected is two.

The last two lines show the limit of the model. For the same client, two policies produce two different prompts: one forced, one soft. But the function that builds the condition never receives the policy, and the records are the same in both cases. The prompt is the way the team talks to the user; operability classifies which capability the client has. A forced prompt does not open the payment, and a soft prompt does not disable it. For the payment to open, the client has to be updated, and the prompt only directs the user to that update.

This distinction determines the order of decisions. First it is measured which feature is disabled on which version, and then the severity of the prompt is chosen accordingly. Setting up a forced prompt for a version on which no feature is disabled is a severity the record does not support.

#### Summary

- This lesson's operability: disabled. `Payments @ client-v4` came out disabled because the client lacks the `min:5` capability and the payment has no fallback; on the same client `Catalog` stayed degraded and `Settings` stayed full.
- The minimum supported version is kept on the server and read remotely; if it is embedded in the application, an old client never sees the new bound.
- A soft prompt can be postponed and steers old versions above the lower bound; a forced update stops only the client below the lower bound.
- Of six records, four were full, one was degraded, and one was disabled; affected was two. The update moved the payment from disabled to full and the catalog from degraded to full on the same device.
- The severity of the prompt did not enter the record; before raising the lower bound, the new version has to be ready in the store.

#### Next Step

In this lesson the client's version was a condition, and the update changed it. An update also has a cost: the application that is downloaded and installed takes up space on the device. The next lesson, `App Size Management`, covers the effect of app size on the decision to download, on-demand asset packs, and packages split by platform. It then measures how well a feature that depends on an asset pack not delivered in the first download works.

### App Size Management

Source: https://academia.sh/en/courses/cross-platform-production/app-size-management

App size management decides what the first download contains and which parts are fetched later on demand. This lesson measures how well a feature that depends on an asset pack not delivered in the first download works.

The previous lesson covered how to steer old versions toward the new version. Every update that does the steering downloads something to the device, and what is downloaded takes up space on the device. This lesson covers how large that download needs to be and how the large parts can be left for later.

The question of the course stays the same: how well does this feature work under this condition? In this lesson the condition is which asset packs are installed on the device.

#### App Size and the Download Decision

Before downloading an application, the user looks at its size, and the look is not always deliberate. A user with a limited connection chooses to postpone a large application. A user with little space left on the device has to delete one of the old applications to make room for a new one. An update brings the same decision back: a large update can make the user stay on the old version, which creates the problem of the previous lesson.

No ratio or byte count is given here. The threshold at which size puts the user off depends on the connection, the market, and the kind of application, and the team has to measure it in its own field. What the lesson can say points in one direction: making the first download smaller makes the download decision lighter.

Three things contribute to size: the application's code, the assets carried inside the application, and the runtime of the framework. A framework that works with its own rendering engine carries the engine inside the application; a framework that connects to native views through a bridge carries the bridge layer. The team does not control the share of either. What the team does control is the assets: images, fonts, sounds, additional data.

#### On-Demand Asset Packs

Not all assets have to arrive in the first download. Not every user uses every feature. Photo filters, additional language data, or an advanced game level are needed only by the user who wants them. These assets are split into an on-demand pack, and the pack is downloaded when the user first uses the feature.

The first encounter with an on-demand pack is a download, and a download depends on the network. Without a connection the pack does not arrive, and a feature that depends on a pack that has not arrived does not work. In operability terms this situation is called deferred: the feature waits so that it can work once the pack arrives. In M31/K06 the question was in how many samples a download budget was exceeded. If an on-demand asset pack has not been downloaded, the feature is deferred; this is not a budget overrun. An overrun is a question of size, while deferred means the pack is not yet on the device.

The fragment below shows how a pack is requested; the fragment is not measured.

```swift
// Platform code: on-demand asset pack
let request = NSBundleResourceRequest(tags: ["filters"])
request.beginAccessingResources { error in
    if error == nil { showFilters() } else { keepFiltersPending() }
}
```

Keeping the feature pending instead of closing it on an error is a design choice. Until the pack arrives, the feature shows the user "preparing", and it opens when the pack has been downloaded. Closing tells the user the feature does not exist; keeping it pending tells the user the feature will be ready soon.

#### The Platform-Split Bundle

The same application does not have to carry the same files to every device. A device's screen density and processor architecture are known; files prepared for other densities and architectures are of no use on that device. Platforms solve this with a split bundle: from the single large package that is uploaded, the store selects the part each device needs and sends only that.

On Android, splitting is on by default when the app bundle is uploaded, and a configuration block sets which splits are produced. On iOS the store makes a similar selection itself, and the team organizes assets with tags. The fragment below shows the Android side; the fragment is not measured.

```kotlin
// Platform code: platform-split bundle
android {
    bundle {
        density { enableSplit = true }
        abi { enableSplit = true }
    }
}
```

A split bundle is a size gain, and at first sight it does not affect operability: the device receives only its own part and the features work the same. The place it affects is the download decision.

#### Unused Assets

A common reason an application grows is that assets that are no longer used stay in the package. When a screen is removed, its images may not go with it; when a library is updated, the old copy may stay in the package. The remedy is a habit of regular scanning: finding which asset is called from which code and removing the ones that are not called. Repeating this scan before every release keeps the size from growing silently.

The scan has a limit. If an asset is reached in code through a name string, the scan may take it for uncalled. Before deleting, it is checked whether the name is built dynamically. An asset deleted by mistake is noticed only when a user reaches that screen.

#### Measuring the Condition of the Pack

For the measurement, take three features. `Gallery` is the gallery; it needs the network and the base pack `pack:base`, and it is present in every install. `Filters` is the photo filters; it needs the network and the `pack:filters` capability, and without the pack it works as deferred. `Stickers` is the stickers; it needs `pack:stickers`, and without the pack it also works as deferred. The core is familiar from the first lesson and is given again in the same form in every lesson; the lesson does not touch it.

```javascript
// operability.mjs — classifies how fully a feature works under a condition
export const LEVELS = ["full", "degraded", "deferred", "disabled"];
const FALLBACKS = ["degraded", "deferred"];

export function feature(name, needs, fallback = {}) {
  return { name, needs, fallback };
}

export function condition(name, has) {
  return { name, has };
}

function judge(item, cond) {
  const missing = item.needs.filter((need) => !cond.has.includes(need)).sort();
  let level = "full";
  for (const need of missing) {
    const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled";
    if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given;
  }
  return { level, feature: item.name, condition: cond.name, missing };
}

export function assess(features, conditions) {
  const records = [];
  for (const cond of conditions) {
    for (const item of features) records.push(judge(item, cond));
  }
  return records;
}

export function printLevels(records) {
  for (const r of records) {
    const missing = r.missing.length === 0 ? "-" : r.missing.join(",");
    console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`);
  }
}

export function countLevels(records) {
  for (const name of LEVELS) {
    console.log(`${name}: ${records.filter((r) => r.level === name).length}`);
  }
  console.log(`affected: ${records.filter((r) => r.level !== "full").length}`);
}
```

After the core comes the lesson's helper file. `packsFor(install)` produces a condition from the packs in an install: the network is present in every install, and each installed pack becomes a capability with the `pack:` prefix. `assetsAtInstall(install)` adds up the number of assets in an install; this number is the lesson's local counter and is not an input to the rule. Size is counted not in bytes but as a number of assets.

```javascript
// packs.mjs — builds a condition from installed packs, adds up the asset count
import { feature, condition } from "./operability.mjs";

const assetCounts = { base: 30, filters: 12, stickers: 8 };

export function packsFor(install) {
  return condition(install.name, ["network", ...install.packs.map((name) => `pack:${name}`)]);
}

export function assetsAtInstall(install) {
  let total = 0;
  for (const name of install.packs) total += assetCounts[name];
  return total;
}

export const packFeatures = [
  feature("Gallery", ["network", "pack:base"]),
  feature("Filters", ["network", "pack:filters"], { "pack:filters": "deferred" }),
  feature("Stickers", ["network", "pack:stickers"], { "pack:stickers": "deferred" }),
];
```

**RO9.** The model uses the asset count as a stand-in for size: thirty, twelve, and eight are asset counts, not bytes. An asset can be an image or a long sound file; the number does not give the size directly. The model offers a counter that is enough only to compare two installs, and it builds this operability's rule; the model is not the framework itself.

The first measurement puts two installs side by side: an install that downloads only the base pack and an install that downloads all the packs. The asset count of each install is printed first, then the six records follow.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { packsFor, assetsAtInstall, packFeatures } from "./packs.mjs";

const installs = [
  { name: "base-install", packs: ["base"] },
  { name: "full-install", packs: ["base", "filters", "stickers"] },
];
for (const install of installs) {
  console.log(`install: ${install.name} assets=${assetsAtInstall(install)}`);
}
printLevels(assess(packFeatures, installs.map(packsFor)));
```

```text
install: base-install assets=30
install: full-install assets=50
full: Gallery @ base-install missing=-
deferred: Filters @ base-install missing=pack:filters
deferred: Stickers @ base-install missing=pack:stickers
full: Gallery @ full-install missing=-
full: Filters @ full-install missing=-
full: Stickers @ full-install missing=-
```

In the base install the gallery is full; the filters and the stickers come out deferred. What is missing is the pack capability, and its fallback is defined as deferred. In the full install all three features are full, but the asset count in the first download rises from thirty to fifty. The operability gain and the growth of the first download are two faces of the same decision.

#### When the Pack Is Downloaded

When the user opens the filters for the first time, the pack is downloaded and the install changes. The pack capability is added to the condition, and `Filters` moves from deferred to full. The sticker pack has still not been downloaded, so it stays deferred.

The second measurement runs the same install before and after the pack is downloaded. The condition names differ, because two conditions cannot share a name. The gallery was filtered out because it is full in both states.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { packsFor, assetsAtInstall, packFeatures } from "./packs.mjs";

const before = { name: "base-install", packs: ["base"] };
const after = { name: "filters-downloaded", packs: ["base", "filters"] };
console.log(`download: assets ${assetsAtInstall(before)} -> ${assetsAtInstall(after)}`);
const records = assess(packFeatures, [before, after].map(packsFor));
printLevels(records.filter((r) => r.feature !== "Gallery"));
```

```text
download: assets 30 -> 42
deferred: Filters @ base-install missing=pack:filters
deferred: Stickers @ base-install missing=pack:stickers
full: Filters @ filters-downloaded missing=-
deferred: Stickers @ filters-downloaded missing=pack:stickers
```

After the download the filters are full and the stickers stay deferred. The download adds only the capability of the pack that was downloaded; it does not affect the other packs. The number of installed assets rises from thirty to forty-two.

**RO10.** The model assumes the download of the pack is a single step and succeeds. In reality the download can be cut halfway, the device may have no space, or the version of the pack may not match the version of the application. The rule does not see these intermediate states; it looks only at whether the pack's capability is in the condition.

#### The Bounding Measurement

The focus record of this lesson is `Filters @ base-install`. The bounding measurement shows two things together. First, the focus record is deferred, and two of six records are affected. Second, deferred is not a budget overrun: if a limit is set for the asset count in the first download, the install that exceeds the limit is the one with the full records, and the install that stays within the limit is the one with the deferred records.

```javascript
// main.mjs
import { assess, printLevels, countLevels } from "./operability.mjs";
import { packsFor, assetsAtInstall, packFeatures } from "./packs.mjs";

const installs = [
  { name: "base-install", packs: ["base"] },
  { name: "full-install", packs: ["base", "filters", "stickers"] },
];
const records = assess(packFeatures, installs.map(packsFor));
printLevels(records.filter((r) => r.feature === "Filters"));
countLevels(records);

const allowance = 40;
const within = (install) => (assetsAtInstall(install) <= allowance ? "within" : "over");
console.log(`first download assets: ${assetsAtInstall(installs[0])} ${assetsAtInstall(installs[1])}`);
console.log(`budget: ${installs[0].name} ${within(installs[0])} ${installs[1].name} ${within(installs[1])}`);
```

```text
deferred: Filters @ base-install missing=pack:filters
full: Filters @ full-install missing=-
full: 4
degraded: 0
deferred: 2
disabled: 0
affected: 2
first download assets: 30 50
budget: base-install within full-install over
```

The first two lines carry the lesson's operability: the same feature, the same code; only whether the pack is installed changed, and the record moved from deferred to full. The count lines say that of six records, four are full and two are deferred; affected is two.

The last two lines show the limit of the model. The lesson's local limit is forty assets. The base install stays within this limit and leaves the filters deferred. The full install exceeds the limit, but its three features are all full. So being deferred is not a sign of an overrun, and being full is not a sign of compliance. The rule does not see the limit; it looks only at whether the pack's capability is in the condition.

This distinction splits the decision into two questions. Is the first download small is a question of size. Do the filters work is a question of operability. Splitting off a pack to make the size smaller turns the answer to the second question from full to deferred; this is a cost, and the team pays it knowingly.

#### Summary

- This lesson's operability: deferred. `Filters @ base-install` came out deferred because the filter pack did not arrive in the first download and its fallback is defined as deferred; on the same install `Gallery` stayed full.
- Making the first download smaller makes the download decision lighter; the threshold at which size puts the user off is a value the team measures in its own field.
- An on-demand asset pack is downloaded later; the feature of a pack that has not arrived does not work, but it opens when the pack arrives. A platform-split bundle sends each device only the part it needs.
- Of six records, four were full and two were deferred; affected was two. When the pack was downloaded, `Filters` moved to full and `Stickers` stayed deferred.
- The install that exceeded the first-download limit had all three features full; deferred is not a budget overrun.

#### Next Step

In this lesson the problem was that something was absent from the device, and the team could know it in advance. The last lesson of the course, `Crash and Usage Monitoring`, covers the unknown: what a build released into the field is doing, where it crashes, and who uses which feature. It measures the crash report that waits until the connection returns, and the condition of the monitoring itself.

### Crash and Usage Monitoring

Source: https://academia.sh/en/courses/cross-platform-production/crash-and-usage-monitoring

Crash and usage monitoring describes how to collect, from both platforms in a unified form, what a released build does in the field. This lesson measures that a crash report created in an offline session stays deferred and that user consent turns usage events off.

The previous lesson covered a pack that did not arrive in the first download. The team could know that situation in advance: which pack is missing where can be read from the install. In a released build, much more is unknown. On which device did it crash, who uses which screen, which feature quietly does not work? This lesson covers collecting that information from the field, and it measures the condition of the collection itself.

The question of the course stays the same: how well does this feature work under this condition? This time the question is asked of the monitoring itself. How well does the mechanism that reports a crash work on a device that has lost the network at the moment of the crash?

#### One Telemetry from Two Platforms

Two platforms mean two separate crash mechanisms and two separate event sources. On iOS the system delivers diagnostic data to the application at the next launch; on Android the application registers a handler for the uncaught error. The format, the name, and the timing of the data differ. The team's job is to close this difference at the collection point: both platforms produce a single event format with the same fields, so on the dashboard "crash" becomes one concept.

The unified format has to carry three things: the kind of the event, the identifier of the session, and the version of the build. Without the version, it is not known which release a crash belongs to. The staged rollout and rollback decisions from the earlier lessons use this link: if the crash reports of a version opened to a new bucket rise, the halt and the remote kill flag are decided with that data.

#### The Crash Report and Symbolication

A crash report carries the trace of the moment of the crash: which function went to which function. In a released build the names in this trace are shortened and the addresses are numeric; to make it readable, the symbol file produced when the build was made is consulted. Turning addresses into function and line names is called symbolication. The symbol file has to be from the same version as the build and has to be stored for every release; the crash of a release whose symbols were not stored cannot be read.

The two fragments below show the platform side of recording the moment of the crash locally. The fragments are not measured.

```swift
// Platform code: iOS diagnostics subscriber
final class DiagnosticsReceiver: NSObject, MXMetricManagerSubscriber {
    func didReceive(_ payloads: [MXDiagnosticPayload]) {
        for payload in payloads { crashStore.save(payload.jsonRepresentation()) }
    }
}
```

```kotlin
// Platform code: uncaught error handler
Thread.setDefaultUncaughtExceptionHandler { _, error ->
    crashStore.save(error.stackTraceToString())
}
```

In both fragments the first job is the same: writing the report to the local store. Sending it over the network is not reliable in a crashing process; the process can die before the sending finishes. Sending is done at the next launch of the application, in a healthy process.

#### Analytics Events and User Consent

An analytics event records what the user does in the application: a screen was opened, a button was pressed. A crash report is the trace of a fault; an analytics event is behavior and can be tied to the user. For this reason the two are not subject to the same consent. If the user has turned off the tracking of behavior, the analytics event is never produced and is not stored to be sent later. Storing is also a form of collecting.

One distinction matters here: consent is a capability, and it has no fallback. When the network is absent the event can wait, because the network comes back. When consent is absent the event does not wait, because consent does not come back by itself. The first is deferred, the second is disabled. The crash-free session count is tied to this as well: to say a session did not crash, it is not enough that no report came from it; the report must have been able to arrive. No ratio is written here; the number is a count of sessions.

#### Measuring the Condition of Monitoring

The measurement takes three features. `Settings` needs no monitoring at all. `CrashReport` needs the network, and without the network it works as deferred. `UsageEvent` needs both the network and the user's consent; the fallback of the network is deferred, and consent has no fallback. The core is as in the earlier lessons and is given again in the same form in every lesson.

```javascript
// operability.mjs — classifies how fully a feature works under a condition
export const LEVELS = ["full", "degraded", "deferred", "disabled"];
const FALLBACKS = ["degraded", "deferred"];

export function feature(name, needs, fallback = {}) {
  return { name, needs, fallback };
}

export function condition(name, has) {
  return { name, has };
}

function judge(item, cond) {
  const missing = item.needs.filter((need) => !cond.has.includes(need)).sort();
  let level = "full";
  for (const need of missing) {
    const given = FALLBACKS.includes(item.fallback[need]) ? item.fallback[need] : "disabled";
    if (LEVELS.indexOf(given) > LEVELS.indexOf(level)) level = given;
  }
  return { level, feature: item.name, condition: cond.name, missing };
}

export function assess(features, conditions) {
  const records = [];
  for (const cond of conditions) {
    for (const item of features) records.push(judge(item, cond));
  }
  return records;
}

export function printLevels(records) {
  for (const r of records) {
    const missing = r.missing.length === 0 ? "-" : r.missing.join(",");
    console.log(`${r.level}: ${r.feature} @ ${r.condition} missing=${missing}`);
  }
}

export function countLevels(records) {
  for (const name of LEVELS) {
    console.log(`${name}: ${records.filter((r) => r.level === name).length}`);
  }
  console.log(`affected: ${records.filter((r) => r.level !== "full").length}`);
}
```

After the core comes the lesson's helper file. `conditionsFor(session)` turns a session's network and consent into capabilities.

```javascript
// telemetry.mjs — turns the session's network and user consent into capabilities
import { feature, condition } from "./operability.mjs";

export function conditionsFor(session) {
  const has = [];
  if (session.network) has.push("network");
  if (session.consent) has.push("consent");
  return condition(session.name, has);
}

export const telemetryFeatures = [
  feature("Settings", []),
  feature("CrashReport", ["network"], { network: "deferred" }),
  feature("UsageEvent", ["network", "consent"], { network: "deferred" }),
];
```

**RO11.** The model ties the crash report to no capability other than the network. In reality the sending of the report may also depend on the user's diagnostics preference; this lesson measures the report only for the lack of the network, and the preference is a capability that could be added separately.

The first measurement runs three sessions on three features: a session with the network and consent, a session without the network, and a session without consent.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { conditionsFor, telemetryFeatures } from "./telemetry.mjs";

const sessions = [
  { name: "online", network: true, consent: true },
  { name: "offline", network: false, consent: true },
  { name: "no-consent", network: true, consent: false },
];
printLevels(assess(telemetryFeatures, sessions.map(conditionsFor)));
```

```text
full: Settings @ online missing=-
full: CrashReport @ online missing=-
full: UsageEvent @ online missing=-
full: Settings @ offline missing=-
deferred: CrashReport @ offline missing=network
deferred: UsageEvent @ offline missing=network
full: Settings @ no-consent missing=-
full: CrashReport @ no-consent missing=-
disabled: UsageEvent @ no-consent missing=consent
```

With the network and consent present, all three features are full. Without the network the report and the event become deferred, and the settings stay full. Without consent the report goes out, the event is disabled, and the missing capability is `consent`. The same `UsageEvent` fell to two different levels with two different missing capabilities: deferred if the missing one is the network, disabled if it is consent.

#### Until the Connection Returns

An application that crashes in an offline session writes the report to a local queue. The queue is the lesson-local `eventQueue` array. When the connection returns, the queue is drained and the report goes out. Sending itself is a job; operability says only whether the report can be sent at that moment.

The second measurement runs one crash at two moments: while there is no connection, and after the connection has returned. In the same measurement, in a list of six sessions, the number of sessions that look crash-free is compared with the number that really did not crash.

```javascript
// main.mjs
import { assess, printLevels } from "./operability.mjs";
import { conditionsFor, telemetryFeatures } from "./telemetry.mjs";

const crashReport = telemetryFeatures.filter((item) => item.name === "CrashReport");
const eventQueue = [];

printLevels(assess(crashReport, [conditionsFor({ name: "offline", network: false, consent: true })]));
eventQueue.push({ kind: "crash" });
console.log(`queue: ${eventQueue.length}`);
printLevels(assess(crashReport, [conditionsFor({ name: "reconnected", network: true, consent: true })]));
console.log(`queue drained: ${eventQueue.splice(0).length}`);

const sessions = [
  { crashed: false, delivered: false },
  { crashed: false, delivered: false },
  { crashed: true, delivered: true },
  { crashed: false, delivered: false },
  { crashed: true, delivered: false },
  { crashed: false, delivered: false },
];
const seen = sessions.filter((item) => !item.delivered).length;
const actual = sessions.filter((item) => !item.crashed).length;
console.log(`sessions: ${sessions.length} crash-free shown: ${seen} actual: ${actual}`);
```

```text
deferred: CrashReport @ offline missing=network
queue: 1
full: CrashReport @ reconnected missing=-
queue drained: 1
sessions: 6 crash-free shown: 5 actual: 4
```

The report is deferred while offline and full after the connection returns. The single report in the queue went out. Two of the six sessions had crashed, but one had not yet been delivered; so the dashboard shows five sessions as crash-free, while in reality four did not crash. A waiting report makes the crash-free session count look better than it is.

**RO12.** The model assumes the queue holds a single event and loses nothing. In reality the queue can fill up, can be deleted when the application is reinstalled, or the sending can be cut halfway. The rule does not see these; it looks only at whether the network is in the condition.

#### The Bounding Measurement

The focus record of this lesson is `CrashReport @ offline`. The bounding measurement shows two things together. First, the focus record is deferred, and three of nine records are affected; in the session without consent `UsageEvent` is disabled because it has no fallback. Second, the number of reports waiting in the queue does not enter the record: whether the queue holds no reports or three, the record is the same.

```javascript
// main.mjs
import { assess, printLevels, countLevels } from "./operability.mjs";
import { conditionsFor, telemetryFeatures } from "./telemetry.mjs";

const sessions = [
  { name: "online", network: true, consent: true },
  { name: "offline", network: false, consent: true },
  { name: "no-consent", network: true, consent: false },
];
const records = assess(telemetryFeatures, sessions.map(conditionsFor));
printLevels(records.filter((r) => `${r.feature} @ ${r.condition}` === "CrashReport @ offline"));
printLevels(records.filter((r) => `${r.feature} @ ${r.condition}` === "UsageEvent @ no-consent"));
countLevels(records);

const crashReport = telemetryFeatures.filter((item) => item.name === "CrashReport");
const queued = [0, 3].map((count) => ({
  count,
  level: assess(crashReport, [conditionsFor(sessions[1])])[0].level,
}));
const same = queued[0].level === queued[1].level ? "yes" : "no";
console.log(`queue ${queued[0].count} and ${queued[1].count}: record same: ${same}`);
```

```text
deferred: CrashReport @ offline missing=network
disabled: UsageEvent @ no-consent missing=consent
full: 6
degraded: 0
deferred: 2
disabled: 1
affected: 3
queue 0 and 3: record same: yes
```

The first two lines give the two ends of the lesson: deferred if the network is missing, disabled if consent is missing. In the count, of nine records six are full, two are deferred, and one is disabled; affected is three. The last line shows the limit of the model: the rule does not see the queue. Many reports waiting in the queue are a workload and are counted separately on the monitoring dashboard; they do not change the operability level.

#### Course Wrap-Up

Over fourteen lessons the course measured fourteen features, each in its own condition, with the same four levels. The table below gives each lesson's focus record, its operability, and the measurement that bounds that lesson. The operability column is the one written in each lesson's `## Summary` section.

| Lesson | Focus record | Operability | Bounding measurement |
|---|---|---|---|
| Modular Code Sharing | `Scan @ android` | disabled | The `scanner` layer was not written, no fallback |
| Design System Adaptation | `DatePicker @ android` | full | Two picker forms, the same `picker` capability |
| Configuration and Environment Management | `Search @ prod-default` | full | `prod-default-off` disabled in the flag whose default is off |
| Accessibility | `CardSwipe @ screen-reader` | degraded | `CustomGesture` without a fallback disabled |
| Offline-First Design | `FeedLocal @ offline` | degraded | Network-first `FeedRemote` disabled |
| Synchronization Strategies | `EditNote @ offline` | deferred | `EditNote` full while connected |
| Background Processing Limits | `MediaBackup @ background-restricted` | deferred | `LiveLocation` without a fallback disabled |
| Connectivity State Management | `Gallery @ weak-network` | degraded | `Chat` deferred in the same condition |
| Mobile Continuous Delivery | `NewCheckout @ production` | disabled | In the model, signing does not enter the record (by definition) |
| Version Management | `OrderHistory @ api-v2` | degraded | The client version does not enter the record |
| Staged Rollout and Rollback | `NewFeed @ device-a` | full | The record does not change when the rollout widens |
| Forced Update Strategy | `Payments @ client-v4` | disabled | The severity of the prompt does not enter the record |
| App Size Management | `Filters @ base-install` | deferred | The first-download limit does not enter the record |
| Crash and Usage Monitoring | `CrashReport @ offline` | deferred | The number of reports in the queue does not enter the record |

Thirteen rows of the table are taken from the earlier lessons; the level of the fourteenth row comes from this lesson's own measurement. The last measurement counts the table and puts beside it the opening count that the core gave in the first lesson.

```javascript
// main.mjs
import { assess } from "./operability.mjs";
import { conditionsFor, telemetryFeatures } from "./telemetry.mjs";

const earlier = ["disabled", "full", "full", "degraded", "degraded", "deferred", "deferred",
  "degraded", "disabled", "degraded", "full", "disabled", "deferred"];
const crashReport = telemetryFeatures.filter((item) => item.name === "CrashReport");
const focus = assess(crashReport, [conditionsFor({ name: "offline", network: false, consent: true })])[0];
const lessons = [...earlier, focus.level];

const order = ["full", "degraded", "deferred", "disabled"];
const spread = order.map((name) => `${name}=${lessons.filter((level) => level === name).length}`);
const opening = { full: 5, degraded: 1, deferred: 2, disabled: 1 };
const openingTotal = Object.values(opening).reduce((sum, count) => sum + count, 0);

console.log(`lessons: ${lessons.length}`);
console.log(`focus: ${focus.feature} @ ${focus.condition} ${focus.level}`);
console.log(`spread: ${spread.join(" ")}`);
console.log(`core opening: records=${openingTotal} affected=${openingTotal - opening.full}`);
```

```text
lessons: 14
focus: CrashReport @ offline deferred
spread: full=3 degraded=4 deferred=4 disabled=3
core opening: records=9 affected=4
```

The distribution of the fourteen lessons is three full, four degraded, four deferred, and three disabled. The opening count of the core is a different thing: of nine records, five were full, one was degraded, two were deferred, and one was disabled, and affected was four. The core counts the nine records of a single application; the table counts fourteen lessons. The two counts are not the same thing and cannot be derived from each other.

The distribution says one more thing: a feature being disabled is not a common outcome, and working fully is not the rule. Each row of the table is a separate measurement showing what one condition does to one feature.

#### Summary

- This lesson's operability: deferred. `CrashReport @ offline` came out deferred because `network` is missing and a deferred fallback is defined; in the same condition `UsageEvent` came out deferred and `Settings` stayed full.
- A crash report is stored in the local store, not in the crashing process, and goes out from a healthy process when the connection returns; the one report in the queue was drained when the connection returned.
- User consent is a capability without a fallback: `UsageEvent @ no-consent` came out disabled, while the same event was only deferred when the network was absent.
- An undelivered report makes the crash-free session count look better than it is: in six sessions five looked crash-free, while four had really not crashed.
- The distribution of the fourteen lessons is three full, four degraded, four deferred, and three disabled.

#### Next Step

This lesson is the last lesson of the course and of the catalog; there is no lesson after it. What remains in your hands is the question of the course: write a feature, a condition, and a missing capability, define its fallback, and measure in your own application which level the record falls to.

