d8fda075da
`storeChatMessages` ended in `onConflictDoUpdate`, so `persistMessages` and `persistTurn` — which are handed a whole snapshot — treated any differing body under an existing message id as a deliberate finalisation. A stale snapshot carrying `wake:watch_1:fired`, the watch consent record, the deterministic confirmation or an investigation settlement card with a different body would overwrite the durable row that was already recorded. The proxy caps body size and metadata but does not rewrite message ids, so this was not an internal-bug-only exposure. The same clause updated only the `message` JSONB and never the `role` column, so `chat_messages.role` could end up disagreeing with `message.role` — and the UI reads one while the quota query reads the other. Ordinary transcript writes are now insert-only. Changing a stored message is its own operation, `finalizeChatMessage`, guarded on chat id, message id and role. `role` is verified rather than updated, and verified on both sides: the stored column must match `expectedRole` and so must the incoming body's own `role`, so the two cannot drift. A finalisation that matches nothing returns false; one whose body contradicts `expectedRole` throws. No production caller depended on the implicit finalisation. Every existing finalisation-shaped path already writes through an insert-only append: `settleInvestigationAndCloseCard`, `settleInvestigationStateAndCloseCard` and the watch request/confirmation/refusal records all use `appendChatMessageOnce(ByChatId)`. Also: re-sending a snapshot no longer reserves positions for messages that are already stored. The chat row is held, the missing ids are read under that lock, and only those get slots. A 40-message chat grown one turn at a time used to burn 1+2+…+40 = 820 slots for its 40 rows; it now burns 40. Deltas would be the proper fix, but that reaches into the agent's turn hooks and is a larger change than this pass. Two smaller repairs in the same file: `messageIdOf`/`messageRoleOf` now fail fast and name the chat and the offending message instead of casting unchecked and surfacing a `NOT NULL` violation from the driver; and a batch carrying the same message id twice throws instead of silently keeping the first, since that is an impossible state and a silent pick is how the upstream bug would stay invisible. The comment on `reserveMessagePositions` claiming the row lock is "released with the statement" was wrong — Postgres holds it to commit — and now says what is true.