Hooks & Workflows: per-app requirements & how to add them
Audience: DRO platform admins and integrators who configure entity behaviour through the admin console. Scope: how lifecycle hooks, declarative EventRules, and stateful DroWorkflows fit together, how to attach each to an entity, and a per-app starter set you can copy from today.
1. The 4-layer automation model
Every entity in DRO can carry automation at four distinct layers. They differ in where they run, what they can do, and whether they can block a write. Picking the right layer is the single most important decision — putting routing logic in a hook or putting a field default in a workflow both create pain later.
| Layer | What it is | Runs… | Can block the write? | State across calls? |
|---|---|---|---|---|
| 1. Lifecycle hooks | Named handlers bound to an entity's create/update/delete moments via template_overrides.controller.hooks. Live in a *_hooks.ex registry, registered into DroCore.HookEngine.Registry. | Synchronously (BEFORE) or fire-and-forget (AFTER) inside the entity write path. | Yes — a before* hook returning {:error, reason} aborts the write. | No (single write only). |
| 2. EventRules | Declarative event_rules rows: when entity X does Y under condition Z, take action A. Edited at /admin with no deploy. Evaluated by EventRuleEngine off EventBus events. | Asynchronously, after a write emits an event. | No — reacts, never blocks. | No (single reaction). |
| 3. DroWorkflows | gen_statem-backed FSM specs: multi-step, multi-actor, time-spanning processes with human-task checkpoints, timers, retries, and rollback. | As a supervised long-running process. | N/A — owns its own state machine. | Yes — durable, resumable. |
| 4. FSM specs (building block) | fsm_specs rows defining the legal state graph an entity's status field may walk (e.g. draft→sent→completed). Hooks and workflows both enforce these. | Invoked from a hook (DroCore.FSM.transition/4) or driven by a workflow. | Indirectly (a hook calling transition/4 blocks on an illegal jump). | The state is the entity row. |
How they chain
A typical end-to-end flow uses all four:
admin/API write
└─► [1] beforeCreate hook → normalize + validate (can ABORT)
└─► row persisted
└─► [1] afterCreate hook → emit entity:created on EventBus (fire-and-forget)
└─► [2] EventRule → "if amount > threshold, start approval"
└─► [3] DroWorkflow → human-task checkpoint, timers, rollback
└─► uses [4] FSM spec to walk legal statesThe decision rule of thumb
- Field default, normalization, validation, referential guard, "must never persist" → Layer 1 hook (BEFORE).
- Cheap cross-entity reaction, notification, projection, single declarative "when→then" → Layer 2 EventRule.
- Multi-step, spans time/days, needs human approval, retries, or rollback → Layer 3 DroWorkflow.
- A status field that must only move along legal edges → define a Layer 4 FSM spec, then enforce it from a hook.
2. Attaching a lifecycle hook to an entity
Hooks are bound through the entity's template_overrides.controller.hooks array. Each element names the lifecycle moments it fires on and the registered handler to call.
2.1 The binding shape
{
"template_overrides": {
"controller": {
"hooks": [
{ "events": ["create", "update"], "handler": "set_timestamps" },
{ "events": ["create"], "handler": "normalize_e164_did" },
{ "events": ["update"], "handler": "guard_did_status_transition" },
{ "events": ["delete"], "handler": "emit_did_lifecycle_event" }
]
}
}
}eventsare the lifecycle points:create,update,delete. The engine resolves these tobeforeCreate/afterCreateetc. based on the handler's declared phase.handleris the registered name — a string key inDroCore.HookEngine.Registry, never a raw MFA in this array.- Handlers run in array order within a phase. Put normalization before validation before event emission.
2.2 BEFORE vs AFTER semantics
| Phase | Behaviour | Use for |
|---|---|---|
before* | Awaited. Return {:ok, changeset} to proceed or {:error, reason} to abort the write. | Normalization, validation, referential/cardinality guards, FSM-transition guards, derived fields that must be server-set. |
after* | Fire-and-forget via Task.Supervisor. Cannot block. | Event emission, audit-trail writes, projections/rollups, notifications, cache busts. |
Hard rule: anything that must never reach the database (plaintext secrets, unbalanced ledgers, illegal status jumps, over-allocations) goes in a BEFORE hook. AFTER hooks are observability and side-effects only.
2.3 The four steps to add a new hook
A handler must exist in all of these places or it is unreachable from one code path:
- Write the handler in the app's
*_hooks.exregistry module (mirror the closest existing one — e.g.cloudpbx_telecom_hooks.ex,finance_hooks.ex,hr_hooks.ex). - Register it by name:
DroCore.HookEngine.Registry.register("normalize_e164_did", &MyHooks.normalize_e164_did/2)(typically fromapplication.exor the registry module'sregister_all/0). - Bind it on the entity by adding the entry to
template_overrides.controller.hooks(via the admin entity-definition CRUD surface — not a seed file; the DB is the source of truth). - Verify the binding takes effect: edit the entity through the admin UI and confirm the handler fires (a
Loggerprobe is reliable since Ecto query logging is off in dev).
2.4 Reuse the builtins before writing new ones
The HookEngine ships builtin handler families you can bind directly without code: set_timestamps, set_updated_at, normalize_email, trim_strings, generate_slug, soft_delete, publish_entity_event, audit_log, plus the guard families validation_hooks, state_transition_hook, unique_constraint_hook, geo_validation_hooks, revenue_hooks. Prefer composing these over hand-rolling a one-off.
3. EventRule vs DroWorkflow — when to use which
Both react to entity events. The dividing line is state and time.
Use an EventRule when the reaction is…
- Single-step and stateless — one condition, one action, done.
- Cross-entity but immediate — "on
deals:updatedtowon, mark the linked quote accepted and notify finance." - Tenant-tunable policy — thresholds, cadences, routing targets that admins change at
/adminwithout a deploy (fraud thresholds, low-stock levels, auto-approve limits, dunning buckets). - A projection or cache-bust — "on
cc_agent_skills:updated, publishacd.skills_changedto bust the router cache."
EventRules are the right home for anything you'd otherwise hardcode in a controller. They are declarative, editable, and per-tenant.
Use a DroWorkflow when the process…
- Spans multiple steps that hold state between them (an order walking pending→paid→shipped→delivered).
- Spans time — days or weeks (number porting, AR dunning ladder, retention aging, fixed-deposit maturity).
- Has human-task checkpoints — approvals, reviews, sign-offs, dispute resolution.
- Needs retries, timers, dead-lettering, or rollback — provisioning with backoff, compensating teardown on a failed step.
- Has branches and terminal states — won/lost, approved/denied, completed/declined/voided/expired.
The litmus test
If you can express it as "WHEN this single event AND this condition, THEN do this one thing" → EventRule. If you need the words "then wait," "then a human approves," "retry," or "if step 3 fails, undo step 2" → DroWorkflow.
They compose
The common pattern is an EventRule that triggers a DroWorkflow:
invoice.overdue (event)
└─► [EventRule] "amount_due > 0 AND past due"
└─► starts [DroWorkflow] ar_dunning_collections
day7 reminder → wait → day14 firm → wait → day30 final
→ human_task owner_review → {escalate | write_off | paid}The EventRule is the cheap, tunable trigger; the workflow carries the multi-week state. Don't model a dunning ladder as five EventRules — you'll have no shared state and no terminal.
4. Per-app quick reference — recommended starter hooks & workflows
The table below is the P0/P1 starter set distilled from a per-app audit. "P0" = ship-first integrity/security/financial guards; "P1" = the stateful processes that build on them. Bind the hooks first (they protect the write boundary), then add the EventRules and Workflows.
| App | P0 starter hooks (BEFORE unless noted) | Key EventRules | Starter DroWorkflows |
|---|---|---|---|
| cloudpbx | normalize_e164_did, enforce_did_uniqueness, guard_did_status_transition, validate_assignment_target (did_assignments); escalate_fraud_signal (afterCreate) | SIP brute-force → fraud_signal; toll-fraud (high-risk prefix/velocity) → throttle; DID released → quarantine aging | fraud_throttle_response, number_porting_orchestration, device_provisioning |
| crm | validate_lead_required, dedup_lead_by_email, derive_weighted_amount (deals), quote_status_and_expiry, contact_pii_normalize_consent | round-robin lead routing; lead→opportunity on qualified; close-won → quote+billing handoff; contact deleted → PII erasure cascade | lead_lifecycle_fsm, opportunity_pipeline_progression, quote_approval_and_send |
| hr | hr_employee_block_hard_delete, hr_employee_pii_audit_trail (afterUpdate), hr_payslip_compute_net, hr_leave_balance_check | leave routing above auto-approve threshold; contract-expiry warning (60/30/14/7d); accepted offer → create employee | employee_onboarding, employee_offboarding, leave_approval |
| finance | validate_double_entry_balanced + block_posted_journal_mutation (journals), validate_payment_allocation + apply_payment_to_documents, compute_invoice_totals + guard_posted_invoice_immutable, normalize_and_dedup_bank_txn | invoice.posted → send PDF; invoice.overdue (7/14/30) → dunning; payment.applied → mark paid; period-close prereq gate | ar_dunning_collections, ap_invoice_to_pay, bank_reconciliation, fiscal_period_close |
| banking | validate_transaction_amount_and_currency, generate_transaction_reference, enforce_transaction_immutability, emit_ledger_double_entry (afterCreate), validate_transfer_funds_and_limits | AML structuring/watchlist → alert; loan DPD buckets → reclassify; KYC expired → restrict account | customer_onboarding_kyc, loan_origination_to_disbursement, aml_sar_investigation |
| bss | normalize_msisdn_imsi_iccid, enforce_unique_msisdn_imsi, subscriber_status_transition_guard (KYC gate), mask_pii_on_subscriber, validate_payment_reference, invoice_status_transition_guard | captured payment → reconcile invoice + lift credit hold; topup completed → credit wallet; invoice overdue → open dunning | subscriber_onboarding_provisioning, dunning_and_suspension, mnp_porting_orchestration, billing_cycle_run |
| hivemind | classify_spend_request_decision + guard_spend_approval_authority, enforce_work_item_fsm_transition (stories), redact_and_hash_llm_payload | standing breached → suspend agent; presence stale → reap + release batons; LLM spend > budget warn% → throttle | agent_spend_approval, work_item_lifecycle, llm_budget_dunning |
normalize_mailbox_address, enforce_mailbox_unique_per_domain, guard_mailbox_freeze_transition, block_delete_with_undelivered_or_legal_hold, normalize_app_password_and_set_expiry | hard bounce → auto-suppress; abuse reports ≥N → blocklist; compromise freeze → kill sessions | compromised_mailbox_containment, domain_onboarding_verification, tenant_email_offboarding | |
| sign | sign_event_append_only_guard (reject all update/delete), sign_recipient_seal_signature_evidence, sign_request_guard_terminal_and_transition, sign_document_validate_and_hash | completed → notify + project signed doc; declined → notify sender + recovery; viewed-no-sign → nudge | signer_reminder_and_expiry_sweep (P0 — nothing drives expiry/reminders today), envelope_signing_lifecycle, audit_certificate_generation |
| meet | meet_session_status_transition_guard, meet_session_lifecycle_emit (afterUpdate), meet_recording_ready_dispatch (afterUpdate), meet_room_secure_defaults (use crypto.strong_rand_bytes), meet_recording_soft_delete_guard | scheduled → invite+reminder; ended+recording → AI pipeline; recording ready → compliance fan-out + host notify | meeting_recording_finalization, recording_retention_lifecycle, recurring_meeting_provisioning |
| contact-center | cc_validate_queue_config (reject self-overlap), cc_queue_config_reload (afterUpdate), cc_validate_campaign_window, cc_dialer_dnc_scrub, cc_agent_referential_guard (soft-delete) | SLA breach → notify supervisor + overflow; failing QA → coaching task; detractor CSAT → recovery callback | agent_onboarding_provisioning, outbound_campaign_lifecycle, quality_evaluation_dispute, callback_fulfillment_sla |
| messaging | record_message_edit_audit (afterUpdate), soft_delete_message_redact, scrub_message_pii_and_links, conversation_status_transition_guard, validate_bot_webhook_and_secret (no SSRF) | inbound → auto-create + route conversation; keyword/sentiment → priority; bot pattern → signed webhook | customer_conversation_sla, bot_webhook_delivery, contact_consent_and_optout, attachment_av_scan_gate |
| oss | oss_change_request_approval_gate (no self-approve) + oss_change_request_audit_trail (afterUpdate), oss_sim_state_transition_guard, oss_msisdn_validate_and_reserve, oss_alarm_normalize_and_stamp | critical alarm → auto-open incident; pool ≥85% → depletion alarm; QoS breach → SLA alarm | sim_activation_provisioning, incident_lifecycle_mttr, change_request_cab_approval, number_portability_port_in |
| admin | validate_entity_definition_integrity + invalidate_entity_resolution_cache (afterUpdate) + guard_entity_definition_referential, validate_canonical_view_template_exists (amer_surfaces), emit_rbac_audit_and_bust_policy_cache (role_permissions), hash_service_account_secret_and_scope | privileged RBAC change → security alert + approval; high-priv/non-expiring credential → SIEM; flag→100% → deploy-class audit | platform_promotion, privileged_access_approval, credential_rotation, config_change_approval |
| workspace | validate_wireguard_pubkey, allocate_peer_overlay_ip, guard_active_session_on_peer_delete, guard_ip_pool_overlap, endpoint_compliance_enforcer (afterCreate, rebind orphaned waggle_*) | non-compliant (soft) → remediation task; quota 100% → throttle tunnel; member deleted → revoke peer+IP+endpoints | member_onboarding, member_offboarding, invoice_dunning, endpoint_remediation |
| infra_ops | encrypt_and_mask_secret + secret_referential_guard, golden_image_referential_guard, normalize_drift_finding + drift_dedup_guard, control_finding_state_guard | critical/high drift → notify on-call + open POA&M; fail2ban ban → threat_indicator; secret rotation due (14d) → start rotation | infra_drift_reconciliation, secret_rotation, cab_change_approval, compliance_finding_remediation |
| ecommerce | compute_order_totals (price-tamper guard), guard_order_status_transition, guard_non_negative_stock (oversell), validate_refund_amount (over-refund), order soft_delete_set_deleted_at | order created → decrement inventory; delivered → LTV + review request; refund completed → restock + reverse spend; low stock → seller alert | order_fulfillment, returns_rma, seller_onboarding, seller_payout_reconciliation |
| projects | state_transition_hook on projects/stories/sprints, validate_project_dates_and_budget, projects soft_delete_guard, append_decision_audit_trail (key_decisions) | last story done → auto-close epic; spend > 90% budget → risk high + alert; critical task unassigned → notify | sprint_planning_and_close, milestone_gate_review, project_intake_and_provisioning, key_decision_ratification |
| marketing | validate_campaign_invariants, validate_campaign_status_transition, audit_campaign_change (afterUpdate), normalize_template_content (XSS-strip), set_segment_defaults | campaign → running → start analytics worker; campaign completed → publish + freeze scorecard; segment → 0 members → alert | campaign_launch_and_lifecycle, audience_materialization_and_sync, social_lead_to_nurture |
| dealers | generate_po_number_and_totals, compute_order_line_total, guard_partner_status_transition, block_partner_delete_with_balance, freeze_approved_settlement | partner downgraded → rebate clawback; inventory ≤ reorder → replenish alert; PO confirmed → maintain outstanding_balance | partner_onboarding_kyc, settlement_payout_disbursement, channel_order_fulfillment, credit_dunning |
Cross-cutting notes
- PII entities (subscribers, contacts, customers, mailboxes, employees, llm_interactions) need a normalize/mask hook and a soft-delete/erasure path — hard-deleting PII with order/audit FKs is a compliance gap (27701:7.4). Use soft-delete + anonymize, never physical delete.
- Money entities (invoices, payments, journal entries, transactions, settlements, orders) need server-side total computation + a posted-immutability guard. Never trust client-sent totals; corrections happen via reversing/credit documents, not edits.
- Dual-bridged modules (e.g. messaging conversations bridged into chat; oss entities bridged into mvno) require tenant-scoped hooks, not app-scoped — an app-wide hook leaks behaviour across the bridge.
- Bind hooks to the canonical entity_definition, not duplicate module bridges or dashboard
proj_*/legacy copies — otherwise handlers fire inconsistently depending on the write path. - The recurring anti-pattern across nearly every app: rich domain logic exists (CompromiseDetector, AutoProvisioner, agent_state GenServer, Retention, ComplianceWebhook) but is invoked imperatively from REST/Oban paths and not bound to the entity lifecycle — so admin-CRUD and API writes bypass all of it. The fix is almost always to wire existing modules in as named HookEngine handlers, not to write new logic.