# Follow-Up Architecture — the canonical doctrine

> **Read this before you build, extend, or debug any multi-stage follow-up (FU) sequence.** A
> follow-up is not a prompt behavior and not a single workflow — it is an **ordered set of coupled
> workflows** gated on boolean stamps. Every operator who re-derives this from scratch ships a
> duplicate-firing agent. This doc is the model, assembled in one place.
>
> Companion docs (this doc cites them, don't duplicate them): `workflows.md` (the workflow engine +
> conditions), `custom-properties.md` (the `check_until_match` terminal-freeze doctrine),
> `platform-internals.md §3a/§3b` (NULL-condition silent block + `needs_attention` block),
> `outbound-whatsapp-playbook.md` / `post-booking-outreach.md` (the 24h-window template chain).

---

## 1. What a follow-up actually is

A follow-up is a **`workflows` row with `type = FOLLOW_UP`** plus a set of **property conditions**
(`workflow_property_conditions`). The workflow's timer decides *when* the engine looks; the condition
set decides *who* it fires for. **The condition set IS the stage gate** — there is no other place
where "which stage is this lead in" is expressed.

- **One FU ≠ a sequence.** A single FU re-engages one population once (or on a repeating interval).
- **A sequence is N FUs** whose condition sets encode order. Nothing in the platform "knows" they
  form a sequence — the ordering is an emergent property of how you write each FU's conditions. Get
  one condition wrong and the sequence silently overlaps or skips.

The message itself is generated by the FU's own prompt + conversation context. The conversational
agent (system prompt + v5Config) plays **no** role in follow-up timing or gating — do not try to make
it "follow up" (that is anti-pattern PAT-039 / Cat 1 in `prompt-antipatterns.md`).

---

## 2. The stage-N invariant (copy-pasteable rule)

A **stage-N follow-up** must gate on **all** of:

1. stage-N **entry stamp** `= true` (i.e. `NOT_EQUALS true` is FALSE — the lead has entered this
   stage), AND
2. **every downstream stage entry stamp** `NOT_EQUALS true` (cross-stage exclusion — the lead has not
   advanced past this stage), AND
3. **every stop property** `NOT_EQUALS true` (`Bad fit`, `Lead declined`, `Escalation needed`,
   `Deposit confirmed`, minor, …), AND
4. **anchored to its own stage stamp** via `delayTriggerPropertyId` = the stage-N entry stamp +
   `delayMinutesAfterPropertySet` = the offset for this specific FU, with `minMinutesSinceLastInbound`
   as the silence gate.

> **Use `NOT_EQUALS true`, never `EQUALS false`, for the exclusions and stops.** `EQUALS false`
> blocks on NULL rows, and 81–98% of boolean props accumulate NULLs (`default_value` is metadata
> only, it does not populate rows). `NOT_EQUALS true` treats NULL as "not yet confirmed → fire",
> which is what you want for a downstream/stop exclusion. See `platform-internals.md §3a` — this is
> the single most common reason a correctly-shaped FU never fires.

Stamps are **boolean custom properties set by an external evaluator**, never by the conversational
agent (`platform-internals.md §2`). All gating properties are boolean or number — never string
(`prompt-antipatterns.md` boolean-gating rule).

### Worked example — 3-stage funnel

Stamps (evaluator sets each when the agent completes that milestone):
`welcome_sent` → `age_qualified` → `registration_link_sent`.
Stops (disqualifiers): `Bad fit`, `Lead declined`, `Escalation needed`, minor.

| FU | fires when (entry) | downstream exclusions (`NOT_EQUALS true`) | stops (`NOT_EQUALS true`) | anchor (`delayTriggerPropertyId`) |
|---|---|---|---|---|
| Etapa 0 · 20 min | `welcome_sent = true` | `age_qualified`, `registration_link_sent` | all stops | `welcome_sent`, +20 min |
| Etapa 0 · 2 h | `welcome_sent = true` | `age_qualified`, `registration_link_sent` | all stops | `welcome_sent`, +120 min |
| Etapa 1 · 30 min | `age_qualified = true` | `registration_link_sent` | all stops | `age_qualified`, +30 min |
| Etapa 2 · 1 h | `registration_link_sent = true` | *(none downstream)* | all stops | `registration_link_sent`, +60 min |

Read the table top-to-bottom: a lead who has reached `registration_link_sent` has that stamp `true`,
so **every** Etapa 0 and Etapa 1 FU is excluded by its downstream-exclusion column. Only Etapa 2
fires. That is the whole point.

---

## 3. Follow-ups are a coupled set, not independent objects (the dependency graph)

**This is the rule operators miss most.** Each stage must exclude the entry stamps of **all later
stages**. The dependency is directional (later stages exclude nothing above them), but the *authoring*
is bidirectional:

> **Adding a new stage forces edits to every earlier stage.**

Concretely: you have Etapa 0 FUs live. You add an Etapa 1 FU with entry stamp `age_qualified`. You are
**not done** — you must go back and add `age_qualified NOT_EQUALS true` to the already-live Etapa 0
FUs. If you don't, Etapa 0 keeps chasing leads who already advanced to Etapa 1 → **cross-stage
duplicates** (a stage-1 lead still gets stage-0 follow-ups).

### Exclusion matrix — 3-stage funnel

| FU stage | entry stamp `= true` | must ALSO exclude (`NOT_EQUALS true`) |
|---|---|---|
| Etapa 0 | `welcome_sent` | `age_qualified`, `registration_link_sent` + all stops |
| Etapa 1 | `age_qualified` | `registration_link_sent` + all stops |
| Etapa 2 | `registration_link_sent` | *(no downstream)* + all stops |

### The retrofit checklist step

> **After adding stage N, retrofit stages 0..N−1 to exclude stage N's entry stamp.**

This is mechanical, not judgment — a stage that predates a later stage is *guaranteed* stale until you
retrofit it. The conformance check `fu_conformance.py` rule **R2** ("a FU must exclude all downstream
stage stamps") is the mechanical guard that catches a set that drifted out of sync; run it against the
per-agent `fu-model.json` after every stage addition. (Both live on the tooling side — see the
companion `cortex-gateway-service/docs/followup-mcp-spec.md`.)

---

## 4. Stamps vs stops

Two kinds of boolean gate the sequence, and they do opposite jobs:

| | **Stamps** (progress milestones) | **Stops** (disqualifiers) |
|---|---|---|
| What they mean | the agent completed a milestone | the lead should get no more FUs |
| Examples | `welcome_sent`, `age_qualified`, `registration_link_sent`, `deposit_confirmed` | `Bad fit`, `Lead declined`, `Escalation needed`, `Deposit confirmed`, minor |
| Role in a stage-N FU | entry stamp fires it; downstream stamps exclude it | every stop excludes it |
| Set by | external evaluator | external evaluator |

**Every FU excludes every downstream stamp AND every stop.** A stop is not stage-specific — one
disqualifier (`Lead declined`) should kill the whole sequence, so it belongs in the exclusion set of
*every* FU at *every* stage. Note `Deposit confirmed` can act as both a terminal stamp and a stop,
depending on the funnel — it means "converted, stop chasing".

---

## 5. Anchoring & stagger — why each same-stage FU needs its own anchor

A stage can have several FUs (a 20-minute nudge and a 2-hour nudge off the same entry stamp). Each one
needs its **own** `delayTriggerPropertyId` + `delayMinutesAfterPropertySet`:

- `delayTriggerPropertyId` = the stage entry stamp — start the clock when the lead *entered this
  stage*, not when the conversation started.
- `delayMinutesAfterPropertySet` = this FU's offset (20 min, 120 min, …) — this is what **staggers**
  the same-stage FUs apart.

**Without the per-FU anchor**, all same-stage FUs hang off the same one entry variable and the same
one last-inbound clock. When the evaluator stamps the entry variable late (it runs post-hoc, in a
batch), the runner's *next* tick sees the stamp appear and finds **all** the same-stage FUs eligible
at once → they fire minutes apart. That is the **same-stage burst** (see §6).

---

## 6. The two duplicate mechanisms — the diagnostic fork

When an operator says "leads are getting duplicate follow-ups", there are **two** distinct causes with
**different fixes**. Conflating them wastes cycles — identify which one first.

| | **Cross-stage duplicate** | **Same-stage burst** |
|---|---|---|
| Symptom | different stages co-fire — a stage-3 lead still gets stage-1 FUs | the 20-min and 1-h FU of *one* stage land minutes apart |
| Root cause | a FU is missing a downstream stamp in its exclusion set (usually because a later stage was added and the earlier FUs weren't retrofitted — §3) | same-stage FUs share one anchor variable + one last-inbound clock, so a late-stamped variable makes several eligible in one runner tick (§5) |
| Fix | add the downstream stamp as a `NOT_EQUALS true` exclusion on the offending earlier FU | give each same-stage FU its own `delayTriggerPropertyId` + `delayMinutesAfterPropertySet` (the stagger anchor) |
| Guard | `fu_conformance.py` R2 | `fu_conformance.py` anchor/stagger rule |

**Do not** apply the stagger anchor to fix a cross-stage duplicate, or add an exclusion to fix a
same-stage burst — you'll change nothing and conclude the platform is broken.

---

## 7. The evaluator-miss failure class

The stamps that gate the whole sequence are set by an **external evaluator** running post-hoc, not by
the conversational agent. Two ways this silently breaks a FU:

1. **NO_RESPONSE sets no property.** When the agent chooses `NO_RESPONSE` on a hostile, converted, or
   off-topic lead, the conversation just goes quiet — **no stamp and no stop flips**. Unless the
   evaluator independently flips a boolean *stop* (`Lead declined`, `Bad fit`), the FU sequence keeps
   chasing that lead. NO_RESPONSE is **not** a stop.
2. **A free-text reason is not a gate.** A "drop-off reason" or "disqualification note" stored as a
   **string** property cannot gate a FU — conditions gate reliably only on **boolean / number**
   (`prompt-antipatterns.md` boolean-gating rule). A string like `"declined"` will not reliably
   satisfy `NOT_EQUALS`/`EQUALS`. The stop must be its own boolean.

Takeaway: for every way a lead can drop out of the funnel, there must be a **boolean stop** the
evaluator sets, and every FU must exclude it. Relying on the agent to "just stop" is the bug.

---

## 8. Measuring FUs correctly

**Ground truth for a follow-up SEND is `messages.source_workflow_id IS NOT NULL`.** That row exists
only when a real outbound message was emitted and attributed to a workflow. (It measures the *send*,
not delivery/read — those are a separate Meta status — but for "did a FU actually go out" it is the
truth, and it is what distinguishes a send from a runner evaluation.)

Do **not** measure sends from the runner tables:

- `workflow_runs.status = COMPLETED` and `workflow_logs.action = 'SENT'` are the runner's
  **evaluation attempts**, not deliveries. They **over-count** massively — a single dead futbolitos
  conversation logged **560 COMPLETED runs for 1 delivered message**.
- Coverage is the real question: **"did this lead EVER get its stage-N FU"**, not "how many sends in a
  fixed window". Measure per-lead, per-stage, off `source_workflow_id`.

The old `knowledge/workflows.md` "Workflow Runs" SQL and `prompt-engineering-guide.md`'s follow-up
timing query point at the runner tables — treat their output as "the engine looked", never "the lead
got a message".

---

## 9. Property flags that break follow-ups

Four property-level settings quietly disable a whole sequence:

- **Type must be boolean/number, not string.** String comparisons in conditions are unreliable
  (`"true"` ≠ `true`). Any property you gate a FU on is boolean or number.
- **`= false` gates block on NULL** — use `NOT_EQUALS true`. (§2, `platform-internals.md §3a`.)
- **`check_until_match` must be `true` on terminal stops**, with the terminal target set. Otherwise
  an early `false` freezes and the later converting signal never flips it → the stop never fires and
  the FU chases a converted lead forever (`custom-properties.md` — the terminal-freeze doctrine, with
  the betting-VIP casualty: `vip_access_granted` frozen `false` on 93 of 98 flow-completed leads).
- **`include_in_llm` vs `include_in_agent_context` — do not confuse them.** `include_in_llm` is the
  **evaluator pipeline** that *sets* the value; turn it off and the property never updates, which
  **freezes the entire sequence** that gates on it. `include_in_agent_context` is **prompt injection**
  (presentational — surfacing the value to the conversational agent). Turning off the wrong one has
  very different blast radii (`custom-properties.md`).

Also platform-level: **`needs_attention = true` blocks ALL follow-ups**, including rescue workflows,
on exactly the conversations where the lead spoke last (`platform-internals.md §3b`). A rescue FU's
reach is silently capped; lean on `minMinutesSinceLastInbound` and set expectations.

---

## 10. Meta 24-hour window (day-scale re-engagement)

Any FU that reaches a lead on IG/Messenger/WhatsApp **more than 24h after their last inbound** must go
out as an **approved Meta WhatsApp template** (`WHATSAPP_TEMPLATE` destination), not free-form text —
a free-text workflow outside the window silently fails to deliver. The property-gated
workflow → notification → destination → approved-template chain is documented end-to-end in
`outbound-whatsapp-playbook.md` and `post-booking-outreach.md` (incl. the Meta empty-token trap /
error 131008). Day-scale FU stages (Etapa N at +2 days) are exactly this case.

---

## 11. Conformance — don't re-audit with symptom queries

Once a sequence is modeled, its correctness is a **mechanical** property, not something to re-discover
by DB spelunking each time:

- Keep a per-agent **`fu-model.json`** describing the stages, stamps, stops, and anchors.
- Run **`fu_conformance.py`** against it — it enforces R2 (exclude all downstream stamps), the
  anchor/stagger rule, the boolean-gating rule, the `check_until_match`-on-stops rule, and the
  `NOT_EQUALS true`-not-`= false` rule. A drifted set (e.g. after a stage addition without a retrofit)
  fails R2.
- Both live on the tooling side — see the companion `cortex-gateway-service/docs/followup-mcp-spec.md`.

When someone reports a duplicate or a silent no-fire, run conformance **before** writing symptom SQL.
The model is the source of truth; the check tells you exactly which rule the live config violates.

---

## See also

- `prompt-antipatterns.md` — "Agente sin infraestructura de activación" (empty `propertyConditions`,
  non-gating terminal property) names the symptom class this doc's architecture prevents; and the
  Cat 1 rule that follow-up timing never belongs in the prompt.
- `custom-properties.md` — `check_until_match` terminal-freeze doctrine (§9 above cites it).
- `platform-internals.md §3a/§3b` — NULL-condition silent block + `needs_attention` block (the best
  FU-diagnostic material in the repo, with named production casualties).
- `workflows.md` — the workflow engine, condition operators, and the anchor/stagger knobs.
- `templates/follow-up-sequence.md` — a copy-paste N-stage skeleton built to this doctrine.
- `outbound-whatsapp-playbook.md` / `post-booking-outreach.md` — the 24h-window template chain.
</content>
</invoke>
