# 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.

- **Status:** Available
- **Audience:** both, developer
- **In the app:** #/relationships
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/docs/relationships/stage-normalisation/

## 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** — `advance` refuses a destination outside the six with `unknown_stage` and a 422. The vocabulary cannot grow by accident.
**Reading** — `normalise` and `contact_rank` never 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_person` and `duplicate` carry 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.

1. A message arrives for a contact whose stage word is `new`.
   - Result: The lookup raises rather than returning a position.
2. The engine retries.
   - Result: It raises again, for the same reason, because nothing about the data has changed.
3. 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.

> **Careful** A total read function is not defensive programming here. The failure mode of a partial one was a customer's message going unanswered for a reason no screen could show, which is the worst class of fault this product has: invisible rather than loud.

## 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_stage` rather 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.

## Related

- [Lifecycle stages](https://connectbyjbrh.com/docs/relationships/lifecycle-stages/)
- [People](https://connectbyjbrh.com/docs/relationships/people/)
- [Relationships in Connect](https://connectbyjbrh.com/docs/relationships/)
- [Moving a relationship through its lifecycle](https://connectbyjbrh.com/docs/workflows/lifecycle-progression/)
- [Prospecting in Connect](https://connectbyjbrh.com/docs/prospects/)
- [When a call is answered in silence](https://connectbyjbrh.com/research/silent-failure/)

## What this page is based on

- `backend/app/journey.py` — `normalise`, `contact_rank`, `LEGACY_FIRST` and the alias tables
- `docs-source/sources/CHANNELS.md` §5 — `stages()` as the vocabulary
- `docs-source/sources/GENERAL.md` §3 — canonical records and the bridge
