Project 03 · lifecycle-lint

The defect lives
in the overlap.

Each flow passes review on its own. Together, two of them reach the same audience and charge a weekly pressure of 9.5 to the same contact, against a ceiling of 6.0. That sum appears in neither file.

Sections on this page

12 rules of CRM operation, not of software engineering
2 portfolio rules, which read the set of journeys
21 tests, each rule with a positive and a negative case
0 model calls in the diagnosis

How these numbers are counted: twelve rules is what lifecycle-lint rules prints, each with an id, a severity and a scope. Twenty one is the test count in tests/test_rules.py, which CI runs on Python 3.10 and 3.12. Zero model calls is verifiable in the code, since none of the twelve rules imports an API client and the prose explanation sits behind an optional flag. The single dependency is PyYAML, declared in pyproject.toml.

01

Narrative

01
Problem

A journey with a construction defect keeps running. The flow fires, the messages go out, the report closes with a positive number, and whoever already converted keeps receiving the sequence built for those who did not. The effect shows up weeks later, in unsubscribes and in domain deliverability.

02
Decision

Declare the journey in YAML and audit the file before it ships. Exit criteria, suppression list, frequency cap and control group are four fields the automation interface never asks for, which is why they stay empty. In a declared format, their absence becomes a failure with an id and an exit code.

03
Execution

Twelve rules. Ten read one journey at a time, and two read the portfolio of active journeys. The two portfolio rules are why the tool exists. L011 measures the overlap between audience definitions, L012 sums the weighted weekly pressure those journeys charge to the same contact and compares it with the configured ceiling.

04
Learning

The first version of L011 used the Jaccard index and flagged nothing in the examples. It returned 0.5 on exactly the case that motivated the rule, because one of the sets carries an extra filter. A rule that misses the case that created it is worse than no rule, because it reports green.

02

The audit in action

The same command that runs locally runs in CI. The output stays in Portuguese, the language of the operation the tool was written for, in both versions of this page.

CLI runs

$ lifecycle-lint audit examples/journeys
winback_inativos · Reengajamento de inativos
ERRO L001 Jornada ativa sem critério de saída
ERRO L003 [repete_ate_reagir] Loop sem limite de iterações
ERRO L004 Audiência sem lista de supressão
AVISO L005 Segmento sem decaimento temporal
AVISO L008 Jornada ativa sem grupo de controle
checkout_abandonado · Recuperação de checkout abandonado
ERRO L002 [decide_desconto] Branch sem caminho padrão
ERRO L012 (com winback_inativos) Pressão agregada acima do teto por contato
As jornadas ['checkout_abandonado', 'winback_inativos'] alcançam a mesma audiência e somam pressão semanal de 9.5 (teto: 6.0).
AVISO L011 (com winback_inativos) Jornadas ativas concorrendo pela mesma audiência
5 erro(s) · 12 aviso(s) · 0 info
exit code 1CI barraria o merge

The exit code is 1 when there is an error, which allows it to run in CI and block a merge. The --explain flag does not change the diagnosis: it operates on the already closed result and writes prose to stderr, separate from the stdout that automation reads.

Tacit operational knowledge does not scale through manual review. It scales when it becomes an executable rule, with an id, a severity and an exit code.

03

The defect no file contains

The two portfolio rules read what individual reviews cannot see. Step through the four stages to follow where exit code 1 comes from.

step 1/4
L011 e L012 Duas jornadas passam em revisão isolada, alcançam a mesma audiência, somam pressão semanal de 9.5 contra um teto de 6.0 e o linter devolve exit code 1. winback_inativos reviewed alone: passes checkout_abandonado reviewed alone: passes L011 · escopo portfólio same audience · 1.0 L012 · escopo portfólio weekly pressure 9.5 · ceiling 6.0 exit code 1

Each file passes individual review. Nothing here is wrong when you read one flow at a time.

04

Weekly pressure

Each journey on its own fits under the journey ceiling. Summed over the same contact, they blow past the portfolio ceiling. These are two different limits, and only the second requires reading the set.

portfolio ceiling · 6.0
journey ceiling · 4.0
5,5
4,0
9,5
checkout_
abandonado
winback_
inativos
aggregate
(L012)

scale 0 to 10 · channel weights: email 1.0 · push 1.5 · SMS and WhatsApp 2.0
weekly_pressure = load × 7 / max(duration_days, 7)

A plain message count does not work, because push, SMS and email carry different attention costs. Each channel got a weight reflecting perceived intrusiveness, and journey pressure is the weighted load over a seven day window, with a seven day floor in the denominator. Without the floor, a checkout flow with three touches in 24 hours would read as 21 touches per week, a number that never happens because the journey ends first. False positives are the most common reason a linter gets abandoned, and calibrating that formula took the longest in the project.

.lifecycle-lint.yaml
# limites são decisão de operação, versionados no repositório
max_weekly_pressure: 6.0
max_journey_pressure: 4.0
audience_overlap_threshold: 0.6
holdout_required_above: 5000
disabled_rules: []
downgrade_to_warning: [L007]

Why the limits live in a file: pressure ceiling and overlap threshold are operational decisions, not code constants. In the repository, changing them is a reviewable commit, and switching a rule off leaves a trace instead of becoming a verbal agreement.

05

The calculation decisions

"Inactive for 60 days" and "inactive for 60 days who abandoned checkout" reach the same group of people: the second set is contained in the first. Same numerator, two denominators, two verdicts.

Jaccard index · the default choice
|A ∩ B||A ∪ B|
0,5 below the 0.6 threshold

Divides by the union. Since the contained set is smaller, the union grows and the result falls below the threshold. Green on the very case that created the rule.

Overlap coefficient · what shipped
|A ∩ B|min(|A|, |B|)
1,0 L011 fires

Divides by the smaller set's cardinality. Under containment, the intersection is the smaller set itself, so the coefficient is 1.0 and the rule finally sees the conflict.

06

The twelve rules

They encode CRM operational practice, not software engineering convention. Scope is the column that matters: ten read one journey at a time, and two read the whole portfolio.

ID Scope Rule
L001journeyActive journey with no exit criterion
L002journeyBranch with no default path
L003journeyLoop with no iteration cap
L004journeyAudience with no suppression list
L005journeySegment with no time decay
L006journeyHigh message pressure within the journey
L007journeyIntrusive channel with no quiet hours
L008journeyActive journey with no control group
L009journeySuccess metric missing or a vanity metric
L010journeyRe-entry with no cooldown period
L011portfolioActive journeys competing for the same audience
L012portfolioAggregate pressure above the per contact ceiling

Why only the portfolio rules justify the tool: the first ten could become a review checklist. The last two could not, because they require reading every active journey at once and doing arithmetic no reviewer does in their head. Each rule has a positive and a negative test case, and the negative case exists to hold back false positives.

07

Where the model comes in

The --explain flag sends the already closed findings to an AI assistant and prints a prose summary to stderr, to take into the review meeting. It is optional. The diagnosis is entirely deterministic: none of the twelve rules calls a model, and with no API key the linter runs the same, with the flag reporting that the explanation is unavailable.

An LLM on the critical path of an audit trades a reproducible result for one that varies between runs, and an audit with that property cannot serve as a publication criterion. The criterion I applied: where a rule can be determined, I write the rule. The model comes in at synthesis and at translating the finding into the language of whoever decides.

08

The suite

Three repositories on the same thesis: a customer journey is a versionable artifact. If it can be declared in a file, it can be read, audited and reviewed in a pull request, with history and peer review. Today it lives inside the automation tool's interface, where there is no diff between versions and no record of why that delay is 48 hours.

The output of one is the input of the next, which makes the agent's acceptance criterion objective: the proposed journey has to pass the linter.

01 · Audit
lifecycle-lint

Reads journeys declared in YAML and flags the defect before publication, including the one that only exists between two flows.

This page
02 · Propose

Reads product events and returns the segment brief with the proposed journey, already validated by the linter.

03 · Read

Holds the versioned reading prompts, with verifiable assertions and a scoreboard that measures regression in CI.

09

Stack

View the repository