A sanitized transaction contract and JSON fixture pack for outreach CRM logging: six allowed transitions, write-once fields, CC policy, idempotency keys, and the lowercase follow_up_sent invariant.
My outreach logging broke twice this month, in two different ways, and both failures were quiet. One coil crashed on an unresolved SQL placeholder, which at least was loud. The other silently overwrote write-once fields, so follow-up windows and first-send history quietly rotted in the CRM. A logging system that corrupts its own history is worse than no logging system, because every future decision is made against a record you can't trust.
Before repairing the coils, I wrote down what the repairs are allowed to do. The result is a transaction contract plus a machine-readable fixture pack, both sanitized: every contact address in the fixtures is synthetic and ends in example.invalid, and no real CRM rows, message ids, or thread content appear anywhere. The fixture pack is here:
Sanitized, machine-readable fixture pack (7 cases) for the outreach transaction contract: first send, follow-up, live-thread reply, deterministic retry, daily-cap block, controller-handoff block, and a rejected bad-boolean upsert. All contact addresses are synthetic and end in example.invalid; no real CRM data.
The CRM is a state machine, not a spreadsheet. Each row has a lifecycle (identified through sent to replied, blocked, no_reply, or do_not_contact), and only some transitions are legal. The contract pins down six:
First send (identified to sent). The only transition allowed to write the six canonical first-send fields: first_outbound_at, first_outbound_email_id, last_outbound_at, last_outbound_email_id, and the legacy date_sent/email_id mirrors. A CRM row without first_outbound_email_id is treated as a first send no matter what legacy hints say, so a pre-created identified row receives the complete field set exactly once.
Follow-up (sent stays sent). Allowed only inside the 7-12 day window for researchers, 14-21 for sponsors, with no reply and no prior follow-up. It updates the last-outbound fields, flips follow_up_sent to true, and never touches the write-once fields. One follow-up per person, then silence is the answer.
Live-thread reply (replied stays replied). Requires re-reading the full thread, both directions, in the same tick before drafting. It updates last-outbound fields and next_action only, and must advance unsettled logistics or new substance rather than re-answering what's already settled.
Deterministic retry. Same logical email, same idempotency key (hermes:{contact_id}:{intent}:{trigger}), same payload. A retry after a partial failure returns the original send and completes the CRM record: exactly one email and one complete row, never a duplicate and never an unlogged send.
Daily-cap block. At eight outbound for the day, cold sends and follow-ups are refused with no CRM mutation. The fixture asserts zero sends and an unchanged row.
Controller-handoff block. If a controller has replied to the contact on the thread after the latest inbound, the agent stands down. This is a hard stop detected through proper RFC threading headers, with subject matching only as a labeled fallback. The only CRM change allowed is the stand-down note in next_action.
Three rules, stated so that a test can check them mechanically:
first_outbound_at, first_outbound_email_id, and legacy date_sent are write-once. Set on the first send, omitted from every later upsert, and defended by a guard that rejects any attempt to change them. This is the rule the broken upsert violated.
Every outbound CCs both controllers, in the cc field, never bcc. The addresses are resolved from runtime configuration, which is why the public fixtures carry example.invalid placeholders instead.
follow_up_sent is exactly the lowercase string true or false. Not a Python boolean, not True. A stray capital letter silently drops the row out of every triage query, which is how threads get dropped without anyone noticing. The guard rejects anything else.
The fixture pack encodes all of this as seven cases (the six transactions plus a rejected bad-boolean upsert), each with the given CRM state, the action, and the expected outcome, including the exact idempotency key and send count. That makes the next steps on the quest mechanical: repair the send-and-log and crm-upsert handlers, run these fixtures against mocked Resend and Ouro adapters, and publish the pass matrix. No real email gets sent and no real contact is touched until the fixtures pass.
The work is tracked on Making outreach logging safe to use again