Developer guide

Emergency recovery

When a release misbehaves in production, emergency policies switch a named feature off, make it read-only, show a maintenance message or ask for an update, in the apps already on your users' phones and on your own server, without shipping a build. You decide in the dashboard; the SDK fetches a signed policy document, verifies it against a key compiled into your app, caches it and evaluates it on the device.

i

Needs a newer SDK release. Emergency recovery ships in the next Flutter, Android and iOS SDK releases and in @bugcapsule/emergency-guard. The releases listed on Installation and upgrades don't include it yet. You can set up Emergency Recovery in the dashboard now, but policies only reach apps built with those releases.

What it is, and what it is not

Before an incident

  1. In the dashboard, open Emergency Recovery → Settings and create the signing key. Copy the trust anchor (bcek.v1…).
  2. Add the trust anchor to your app's emergency configuration (below) and ship that build. Apps without it can't receive policies.
  3. Choose feature keys for the flows you may need to stop, such as checkout.confirm_order or wallet.transfer. Keys are lower-case dotted names; a policy on checkout.* covers every key below checkout.
  4. Mark keys that move money FAIL_CLOSED, in the app and in the server guard (failure modes).
  5. Run a drill in a staging environment.

Add the guard to your app

await BugCapsule.emergency.initialize(const EmergencyConfig(
  trustAnchor: 'bcek.v1.…', // from the dashboard; compile it into the app
  environment: 'production',
  failureModes: {'checkout.*': EmergencyFailureMode.failClosed},
));

final decision = BugCapsule.emergency.evaluate('checkout.confirm_order');
if (!decision.allowed) {
  showUnavailable(decision.publicUserMessage); // decision.action: BLOCK, READ_ONLY, …
  return;
}

BugCapsule.emergency.changes is a stream of new policy snapshots; status reports the document state, revision and last error. Flutter guards run on Android and iOS; on web and desktop every feature uses its failure mode.

BugCapsule.emergency.initialize(applicationContext, EmergencyConfig.builder()
    .trustAnchor("bcek.v1.…")
    .environment("production")
    .failureMode("checkout.*", EmergencyFailureMode.FAIL_CLOSED)
    .build())

val decision = BugCapsule.emergency.evaluate("checkout.confirm_order")
if (!decision.allowed) {
    showUnavailable(decision.publicUserMessage)
    return
}

The API is usable from Java (BugCapsule.getEmergency()). Listeners added with addListener run on the main thread.

BugCapsule.emergency.initialize(EmergencyConfiguration(
    trustAnchor: "bcek.v1.…",
    environment: "production",
    failureModes: ["checkout.*": .failClosed]))

let decision = BugCapsule.emergency.evaluate(featureKey: "checkout.confirm_order")
guard decision.allowed else {
    showUnavailable(decision.publicUserMessage)
    return
}

BugCapsule.emergency.changes is an AsyncStream; observe(_:) calls back on the main thread. Objective-C apps use BCEmergency.sharedInstance.

Decisions

ActionWhat your app does
ALLOWRuns the feature (allowed is true only for this action).
BLOCKStops the feature and shows publicUserMessage.
DISABLE_FEATUREHides or disables the entry point.
READ_ONLYShows data but doesn't let the user change anything.
SHOW_MAINTENANCEShows a maintenance screen.
REQUIRE_UPDATEAsks the user to update to requiredVersion or later. Sending users to the store is up to your app; iOS has no forced in-app update.
USE_SAFE_FALLBACKUses the app-defined fallback named in fallback.

A policy can target platforms, app version ranges and a percentage of installations. When several policies match a key, the one with the highest priority wins, then the most restrictive.

Failure modes

A verified document is valid for a limited time (one hour by default; the SDK refreshes long before). When there is no usable document, for example a fresh install that has never been online, each feature uses its failure mode:

ModeNo usable documentUse for
FAIL_OPEN (default)AllowedEverything that is safe to run
FAIL_CLOSEDBlocked (POLICY_UNAVAILABLE)Actions that move money
USE_LAST_VALID_POLICYThe last verified document, even expired; allowed if there was noneFeatures where flapping is worse than staleness

The whole app never fails closed by default: one key at a time opts in.

Server enforcement

For Node.js servers, @bugcapsule/emergency-guard fetches and verifies the same document and refuses protected operations with a 503 problem response. Check right before the irreversible step, and use idempotency keys so a retried request never charges twice.

const guard = createEmergencyGuard({
  credential: process.env.BUGCAPSULE_SDK_TOKEN!,
  trustAnchor: process.env.BUGCAPSULE_EMERGENCY_ANCHOR!,
  environment: 'production',
  failureModes: { 'checkout.*': 'FAIL_CLOSED' },
});
await guard.start();

// In the request handler, before charging:
await guard.enforce({ featureKey: 'checkout.confirm_order', operation: 'write', subject: userId });

By default the guard refreshes every 5 seconds. With consistency: 'strong' it checks for a newer document right before deciding. Neither stops an operation that passed the check just before a block was published, so record the decision's revision with the operation and reconcile what committed after the block. READ_ONLY lets operation: 'read' through. For other server languages, enforce the same rule in your own code: never rely on the app alone.

During an incident

  1. File an incident in Emergency Recovery and link the capsules that show the failure.
  2. Create a policy for the narrowest key, platforms and versions that cover the bug. Review its estimated scope.
  3. Request activation. In production a second person approves it; project owners can break glass with a reason when nobody else is reachable.
  4. Watch the coverage: how many reporting installations applied the new revision.
  5. Ship the fix (below), then restore the feature in stages.

Every step is recorded in the incident's timeline and an append-only audit trail.

Ship the fix

A recovery operation takes a fix from a candidate build to your users.

  1. BugCapsule replays the incident's capsules on the candidate build.
  2. A second person approves the deployment.
  3. The approved build ships through a recovery provider, the one delivery mechanism you choose for this fix.

An admin sets providers up once, under Emergency Recovery → Settings → Recovery providers. Credentials are stored sealed and are never shown again.

ProviderWhat it doesRollback
ManualYour team releases the build its usual way and reports the outcome in the dashboard.What the person reports
Remote mitigationThe fix is a policy, for example a permanent safe fallback, that is already active.Revoke the policy
Release pipelineBugCapsule sends a signed request to your CI/CD, which releases the build and reports progress back. Store keys stay in your pipeline. The dashboard shows how to verify requests when you add the pipeline.What your pipeline does
ShorebirdA pipeline job runs shorebird patch from the validated source. The app needs the bug_capsule_shorebird adapter, so BugCapsule can see which installations can take the patch and which already run it.The patch is rolled back. Devices drop it on their next start.
Google PlayPuts the uploaded version code on a track at the rollout share you choose.Halts the rollout. Devices that installed it keep it.
App Store ConnectReleases a version Apple has already approved, if it carries the validated build.Pauses a phased release. The version stays on the App Store.

Before anything ships, the recovery page runs the provider's compatibility checks. Each check passes, warns, fails, or can't tell:

For example, a release built without Shorebird fails the check: it can only be fixed by a store release.

Afterwards the page shows the provider's own answers:

Deploying, rolling back or resolving never lifts a policy. Guarded features stay guarded until you restore them.

Some things no provider can do:

Restore in stages

Once a fix has shipped, you can lift a block gradually instead of all at once. On the incident page, choose Plan restoration on the active restriction.

You can roll a plan back at any time. A paused plan is still watched. Cancelling stops the plan where it is: stages already lifted stay lifted.

i

Guards read the whole environment, not only the users who got the feature back. At 1%, choose thresholds that would still notice a problem in that small group.

Automation rules

Under Emergency Recovery → Automation, a rule watches one health signal. When the signal crosses its threshold with enough data, the rule files a suspected incident.

A rule can also propose a restriction. A proposal always waits for a person to approve it; automation never publishes or lifts a restriction by itself.

Before you make a rule active, run it in dry-run mode, or simulate it over the last day, to see when it would have fired.

AI assistance

On an incident, BugCapsule can draft:

It is advice only. It never changes the incident, publishes a policy, starts a recovery or deploys anything. People act through the normal, approved flows. Proposed test code is a sketch to adapt, never run or committed by BugCapsule.

Update and hotfix adapters

A REQUIRE_UPDATE decision carries the first version without the restriction. The adapters help the guarded feature get users there through the platform's own mechanism. They also report what they know (with the policy receipts, at most once a minute, never during replay), which drives the compatibility checks and adoption figures above. No adapter installs a package, downloads native code or restarts your app.

// pubspec: bug_capsule_shorebird (builds made with `shorebird release` only)
await BugCapsule.emergency.initialize(EmergencyConfig(
  trustAnchor: 'bcek.v1.…',
  hotfixProvider: BugCapsuleShorebird(),
));

final result = await BugCapsule.emergency.hotfix!.downloadAvailablePatch();
if (result is HotfixRestartRequired) {
  // Active after the next start. When that happens is up to you.
}

Other builds report that they can't be patched and need a store release. Code push changes Dart code only. Other code push services can implement HotfixProvider.

// implementation("io.github.bugcapsule:bugcapsule-android-play:…")
BugCapsule.emergency.initialize(this, EmergencyConfig.builder()
    .trustAnchor("bcek.v1.…")
    .updateProvider(PlayEmergencyUpdateProvider.builder(this).build())
    .build())

if (decision.action == EmergencyAction.REQUIRE_UPDATE) {
    BugCapsule.emergency.updates.startUpdate(activity, decision)
}

This uses Google Play In-App Updates.

  • The flexible flow is the default.
  • The immediate flow is used only when all three hold: you opt in with allowImmediateUpdates(true), the decision is P0 or P1, and Play allows it.
  • A downloaded flexible update installs only when you call completeUpdate().
  • Apps distributed outside Google Play implement EmergencyUpdateProvider themselves.
BugCapsule.emergency.initialize(EmergencyConfiguration(
    trustAnchor: "bcek.v1.…",
    updateProvider: AppStoreUpdateProvider()))

if decision.action == .requireUpdate,
   let update = try? await BugCapsule.emergency.updates.checkAvailability(for: decision),
   update.isAvailable {
    _ = await BugCapsule.emergency.updates.presentUpdate(from: viewController)
}

iOS has no in-app install, so the adapter only does two things:

  • It checks the App Store's listed version through Apple's public lookup.
  • It shows the App Store page.

Whether the user then updates shows up later, as the installation's new app version. If the store doesn't list the version yet, show your own message instead.

Signed patches (early access)

i

Early access. The patch runtime is not in the public SDK packages, so the Patches tab does nothing for an app until that app ships with it. Ask your BugCapsule team about access. No app store or other distribution channel has reviewed this feature: check the rules of your channel before you use it.

A signed patch changes logic or layout that your app registered as patchable when it was built, without a new store release. It can't reach anything else in the app.

What can changeFlutterNative AndroidNative iOS
Logic in a registered pure moduleYesYesYes
Layout of a registered surface, using components and actions already in the appYesYes (Compose)Yes (SwiftUI)
An existing function@Patchable top-level pure functions on int, bool and StringNoNo

Still needs a store release: Kotlin, Java, Swift or Objective-C code, unregistered Dart, new modules, surfaces, components or actions, plugins and native dependencies, permissions and entitlements, runtime or Flutter updates, and irreversible data migrations.

So far this has been tested on physical iPhone release builds (Flutter and native Swift) and on Android emulators, not on physical Android devices.

Signing key rotation

Create the next key in Settings; the trust anchor then lists two keys. Ship apps with the new anchor, and promote the next key once enough installations carry it. Apps that only know the old key keep their last document until it expires, then use their failure modes.