Why AI Agents Need a Reconciliation Layer: Modeling the Gap Between Dispatch and Confirmation The StareBrain team built a reconciliation layer for AI agents that separates delivery state from acceptance state, so an action's outcome is never collapsed into a single success/failure flag. The design introduces a permanent DENIED_UNRESOLVED status that never decays into success or triggers silent retries, plus a provider capability tier model (FULL, RECONCILABLE, PARTIAL, BLIND) that governs what reconciliation is possible after an ambiguous dispatch. The team argues that generating client references before dispatch and gating reconciliation attempts by a minimum age prevent duplicate bookings and false negatives. 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: 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. Every dispatchable action has two independent state progressions: enum class DeliveryState { PENDING, CONFIRMED, FAILED } enum class AcceptanceState { PENDING, ACCEPTED, REJECTED } 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 } 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. 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 } 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. 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 } The tier determines what reconciliation is possible after a DENIED UNRESOLVED : data class ReconciliationPolicy val tier: ProviderCapabilityTier, val minAgeBeforeEligible: Duration, val maxAttempts: Int, val clientReferenceKey: String? fun ActionRecord.isEligibleForReconciliation : Boolean { return Instant.now .isAfter eligibleForReconciliationAt } The minAgeBeforeEligible gate prevents reconciliation attempts against records that are still within the provider’s normal processing window — querying too early produces false negatives. 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 } 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. 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" } 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. 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.