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.

LayerWhat it isRuns…Can block the write?State across calls?
1. Lifecycle hooksNamed 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. EventRulesDeclarative 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. DroWorkflowsgen_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 states

The 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 rollbackLayer 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" }
      ]
    }
  }
}
  • events are the lifecycle points: create, update, delete. The engine resolves these to beforeCreate/afterCreate etc. based on the handler's declared phase.
  • handler is the registered name — a string key in DroCore.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

PhaseBehaviourUse 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:

  1. Write the handler in the app's *_hooks.ex registry module (mirror the closest existing one — e.g. cloudpbx_telecom_hooks.ex, finance_hooks.ex, hr_hooks.ex).
  2. Register it by name: DroCore.HookEngine.Registry.register("normalize_e164_did", &MyHooks.normalize_e164_did/2) (typically from application.ex or the registry module's register_all/0).
  3. 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).
  4. Verify the binding takes effect: edit the entity through the admin UI and confirm the handler fires (a Logger probe 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:updated to won, mark the linked quote accepted and notify finance."
  • Tenant-tunable policy — thresholds, cadences, routing targets that admins change at /admin without a deploy (fraud thresholds, low-stock levels, auto-approve limits, dunning buckets).
  • A projection or cache-bust — "on cc_agent_skills:updated, publish acd.skills_changed to 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.

AppP0 starter hooks (BEFORE unless noted)Key EventRulesStarter DroWorkflows
cloudpbxnormalize_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 agingfraud_throttle_response, number_porting_orchestration, device_provisioning
crmvalidate_lead_required, dedup_lead_by_email, derive_weighted_amount (deals), quote_status_and_expiry, contact_pii_normalize_consentround-robin lead routing; lead→opportunity on qualified; close-won → quote+billing handoff; contact deleted → PII erasure cascadelead_lifecycle_fsm, opportunity_pipeline_progression, quote_approval_and_send
hrhr_employee_block_hard_delete, hr_employee_pii_audit_trail (afterUpdate), hr_payslip_compute_net, hr_leave_balance_checkleave routing above auto-approve threshold; contract-expiry warning (60/30/14/7d); accepted offer → create employeeemployee_onboarding, employee_offboarding, leave_approval
financevalidate_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_txninvoice.posted → send PDF; invoice.overdue (7/14/30) → dunning; payment.applied → mark paid; period-close prereq gatear_dunning_collections, ap_invoice_to_pay, bank_reconciliation, fiscal_period_close
bankingvalidate_transaction_amount_and_currency, generate_transaction_reference, enforce_transaction_immutability, emit_ledger_double_entry (afterCreate), validate_transfer_funds_and_limitsAML structuring/watchlist → alert; loan DPD buckets → reclassify; KYC expired → restrict accountcustomer_onboarding_kyc, loan_origination_to_disbursement, aml_sar_investigation
bssnormalize_msisdn_imsi_iccid, enforce_unique_msisdn_imsi, subscriber_status_transition_guard (KYC gate), mask_pii_on_subscriber, validate_payment_reference, invoice_status_transition_guardcaptured payment → reconcile invoice + lift credit hold; topup completed → credit wallet; invoice overdue → open dunningsubscriber_onboarding_provisioning, dunning_and_suspension, mnp_porting_orchestration, billing_cycle_run
hivemindclassify_spend_request_decision + guard_spend_approval_authority, enforce_work_item_fsm_transition (stories), redact_and_hash_llm_payloadstanding breached → suspend agent; presence stale → reap + release batons; LLM spend > budget warn% → throttleagent_spend_approval, work_item_lifecycle, llm_budget_dunning
emailnormalize_mailbox_address, enforce_mailbox_unique_per_domain, guard_mailbox_freeze_transition, block_delete_with_undelivered_or_legal_hold, normalize_app_password_and_set_expiryhard bounce → auto-suppress; abuse reports ≥N → blocklist; compromise freeze → kill sessionscompromised_mailbox_containment, domain_onboarding_verification, tenant_email_offboarding
signsign_event_append_only_guard (reject all update/delete), sign_recipient_seal_signature_evidence, sign_request_guard_terminal_and_transition, sign_document_validate_and_hashcompleted → notify + project signed doc; declined → notify sender + recovery; viewed-no-sign → nudgesigner_reminder_and_expiry_sweep (P0 — nothing drives expiry/reminders today), envelope_signing_lifecycle, audit_certificate_generation
meetmeet_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_guardscheduled → invite+reminder; ended+recording → AI pipeline; recording ready → compliance fan-out + host notifymeeting_recording_finalization, recording_retention_lifecycle, recurring_meeting_provisioning
contact-centercc_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 callbackagent_onboarding_provisioning, outbound_campaign_lifecycle, quality_evaluation_dispute, callback_fulfillment_sla
messagingrecord_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 webhookcustomer_conversation_sla, bot_webhook_delivery, contact_consent_and_optout, attachment_av_scan_gate
ossoss_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_stampcritical alarm → auto-open incident; pool ≥85% → depletion alarm; QoS breach → SLA alarmsim_activation_provisioning, incident_lifecycle_mttr, change_request_cab_approval, number_portability_port_in
adminvalidate_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_scopeprivileged RBAC change → security alert + approval; high-priv/non-expiring credential → SIEM; flag→100% → deploy-class auditplatform_promotion, privileged_access_approval, credential_rotation, config_change_approval
workspacevalidate_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+endpointsmember_onboarding, member_offboarding, invoice_dunning, endpoint_remediation
infra_opsencrypt_and_mask_secret + secret_referential_guard, golden_image_referential_guard, normalize_drift_finding + drift_dedup_guard, control_finding_state_guardcritical/high drift → notify on-call + open POA&M; fail2ban ban → threat_indicator; secret rotation due (14d) → start rotationinfra_drift_reconciliation, secret_rotation, cab_change_approval, compliance_finding_remediation
ecommercecompute_order_totals (price-tamper guard), guard_order_status_transition, guard_non_negative_stock (oversell), validate_refund_amount (over-refund), order soft_delete_set_deleted_atorder created → decrement inventory; delivered → LTV + review request; refund completed → restock + reverse spend; low stock → seller alertorder_fulfillment, returns_rma, seller_onboarding, seller_payout_reconciliation
projectsstate_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 → notifysprint_planning_and_close, milestone_gate_review, project_intake_and_provisioning, key_decision_ratification
marketingvalidate_campaign_invariants, validate_campaign_status_transition, audit_campaign_change (afterUpdate), normalize_template_content (XSS-strip), set_segment_defaultscampaign → running → start analytics worker; campaign completed → publish + freeze scorecard; segment → 0 members → alertcampaign_launch_and_lifecycle, audience_materialization_and_sync, social_lead_to_nurture
dealersgenerate_po_number_and_totals, compute_order_line_total, guard_partner_status_transition, block_partner_delete_with_balance, freeze_approved_settlementpartner downgraded → rebate clawback; inventory ≤ reorder → replenish alert; PO confirmed → maintain outstanding_balancepartner_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.
© 2026 DRO Platform