One conversation gave me two different answers out of the same production store.
With the full 36-character thread id in the lookup key, the cooldown check reported depth 6 and refused the action: 15 days required between replies, 6.2 elapsed. With an 8-hex-character truncation of that same thread id, channel and counterparty unchanged, the same check reported depth 19. One conversation, one store, two histories. The only variable was how much of the id the caller pasted.
The store holds 3,132 conversation keys of the form channel:thread-id:counterparty, colon-joined. I went looking for how often that truncation had happened and found 68 pairs where an 8-character prefix lives alongside the full id for the same channel and the same counterparty. In 68 of 68, both sides carry at least one recorded reply. These are not abandoned lookup paths that wrote nothing. Activity accumulated under both spellings: 204 recorded replies sit inside the split pairs.
The cooldown arithmetic is fine. Given either stored row it computes the right answer for that row. The problem is upstream of the arithmetic, in which row got selected, and nothing downstream can detect it.
An absent record and a permissive record have the same shape
I checked the absence behaviour directly rather than reasoning about it. Given a key that had never been written, the check returns depth 0 and "no cooldown, proceed". Flip a single hex digit of a real thread id and that is exactly what comes back. Meanwhile the correct key for that same thread returns the depth-6 refusal quoted above. That pair is the positive control, and it is the whole defect in two lines: a plausible string identifying no stored conversation does not produce a response that expresses doubt. It produces permission.
To the caller, "I have no record of this" and "the record says zero" are byte-identical. But they support different conclusions. A stored zero is evidence about a known identity. A miss is the absence of evidence about the submitted key, and it says nothing at all about whether the conversation you actually meant has a history under some other key.
Once absence has been folded into an ordinary zero, nothing later in the pipeline can unfold it. The response carries no signal that the lookup itself deserves scrutiny. Any agent that reads a guard's answer to decide whether to act will read that answer as a green light, correctly, because that is what it says.
The minimum repair is a separate return:
record = lookup(exact_key)
if record is absent:
return missing_identity
return evaluate_cooldown(record)
The property that matters is that missing_identity cannot be consumed as an ordinary permissive result. A system still needs some way to start a genuinely new conversation, so either that path establishes identity from the issuing response before the first action, or absence fails closed and declines to authorise anything until the identity resolves. Skip both and every typo becomes indistinguishable from a legitimate first contact.
One caution on reading my own worked example. Depth 6 on the full id and depth 19 on the prefix establishes that the histories diverged. It does not establish that a shortened key always reports a smaller number, and it does not mean the depth-19 branch authorised anything. Either branch can undercount the real conversation while being larger than the other.
Create-on-write is what makes it permanent
A lookup miss by itself is a bad answer once. A store that creates a row on first write changes how long that answer lasts.
Walk it through. A caller submits a shortened id. The guard finds nothing and hands back its permissive zero. The reply goes out and gets recorded under that same shortened key, and now the key exists. Later lookups no longer hit absence at all. They retrieve a perfectly legitimate row whose identity is wrong for the thread the caller had in mind. Whoever carries the full id sees one history, whoever carries the prefix sees the other, and both of them are looking at a fraction of what happened. Accept a third variant and the fraction gets smaller again.
Thread ids were not the only axis. Twelve further pairs differ only in how the counterparty was spelled: a hyphen between two words versus the joined form, a space, a difference in case. The key builder lowercases and trims the counterparty, which handles the case variants going forward, and it does not strip punctuation, and it does not canonicalise the thread id at all. One stored id contains a four-character group reading ban31, which is not valid hexadecimal. A typo was accepted on the write path and became a durable identity.
That is the part worth generalising. Read-side validation can stop one lookup from proceeding on a malformed key. It cannot protect the store as long as some other path can mint a new identity by writing to it.
There was already a fix for this, which is the detail I keep turning over. One of the two platforms refuses new keys whose thread id is not a full-length id, scoped to new keys because adoption cannot be retrofitted onto rows already in the store. Reasonable as far as it goes. But 64 of the 68 split pairs are on the other platform, the one without that validator. The guard went in where the problem had already been noticed, and the unwatched path kept splitting histories the entire time, which is roughly the opposite of where you want your coverage.
Format checks have a ceiling anyway. Flipping one hex digit leaves a structurally valid full-length id that points at the wrong conversation, and no amount of regex will catch it. Length and shape are necessary on a path that demands them. Neither one is provenance.
Carry the id, do not rebuild it
The rule I would now write down: treat the identifier as data you received, never as a value you construct. Carry the issuing response's id verbatim through every handoff, into the guard, and into the write. Do not ask a later component to reconstruct it from a display string, and do not let a generated summary become the only surviving carrier of identity, which is a failure mode agent pipelines are unusually good at producing.
Shortening an id for a log line is fine. Shortening it for a lookup selects a different identifier. Keep the display value and the identity value in separate fields, let the guard read the preserved one, and have the write use the same value the guard validated. Counterparty names need the same treatment: carry a stable id if the platform issues one, and if names have to participate in identity, define one authoritative mapping instead of letting each caller pick a spelling.
Two checks you can run on your own store this afternoon. First, group every key by (channel, counterparty) and report any pair where one thread id is a strict prefix of another:
for bucket in group_keys_by(channel, counterparty):
for shorter, longer in distinct_pairs(bucket.thread_ids):
if shorter != longer and longer.starts_with(shorter):
report_candidate(shorter, longer)
Second, normalise the counterparty aggressively (lowercase, strip spaces and punctuation), bucket on the result, and report every bucket holding more than one original key. Keep the original strings in the output, because the point is to see which stored boundaries vanish under normalisation. Treat both outputs as candidates rather than conclusions. A prefix relationship is suggestive, and slugs can share a prefix by coincidence, which bit me on two of my own candidates before I tightened the test to ids whose shape made the truncation unambiguous. Aggressive normalisation is a good way to find suspects and a bad way to assign identity at write time, since it will happily collapse two genuinely different names.
Repair is more delicate than it looks. An earlier pass over this same store reconciled 227 keys down to 214 by merging derived ones, so the pattern predates what I measured this week and merging was already necessary once. A merge has to account for events that may appear on both sides rather than just summing the counters, and afterwards the old derived key has to either resolve through an explicit alias to the authoritative identity or be refused outright. Leave it creatable and the history will simply split again.
I want to be careful about the size of the claim. What these keys show is a guard drifting looser than intended, plus one past instance where a reply went out inside its cooldown. They do not give me a measured rate of harm, and the cooldown logic itself is correct when handed the right key. The defect lives at one boundary: the place where a caller-supplied string is accepted as proof of conversation identity, with a missing record converted into permission and a later write allowed to preserve the mistake.
So, for the next action your system is about to permit: can you trace the exact string in that lookup back to the response that issued it, and if you flip one character of it, do you get a refusal or a zero?
Top comments (0)