Document readAt viewer semantics and drop dead column from SELECTs

AgentMessageRecord.readAt has always been a per-viewer field — what
the snapshot calls "readAt" means "the Board operator has
acknowledged", what listAgentMessages calls "readAt" means "this
agent has acknowledged". The hidden contract was not documented and
the SELECT list kept pulling agent_messages.read_at even though no
code writes it, inviting the same bug §1.1 just fixed.

Spell out the viewer-scoped contract in snapshot.ts and drop the
dead column from the agent_messages SELECT list; readAt is resolved
exclusively from agent_message_reads.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 19:20:21 -04:00
1 parent 74ae5e922b
commit b6184b5bf3
1 file changed
+14 -2
+14 -2
View File
@@ -36,6 +36,13 @@ export function buildSnapshotMethods(
const connectors = deps.readConnectors();
const theme = (deps.getSetting("theme") as "system" | "light" | "dark" | null) ?? "system";
const locale = (deps.getSetting("locale") as "en" | "zh" | null) ?? "en";
// AgentMessageRecord.readAt is a per-viewer field — its meaning is
// "has the current viewer acknowledged this message?". The renderer
// view has exactly one human viewer (the Board operator), so the
// renderer snapshot fills it from that reader's agent_message_reads
// rows. Agent-side queries (listAgentMessages) resolve it against
// the current agent's receipts. The raw agent_messages.read_at
// column is never read on any path and stays NULL by design.
const boardReadReceiptMap = new Map(
readAll<{ messageId: string; readAt: string }>(
`select reads.message_id as messageId, reads.read_at as readAt
@@ -176,13 +183,18 @@ export function buildSnapshotMethods(
createdAt: r.created_at, updatedAt: r.updated_at,
})),
agentMessages: readAll<SqliteRow>(
`select id, company_id, from_agent_id, to_agent_id, sender_kind, channel, channel_target_id, subject, body, priority, read_at, parent_message_id, attachments_json, created_at from agent_messages${companyWhere} order by created_at desc limit ${companyId ? 1000 : 1000}`,
// The raw `read_at` column is intentionally omitted from the
// SELECT list. It is never written — readAt semantics live in
// the agent_message_reads receipts table, resolved against the
// current viewer above.
`select id, company_id, from_agent_id, to_agent_id, sender_kind, channel, channel_target_id, subject, body, priority, parent_message_id, attachments_json, created_at from agent_messages${companyWhere} order by created_at desc limit 1000`,
...companyArgs,
).map((r) => ({
id: r.id, companyId: r.company_id, fromAgentId: r.from_agent_id,
toAgentId: r.to_agent_id, senderKind: r.sender_kind ?? "agent",
channel: r.channel, channelTargetId: r.channel_target_id,
subject: r.subject, body: r.body, priority: r.priority, readAt: boardReadReceiptMap.get(r.id) ?? null,
subject: r.subject, body: r.body, priority: r.priority,
readAt: boardReadReceiptMap.get(r.id) ?? null,
parentMessageId: r.parent_message_id, attachmentsJson: r.attachments_json,
createdAt: r.created_at,
})),