Dock Blocks, lifecycle
1 October 2026
One journey, defined as data, executed durably, sent through the gate, with events coming back. Done is a rung the catalogue already defines and nothing has ever reached. Composition is a separate problem and is not in here.
Part 1
One real journey, defined as data, executed by DBOS, sent through the gate, with events coming back and stopping the sequence for people who reply or unsubscribe. Not a framework. One journey, running.
# A journey, as a file. Shape now settled by the journey-as-data research
# rather than sketched: string ids, string-reference edges, no coordinates,
# content as a POINTER, conditions as expressions.
id: dormant-winback-2026
client: dockblocks # a parameter, not an assumption
published: 2026-10-01 # version by publish event, audience pinned per wave
entry: # FOUR separate controls, not one. Braze's own
# entry screen splits these, and conflating them
# is how "why did they get it twice" happens.
audience: audiences/gate1_pilot # WHO may enter. A pinned ref, not an inline list
when: 'last_order_at < now - 365d' # WHAT fires. Suppression is not expressed here
reentry: never # may someone who finished start again
limit: 150 # entrance VOLUME. Gate 1 is a pilot, so say so
suppression: # FIRST CLASS, not a branch condition.
shared_with: [brevo, mailtrap] # one truth or a person hears from the other
stops: [replied, unsubscribed, converted, stop_status]
nodes: # edges are string references, like Braze next_paths
- id: touch-1
type: message
content: blocks/winback/touch-1 # a pointer. Copy lives with the blocks, not here
tag: winback-2026.touch-1 # PER NODE, not per campaign. Brevo reports by
# campaign, so without a tag per step nobody can
# say which STEP earned the open. An untagged
# message is permanently unattributable.
absent: abort # this person only, journey continues
next: wait-5d
- id: wait-5d
type: wait
for: 5d
next: did-they-open
- id: did-they-open
type: decision # an EVENT inside a WINDOW, not a state read.
event: opened # Mautic names the two outcomes yes and no and
of: touch-1 # records which path a person took (decisionPath).
within: 5d # Without the window this silently means "ever".
yes: touch-2a
no: touch-2b
# A BRANCH is the other thing, and conflating them is the trap: a branch reads
# STATE right now, first match wins, so the ORDER of its paths IS the rule.
# - id: which-tier
# type: branch
# paths: [{when: 'ltv > 5000', next: vip}, {default: standard}]
- id: touch-2a { type: message, content: blocks/winback/touch-2a, next: done }
- id: touch-2b { type: message, content: blocks/winback/touch-2b, next: done }
- id: done { type: exit }
# No holdout node: a holdout is a branch with an empty path, which is what every
# vendor checked actually does. Inventing a primitive for it would be our own idea.
The candidate is win-back. It is already a definition file rather than code, its audience exists, gate 1 is a 150-address pilot stratified across five owners, and it is deliberately plain text. That last part is why it is the right pilot: it proves orchestration without dragging composition in beside it.
Building a fresh journey to prove the system would test the system against the easiest possible case. Win-back is already half-built, already blocked on real things, and already carries the awkward parts: a stratified pilot, a sender identity that is not the CEO's mailbox, merge fields that are blank on every row of the current export.
Explicitly out of scope: a second journey, a composition surface, multi-tenancy, and any editor. The client stays a parameter rather than an assumption, which costs nothing now and is expensive to retrofit, but nothing is built FOR other clients yet.
Part 2
The CDP is delivered and in production. This prototype is not built on an empty foundation, and saying otherwise would misrepresent work that is already carrying the reports everyone reads each morning.
| What it produces | Where | Who consumes it today |
|---|---|---|
| Identity, person_map | assets/identity.py, live 02:30 | Everything downstream inherits person_id. |
| Per-channel consent | assets/consent_events.py, consent_state.py | The eligibility gate, which has no production caller yet. |
| Per-contact stage with dwell | assets/person_lifecycle.py, live 03:00 | Entry and branch conditions would read this. Today nothing does at send time. |
| Attribution marts | gold/lead_cause, deal_cause, order_cause, visits_cause, calls_attributed | pipeline_aging.py, and the attribution question Matt raised when revenue fell by half. |
| Call and web activity | gold/call_activity, person_web_activity | build_rep_send_examples.py and build_rep_daily_summary.py, the rep digests that send every morning. |
| Bronze sources | Apollo, Brevo, GA4, Google Ads, Aircall, eVoice, HubSpot, Search Console, Zoho audit log | The marts above. Roughly 30 assets. |
The CDP computes identity, consent, stage and attribution every night, and our reporting already reads it. What no journey does is CONSULT it at the moment of sending. That is the last mile, and it is the same sentence as the gate having no callers. The prototype is not a new platform on top of nothing, it is the missing call between two things that both already work.
This also reframes what success looks like. A journey that reads person_lifecycle at send time, refuses on consent_state, and writes its outcome back where the next send can read it, is the whole point. Nothing about that requires new infrastructure.
Part 3
This is the decision the prototype actually forces. Today the boundary is implicit and both sides are quietly assuming different things.
| Piece | Repo | Owner | Why there |
|---|---|---|---|
| Journey definitions | dockblocks-data-ops | Njui | Beside the engine that executes them. dormant-winback-2026.yaml already is one. |
| The durable engine | dockblocks-data-ops | Njui | DBOS. Per-person execution, waits and branches that survive a restart. |
| The CDP: identity, consent, stage, gold | dockblocks-data-ops | Njui | person_map at 02:30, consent_state, person_lifecycle at 03:00. Every entry and branch condition reads from here. The example's last_order_at comes from gold, nowhere else. |
| Post-send events | dockblocks-data-ops | Njui | Mailtrap webhook, persisted first, then fires a run. Unsub, reply and click stop the sequence for that person. |
| Audience and segment definitions | dockblocks | Pivot | They read Zoho and the gold layer, and they are what a new client would need rewritten first. |
| The send gate | dockblocks | Pivot | Eight refusal conditions. The thing that says no. |
| Blocks and tokens | dockblocks | Pivot | Already built, measured from delivered mail. |
docs/handoff/cdp-loop-handoff.html, written 2026-09-07 for Njui and verified against origin/main 31b6b89, already says buy nothing and already names the gate with no callers. Its status line still reads not started, nothing built. Its four steps are: give findings a durable sink, give eligible() its first caller, build the send-outcome ledger, add Apollo as a bronze source. Steps 1 and 2 are PRECONDITIONS for this prototype rather than alternatives to it: a journey that cannot refuse a suppressed person is not a journey worth running. This document does not supersede that one. If the two ever disagree, that handoff is the older and more carefully evidenced claim.
Two senders, one guarded. Our eight refusals live in brevo_send.py. Mail leaving through Mailtrap does not pass them. Suppression has to be shared truth or a person who unsubscribes from one keeps hearing from the other. The shared eligibility gate that would solve it exists in data-ops, is tested, is validated against real data, and has no production caller.
Part 4
If adding a journey is not write one file, open a pull request, then watch it run, the prototype has not worked.
Part 5
Not invented here. The catalogue already defines the bar, and nothing in 62 rows has ever reached it.
| Check | What it asks |
|---|---|
trigger_right | fires on the intended event only, no double-fire |
audience_scoped | excludes converted, opted-out and returning |
content_renders | merge tokens resolve, copy editable rather than image-locked |
stop_exit_honored | stop-status, convert and Apollo-stop actually halt sends |
tracking_flows_back | sends, clicks and replies land in the Zoho record |
The prototype is done when ONE journey is VERIFIED: all five true and monitoring closed. That is a definition we did not choose, cannot argue with, and have never met.
Across all 62 rows, content_renders is false 62 times with no row ever true, and tracking_flows_back is false 62 times with no row ever true. The other three have each been answered true somewhere. A check false 62 times out of 62 with no exception is not a measurement of 62 failures, it is a question nobody asked. The booleans have no third state, so unmeasured and failed look identical. Fixing that comes first, or the VERIFIED count stays meaningless.
Part 6
Written down so it does not get built by accident, and so a later session does not read the gaps as an oversight.
Part 7
Nothing here is a research question. Each is a decision somebody has to make.
| Open question | Held by | Blocks |
|---|---|---|
| The sending domain for win-back | Alecia | The pilot send. m.dock-blocks.com is a Mailtrap staging identity with no SPF or DKIM, and under adkim=r it authenticates as the client's root domain. |
| Where suppression truth lives across two senders | Alecia with Njui | Everything. This is the one that cannot be deferred. |
| Whether fork-forward is allowed as an in-flight policy | Njui | Nothing yet. Every canvas defaults to drain until the ledger question is answered, because a fork issues a new workflow id. |
| The journey definition format | Pivot, informed by research in flight | The first file. Everything hangs off it. |