> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/security-observability/recaptcha-enterprise-token-lifecycle-flutter.md).

# reCAPTCHA Enterprise Token Lifecycle in Flutter

How I separate the RecaptchaClient lifecycle, one-shot tokens, submit idempotency, and backend assessment into testable Flutter contracts

## Result

In the Flutter source I verified, reCAPTCHA Enterprise already appears in selected registration and contact-update flows. The app selects a site key by platform, obtains a `RecaptchaClient`, calls `execute`, and propagates the result through UI, Redux, and the service request.

However, “the client obtained a token” is not the final security result. Client source does not prove that the backend creates an assessment, compares the action, checks freshness, app identity, or replay, or applies a score policy. I separate the system into two lifecycles and one authority boundary.

The first lifecycle only prepares the client:

```
APP-LIFETIME CLIENT READINESS

app bootstrap
     │
     ▼
load platform + environment site-key configuration
     │
     ▼
fetch/cache one RecaptchaClient
     │
     ├─ unavailable ─► typed readiness + sanitized metric
     │
     └─ ready ───────► do not create a token yet
```

The second lifecycle starts from a validated user intent:

```
REQUEST-LIFETIME PROOF

immutable logical command attempt
     │  owns one stable idempotency key
     ▼
exact-attempt single-flight
     │
     ▼
execute(action mapped from purpose) just-in-time
     │
     ├─ unavailable ─► explicit fail-closed / fail-open policy
     ├─ UX deadline ─► wait for source settlement; no overlapping retry
     └─ ready ───────► private one-shot credential
                              │
                              ▼ consumeOnce
                    internal request assembler
                              │
                              ▼
                     protected business request
                              │
                              ▼
                    backend assessment authority
```

The resulting design is:

* `RecaptchaClient` is warmed up early for the app/site-key lifetime; the token is not prefetched.
* `CaptchaPurpose` maps exhaustively to the correct `CaptchaAction` and policy; callers cannot assemble purpose/action pairs themselves.
* `CaptchaOutcome` contains typed metadata only, not a public `String token` or an empty-string fallback.
* The raw token lives only in a private credential; the internal request assembler consumes it once and clears the reference before `await`.
* A duplicate is coalesced only when it is the same immutable logical attempt. A different draft or purpose receives a typed conflict, not the Future of the command already running.
* The SDK-supported timeout and UX deadline are different clocks. A UX timeout does not cancel the source Future and must not open the mutex for another native execute.
* The idempotency key belongs to the logical command attempt, not one CAPTCHA execution. A fresh token still uses the same key when retrying or reconciling the same command.
* The backend selects the expected action/site key/policy from trusted configuration, creates the assessment, prevents replay, and applies the business effect idempotently.
* Operational telemetry contains only allowlisted enums/buckets; it contains no raw exception, message, provider payload, site key, token, or identifier.

This is a **proposed design** derived from source review, not a claim that the current implementation already has every contract above. **Runtime/backend/dashboard behavior remains unknown**: I did not run the provider on a device, I do not have backend source or deployed logs, and I do not have console access.

## Problem

### Client warm-up and token execution are combined on the same critical path

**Source verified:** the helper currently reads the site key, calls `fetchClient`, and then calls `execute` for every submit. Both CAPTCHA callsites invoke the helper after the user taps submit. Execution therefore remains close to the request, but client initialization is also placed on the submit critical path.

Warming up a client and prefetching a token are different operations. The client can live for the app/site-key lifetime. A token is request-lifetime proof: creating it too early loses freshness, while recreating the client for every submit increases cold latency and opens more concurrency surface.

The correct design preserves two invariants:

```
warmUp()  = prepare/cache client, no proof
execute() = create proof for one validated protected action
```

**Runtime unknown:** source does not show whether the native SDK actually creates a new client every time or performs its own internal caching; I also did not measure cold/warm latency on Android or iOS.

### Outer `Future.timeout` does not cancel the native operation

**Source verified:** the helper wraps both `fetchClient` and `execute` in a three-second Dart `Future.timeout`, but it does not pass the SDK-supported execute timeout into the call. The plugin API at the referenced lock suggests a ten-second timeout and a five-second minimum.

Dart only stops waiting for the wrapper. The source Future can still complete after the timeout. If the gateway clears `_inFlight` as soon as the wrapper times out, a retry can start another execute while the previous native operation has not settled:

```
t0  execute generation A
t1  UX wrapper timeout
t2  mutex released incorrectly
t3  execute generation B starts
t4  generation A completes late
```

This is an ownership concurrency bug, not merely a poorly chosen timeout number. A late success can carry a token with no remaining owner; a late error can become unhandled or incorrectly sanitized; generation B can overlap native work with A.

**Proposed design:** the SDK-supported timeout is the native execution boundary. The UX/measurement deadline only moves the UI to `retryWaitingForSourceSettlement`. The gateway still keeps one active execute per client until the source Future settles, or until the exact SDK version proves cancellation and cancellation has completed.

### One action currently represents two business purposes

**Source verified:** the helper currently uses the same built-in signup action for initial account registration and mobile update from an OTP edit page. Callsites do not have a typed `CaptchaPurpose`, and the backend expected action is not present in the client repository.

An action is input to assessment, not a decorative label. When several business purposes share one action, the backend cannot easily apply separate policies and reviewers cannot prove which route expects which action.

The client also must not send an `expectedAction` string and ask the server to trust it. The server must derive the expected action from the trusted route/use case. The client only executes an action locked by policy mapping.

### Empty string turns a technical error into an implicit policy

**Source verified:** exceptions from fetch, execute, or timeout are caught, an analytics error is sent, and the helper returns an empty string. Initial registration still serializes token/site-key fields; the update service only adds the token when it is non-empty, but the request still continues.

At the client boundary, this behavior is an implicit fail-open in the sense that “CAPTCHA unavailable does not stop submit.” The backend may reject that request, but **backend behavior is unknown**. An empty string cannot distinguish misconfiguration, offline state, SDK timeout, provider rejection, or cancellation.

Failure policy must be explicit for each purpose:

| Purpose/condition               | Proposed default                   | UX                                                | Backend requirement                              |
| ------------------------------- | ---------------------------------- | ------------------------------------------------- | ------------------------------------------------ |
| Create account, SDK unavailable | Fail-closed                        | Preserve the draft safely and allow bounded retry | Do not create the account when proof is required |
| Update mobile/contact           | Fail-closed or step-up             | Retry or use another verification method          | Re-auth/step-up, audit, idempotency              |
| Assessment provider outage      | Server-owned policy                | Retry later or step up                            | Circuit breaker, alert, quota/risk control       |
| Explicit emergency fail-open    | Only a policy with an owner/expiry | Degraded state when appropriate                   | Tight rate limit, monitoring, rollback           |

Offline state, captive portals, reachability, and VPN suspicion are not the same signal. The connectivity article below owns network-context classification and offline UX; the CAPTCHA coordinator only consumes typed connectivity state.

{% content-ref url="/pages/kThPOkuxgcDBnbaVwp31" %}
[Flutter Connectivity and Network Failures](/flutter/my-flutter/systems-realtime/connectivity-offline-ux-vpn.md)
{% endcontent-ref %}

### The raw token passes through too many owners

**Source verified:** on the registration path, the token passes through the page request object, Redux action payload, and service body. Source does not show the token being persisted in Redux state, but it also has no consumed marker, disposal, or maximum hold time.

A public `String token` cannot preserve a one-owner/one-consume invariant. Any caller can:

* Interpolate the token into a log or error message.
* Copy it into a draft, Redux state, local retry queue, or crash context.
* Serialize it through a generic `toJson`.
* Send it again after a response timeout.
* Use the token for another purpose.

A CAPTCHA token is not a long-lived application credential. It does not belong in Keychain/Keystore and should not be “stored for retry.” The Secure Storage article owns the reusable credential/persistence boundary; the one-shot proof in this article must disappear after request assembly.

{% content-ref url="/pages/Fbl8YWIUovHWvjb7NUlz" %}
[Secure Storage: Keychain and Keystore](/flutter/my-flutter/security-observability/secure-storage-keychain-keystore.md)
{% endcontent-ref %}

### UI loading and Redux serialization can still guard too late

**Source verified:** both CAPTCHA pages set loading before execution, but their handlers do not have an explicit early-return guard. After dispatch, middleware serializes actions of the same type while the previous action remains loading.

Redux serialization is defense in depth for the business action, but the token is executed **before** dispatch. Two handlers can still run and create two tokens; the second token then waits behind the first action and grows stale.

The guard must live in the submit coordinator before `execute`. The store receives only typed business outcome/state, never the credential. Feature state, reducer, and middleware organization belong to the Redux architecture article:

{% content-ref url="/pages/flT55X8RgxKD0XpLXtUC" %}
[Redux/Flutter Redux at Scale](/flutter/my-flutter/architecture-state/redux-flutter-redux-large-app.md)
{% endcontent-ref %}

When an action stream needs `exhaustMap`, `concatMap`, cancellation, or retry, an Epic must still distinguish stream cancellation from actual cancellation of the source Future. The Redux Epics article covers that boundary:

{% content-ref url="/pages/YyM552Bk6JgngO4Jb0D9" %}
[Redux Epics and RxDart](/flutter/my-flutter/architecture-state/redux-epics-rxdart.md)
{% endcontent-ref %}

### Token retry and business idempotency lack one shared logical owner

**Source verified:** a manual retry calls the helper again, so there is no explicit token reuse. However, the traced request contract has no dedicated idempotency key, and CAPTCHA errors are swallowed into an empty string.

Two rules must both hold:

1. Every retry needs fresh CAPTCHA proof.
2. The same logical business command must keep the same idempotency key across a fresh token, an ambiguous response, and reconciliation.

If `_runCreate()` creates a key every time it is called, a first response that committed but was lost makes the retry look like a new command. Conversely, reusing an old token to “keep the request identical” violates one-shot and freshness constraints.

HTTP request timeout, auth context, stale responses, and transport retry belong to the API owner. A CAPTCHA timeout must not cancel or change a business operation that has already started.

{% content-ref url="/pages/r1BG7cXgqoay4tvlyEeO" %}
[Production HTTP Client for Flutter](/flutter/my-flutter/systems-realtime/production-http-client.md)
{% endcontent-ref %}

### A site key in the app is not a backend credential

**Configured:** tracked environment configuration contains non-empty Android and iOS site-key entries for the main environments. Their values are not included in this article.

The site key must be present in the mobile binary so the SDK knows which key is instrumented. Because a binary can be extracted or repackaged, the site key is not a server secret. IAM/service credentials used to create assessments belong in the backend secret manager.

Client source proves only token/site-key propagation. It does not prove:

* The backend creates an assessment.
* The server chooses a trusted expected action/site key.
* Token validity/create time is checked.
* Android/iOS app identity is compared.
* A replay store or idempotency binding exists.
* Score/reasons lead to allow, limit, step-up, or deny.
* Console key allowlists, quotas, or dashboard success are correct.

This boundary resembles app/device integrity: the client only sends opaque proof; the backend verifies freshness, identity, request binding, and replay before using a verdict.

{% content-ref url="/pages/0DLgCNDBcLATtINEcs10" %}
[Device Integrity and App Shielding in Flutter](/flutter/my-flutter/security-observability/device-integrity-app-shielding-flutter.md)
{% endcontent-ref %}

### Current telemetry has raw errors but lacks lifecycle dimensions

**Source verified:** the helper sends a formatted exception in an analytics property. Submit/edit callsites also send formatted errors. Source does not show callsites directly logging the token/site key, but raw provider exceptions have no allowlist guarantee.

Current telemetry also does not distinguish warm-up from execute, SDK timeout from UX deadline, purpose/action, retry bucket, stale completion, policy mode, or backend assessment outcome.

Raw exceptions need a separate crash-diagnostic sanitizer; analytics operational metrics accept only enums/buckets. The two observability articles below own error evidence and the multi-provider data contract respectively:

{% content-ref url="/pages/ATOHUDA9JCzn2wmCbZD9" %}
[Sentry for Crashes and Tracing](/flutter/my-flutter/security-observability/sentry-flutter-crash-http-navigation-tracing.md)
{% endcontent-ref %}

{% content-ref url="/pages/7FxGn5quNwHeyTJDP6IJ" %}
[Multi-Provider Analytics in Flutter](/flutter/my-flutter/security-observability/multi-provider-analytics-flutter.md)
{% endcontent-ref %}

### Dependency lock and local cache are not runtime proof

**Source verified:** the tracked `pubspec.lock` and iOS Pod lock agree on plugin/native dependency `18.9.1`. However, local generated package/plugin metadata and the Pods manifest still reflect `18.5.0`.

The correct conclusion is:

* `18.9.1` is the exact tracked lock that was reviewed.
* `18.5.0` is stale local generated/cache evidence.
* Neither version number proves which version a runtime binary executes.
* Clean dependency resolution, generated plugin registration, and Android/iOS builds are required to verify the artifact.

A focused configuration test was attempted with `--no-pub`, but the test did not load because the local cache was missing several dependencies and a plugin default implementation. The result was `0 passed` due to a dependency/cache blocker, **not a CAPTCHA test failure**. I did not run `pub get` in the source workspace to avoid changing the lock or private dependency state.

## Solution

### Lock purpose, action, and policy behind one construction path

The public API does not let UI pair `CaptchaPurpose.updateMobile` with the account-creation action. The policy constructor is private, and an exhaustive switch is the only mapping source:

```dart
enum CaptchaPurpose { createAccount, updateMobile }

final class CaptchaAction {
  const CaptchaAction._(this.value);

  final String value;

  static const accountCreate = CaptchaAction._('account_create');
  static const mobileUpdate = CaptchaAction._('mobile_update');
}

enum CaptchaFailurePolicy { failClosed, failOpenWithRiskControls }

final class CaptchaPolicy {
  const CaptchaPolicy._({
    required this.purpose,
    required this.action,
    required this.onUnavailable,
  });

  final CaptchaPurpose purpose;
  final CaptchaAction action;
  final CaptchaFailurePolicy onUnavailable;

  static CaptchaPolicy forPurpose(CaptchaPurpose purpose) => switch (purpose) {
    CaptchaPurpose.createAccount => const CaptchaPolicy._(
      purpose: CaptchaPurpose.createAccount,
      action: CaptchaAction.accountCreate,
      onUnavailable: CaptchaFailurePolicy.failClosed,
    ),
    CaptchaPurpose.updateMobile => const CaptchaPolicy._(
      purpose: CaptchaPurpose.updateMobile,
      action: CaptchaAction.mobileUpdate,
      onUnavailable: CaptchaFailurePolicy.failClosed,
    ),
  };
}

enum CaptchaFailureKind {
  misconfigured,
  notReady,
  offline,
  sdkTimeout,
  providerRejected,
  busyUntilSourceSettles,
  cancelled,
  unknown,
}

sealed class CaptchaOutcome {
  const CaptchaOutcome({required this.purpose, required this.action});

  final CaptchaPurpose purpose;
  final CaptchaAction action;
}

final class CaptchaReady extends CaptchaOutcome {
  const CaptchaReady({
    required super.purpose,
    required super.action,
    required this.observedAt,
  });

  final DateTime observedAt;
}

final class CaptchaUnavailable extends CaptchaOutcome {
  const CaptchaUnavailable({
    required super.purpose,
    required super.action,
    required this.kind,
    required this.retryable,
  });

  final CaptchaFailureKind kind;
  final bool retryable;
}

enum CaptchaConsumeFailure { alreadyConsumed, unavailable }

sealed class CaptchaConsumeResult<T> {
  const CaptchaConsumeResult();
}

final class CaptchaConsumed<T> extends CaptchaConsumeResult<T> {
  const CaptchaConsumed(this.value);

  final T value;
}

final class CaptchaConsumeRejected<T> extends CaptchaConsumeResult<T> {
  const CaptchaConsumeRejected(this.failure);

  final CaptchaConsumeFailure failure;
}

// Internal library: do not export the assembler or credential to feature/UI.
abstract interface class _CaptchaRequestAssembler<T> {
  Future<T> sendWithCaptchaToken(String rawToken);
}

final class _CaptchaCredential {
  _CaptchaCredential._(String rawValue) : _rawValue = rawValue;

  String? _rawValue;

  Future<CaptchaConsumeResult<T>> consumeOnce<T>(
    _CaptchaRequestAssembler<T> assembler,
  ) async {
    final rawValue = _rawValue;
    if (rawValue == null) {
      return CaptchaConsumeRejected<T>(CaptchaConsumeFailure.alreadyConsumed);
    }

    // Clear before await: a send failure must not make the credential reusable.
    _rawValue = null;
    final value = await assembler.sendWithCaptchaToken(rawValue);
    return CaptchaConsumed(value);
  }

  void discard() => _rawValue = null;

  @override
  String toString() => 'CaptchaCredential(<redacted>)';
}

final class _CaptchaExecution {
  _CaptchaExecution.ready({
    required CaptchaReady outcome,
    required String rawToken,
  }) : outcome = outcome,
       _credential = _CaptchaCredential._(rawToken);

  const _CaptchaExecution.unavailable(CaptchaUnavailable outcome)
    : outcome = outcome,
      _credential = null;

  final CaptchaOutcome outcome;
  final _CaptchaCredential? _credential;

  Future<CaptchaConsumeResult<T>> consumeOnce<T>(
    _CaptchaRequestAssembler<T> assembler,
  ) {
    final credential = _credential;
    if (credential == null) {
      return Future.value(
        CaptchaConsumeRejected<T>(CaptchaConsumeFailure.unavailable),
      );
    }
    return credential.consumeOnce(assembler);
  }

  void discardCredential() => _credential?.discard();

  @override
  String toString() =>
      'CaptchaExecution(outcome: $outcome, credential: <redacted>)';
}
```

`CaptchaOutcome` does not contain the raw token. `_CaptchaExecution` and `_CaptchaCredential` live only in the internal protected-command library. Feature/UI does not import them, there is no token getter, and there is no generic `toJson`.

The first consume clears the raw value before `await`; even when API send fails, the credential remains consumed. The second call returns `CaptchaConsumeFailure.alreadyConsumed`. `toString`/interpolation contains only `<redacted>`. The credential does not participate in equality props, Redux state, retry queues, or persistence.

### Keep one native execute active until source settlement

The gateway contract must distinguish client phase from the final CAPTCHA outcome:

```dart
enum CaptchaClientPhase {
  idle,
  warming,
  executing,
  retryWaitingForSourceSettlement,
  settlingStaleGeneration,
  disposed,
}

enum CaptchaRetryDisposition {
  waitForSourceSettlement,
  rotateAfterVerifiedCancellation,
}

abstract interface class _CaptchaGateway {
  Stream<CaptchaClientPhase> get phases;

  Future<void> warmUp();

  Future<_CaptchaExecution> execute(CaptchaPurpose purpose);
}
```

The implementation preserves these invariants:

1. `warmUp` and `execute` share the same lifecycle gate, so they cannot race on client initialization.
2. One client has at most one active native execute.
3. The SDK-supported execute timeout is passed to the SDK according to the exact-version contract.
4. The UX deadline emits `retryWaitingForSourceSettlement`; it does not falsely complete or cancel the source Future.
5. A retry before source settlement receives typed `busyUntilSourceSettles` or is queued by policy; it does not overlap execute.
6. Dispose/background marks the generation stale but does not release the mutex.
7. A late success creates a credential and then calls `discardCredential`; a late error is normalized. Only after settlement does the gateway return to idle.
8. `rotateAfterVerifiedCancellation` is used only when the exact SDK/version has cancellation evidence, cancellation has completed, and the previous client is isolated. With current evidence, the default is `waitForSourceSettlement`.

```
executing generation A
        │
        ├─ source settles before UX deadline ─► ready/unavailable ─► idle
        │
        └─ UX deadline
              │
              ▼
       retryWaitingForSourceSettlement
              ├─ retry tap ─► typed busy/wait, no execute B
              ├─ dispose ───► mark A stale, mutex stays held
              └─ A settles
                    ├─ late success ─► discard private credential
                    └─ late error ───► sanitized settle
                              │
                              ▼
                            idle
```

### Put idempotency on the immutable logical attempt

The API/application owner creates a logical attempt once from an immutable command snapshot. The key is not created inside `_run`:

```dart
final class _IdempotencyKey {
  const _IdempotencyKey(this.value);

  final String value;
}

final class _CommandDigest {
  const _CommandDigest(this.value);

  final String value;
}

final class ProtectedCommandAttempt<T> {
  const ProtectedCommandAttempt._({
    required this.purpose,
    required this.command,
    required this.commandDigest,
    required this.idempotencyKey,
  });

  final CaptchaPurpose purpose;
  final T command;
  final _CommandDigest commandDigest;
  final _IdempotencyKey idempotencyKey;
}

abstract interface class _ProtectedAttemptFactory<T> {
  ProtectedCommandAttempt<T> create({
    required CaptchaPurpose purpose,
    required T immutableCommand,
  });
}

sealed class SubmitStart<T> {
  const SubmitStart();
}

final class SubmitAccepted<T> extends SubmitStart<T> {
  const SubmitAccepted(this.completion);

  final Future<T> completion;
}

final class SubmitCoalesced<T> extends SubmitStart<T> {
  const SubmitCoalesced(this.completion);

  final Future<T> completion;
}

final class SubmitConflict<T> extends SubmitStart<T> {
  const SubmitConflict();
}

final class _ActiveSubmission<T> {
  const _ActiveSubmission(this.attempt, this.completion);

  final ProtectedCommandAttempt<T> attempt;
  final Future<void> completion;
}

abstract interface class _RequestAssemblerFactory<T> {
  _CaptchaRequestAssembler<void> forAttempt(ProtectedCommandAttempt<T> attempt);
}

final class ProtectedActionUnavailable implements Exception {
  const ProtectedActionUnavailable(this.kind);

  final CaptchaFailureKind kind;
}

final class CaptchaCredentialRejected implements Exception {
  const CaptchaCredentialRejected(this.failure);

  final CaptchaConsumeFailure failure;
}

final class SubmitCoordinator<T> {
  SubmitCoordinator(this._captcha, this._assemblerFactory);

  final _CaptchaGateway _captcha;
  final _RequestAssemblerFactory<T> _assemblerFactory;

  _ActiveSubmission<T>? _active;

  SubmitStart<void> submit(ProtectedCommandAttempt<T> attempt) {
    final active = _active;
    if (active != null) {
      if (identical(active.attempt, attempt)) {
        return SubmitCoalesced(active.completion);
      }
      return const SubmitConflict<void>();
    }

    final completion = _run(attempt).whenComplete(() {
      if (identical(_active?.attempt, attempt)) _active = null;
    });
    _active = _ActiveSubmission(attempt, completion);
    return SubmitAccepted(completion);
  }

  Future<void> _run(ProtectedCommandAttempt<T> attempt) async {
    final execution = await _captcha.execute(attempt.purpose);

    switch (execution.outcome) {
      case CaptchaReady():
        final consumed = await execution.consumeOnce(
          _assemblerFactory.forAttempt(attempt),
        );
        switch (consumed) {
          case CaptchaConsumed():
            return;
          case CaptchaConsumeRejected(:final failure):
            throw CaptchaCredentialRejected(failure);
        }
      case CaptchaUnavailable(:final kind):
        throw ProtectedActionUnavailable(kind);
    }
  }
}
```

Coalescing uses the object identity of the exact immutable attempt. Two objects that happen to have identical fields still do not share a Future. A different draft or purpose receives `SubmitConflict` while another attempt is active.

The same attempt object can be retried after a CAPTCHA failure or ambiguous network response; the assembler creates fresh proof but uses the existing `_IdempotencyKey`. A definitively abandoned attempt is closed; a new command must go through the factory to receive a new key.

If reconciliation must survive a process restart, persist only minimal, security-reviewed attempt metadata. The CAPTCHA credential/raw token is never serialized. Command-digest and idempotency-key creation are backend/application security design; this article publishes neither formulas nor values.

### Make the request assembler the only raw-token consumer

`_RequestAssemblerFactory` lives in the same internal library as the credential. The assembler receives the immutable command, command digest, and stable idempotency key; `sendWithCaptchaToken` is the only method that receives the raw token.

Do not return a request DTO containing the token to UI/Redux. Do not place the token in a draft to “send later.” API send begins inside `consumeOnce`, and the raw value has already been cleared from the credential before the network Future is awaited.

A business request has three outcomes that must remain distinct:

```
definite pre-send failure
  └─ the same logical attempt may execute fresh proof

definite rejection / abandoned command
  └─ close the attempt; a new command gets a new idempotency key

ambiguous response after send
  └─ keep the same attempt/key; reconcile or retry with fresh proof
```

The CAPTCHA deadline does not cancel a business request that has already started. The HTTP owner decides request timeout, status query, and idempotent replay.

### Keep backend assessment as the authority

The mobile request carries only the protected command, opaque proof, and idempotency envelope. The backend does not trust a client-supplied site key, expected action, or “allow” flag.

```
receive protected command
        │
        ├─ validate auth/input envelope
        │
        ▼
reserve idempotency binding
(key + purpose + canonical command digest)
        │
        ├─ same key, different binding ─► reject conflict
        ├─ same completed binding ──────► return stored outcome
        └─ same in-flight binding ──────► wait/reconcile
        │
        ▼
reserve keyed token digest / opaque replay record
        │
        ├─ already owned/consumed ─► reject or reconcile
        └─ owner acquired
               │
               ▼
      external provider assessment call
               │
               ├─ invalid / wrong action / wrong app identity ─► reject
               ├─ stale / replay ───────────────────────────────► reject
               ├─ provider unavailable ────────────────────────► outage policy
               └─ valid ─► risk decision
                              │
                              ▼
                   apply/store business outcome idempotently
```

An external provider call cannot honestly be described as one database transaction. The backend needs a recoverable state machine:

```
reserved ─► assessed ─► applied/completed
    │           │
    └───────────┴────► failed/retryable with owned reservation
```

One implementation can use a short transaction for reservation, call the provider outside that transaction, and then use compare-and-set/lease plus a reviewed transactional outbox or saga to commit the business effect exactly once.

Backend invariants:

* The route/use case derives `purpose`, expected action, expected mobile key, and policy from trusted configuration.
* The first reservation binds the idempotency key to purpose + canonical command digest.
* The same key with a different digest/purpose is rejected before assessment/effect.
* The raw token lives only in request memory until the provider call; the backend does not persist it.
* The replay record uses a keyed digest or opaque handle under a security-reviewed retention policy; this article publishes no keying/TTL formula.
* Assessment checks validity, action, create time/freshness, and platform app identity before the risk score.
* Token reservation and business outcome remain recoverable/idempotent across crashes and concurrent retries.
* Provider outage applies a server-owned fail-open/fail-closed/step-up policy; the client cannot grant itself permission.

**Backend/runtime unknown:** client source does not prove that this state machine exists. This is a contract that requires backend/security review and integration tests.

### Emit typed operational metrics only

The telemetry contract accepts no `Object`, message, stack trace, provider payload, site key, token, or account/attempt/request/assessment identifier:

```dart
enum CaptchaStage { warmUp, execute, settle, submit }

enum CaptchaMetricOutcome {
  ready,
  misconfigured,
  notReady,
  offline,
  sdkTimeout,
  uxDeadlineExceeded,
  providerRejected,
  busyWaitingForSettlement,
  cancelled,
  staleSuccessDropped,
  staleErrorSettled,
  unknown,
}

enum LatencyBucket {
  underOneSecond,
  oneToThreeSeconds,
  threeToTenSeconds,
  tenSecondsOrMore,
}

enum RetryBucket {
  none,
  one,
  two,
  threeOrMore;

  static RetryBucket fromCount(int count) => switch (count) {
    <= 0 => RetryBucket.none,
    1 => RetryBucket.one,
    2 => RetryBucket.two,
    _ => RetryBucket.threeOrMore,
  };
}

enum CaptchaPlatform { android, ios }

CaptchaMetricOutcome metricOutcomeFor(CaptchaOutcome outcome) =>
    switch (outcome) {
      CaptchaReady() => CaptchaMetricOutcome.ready,
      CaptchaUnavailable(kind: final kind) => switch (kind) {
        CaptchaFailureKind.misconfigured => CaptchaMetricOutcome.misconfigured,
        CaptchaFailureKind.notReady => CaptchaMetricOutcome.notReady,
        CaptchaFailureKind.offline => CaptchaMetricOutcome.offline,
        CaptchaFailureKind.sdkTimeout => CaptchaMetricOutcome.sdkTimeout,
        CaptchaFailureKind.providerRejected =>
          CaptchaMetricOutcome.providerRejected,
        CaptchaFailureKind.busyUntilSourceSettles =>
          CaptchaMetricOutcome.busyWaitingForSettlement,
        CaptchaFailureKind.cancelled => CaptchaMetricOutcome.cancelled,
        CaptchaFailureKind.unknown => CaptchaMetricOutcome.unknown,
      },
    };

final class CaptchaMetric {
  const CaptchaMetric({
    required this.purpose,
    required this.stage,
    required this.outcome,
    required this.latency,
    required this.retry,
    required this.platform,
    required this.policyMode,
  });

  final CaptchaPurpose purpose;
  final CaptchaStage stage;
  final CaptchaMetricOutcome outcome;
  final LatencyBucket latency;
  final RetryBucket retry;
  final CaptchaPlatform platform;
  final CaptchaFailurePolicy policyMode;
}
```

`metricOutcomeFor` is an exhaustive typed projection. UX-deadline and stale-settlement metrics come from gateway phase transitions; they are not invented as a new `CaptchaOutcome` that makes the source Future appear cancelled.

The serializer only encodes enums at the telemetry sink boundary. Backend assessment telemetry has a separate namespace and retention policy; dashboard correlation uses aggregate dimensions, not a raw token or identifier.

### Test the lifecycle instead of mocking one success string

Minimum test matrix:

| Layer            | Case                                             | Required assertion                                           |
| ---------------- | ------------------------------------------------ | ------------------------------------------------------------ |
| Policy           | Every purpose                                    | Exhaustive mapping; caller cannot forge purpose/action       |
| Outcome          | Provider returns empty/unavailable               | Do not create a fake `CaptchaReady`/credential               |
| Credential       | `toString`, interpolation, diagnostic projection | Only `<redacted>`, no raw token                              |
| Credential       | Generic JSON/equality/state                      | No serializer/props/Redux/persistence retention              |
| Credential       | Consume twice                                    | Assembler runs once; second call is typed `alreadyConsumed`  |
| Gateway          | SDK success after UX deadline                    | Mutex held until settlement; stale credential discarded      |
| Gateway          | SDK error after UX deadline                      | Error normalized; release only after settlement              |
| Gateway          | Retry before orphan settles                      | Busy/wait; no execute overlap                                |
| Lifecycle        | Dispose/background during execute                | Mark stale, do not unlock early or navigate late             |
| Lifecycle        | `warmUp` races `execute`                         | One lifecycle gate, deterministic ordering                   |
| Submit           | Double tap on same attempt                       | One execute/request, coalesce the same Future                |
| Submit           | Different draft/purpose while active             | Typed conflict, do not share Future                          |
| Idempotency      | Same command with a fresh token                  | Keep the same key through retry/reconcile/ambiguous response |
| Idempotency      | New/abandoned command                            | New key; old attempt does not revive                         |
| Telemetry        | Every outcome/latency/retry                      | Exhaustive enum projection; no raw field                     |
| Backend binding  | Same key, different digest/purpose               | Reject conflict before assessment/effect                     |
| Backend replay   | Same token concurrently/twice                    | One reservation owner; no raw-token persistence              |
| Backend recovery | Crash between reserve/assessment/effect          | Recoverable state/outbox; one business effect                |
| Platform         | Android/iOS key/config matrix                    | Correct typed config; unsupported platform fails typed       |
| Device/network   | Offline, slow, background/resume                 | Bounded UX, no duplicate/late token                          |
| Console/manual   | Environment keys and app allowlists              | Conclude only after console/device evidence                  |

**Current test evidence:** scoped source search found no unit/widget/integration test that calls the helper, SDK fetch/execute, a protected callsite, or the backend assessment contract. The broad AppConfig test does not isolate site key/action/readiness. The focused config test was blocked before execution by stale/incomplete local dependencies, so there is no CAPTCHA pass count.

Unit tests with a fake gateway prove only the coordinator/state machine. Production assurance also requires Android/iOS device tests, a provider sandbox, backend assessment/replay concurrency tests, outage/quota drills, and dashboard verification.

### Verified version and scope

* Tracked Flutter dependency lock: `recaptcha_enterprise_flutter 18.9.1`.
* Tracked iOS Pod lock: plugin/native reCAPTCHA components `18.9.1`.
* Local generated package/plugin metadata and Pods manifest: stale `18.5.0`.
* Dependencies were not clean-resolved, CocoaPods/Gradle builds were not run, and generated plugin registration was not verified from a clean cache.
* The SDK was not run on an emulator, simulator, or physical device.
* No real token was obtained and no provider/backend assessment was called.
* Console configuration, score distribution, quota, and dashboard delivery were not verified.
* The focused test hit a dependency/cache blocker; `0 passed` is not treated as a CAPTCHA failure.

When upgrading the plugin, re-check the Flutter wrapper API, native dependency lock, Android/iOS minimum requirements, execute-timeout contract, generated registration, and the full device/backend matrix. The exact lock is reproducibility evidence, not runtime-success evidence.

### References

* [reCAPTCHA Enterprise Flutter package](https://pub.dev/packages/recaptcha_enterprise_flutter)
* [Flutter `RecaptchaClient` API](https://pub.dev/documentation/recaptcha_enterprise_flutter/latest/recaptcha_client/RecaptchaClient-class.html)
* [Instrument Android apps](https://docs.cloud.google.com/recaptcha/docs/instrument-android-apps)
* [Instrument iOS apps](https://docs.cloud.google.com/recaptcha/docs/instrument-ios-apps)
* [Create assessments for mobile apps](https://docs.cloud.google.com/recaptcha/docs/create-assessment-mobile)
* [Interpret assessments for mobile apps](https://docs.cloud.google.com/recaptcha/docs/interpret-assessment-mobile)
* [Dart `Future.timeout`](https://api.dart.dev/dart-async/Future/timeout.html)

## Conclusion

The correct reCAPTCHA Enterprise lifecycle in Flutter is not “fetch a token and attach it to a request.” The app should warm up one client early, but execute the correct action only immediately before a protected submit guarded by exact-attempt single-flight.

The raw token is a one-shot capability: private, never logged or persisted, absent from Redux/UI state, and consumed once only by the internal request assembler. Every retry obtains fresh proof, while the same logical business command keeps its existing idempotency key across an ambiguous response and reconciliation.

`Future.timeout` does not cancel the source Future. A UX deadline can change visible state, but the gateway must retain ownership until native settlement and drop a stale generation's late result before allowing a new retry to execute. Business-request timeout is a separate boundary owned by the API layer.

Finally, client token acquisition is not a security verdict. The backend is the authority that chooses the trusted expected action/site key, creates the assessment, checks validity/freshness/app identity/replay, applies outage/risk policy, and commits the business effect idempotently. Current source does not prove that backend, runtime-device, or dashboard behavior; tracked lock `18.9.1` and stale local artifacts `18.5.0` do not replace clean-build/device evidence either.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
