Enforcement Dispatcher & Guard Architecture

The mandatory choke point for all financial decisions. 18 production guards, manifest-driven ordering, override-aware evaluation, and the two-event pattern that ensures every decision is logged.

11 min read Governance & Controls Published Nov 25, 2024 Updated Sep 1, 2026

CommunityPay's enforcement dispatcher is the mandatory choke point for all financial decisions. Every journal entry, fund transfer, bill payment, invoice, payment receipt, and fiscal year close flows through the same evaluation pipeline. This page describes how that pipeline works, what it evaluates, and how the resulting decisions are recorded.

The Dispatcher

The enforcement dispatcher has a single entry point: enforce_pre_persist(). It takes an enforcement context — which HOA, which transaction type, which user, what amounts, which funds are touched — and returns an enforcement outcome.

The outcome contains:

Field Description
Decision ALLOW, BLOCK, OVERRIDE, or ERROR
Guards expected Which guards should have run for this flow and transaction type
Guards ran Which guards actually executed
Guard results Per-guard outcome, reason codes, and timing
Blocking guard If blocked, which guard blocked it and why
Duration Total evaluation time in milliseconds
Policy snapshot hash SHA-256 of the enforcement universe at evaluation time: the guard manifest, the flow registry, the association's fund policies and duty settings, and the active eligibility rules and exclusion triggers

This is the only return type from enforcement. Every caller receives the same structure regardless of the transaction type, the guards that ran, or the outcome. One interface, one return type.

Guard Manifest

Guards are registered in a static manifest — not loaded dynamically, not discovered at runtime, not configured through a database. The manifest defines each guard's identity, execution order, applicability, and override policy.

Production Guards

The manifest holds 18 guards. 12 cannot be overridden at any authorization level and 6 can.

Guard Order Category Overridable Scope
balance 10 Invariant No All flows
closed_period 20 Temporal Yes All flows
fund_segregation 30 Policy Yes All flows
fund_consistency 31 Policy No All flows
trust_segregation 35 Invariant No Trust transaction, journal entry, bill payment, fund transfer
subledger 40 Invariant No All flows
invariant 50 Invariant No All flows
l5_exclusion 55 Risk Intelligence No All flows
fund_eligibility 60 Eligibility Yes All flows
bill_payment 70 Policy No Bill payment flow only
vendor_risk 75 Policy Yes Bill payment flow only
disbursement_compliance 76 Policy Yes Bill payment flow only
connect_balance 77 Policy No Bill payment flow, Stripe vendor payout type only
fund_policy 78 Policy No Bill payment flow only
reversal 80 Invariant No Journal entry flow, reversal type only
loan_covenant 90 Policy Yes Loan origination, loan payment, bill payment, fund transfer
payment_split 100 Invariant No Loan payment flow only
interest_calculation 110 Invariant No Loan accrual flow only

Ordering

Guards execute in manifest order (10, 20, 30, ...). The balance guard always runs first because an unbalanced entry is a fundamental accounting violation that should be caught before evaluating policy or eligibility. The guards that apply to a narrow set of transactions, such as reversal, payment split, and interest calculation, sit later in the order.

Ordering is stable unless explicitly changed. The manifest enforces unique order values — two guards cannot share the same order position.

Applicability

Each guard declares which flows and transaction types it applies to. Guards scoped to all flows (*) run on every transaction. Guards scoped to specific flows (e.g., bill_payment) only run when the transaction is routed to that flow.

When a guard does not apply to the current flow or transaction type, it is recorded as SKIP in the guard results — not silently omitted. This means the audit record shows which guards were expected, which ran, and which were correctly skipped.

Policy Snapshot Hash

Every enforcement decision records a policy_snapshot_hash: the SHA-256 of a canonical description of the enforcement universe at the moment of the decision. The guard manifest's own hash is one input. The others are the flow registry, the association's active fund policies and enforcement point configuration, the duty settings in force (whether self-approval is allowed and whether a separate payer is required), and the active eligibility rule versions and exclusion triggers. Annotation-only fields are left out because they do not change enforcement behavior.

If any of these changes between two decisions — a guard is added or reordered, a fund policy is edited, a duty setting is switched — the hash changes. This makes it possible to detect when two decisions were evaluated under different rules, even months apart, and the canonical description behind each hash is stored and never updated by the application. The row is immutable by convention, not by a save or delete override.

Flow Registry

The flow registry maps transaction types to enforcement flows. A flow determines which guard chain runs for a given transaction. 10 flows map 48 transaction types.

Flows and Transaction Types

Flow Transaction Types
Bill Payment pay_bill, stripe_vendor_payout
Fund Transfer transfer_to_reserve, transfer_from_reserve, fund_equity_transfer
Payment Receipt receive_payment
Invoice Creation record_assessment, record_late_fee, record_nsf_fee
Year-End Close year_end_close
Loan Origination loan_origination, loan_draw, loan_fee, resident_loan_origination
Loan Payment loan_payment, loan_payoff, loan_prepayment_penalty, resident_loan_payment, resident_loan_payoff
Loan Accrual loan_interest_accrual, loan_dfc_amortization, loan_rate_reset, loan_modification, loan_current_reclass, resident_loan_interest_accrual
Trust Transaction trust_deposit, trust_disbursement, trust_fee_withdrawal, trust_interest_allocation
Journal Entry record_bill, direct_payment, bank_fee, interest_income, other_income, write_off, refund, standard, adjusting, closing, reversal, opening_balance, void, correction, waive_late_fee, platform_subscription_fee, stripe_buffer_sweep, stripe_buffer_topup

Unknown transaction types default to the journal entry flow. This ensures every transaction is enforced — there is no "unclassified" path that bypasses the guard chain.

The registry uses a reverse lookup for O(1) resolution from transaction type to flow name. The forward mapping (flow to transaction types) is canonical; the reverse is computed once at module load.

Guard Evaluation

When a transaction enters the dispatcher:

  1. Flow resolution: The transaction type is mapped to a flow via the flow registry.
  2. Guard selection: The manifest is consulted for guards applicable to this flow and transaction type.
  3. Sequential evaluation: Each applicable guard runs in order.
  4. Fast-fail on BLOCK: If a guard returns FAIL, the dispatcher checks for an active override. If no valid override exists, evaluation stops and the decision is BLOCK.
  5. Override check: If a guard fails but is marked overridable, the dispatcher checks for a valid AuditOverride. If one exists and is within its validity window, the decision becomes OVERRIDE (not ALLOW — the distinction is recorded).
  6. Guard exceptions: If a guard raises an exception, the decision is ERROR. The exception is logged and the guard result captures the error message.

What Each Decision Means

Decision Outcome Ledger Impact
ALLOW All guards passed Transaction proceeds
BLOCK A required guard failed, no valid override Transaction rejected, no journal entry created
OVERRIDE A guard failed but a valid AuditOverride was applied Transaction proceeds, override is linked in the decision record
ERROR A guard or the dispatcher itself raised an exception Transaction rejected, error is captured

Two-Event Pattern

Each enforced transaction produces one or two decision records. A BLOCK leaves one. An ALLOW leaves a second: POST_PERSIST if the commit succeeds, or PERSIST_ERROR if it fails. Never both.

PRE_PERSIST (decision_seq=0)

Created before the database commit. This is the enforcement evaluation itself — which guards ran, what they found, and what the decision was. If the decision is BLOCK, no journal entry is created and no further events are recorded.

POST_PERSIST (decision_seq=1)

Created after a successful commit. This confirms that the allowed transaction actually persisted. The POST_PERSIST record links to the created journal entry's ID, closing the loop between the enforcement decision and the resulting ledger entry.

PERSIST_ERROR (decision_seq=2)

Created if a commit fails after an ALLOW decision. This captures situations where the enforcement layer approved the transaction but the database layer rejected it (constraint violation, connection error, etc.). The PERSIST_ERROR record uses in-memory data from the outcome object — not a database query — because the transaction may have rolled back.

The Database Backstop

Guards evaluate before an entry posts. The database holds a second line: a constraint trigger refuses any entry that carries no enforcement decision, whatever wrote the row. A nightly probe reads the database catalog to confirm that the eight ledger triggers and the ledger's constraints are installed. A BLOCK has refused the posting since the January 2025 cutover. A guard that raises an error, and an entry that carries no decision, have refused it since August 10, 2026. The guard count and the ledger rules print, with a digest and the date they were read, at /verify/platform/.

Why Three Events

The two-event pattern (with the third for errors) answers three distinct questions:

  1. Was the transaction evaluated? (PRE_PERSIST exists)
  2. Did the evaluated transaction actually commit? (POST_PERSIST exists)
  3. Did an approved transaction fail to commit? (PERSIST_ERROR exists)

If only PRE_PERSIST exists with an ALLOW decision but no POST_PERSIST, it means either the transaction is still in flight or it failed without recording a PERSIST_ERROR. Both cases are detectable and auditable.

Signal Registry

Guards evaluate their conditions against signals — named data points gathered from the platform's models. Signals are registered in a canonical registry that maps signal keys to their source models, field paths, and types.

Signal Namespaces

Namespace Signal Count Source
RISK_SIGNAL 6 Governance risk coefficient and tier, composite risk, financial health and governance scores, dispute incidence
TELEMETRY 6 Blocked and escalated transaction counts and amounts, total transactions, SLA breach counts (rolling 12-month)
UNDERWRITING 11 Credential continuity, dispute incidence per thousand payments, verified payment volume and count, months on the platform, insurance status, delinquency, reserve funding, and operating expense ratios, board attestation currency
VENDOR_COMPLIANCE 8 Linked vendor counts, expired insurance and license counts, critical alerts, compliance rates
DEBT_SERVICE 9 Outstanding debt, annual debt service, coverage ratio, resident loan delinquency, maturities, covenant breaches, unpaid accrued interest, debt-ledger reconciliation, reserve ratio with debt

Each signal has a declared type (DECIMAL, INT, STR, BOOL, DATE) for normalization. The registry acts as the data dictionary for the underwriting system — rule expressions reference signal keys, and the registry resolves them to their source models.

Signal Resolution

When a guard or eligibility rule references a signal (e.g., governance_coefficient), the system:

  1. Looks up the signal in the registry
  2. Identifies the source model and field
  3. Fetches the current value for the HOA being evaluated
  4. Normalizes the value to the declared type

The signal values at evaluation time are captured in the enforcement decision's signals_snapshot. This ensures the exact inputs to the decision are preserved, not just the outcome.

Manifest Validation

The guard manifest includes a validation function, and a test in the suite runs it:

  • Every guard has required fields (order, overridable, required, flows, transaction_types)
  • Order values are unique across all guards
  • Common flow/transaction-type combinations have at least 3 required guards

If validation fails, the test fails. It catches a missing field, a duplicate order value, or too few required guards on the common flows. It does not detect a guard that is removed or reordered.

Extension Points

Adding a Guard

  1. Create the guard class with an evaluate(context) method
  2. Add an entry to the guard manifest with a unique order value
  3. Specify which flows and transaction types the guard applies to
  4. Run manifest validation to confirm the configuration is sound
  5. The guard will automatically be included in evaluations for its applicable flows

Adding a Transaction Type

  1. Add the transaction type string to the appropriate flow in the flow registry
  2. The reverse lookup is rebuilt automatically at module load
  3. All guards applicable to that flow will evaluate the new transaction type

Adding a Signal

  1. Add the signal to the signal registry with namespace, source model, type, and description
  2. Add fetch logic in the eligibility evaluator
  3. The signal is now available for use in eligibility rule expressions and guard evaluations

This describes production controls as of October 2026. Controls evolve; request a current posture packet for diligence.

How CommunityPay Enforces This
  • Single entry point: all enforcement flows through enforce_pre_persist() — one interface, one return type
  • 18 production guards ordered by manifest — 12 of them cannot be overridden at any authorization level
  • Policy snapshot hashed with SHA-256 over the guard manifest, flow registry, fund policies, duty settings, and active eligibility rules and exclusion triggers, for drift detection across decisions
  • A database constraint trigger refuses any entry that carries no decision; a BLOCK has refused the posting since the January 2025 cutover, and an error or a missing decision has since August 2026
  • Two-event pattern: PRE_PERSIST (before commit), POST_PERSIST (after commit), PERSIST_ERROR (on failure)
  • 10 enforced flows mapping 48 transaction types via flow registry with O(1) reverse lookup
  • 40 registered signals across 5 namespaces (RISK_SIGNAL, TELEMETRY, UNDERWRITING, VENDOR_COMPLIANCE, DEBT_SERVICE)
  • Fast-fail on BLOCK with override check before rejection — every use of an override is an immutable audit record
Login