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:
- Flow resolution: The transaction type is mapped to a flow via the flow registry.
- Guard selection: The manifest is consulted for guards applicable to this flow and transaction type.
- Sequential evaluation: Each applicable guard runs in order.
- 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.
- 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).
- 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:
- Was the transaction evaluated? (PRE_PERSIST exists)
- Did the evaluated transaction actually commit? (POST_PERSIST exists)
- 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:
- Looks up the signal in the registry
- Identifies the source model and field
- Fetches the current value for the HOA being evaluated
- 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
- Create the guard class with an
evaluate(context)method - Add an entry to the guard manifest with a unique order value
- Specify which flows and transaction types the guard applies to
- Run manifest validation to confirm the configuration is sound
- The guard will automatically be included in evaluations for its applicable flows
Adding a Transaction Type
- Add the transaction type string to the appropriate flow in the flow registry
- The reverse lookup is rebuilt automatically at module load
- All guards applicable to that flow will evaluate the new transaction type
Adding a Signal
- Add the signal to the signal registry with namespace, source model, type, and description
- Add fetch logic in the eligibility evaluator
- 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