Normalising a stage
journey.normalise turns whatever a stage column says into one of the six, or into closed. A word it does not recognise is returned unchanged rather than guessed at, and then ranks as *before everything* — the safe answer, because a stage Connect does not know must never block a real transition. Writes are strict; reads are total.
One column, three vocabularies#
contacts.stage has carried three sets of words over this product's life and holds all three today. Anything reading that column has to accept all of them, which is why the translation lives in one place instead of at each call site.
| Vocabulary | Written by | Example words |
|---|---|---|
| The engine's own progression | agent.upsert_contact, from the classified intent | research, conversation, interested, qualification, demo, installation |
| The Owner pipeline's words | Read by operator_ui.stage_for | new, contacted, replied, opportunity, client, closed |
| The six lifecycle stages | journey.advance, keeping the contact in step | prospect, qualified, opportunity, client, onboarding, active |
Words are compared after being lowered, trimmed and having spaces turned into underscores, so *Not Interested* and not_interested are one word. The empty value, new, lead and contacted all read as the first position — a person grown from an old contact row would otherwise appear to leap out of nowhere at their first real transition.
Strict on write, total on read#
- Writing
advancerefuses a destination outside the six withunknown_stageand a 422. The vocabulary cannot grow by accident.- Reading
normaliseandcontact_ranknever raise. An unrecognised word comes back as itself and ranks -1, which reads as *before everything*.- Terminal words
closed,lost,not_fit,not_interested,dnc,wrong_personandduplicatecarry no position at all. Somebody who was closed and then writes again is starting over.- Mapping direction
- Translation is non-decreasing along the engine's progression, so a contact the engine advances never makes the relationship go backwards.
That asymmetry is the design. A closed vocabulary on the way in keeps the meaning of a position stable; a total function on the way out means no reader can be broken by a word written years ago by code that no longer exists.
The failure this replaced#
Before the translation had one home, agent read the column by calling list.index on the engine's nine words. Every other word raised — including new, which is what every contact bridged in from a customer workspace carries, and client, which advance itself writes.
A message arrives for a contact whose stage word is
new.Result The lookup raises rather than returning a position.
The engine retries.
Result It raises again, for the same reason, because nothing about the data has changed.
Three attempts pass.
Result The message is held — and never answered. Nothing on any screen said *this stage word is unreadable*; it read as a message that had simply not been dealt with.
What this means for imports and integrations#
- An import may carry any word. It is read charitably and ranks last, so it never blocks a later move. It is not adopted into the vocabulary.
- Setting a position through the API or the Assistant uses the six. Anything else is refused with
unknown_stagerather than stored. - Casing and spacing do not matter. Comparison is on the normalised word.
- Two screens can show different words for the same relationship while old rows survive — the lifecycle position is the one written by
advance, and it is what other behaviour reads.
Questions#
What happens to a stage word Connect does not recognise?
It is returned unchanged and ranked -1, meaning *before everything*. Nothing is guessed and nothing is overwritten, and because it ranks last it cannot prevent a genuine forward move.
Can I add a word to the mapping?
The mapping is code, not configuration, and the six destinations are fixed. If an import uses your own vocabulary, map it on the way in — the alternative is a seventh position appearing by accident, which is what the strict write path exists to prevent.
Why do the prospect list and the relationship screen sometimes disagree?
Because the prospect list and the pipeline read contacts.stage, which holds older words, while the relationship reads people.stage. advance writes both at the same moment, so a relationship moved since that change agrees with itself; a record untouched since carries whatever it carried.