Pivot

Dock Blocks, lifecycle

The orchestration prototype, scoped before it is built

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.

62
programs catalogued
36
live right now
0
ever reached VERIFIED
1
journey defined as data

Part 1

1What the prototype is

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.
What a person reviews. Not code, not a canvas: the definition itself, legible enough to say what somebody will receive without opening a renderer.

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.

Why this one and not a new one

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

2What already exists, and is already in use

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 producesWhereWho consumes it today
Identity, person_mapassets/identity.py, live 02:30Everything downstream inherits person_id.
Per-channel consentassets/consent_events.py, consent_state.pyThe eligibility gate, which has no production caller yet.
Per-contact stage with dwellassets/person_lifecycle.py, live 03:00Entry and branch conditions would read this. Today nothing does at send time.
Attribution martsgold/lead_cause, deal_cause, order_cause, visits_cause, calls_attributedpipeline_aging.py, and the attribution question Matt raised when revenue fell by half.
Call and web activitygold/call_activity, person_web_activitybuild_rep_send_examples.py and build_rep_daily_summary.py, the rep digests that send every morning.
Bronze sourcesApollo, Brevo, GA4, Google Ads, Aircall, eVoice, HubSpot, Search Console, Zoho audit logThe marts above. Roughly 30 assets.
So the gap is narrower and more specific than it looked

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

3Where it lives, and who owns which half

This is the decision the prototype actually forces. Today the boundary is implicit and both sides are quietly assuming different things.

PieceRepoOwnerWhy there
Journey definitionsdockblocks-data-opsNjuiBeside the engine that executes them. dormant-winback-2026.yaml already is one.
The durable enginedockblocks-data-opsNjuiDBOS. Per-person execution, waits and branches that survive a restart.
The CDP: identity, consent, stage, golddockblocks-data-opsNjuiperson_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 eventsdockblocks-data-opsNjuiMailtrap webhook, persisted first, then fires a run. Unsub, reply and click stop the sequence for that person.
Audience and segment definitionsdockblocksPivotThey read Zoho and the gold layer, and they are what a new client would need rewritten first.
The send gatedockblocksPivotEight refusal conditions. The thing that says no.
Blocks and tokensdockblocksPivotAlready built, measured from delivered mail.
This is not the first plan, and the earlier one is better on two steps

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.

The seam that is currently open

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

4How we use it

If adding a journey is not write one file, open a pull request, then watch it run, the prototype has not worked.

  1. Write the definition. A file in the campaigns directory: entry, audience, steps, waits, branches, exits, sender. The format comes out of the journey-as-data research, not from this document.
  2. Review it. A human reads the definition, not the code. That is the test of whether the format is legible: Alecia should be able to tell what a person will receive without opening a renderer.
  3. It runs. DBOS executes per person. A deploy does not silently strand anyone, because the version-mismatch sensor says when workflows are stuck on an old application version.
  4. Events return. Mailtrap webhooks land, are persisted, then fire a run that updates state. A reply or an unsubscribe stops the sequence for that person.
  5. State is reportable. The catalogue row for that journey moves up its own ladder, and the five checks are answered rather than assumed.

Part 5

5How we know it worked

Not invented here. The catalogue already defines the bar, and nothing in 62 rows has ever reached it.

CheckWhat it asks
trigger_rightfires on the intended event only, no double-fire
audience_scopedexcludes converted, opted-out and returning
content_rendersmerge tokens resolve, copy editable rather than image-locked
stop_exit_honoredstop-status, convert and Apollo-stop actually halt sends
tracking_flows_backsends, 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.

Two of the five have never been asked of anything

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

6What we are not building

Written down so it does not get built by accident, and so a later session does not read the gaps as an oversight.

  • A composition surface. That is the next problem, not this one, and the editor research priced it: nothing self-hostable has both block locking and MJML round trip, and the real candidates run $3,000 to $14,400 a year.
  • Multi-tenancy. The client is a parameter. Nothing is built for a second client until a second client exists.
  • A replacement for Brevo. Lifecycle keeps leaving from Brevo. Mailtrap carries volume so the Brevo reputation is not the thing at risk.
  • A canvas UI. A journey is a file a person can read. If the file is not legible, a diagram over the top of it will not fix that.
  • Anything that moves live programs. Thirty-six are running and none of them is waiting for this.

Part 7

7Open, and who holds each one

Nothing here is a research question. Each is a decision somebody has to make.

Open questionHeld byBlocks
The sending domain for win-backAleciaThe 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 sendersAlecia with NjuiEverything. This is the one that cannot be deferred.
Whether fork-forward is allowed as an in-flight policyNjuiNothing yet. Every canvas defaults to drain until the ledger question is answered, because a fork issues a new workflow id.
The journey definition formatPivot, informed by research in flightThe first file. Everything hangs off it.