Why AI Agents Need a Reconciliation Layer: Modeling the Gap Between Dispatch and Confirmation

# android# kotlin# architecture# agents
Why AI Agents Need a Reconciliation Layer: Modeling the Gap Between Dispatch and ConfirmationStareBrain

When an AI agent dispatches an action — booking a slot, sending a message, writing a file — there are...

When an AI agent dispatches an action — booking a slot, sending a message, writing a file — there are two questions that need answering, and they’re not the same question:

  1. Did the action reach the provider?
  2. Did the provider accept it?

Most agent implementations collapse these into one. The action fires, something comes back, and the outcome is recorded as success or failure. That works until the network gets involved, the provider is slow, or the response never arrives.

This post covers the reconciliation layer we built in StareBrain to handle the gap between dispatch and confirmation — and why collapsing those two questions into one creates failure modes that are invisible until they’re not.

The Two-Axis State Model

Every dispatchable action has two independent state progressions:

`enum class DeliveryState { PENDING, CONFIRMED, FAILED }
enum class AcceptanceState { PENDING, ACCEPTED, REJECTED }`
Enter fullscreen mode Exit fullscreen mode

DeliveryState tracks whether the action reached the provider. AcceptanceState tracks whether the provider acted on it. These are orthogonal — delivery can be confirmed while acceptance is still pending, and acceptance can never move out of PENDING until delivery is CONFIRMED.

`data class ActionRecord(
    val actionId: String,
    val dispatchId: String,
    val providerObjectId: String?,
    val delivery: DeliveryState,
    val acceptance: AcceptanceState,
    val dispatchedAt: Instant,
    val confirmedAt: Instant?,
    val eligibleForReconciliationAt: Instant
)

fun ActionRecord.tryAccept(verdict: AcceptanceState): ActionRecord {
    check(delivery == DeliveryState.CONFIRMED) {
        "Cannot evaluate acceptance before delivery is confirmed."
    }
    return copy(acceptance = verdict)
}`
Enter fullscreen mode Exit fullscreen mode

The structural invariant — acceptance cannot move until delivery is confirmed — prevents a class of race conditions where an optimistic acceptance gets recorded before the provider has even received the request.

DENIED_UNRESOLVED: A First-Class Outcome

The failure mode most systems miss: the action dispatched, the provider was reached, but the outcome is unknown. Network timeout after dispatch. Provider returned a non-terminal status. Response arrived but was malformed.

This isn’t a failure. It isn’t a success. It’s an unresolvable state — and it needs to stay that way permanently.

`enum class ActionStatus { CONFIRMED, FAILED, DENIED_UNRESOLVED }`
Enter fullscreen mode Exit fullscreen mode

DENIED_UNRESOLVED never decays into success on timeout. It never gets silently retried. It sits in the record as a permanent signal that this action’s outcome is unknown and requires explicit reconciliation before any replacement dispatch.

The reason this matters: if a booking dispatch returned DENIED_UNRESOLVED and the agent silently retries, the provider may have already processed the first request. The result is a duplicate booking the user never approved.

Provider Capability Tiers

Not every provider supports the same reconciliation capabilities. We model this explicitly:

`enum class ProviderCapabilityTier {
    FULL,           // Native webhook + queryable by our reference
    RECONCILABLE,   // No webhook, but queryable by client reference
    PARTIAL,        // Queryable by provider ID only (we must store their ID)
    BLIND           // No query capability — outcome unknowable after dispatch
}`
Enter fullscreen mode Exit fullscreen mode

The tier determines what reconciliation is possible after a DENIED_UNRESOLVED:

  • FULL: reconcile via webhook or active query using our own reference
  • RECONCILABLE: requires that our client reference was transmitted in the request AND is queryable by the provider — both conditions must hold
  • PARTIAL: we can query, but only if we captured the provider’s object ID at dispatch time
  • BLIND: reconciliation is impossible — surface to user for manual resolution
`data class ReconciliationPolicy(
    val tier: ProviderCapabilityTier,
    val minAgeBeforeEligible: Duration,
    val maxAttempts: Int,
    val clientReferenceKey: String?
)

fun ActionRecord.isEligibleForReconciliation(): Boolean {
    return Instant.now().isAfter(eligibleForReconciliationAt)
}`
Enter fullscreen mode Exit fullscreen mode

The minAgeBeforeEligible gate prevents reconciliation attempts against records that are still within the provider’s normal processing window — querying too early produces false negatives.

The Pre-Dispatch Reconciliation Key

For RECONCILABLE and FULL tiers, the client reference must be generated before dispatch — not after. If it’s generated after, a network failure between generation and transmission means we have a reference that doesn’t match anything on the provider side.

`data class DispatchContext(
    val actionId: String,
    val reconciliationKey: String,  // generated before dispatch
    val providerTier: ProviderCapabilityTier,
    val dispatchedAt: Instant
)

fun buildDispatchContext(actionId: String, tier: ProviderCapabilityTier): DispatchContext {
    return DispatchContext(
        actionId = actionId,
        reconciliationKey = UUID.randomUUID().toString(),
        providerTier = tier,
        dispatchedAt = Instant.now()
    )
}`
Enter fullscreen mode Exit fullscreen mode

The key is stored locally before the network call. If the call fails before transmitting the key, the record is DENIED_UNRESOLVED with no matching provider-side reference — which is the correct state, because reconciliation by reference isn’t possible.

Idempotency Scoping

Retries on non-terminal states need idempotency keys to prevent duplicate execution. We scope the key to event ID + action type, not just event ID:

`fun buildIdempotencyKey(eventId: String, actionType: String): String {
    return "$eventId:$actionType"
}`
Enter fullscreen mode Exit fullscreen mode

Scoping to event ID alone fails when the same event produces multiple actions of different types — the second action gets rejected as a duplicate of the first. Scoping to event ID + action type keeps retries safe without blocking legitimate parallel actions.

What This Closes

The two-axis model makes it impossible to record acceptance before delivery is confirmed. DENIED_UNRESOLVED as a permanent state prevents silent retry on unknown outcomes. Provider capability tiers make reconciliation strategy explicit at the architectural level rather than scattered across action handlers. Pre-dispatch key generation closes the reference mismatch on network failure.

The invisible failure mode — action dispatched, outcome unknown, agent moves on — becomes a named, queryable, reconcilable state.