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 guard manifest at evaluation time |
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
| 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 |
| subledger | 40 | Invariant | No | All flows |
| invariant | 50 | Invariant | No | All flows |
| fund_eligibility | 60 | Eligibility | Yes | All flows |
| bill_payment | 70 | Policy | No | Bill payment flow only |
| reversal | 80 | Invariant | No | Journal entry flow, reversal type 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 reversal guard runs last because it only applies to a narrow set of transactions.
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.
Manifest Hash
The entire guard manifest is hashed with SHA-256 at application startup. This hash is recorded as the policy_snapshot_hash in every enforcement decision.
If the manifest changes between deployments — a guard is added, removed, or reordered — the hash changes. This makes it possible to detect when two decisions were evaluated under different guard configurations, even months apart.
Flow Registry
The flow registry maps transaction types to enforcement flows. A flow determines which guard chain runs for a given transaction.
Flows and Transaction Types
| Flow | Transaction Types |
|---|---|
| Bill Payment | pay_bill |
| Fund Transfer | transfer_to_reserve, transfer_from_reserve, fund_equity_transfer |
| Payment Receipt | receive_payment |
| Invoice Creation | record_assessment, record_late_fee |
| Year-End Close | year_end_close |
| Journal Entry | record_bill, direct_payment, bank_fee, interest_income, other_income, write_off, refund, standard, adjusting, closing, reversal, opening_balance, void, correction |
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 up to three decision records:
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.
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 scores, financial health indicators, dispute rates |
| TELEMETRY | 6 | Blocked/escalated transaction counts, SLA breach metrics (rolling 12-month) |
| UNDERWRITING | 5 | Credential continuity, platform tenure, insurance status, payment volume bands |
| VENDOR_COMPLIANCE | 9 | Linked vendor counts, expired COI/license counts, compliance rates |
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 that runs in CI:
- 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
- No guard is referenced in a flow but missing from the manifest
If validation fails, the CI build fails. Guards cannot be silently removed, reordered, or misconfigured.
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
How CommunityPay Enforces This
- Single entry point: all enforcement flows through enforce_pre_persist() — one interface, one return type
- Eight production guards ordered by manifest (balance, closed_period, fund_segregation, subledger, invariant, fund_eligibility, bill_payment, reversal)
- Guard manifest hashed with SHA-256 for policy snapshot drift detection across decisions
- Two-event pattern: PRE_PERSIST (before commit), POST_PERSIST (after commit), PERSIST_ERROR (on failure)
- Six enforced flows mapping 20+ transaction types via flow registry with O(1) reverse lookup
- 26 registered signals across 4 namespaces (RISK_SIGNAL, TELEMETRY, UNDERWRITING, VENDOR_COMPLIANCE)
- Fast-fail on BLOCK with override check before rejection — overrides are themselves immutable audit records