When a user confirms an action in an AI agent, they’re approving a plan built from a snapshot of the world at that moment. By the time the action dispatches — even seconds later — that snapshot may no longer be valid.
We call this the stale-plan problem. It’s distinct from stale data (a UI showing old info) and distinct from execution failure (the action tried and errored). It’s the gap between confirmed and still valid at dispatch time.
Here’s how we structured the protection layer in StareBrain.
The Two Moments That Matter
Most action pipelines treat confirmation as the green light. We split it into two checkpoints:
- Confirmation time — user approves the plan built from current evidence
- Dispatch time — action fires against live state
The dependency check happens at dispatch time, not confirmation time. If evidence has changed in a way that affects the approved intent, the action blocks before any side effect occurs.
Modeling the Evidence Dependency
Every dispatchable action declares which fields it depends on:
`data class EvidenceDependency(
val sourceId: String,
val fingerprint: String,
val capturedAt: Instant,
val fields: List<String>
)
data class ActionPlan(
val actionId: String,
val intent: String,
val steps: List<PlannedStep>,
val evidenceDependencies: List<EvidenceDependency>,
val estimatedSideEffects: List<SideEffect>,
val requiresConfirmation: Boolean
)`
The fingerprint is computed at confirmation time over only the fields that affect intent — not the whole record. This matters: an unrelated field changing in the source record shouldn’t invalidate a valid plan.
The Pre-Dispatch Validator
Before any step with side effects executes, we revalidate each dependency:
`sealed class DependencyCheckResult {
object Valid : DependencyCheckResult()
data class Stale(
val sourceId: String,
val changedFields: List<String>,
val capturedFingerprint: String,
val currentFingerprint: String
) : DependencyCheckResult()
data class Unresolvable(val sourceId: String, val reason: String) : DependencyCheckResult()
}
class PreDispatchValidator(
private val evidenceStore: EvidenceStore
) {
fun validate(plan: ActionPlan): List<DependencyCheckResult> {
return plan.evidenceDependencies.map { dep ->
val current = evidenceStore.fingerprintNow(dep.sourceId, dep.fields)
?: return@map DependencyCheckResult.Unresolvable(dep.sourceId, "source not found")
if (current == dep.fingerprint) {
DependencyCheckResult.Valid
} else {
DependencyCheckResult.Stale(
sourceId = dep.sourceId,
changedFields = evidenceStore.diffFields(dep.sourceId, dep.fingerprint, current),
capturedFingerprint = dep.fingerprint,
currentFingerprint = current
)
}
}
}
}`
Fail-Closed by Default
If any dependency comes back Stale or Unresolvable, we block before execution and surface a structured result:
`sealed class DispatchOutcome {
data class Executed(val actionId: String, val completedAt: Instant) : DispatchOutcome()
data class BlockedStale(
val actionId: String,
val staleDependencies: List<DependencyCheckResult.Stale>
) : DispatchOutcome()
data class BlockedUnresolvable(
val actionId: String,
val reason: String
) : DispatchOutcome()
}
class ActionDispatcher(
private val validator: PreDispatchValidator,
private val executor: StepExecutor
) {
fun dispatch(plan: ActionPlan): DispatchOutcome {
val results = validator.validate(plan)
val stale = results.filterIsInstance<DependencyCheckResult.Stale>()
val unresolvable = results.filterIsInstance<DependencyCheckResult.Unresolvable>()
if (stale.isNotEmpty()) {
return DispatchOutcome.BlockedStale(plan.actionId, stale)
}
if (unresolvable.isNotEmpty()) {
return DispatchOutcome.BlockedUnresolvable(
plan.actionId,
unresolvable.first().reason
)
}
executor.execute(plan.steps)
return DispatchOutcome.Executed(plan.actionId, Instant.now())
}
}`
No side effects run if the plan is stale. The block is logged, the user gets a fresh confirmation prompt with updated state, and the original plan is discarded.
Why Field-Level Fingerprinting
Hashing the whole source record is simpler but creates false invalidations. For a calendar slot:
-
slot_idandstatusaffect intent — if either changes, the plan is stale -
last_modified_byorcolor_labelare irrelevant — changing them shouldn’t block a booking
Field-level fingerprinting keeps the protection tight and avoids prompting users to re-confirm actions that are still semantically valid.
`fun fingerprintFields(record: Map<String, Any?>, fields: List<String>): String {
val relevant = fields.sorted().associateWith { record[it] }
return MessageDigest.getInstance("SHA-256")
.digest(relevant.toString().toByteArray())
.joinToString("") { "%02x".format(it) }
.take(16)
}
`
What This Closes
The confirm-to-dispatch gap is small in time but large in consequence for actions that write state — calendar bookings, messages, file writes. This layer closes it without changing the user-facing confirmation flow: the extra check is invisible unless something actually changed.
The observable behavior: protected path blocks with zero side effects when evidence is stale. Unprotected path executes regardless. That comparison is the test.
Top comments (1)
The field-level fingerprinting detail is the standout — hashing the whole record would flood users with re-confirm prompts, and a confirmation that fires constantly just trains people to rubber-stamp it. We're seeing agents discuss exactly this gap on agenshive.com: the confirm-to-dispatch problem is really about whether the approval still describes the world at dispatch time, and that question needs answering at the mechanism level, not just the UX level.