Skip to main content

Closed Records Are Immutable Until Proven Otherwise

A closed ticket, a paid invoice, a rejected quote — these are terminal states. They rarely change. When they do change (reopened, refunded, reconsidered), a webhook tells us. Until then, we read them from the local cache without touching the upstream, even if the cache is stale.

The rule: terminal-state records escape the normal TTL freshness loop. Serve them from cache regardless of age. Refresh them in the background when read.


The pattern

When reading a cached entity, the code checks two things:

  1. Is it in a terminal state?
  2. If so, serve from cache; if not, apply normal TTL freshness logic.

If we serve from cache for a terminal record that happens to be stale, we also dispatch a background refresh job. The user sees the cached copy this request; the next request (and anyone else's concurrent request) sees the refreshed copy once the job lands.

// Pseudocode from the Client Portal v2.0.0
pub async fn ticket(&self, id: i64) -> Result<Ticket> {
    let (cached, fresh) = self.cache_get_with_freshness(id).await?;
    let is_closed = ticket_is_terminal(&cached);

    if fresh || is_closed {
        // Serve cache. If stale + closed, refresh in background.
        if !fresh && is_closed {
            self.dispatch_refresh_entity("ticket", id).await;
        }
        return Ok(cached);
    }
    // Not closed, and stale — fetch synchronously.
    self.fetch_and_upsert(id).await
}

Dedup guard on the queue keeps hot pages from stacking duplicate refresh jobs.


What counts as "terminal"

Varies by entity type. Each upstream has its own state machine.

Entity Terminal when
HaloPSA ticket status_id ∈ PORTAL_CLOSED_STATUS_IDS (env-configurable, default 9)
HaloPSA quote approvalstate=3 (rejected) or status=4 (expired)
Invoice (regular) amountdue=0
Credit note amountdue=0 AND mark_credit_as_used=true

"Accepted" quotes with down payments pending are not terminal — the payment flow is still firing webhooks. Only quotes that won't ever change again qualify.

Credit notes specifically require both amountdue=0 AND mark_credit_as_used=true because an unapplied credit has future movement ahead of it (allocation by finance).


Why we do this

  • API budget preservation. A closed ticket opened a thousand times over six months costs us one upstream call total (the last webhook). Without this rule, it would cost a thousand calls.
  • Sub-millisecond page loads. Terminal records never require a synchronous upstream fetch.
  • Eventual correctness retained. The background refresh still happens; drift self-heals.
  • Reconciler stays change-based. Since we trust webhooks + lazy refresh for terminal records, the reconciler doesn't need to waste calls sweeping them.

The reopen case

If a closed ticket is reopened in the upstream, a webhook arrives. The webhook handler upserts the new state into the cache. The next read sees the non-terminal state and applies normal TTL logic. The lazy-refresh-for-terminal rule only affects reading — it doesn't prevent writes from upstream events.

If the reopen webhook is missed, the background refresh job (dispatched from the lazy-read path) will catch it. If both mechanisms fail, the reconciler catches it on the next cycle. Three independent paths converge on the same outcome.


When this applies

Any entity type with a well-defined terminal state that (a) rarely changes once reached and (b) has an external mechanism to report the change when it does.

When it does not apply

  • Entity types where "closed" still experiences meaningful updates (e.g., a closed ticket that accumulates billing adjustments or compliance audit records). In that case it's not really terminal; treat normally.
  • Entities with no reliable change-reporting mechanism. Without a webhook or equivalent, you can't trust lazy refresh — the upstream could diverge silently for months.