DockBlocks / Handoff / Njui
The CDP holds state correctly and governs nothing. Findings are detected and then dropped. No send remembers the last one. Four steps close it, and none of them is a new platform.
Alecia asked which open source tool could run DockBlocks outbound and lifecycle marketing end to end, and specifically whether anything tracks what happened today so the next send is not treated as brand new.
Five research lanes covered marketing automation engines, workflow orchestrators, CDP and reverse ETL, and sales engagement tooling. The conclusion is to buy nothing. The platform already exists and you built most of it. What is missing is the loop: the gate has no callers, findings do not survive delivery, and nothing accumulates what our own sends did.
This document is the evidence, the four steps, and an explicit invitation to reject the order I put them in.
Verified on origin/main, not the local checkout. Everything in this table is live unless marked otherwise.
| Capability | Where | State |
|---|---|---|
Identity resolution, person_map | dagster_project/assets/identity.py | live 02:30 |
| Per-channel consent | assets/consent_events.py, consent_state.py | live |
| Per-contact stage with dwell time | assets/person_lifecycle.py | live 03:00 |
| Fail-closed send gate | src/dockblocks_ops/eligibility/gate.py | zero callers |
| Outbound pipeline, six stages, traceable | pivot-outbound/core/pipeline.py | built, dormant |
| Per-person send memory | dockblocks/.logs/comms_ledger.jsonl | live, 338 rows |
| Unsubscribe watchdog | scripts/monitoring/send_watchdog.py | live, 15 min |
| Apollo in the warehouse | no asset, no resource, no connector | absent |
person_lifecycle is the piece every vendor on the shortlist was being evaluated to provide. The five-rung Zoho ladder with person_deal_age_days, is_recent_proposal and stage_skip_pattern, computed against an explicit as_of clock rather than wall time, is dwell-time-aware per-contact state. The as_of parameter is the better design because it makes state reproducible: you can ask what a contact's stage was last Tuesday, which no live-updating journey engine can answer.
Solid lines run today. Dashed red lines are the four things that do not exist. The whole build is visible in one picture: three connections and one source.
eligible(person_ids, channel, as_of) has zero callers anywhere. Not in dockblocks, not in dockblocks-data-ops. On origin/main the only matches are its own definition and its own error strings.
Meanwhile three rival functions of the same name run their own suppression logic:
scripts/brevo/lifecycle_campaigns.py:195 def eligible(p)
scripts/brevo/lifecycle_campaigns.py:242 def eligible(p)
scripts/brevo/sms_drip.py:410 def eligible(contact, seen, now)
A gate with no callers and a gate that always returns true are indistinguishable from outside. Only the call graph separates them.
gate-integrity ran today, found a real breach, enforced a denominator floor correctly, and then lost the result:
BREACH government_sector 413 rows (floor 489)
-> 76 of 489 government leads have no sector
UNKNOWN downstream_seams 14 rows (floor 16)
[deadman] scheduled jobs: 39 healthy, 0 DEAD, 1 unchecked
ops_alert: every token failed (CLICKUP_API_TOKEN: <urlopen error
[Errno 8] nodename nor servname provided, or not known>); alert dropped
government_sector breach is still unread. Step 01 is the fallback that makes detection survive delivery.Gold assets, person_lifecycle included, write a single part-0000.parquet that is overwritten every run. No asset reads its own prior output. brevo_campaigns_bronze is a full snapshot with no history retained, so campaign trend requires diffing something that is not kept.
change_events and consent_events do accumulate, but they diff what Zoho and Brevo changed to. They are not a record of what this pipeline decided. Rep_Worklist is the single place the pipeline writes its own decision back, and by its own docstring it is a same-day snapshot with stale rows deleted each run.
In pivot-outbound, the Drop record whose docstring reads "Why one lead did not make it. Kept, never discarded" is written only when --drops-out is passed, and the file is opened in "w" mode. Nothing reads a prior run.
On the dockblocks side the same habit shows as 17 separate merge_audit_YYYY-MM-DD.jsonl files and roughly a dozen timestamp-named run files containing zero rows.
No bronze asset, no resource, no connector. Cold outbound sequence and reply state is invisible next to a person's Zoho, Brevo and Shopify state, which means half of outbound cannot be reasoned about at person grain.
Four steps. The ordering claim is that 1 and 2 make the system honest about the present, 3 and 4 make it remember, and the read surface comes last. Doing the read surface first produces another console over state that nothing governs and nobody records.
Add an append-only sink to ops_alert so a failed delivery lands in a findings file that the next run reads, instead of vanishing. Smallest change here, and a precondition for the rest, because every step below produces findings that must survive.
gate-integrity drop produces a retained recordSELFTEST=1, so the suite never pages anyoneeligible() its first callerWire zoho_to_brevo.py and campaign_kit to the real gate and retire the three rival eligible() functions. Highest value per line changed in the whole review.
eligibility.gate.eligibleOne append-only row per send: what went out, to which segment, from which sending domain, under which gate decision, and what came back at 24 hours and 7 days. This is Stage 08 of the outbound console, the one labelled "learn: what actually happened".
It belongs in the warehouse as a partitioned asset rather than in .logs/. Bronze already retains daily partitions; .logs/ is where 17 dated files and a dozen empty ones went to be forgotten. Make --drops-out mandatory and appending in pivot-outbound so the drop reasons feed it.
Sequence state, enrolment, bounces and replies at person grain, partitioned daily like brevo_engagement_bronze. pivot-outbound's six-stage spine and its trace(email) are the natural producer.
Read docs/workflows/apollo-engagement-routing-as-built.md before starting. It records several traps that cost days, including that activity_feed and contact_stages live on api.apollo.io/api/v1 and not /v1, where a wrong base path 404s exactly like a missing feature.
person_idOne acceptance test, and it is already written down in the code you would be extending:
Trace one lead from source to enrolled, including why the ones that dropped were dropped, is not a nice-to-have, it is how you tell a working machine from a script that printed a number. pivot-outbound/core/pipeline.py
Concretely: given one email address, return the resolved identity, the current stage and how long it has been held, every send that person received and the gate decision that permitted it, what came back, and what we concluded. Today that answer does not exist in any single place.
You know the warehouse better than I do, and this sequencing was derived from outside it. If steps 3 and 4 are cheaper together, or if the send-outcome ledger is better modelled as an extension of change_events than as a new asset, say so and we take your order instead. The same applies if you think the durable-findings sink belongs in the pipeline rather than in ops_alert.
What is not open to challenge is the goal: the next send must begin knowing what the last one did, and a finding must survive its own delivery.
main in dockblocks-data-ops is 507 commits behind origin/main, dated 2026-06-16 against 2026-09-06. Anything read from the working tree is three months out of date. This produced one wrong conclusion during the review, corrected only by reading the remote.rep-digest-deadman and gate-integrity return non-zero because they found something, which is the intended contract. Do not "fix" them. The defect is the dropped delivery in step 1, not the exit code.| Tool | Category | Why not |
|---|---|---|
| Dittofeed | Journey engine | Best architectural match and upstream is dormant. Last commit to main 2026-03-27, zero commits in 90 days, newest release still alpha, August community PRs unmerged. |
| Laudspeaker | Journey engine | Dead. Acquired by PostHog, 1 commit in 90 days. |
| Mautic | Journey engine | Genuinely live and GPLv3, but adopting it means a second marketing platform beside Brevo and a second sending reputation on an account already at strike two. |
| Keila, Listmonk, phpList | Broadcast | Broadcast only. No per-contact journey state, so they cannot hold a lifecycle gate. |
| n8n | Orchestrator | Strong fit on connectors and observability, but fair-code licensed rather than OSI, and redundant with Dagster which is already deployed. |
| Windmill, Temporal, Airflow, Dagster-alternatives | Orchestrator | Either engineer-only with no visual layer, or batch-DAG shaped and wrong for per-entity lifecycle tracing. |
| RudderStack, Jitsu, Snowplow, PostHog | CDP / events | All move or report data. None decides and holds lifecycle state, which is the part already solved here. Snowplow relicensed to SLULA 2024-01-08 and is no longer OSI open source. |
| Airbyte, Meltano, Multiwoven | EL / reverse ETL | Plumbing only. Their notion of state is a sync cursor. |
| Grouparoo | Reverse ETL | Archived 2022-04-12. |
| Apollo replacements | Sequencer | No credible open source sequencer exists. Every real one is closed SaaS. Keep Apollo. |
The one genuinely unowned gap outside this build is a rep reply surface, where a rep can answer a prospect in thread and have the outcome written back to the CRM. Chatwoot (MIT) is the leading candidate and needs a webhook bridge either way. That is a separate decision, not part of these four steps.
Every assertion above came from these. Run them rather than trusting the prose.
# the checkout is stale, read the remote
git -C dockblocks-data-ops fetch origin
git -C dockblocks-data-ops rev-list --count main..origin/main
git -C dockblocks-data-ops log origin/main --since=2026-06-09 --oneline | wc -l
# the gate has no callers
git -C dockblocks-data-ops grep -n 'eligible(' origin/main -- '*.py' \
| grep -v 'def eligible\|test\|analysis/'
grep -rn 'from dockblocks_ops\|eligible(' dockblocks/scripts/webhooks/zoho_to_brevo.py
# the rival gates
grep -rn 'def eligible' dockblocks/scripts/brevo/
# the dropped finding
tail ~/Library/Logs/pivot/gate-integrity.err
# per-run files that were never read back
ls dockblocks/.logs/merge_audit_*.jsonl | wc -l
# drops are discarded unless a flag is passed, and overwritten when it is
grep -n 'drops_out' pivot-outbound/jobs/source_and_verify.py