I work with the Auto-Respond Team. I used AI assistance while editing this article, then checked the code and technical claims before publishing. The design below is a tested pattern, not a claim about a released product.
A voice workflow can switch from an automated agent to a human operator in a few hundred milliseconds. Later, somebody will ask a deceptively simple question: what happened?
A normal application log rarely gives a reliable answer. One service says the takeover was requested. Another says a worker claimed it. A third overwrote the session row after reading an old version. If the only surviving record is the final state, the path that produced it is gone.
The fix I keep coming back to is an append-only event stream. It keeps the decision trail without keeping the conversation, rejects stale writers, and rebuilds cleanly after a crash.
Write down the invariants first
The storage design should make these statements true:
- Every event has one stable identity.
- Sequence numbers increase by exactly one within a voice session.
- An actor is a company role or service role, never a person's name.
- A decision reason cannot be edited after it is written.
- Audio, transcripts, phone numbers, and free-form operator notes stay outside the audit stream.
- Replaying the same ordered events produces the same state.
Test that last property. If replay changes with the clock, a network call, or whatever order a loose query returns, the log cannot be trusted as state.
Use a narrow event envelope
Here is a TypeScript shape for the append boundary:
type Actor =
| { kind: "service"; ref: "voice-router" | "policy-engine" | "dialer" }
| { kind: "company_role"; ref: "support-agent" | "shift-supervisor" }
| { kind: "system"; ref: "recovery-worker" };
type TakeoverEventType =
| "session.started"
| "takeover.requested"
| "takeover.claimed"
| "takeover.released"
| "automation.resumed"
| "session.ended"
| "content.redacted";
type DecisionReason =
| "caller_requested_human"
| "low_model_confidence"
| "policy_requires_human"
| "operator_claimed"
| "operator_unavailable"
| "connection_lost"
| "session_completed";
type AuditEvent = {
eventId: string;
sessionId: string;
seq: number;
type: TakeoverEventType;
occurredAt: string;
recordedAt: string;
actor: Actor;
reason: DecisionReason;
policyVersion: string;
correlationId: string;
contentRef?: string;
contentDigest?: string;
attributes: Record<string, string | number | boolean>;
};
The actor field deliberately avoids email addresses, display names, and employee IDs. A separate access-controlled system may map an internal operator assignment to a person when there is a legitimate need. The general audit feed only needs to prove which company role or service made the transition.
Keep reason as a controlled value. Free-form text is tempting, but it mixes sensitive content with operational evidence and makes analysis inconsistent. If a new reason appears, add it to the contract and version the policy.
Enforce append-only storage in SQL
The table needs more than a convention that says "please do not update rows."
CREATE TABLE voice_takeover_events (
session_id UUID NOT NULL,
seq BIGINT NOT NULL CHECK (seq > 0),
event_id UUID NOT NULL,
event_type TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
actor_kind TEXT NOT NULL,
actor_ref TEXT NOT NULL,
reason_code TEXT NOT NULL,
policy_version TEXT NOT NULL,
correlation_id UUID NOT NULL,
content_ref TEXT,
content_digest TEXT,
attributes JSONB NOT NULL DEFAULT '{}',
PRIMARY KEY (session_id, seq),
UNIQUE (event_id)
);
CREATE TABLE voice_takeover_heads (
session_id UUID PRIMARY KEY,
last_seq BIGINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
REVOKE UPDATE, DELETE ON voice_takeover_events FROM voice_app;
GRANT SELECT, INSERT ON voice_takeover_events TO voice_app;
The composite primary key prevents two events from occupying the same sequence. The unique event ID makes a retried append recognizable. Database permissions stop the application role from rewriting history.
For stricter deployments, add a database trigger that rejects UPDATE and DELETE regardless of the caller. Retention can still be handled through partition expiry under a separate administrative role. Append-only does not mean keep every event forever.
Guard the sequence in one transaction
The head row is a compare-and-set boundary. A writer must provide the sequence it believes is current.
type AppendInput = Omit<AuditEvent, "seq" | "recordedAt"> & {
expectedSeq: number;
};
async function appendTakeoverEvent(input: AppendInput): Promise<AuditEvent> {
return db.transaction(async (tx) => {
const previous = await tx.events.findByEventId(input.eventId);
if (previous) return previous;
const head = await tx.heads.lockOrCreate(input.sessionId);
if (head.lastSeq !== input.expectedSeq) {
throw new Error(
"STALE_SESSION_VERSION expected=" +
input.expectedSeq +
" actual=" +
head.lastSeq,
);
}
const event: AuditEvent = {
...input,
seq: head.lastSeq + 1,
recordedAt: new Date().toISOString(),
};
await tx.events.insert(event);
await tx.heads.advance(input.sessionId, head.lastSeq, event.seq);
return event;
});
}
The lockOrCreate function should take a row lock. The advance function should update only when last_seq still equals the value just read. The event insert and head update belong in the same transaction.
This closes two awkward failure paths. Concurrent takeover claims cannot both win. A retry with the same eventId gets back the event already written. A retry with a new ID but an old expected sequence fails visibly instead of inventing a second history.
Keep decision evidence immutable
Suppose the policy engine requests a human because confidence falls below a threshold. Store the reason code, policy version, and non-sensitive values used by the decision:
{
"reason": "low_model_confidence",
"policyVersion": "voice-handoff-2026-08",
"attributes": {
"confidenceBucket": "below_0_55",
"attempt": 1
}
}
Do not later replace the reason with "caller_requested_human" because it looks cleaner in a report. If new evidence changes the interpretation, append a new reviewed annotation event. History should show that an interpretation changed.
I also avoid storing raw model prompts and chain-of-thought text. The audit question is about inputs, policy, transition, and actor. Internal reasoning text is neither required nor a safe substitute for structured decision evidence.
Draw a hard redaction boundary
The event stream should contain references, not conversation content.
A contentRef can point to an encrypted object governed by a shorter retention policy. A contentDigest can prove which object was evaluated without exposing it. The digest is only useful if the input space is not guessable, so compute it with a keyed hash or include a secret salt managed outside the event table.
Never put these values in attributes:
- caller phone numbers or email addresses;
- transcript fragments;
- raw audio locations;
- operator free-form notes;
- provider tokens or webhook payloads.
If a deletion request covers the referenced content, delete or redact that object according to policy and append a content.redacted event. The operational history survives, while the sensitive material does not.
Rebuild state with a pure reducer
The replay function should have no database or network access:
type SessionState = {
mode: "automated" | "human" | "ended";
ownerRole?: string;
lastSeq: number;
redactedRefs: Set<string>;
};
function reduceEvent(state: SessionState, event: AuditEvent): SessionState {
if (event.seq !== state.lastSeq + 1) {
throw new Error("SEQUENCE_GAP");
}
switch (event.type) {
case "session.started":
return { ...state, mode: "automated", lastSeq: event.seq };
case "takeover.claimed":
return {
...state,
mode: "human",
ownerRole: event.actor.ref,
lastSeq: event.seq,
};
case "takeover.released":
case "automation.resumed":
return {
...state,
mode: "automated",
ownerRole: undefined,
lastSeq: event.seq,
};
case "session.ended":
return { ...state, mode: "ended", lastSeq: event.seq };
case "content.redacted": {
const refs = new Set(state.redactedRefs);
if (event.contentRef) refs.add(event.contentRef);
return { ...state, redactedRefs: refs, lastSeq: event.seq };
}
default:
return { ...state, lastSeq: event.seq };
}
}
Sort by seq, not timestamps. Two services can have clock skew, and recordedAt may be later for a retried delivery. Sequence defines the session order. Timestamps remain useful evidence, but they do not decide replay.
Test the ugly races
A useful integration test pauses two operators after they both read the same head. Release them together and assert that only one claim is appended. The loser should receive STALE_SESSION_VERSION, reload the state, and stop.
Also test these cases:
- the same event ID arrives five times;
- an insert succeeds but the transaction rolls back before the head advances;
- sequence 8 is missing during replay;
- content is redacted after the session ends;
- an operator role releases control while a recovery worker attempts to resume automation.
The pass condition is stronger than "the final row looks right." The event list must have no gaps, no duplicate IDs, one winning claim, and the same reconstructed state on every replay.
The same boundary appears in AI answering service workflows where automation and a human may share one conversation. If the handoff matters, a mutable flag is the wrong artifact. Record the transition.
Top comments (1)
Append-only is the right instinct for takeover logs. In voice workflows, the handoff itself is a business event: who took over, why, what the caller had already said, and what the AI should no longer attempt to do.