The Turn Audit Log Is a First-Party Table
Why the interview audit trail moves off stored tool calls into an append-only interviewTurnEvents table recording the full agenda and the model's raw structured output
Decision
The interview's audit trail is a first-party append-only table,
interviewTurnEvents, holding one row per turn, keyed by thread and
document, recording the full assembled agenda and the model's raw
structured output.
Rows are written at the turn callsite. The record originally prescribed
population from the Agent component's rawRequestResponseHandler hook; that
did not survive contact with the component — see
Implementation deviation
below.
This supersedes the 2026-05-09 tool-call audit story, under which stored Agent tool calls and tool results were the audit trail, and amends No Separate Interview Event Log (2026-05-02), whose "a compliance or audit requirement needs structured (non-transcript) interview events" revisit clause has now fired.
Context
Under agenda-driven turns, the server assembles the turn's agenda deterministically and the model makes one constrained-output call against it. Grounding moves out of tool calls, so the audit has to move with it: there are no cold-path tool calls left to replay.
That is not a loss. Replayed tool chatter shows what the model asked for; an agenda row shows exactly what the model was shown and exactly what it decided, with no token cost on the turn.
The 2026-05-02 record already prescribed the shape for this case — model it
on authEvents: append-only, typed eventType, metadata blob, indexed by
the relevant scope keys. That is what this table is.
Shape
One row per turn:
- Keyed by thread and working document.
- Typed
eventType,metadatablob, append-only — never patched, never deleted on the interview path. - The full assembled agenda as sent: the scoped target, its unfilled
fields with guides and types, relevant
collectedData, itswriteSafety, and any carryover section. - The model's raw structured output:
reply,writes,requestedStatus,statusReason,escape.
Agendas are deliberately slim — that is what makes the single-completion turn fast — so row size is bounded by the same discipline that bounds the prompt.
Mechanism: rawRequestResponseHandler
The installed @convex-dev/agent exposes rawRequestResponseHandler
(client/types.d.ts:294), called per LLM call with the raw
LanguageModelRequestMetadata / ResponseMetadata, and documented for
exactly this use — logging the raw request body to a table.
The original intent was to populate every row from that hook rather than from a parallel hand-assembled path, for fidelity: the hook captures the literal request as sent, system prompt included, so the audit could not drift from what the model actually saw if agenda assembly later changed.
The reply text is knowingly duplicated between the raw output and the
Agent's stored assistant message. That is an accepted audit-atomicity trade:
the audit row must be self-contained and readable without joining against a
third-party component's store, and a row that recorded everything except
what the user was told would not answer the question the audit exists for.
Implementation deviation: the hook is not the population path
Probed live on 2026-07-31, during implementation. The record's first caveat —
whether the provider path carries a full request body — resolved
favourably: request.body is populated and is an object.
The second thing killed the mechanism. The hook cannot reliably identify the
turn. startGeneration passes its own closure's threadId, which is
populated on the generateText (escape) path and absent on the
thread.generateObject path, where the thread arrives via threadOpts
instead. Measured: 1 hook row against 6 completions. A hook that fires for
one turn kind out of two cannot be the population path for a per-turn audit.
So the row is written at the turn callsite, by a recordTurn beside it. The
callsite has what the hook lacks — the
assembled agenda object and the turn kind (kickoff_turn,
agenda_turn, repair_turn, escape_turn) — and it is what answers the
question the table exists for: what was the model shown, and what did it
decide.
The hook is retained, narrowed: where it does fire, it inserts a
supplementary row tagged metadata.kind: "raw_request" carrying the raw
request body, the response id and model id, and a bodyPresent flag so a
later reader can tell an empty body apart from a provider that does not
surface one. It is never the sole record of a turn, and a reader must
distinguish the two row kinds by that tag.
rawRequest on a raw_request row is a JSON string, not an object. The
body is JSON.stringify'd before the insert because Convex reserves field
names beginning with $, and the generateObject request body embeds a JSON
Schema whose root carries "$schema". Inserting the body as an object fails
validation with Field name $schema starts with a '$', which is reserved. —
and because the insert is fail-soft, every real raw_request row was silently
lost from the table's introduction until this was found (2026-07-31; the only
row that had ever landed was a {"a":1} probe). Stringifying sidesteps the
reserved-key rule entirely and is lossless: the row is a dev-audit read, never
a query target, and nothing reads rawRequest back — the inspector projection
excludes it. A reader parses the string; a null still means no body.
Both writes are fail-soft: an audit insert that throws is logged and swallowed. The row records what happened; it is not a precondition for it happening, and losing one to a transient error beats converting a completed turn into a user-visible failure.
What the turn inspector reads back
Rows are read by the chat's dev-gated write-visibility surface: per-turn action chips, each clickable for a detail panel. Two rules keep that read cheap.
Nothing streams by default. The chip feed carries only the per-chip
summary; the detail comes from a query the client calls on click
(interviewTurnChips:getTurnActionDetail). Full rows never ride the
subscription.
The projection decides what may cross the wire — one pure row-in /
detail-out module, not the query. It
carries the turn's write values and rejection reasons, the agenda's fields
summarised by bucket without their current values, the model's raw structured
output, and — since 2026-07-31 — the rendered TURN AGENDA message, so the
panel can show the instruction text the model actually received. That message
is re-rendered from the stored agenda by renderAgendaMessage, the same
renderer the live turn used, rather than stored a second time as prose: one
template, no drift, no duplicate copy of the prompt in the row.
rawRequest bodies still never cross the wire. They are prompt-sized, the
inspector has no use for them, and the agenda message now answers the question
someone would otherwise open the raw body to answer.
The static system prompt joins at the query layer, not in the
projection — it is deployment code, not row data, so every row would otherwise
carry the same string and the projection would stop being a pure function of
its row. The query's return type is therefore TurnInspectorDetail
(TurnActionDetail plus systemInstructions), and the panel renders both
prompt-sized blocks collapsed by default.
Structured internals stay out of thread recall
A second implementation fact strengthens the separation argument this record
makes below. The constrained turn runs its object call with
saveMessages: "none", because the component's object path serialises the
whole structured object as the assistant message — writes, escape reasons,
field keys, as JSON in the renderable thread and in later turns' context
recall. The turn instead saves its own messages: the person's prompt and
reply as ordinary text.
So the split is clean in both directions. The thread holds the conversation;
interviewTurnEvents holds the agenda and the structured decision. Neither
container is asked to do the other's job — which is precisely the argument
made against saveMessages: "all" in the next section, arrived at from the
opposite end.
Why not Agent message storage (saveMessages: "all")
The component's thread store keeps user prompts and assistant replies under
saveMessages: "promptAndOutput", but never the injected agenda, and it does
not persist raw requests in its own storage. Switching to
saveMessages: "all" would capture agendas — as thread messages, subject
to context recall.
That recreates the prompt bloat the pruning work exists to remove: every stored agenda becomes a candidate for the next turn's context window. It also couples the audit to a third-party component's storage model and to the pruning layer that sits on top of it — two things that may change for reasons that have nothing to do with compliance.
Audit retention and prompt context are different concerns with different lifetimes. Storing one in the other's container is what forces the trade.
Why not inputs plus a hash
The tempting cheap version: store the agenda's inputs and a hash of the assembled agenda, and reconstruct on demand.
It does not work here. The agenda is assembled from moduleVersions state
that is mutated in place on every write. Replaying assembly a week later
reads today's data, not the data the model saw. A hash proves integrity —
that some agenda was not tampered with — but it cannot answer the only
question that matters: what did the model see when it decided that?
Reconstructibility would require an immutable input snapshot per turn, which is the same storage cost as just storing the agenda, minus the readability.
Relationship to the two superseded records
2026-05-09 (tool-call audit). Its premise was that grounding happens
through tool calls, so storing them is free auditability. Agenda-driven turns
remove cold-path tool calls, so the premise is gone. A downstream consequence:
the kickoff turn's multi-step generateText existed largely to produce stored
tool calls for audit, and with this record it becomes a
degenerate agenda turn.
2026-05-02 (No Separate Interview Event Log).
Not overturned — its revisit clause fired as written. Its "cost of a third
write path on every interview turn is not justified" rationale is amended
differently than intended: the hook was expected to make the write free, and
the deviation above means it is an explicit callsite write. It is justified
anyway, because the compliance requirement that fired the revisit clause is
not served by any other store — but it is one write per turn, fail-soft, not
a free ride on an existing hook. Its analytics reasoning (defer/decline queries, branch-decision history) is
untouched — interviewTurnEvents is an audit record of turns, not a
product-analytics event stream, and adding analytics event types to it is a
separate decision.
Revisit when
- Row size stops being bounded by agenda slimness — e.g. if agendas grow to carry full block prose, the table needs a size policy or an offload.
@convex-dev/agentstarts passing a reliablethreadIdtorawRequestResponseHandleron the object path, at which point the hook could take back the fidelity role and the callsite write could narrow to the agenda object and turn kind alone.- The two row kinds (
agenda_turnwritten at the callsite,raw_requestwritten by the hook) start being read as one stream by a consumer that does not checkmetadata.kind. - Anything wants to query into a raw request body, at which point the
stringified
rawRequeststops being free and the body needs either a key-sanitising transform or a different container. - Retention or residency rules land, at which point the append-only posture needs a documented deletion path that does not exist today.
Interview Names the Pass, Not the Agent
The vocabulary rule for retiring "interview" as the domain word for the document agent, and the ledger of which existing names keep it
Interview Writes Stay Object-Encoded
Why the array-shaped writes encoding is not adopted, why the key-discriminated union that would fix its value corruption is strict-legal after all, and why it is rejected on cost rather than on impossibility