Platform Scalability Controls

What holds fixed as an association's ledger grows, what has been measured, and where the measurement stops. Posting cost, report recomputation, scheduled verification coverage, and the limits of the scaling record.

13 min read Security & Infrastructure Published Aug 20, 2026 Updated Sep 1, 2026

An application under load gets slower. A ledger under load can get slower, or it can get wrong. This page describes which properties of CommunityPay hold fixed as volume grows, what has been measured, and where the measurement stops.

The question is not how fast the platform is. It is which guarantees survive volume, and which of them are checked rather than assumed.

What Stays Fixed

The enforcement cost of posting a journal entry does not depend on how many entries precede it.

Every entry enters through JournalEngine.post_transaction() and passes one enforcement choke point. The dispatcher asks the guard manifest which guards apply to this flow and transaction type. That lookup is a dictionary read against a static registry — it does not query the database, and it does not grow with the ledger.

Component Cost per posting Grows with ledger size
Guard selection from the manifest One in-memory lookup No
Guard instances Constructed per posting from a static registry, no database read No
Policy snapshot Cached by hash; one counter query on the hit path No
Enforcement decision rows Two — one before persist, one after No
Journal entry and lines One entry, N lines No

Guards are pure. The same inputs produce the same outputs, and the audit record shows which guards were expected, which ran, and which were skipped.

A guard that is registered in the manifest but missing from the registry does not quietly pass. After the chain runs, the dispatcher compares the guards that were expected against the guards that produced a result, and any gap downgrades the decision from ALLOW to ERROR with the code GUARD_NOT_EVALUATED. The refusal message states the rule directly: an ALLOW cannot be issued on an incomplete guard chain. This check exists because the earlier behavior was the opposite — a missing guard logged a warning, recorded a skip, and left the decision reading as fully evaluated.

The exception, and it is a real one. Allocating the entry number is not fixed-cost. The engine finds the last entry for the association whose number begins with this transaction type's prefix and year, ordered descending, and takes the next sequence. On a mixed ledger that read scans backward from the highest entry number and skips every entry whose prefix sorts after the one being allocated. For a prefix that sorts early — ADJ, BILL, CLS — the work grows with the association's entry count for that year. This is the one part of the posting path that is O(entries), and it is in the write path, not the read path.

Of the 18 guards in the manifest, 12 cannot be overridden by any user at any authorization level. The remaining 6 can be overridden only through a recorded board override: five through the AuditOverride mechanism, which records the reason, the authorizing user, and an expiration, and the reserve-eligibility guard through a reason stated on the posting itself. The evaluation order and the override rules are described in Enforcement Dispatcher & Guard Architecture.

Two Events, Not One

Enforcement writes two records for a successful posting and one for a failed one.

PRE_PERSIST is written before the database commit. It carries the guard results, the decision, the policy snapshot hash, and the legal authorities in force at that moment. POST_PERSIST is written after the commit and reuses the pre-persist hash, so a policy that changed between the two is visible as a discrepancy rather than absorbed silently. PERSIST_ERROR replaces the second event when the write fails.

The context needed for the second event is copied into the outcome object at the first. A failure path does not need a database lookup to record why it failed.

Report Figures Are Recomputed

No financial report is served from a cache.

Every figure on a balance sheet, income statement, trial balance, or cash flow statement is a SUM over posted journal entry lines, executed when the report is requested. A balance as of a date filters those lines by entry date and sums what remains. There is no materialized period table and no cached report payload in the request path.

This is bounded by one account's own lines rather than by the size of the platform. JournalEntryLine carries a composite index on account and journal entry; JournalEntry carries composite indexes on association, status, and entry date, and on association, fund, status, and entry date. Every query is association-scoped.

The Account model also stores a current_balance field, maintained incrementally by the posting path. Reports do not read it. The nightly integrity scan recomputes each account's balance from the ledger and compares it to the stored value; a divergence is recorded as ACCOUNT_BALANCE_DRIFT at CRITICAL severity, with the instruction to investigate the posting path rather than correct the number. The scan and its findings are described in Ledger Integrity Verification.

The adverse fact: a report caching layer exists in the codebase — key generation, TTL management, and invalidation — and nothing imports it. It is not wired into any view or report service. Reports are recomputed because the caching layer was never connected, not because connecting it was rejected. The property described above is real; the reason for it is less deliberate than it looks.

Measured Latency

CommunityPay ships a scalability harness as a management command. It creates associations, initializes a chart of accounts for each, posts journal entries through the real enforcement dispatcher, runs integrity scans, and posts concurrently from four threads to check for races.

The figures below come from a run on 2026-08-21: 10 associations, 1,000 entries each, 10,000 enforcement evaluations, 230 seconds end to end.

Test Avg P50 P95 P99
Journal entry posting, full enforcement 22.83 ms 22.91 ms 30.43 ms 36.19 ms
Association setup and chart of accounts 77.57 ms 69.44 ms 143.66 ms 143.66 ms
Multi-association journal query, 10,000 rows 3.65 ms 3.65 ms 5.90 ms 5.90 ms
Integrity scan, per association 21.16 ms 19.94 ms 28.95 ms 28.95 ms
Concurrent posting, 4 threads 61.45 ms 55.16 ms 91.48 ms 91.48 ms
Race conditions detected 0

These are measured latencies from the real dispatcher, not synthetic benchmarks. Every posting carried full guard evaluation and wrote its decision records.

The comparison against the previous run is the part that matters. In February 2026 the same harness ran 500 postings and recorded a 23.9 ms average with a 55.3 ms P99. The manifest had grown from 11 to 14 guards by then. The August run put 20 times as many entries through the longer chain and recorded a 22.83 ms average with a 36.19 ms P99. Posting cost did not grow with the depth of the ledger.

The read path did grow, and sublinearly. A journal query returning 500 rows took 0.44 ms in February; returning 10,000 rows it took 3.65 ms — 20 times the rows for 8 times the time. The per-association integrity scan moved from 15.3 ms over a 50-entry ledger to 21.16 ms over a 1,000-entry ledger: 20 times the data for 1.4 times the work.

What the run does not establish. It is 10,000 postings, not ten million. It was one run, not a trend. It ran on developer hardware against a local database, so it excludes network latency to a managed database and excludes contention from concurrent production traffic. The two runs were made on different machines, so the February and August figures are not a controlled comparison of the code alone. Nothing here reports production load, because production load at this volume has not occurred.

It also posted one transaction type. Every entry in the run carried the same number prefix, so the backward scan described above found its match on the first row it read and the one part of the posting path that grows with the ledger was never exercised. A real book interleaves bills, payments, receipts, and transfers. The flat posting curve above is measured, and it is measured on the favorable case.

Scheduled Work

The schedule holds 79 tasks. A single scheduler process owns the timetable, and two worker types consume it on separate queues.

The split is deliberate. Payment webhooks, email delivery, and accounting tasks run on the default queue. Statute scraping and drift detection, directory ingestion, and search analytics ingestion run on a second queue, because those jobs can run for hours and a single shared queue would let an ingestion run starve a payment webhook behind it. The scraping worker also recycles on a memory ceiling, so a leak in a long crawl cannot take the process down with it.

Each worker runs at a concurrency of one.

The nightly verification chain is ordered, and the order carries a reason:

Time Task Reads
02:00 Ledger integrity scan, per association The ledger
02:30 Chart of accounts role and fund scan The ledger
03:00 Control plane health snapshot The orphan count from 02:00
06:30 Gate muster Everything above, and the database catalog: the eight ledger triggers and 29 constraints must be installed

An hourly task, at 17 minutes past, verifies that the processor's default bank is the association's pinned payout bank. Automatic payouts pause when the two disagree.

The 03:00 snapshot is scheduled after the 02:00 scan so that the orphan count it reads is same-night fresh rather than a day old.

The adverse fact: these scans iterate associations one at a time on a worker with a concurrency of one. The nightly attestation is serial, and its cost is the sum of every association's ledger, not the largest one. At the measured 21.16 ms per association it is nowhere near the one-hour gap between the scan and the snapshot that reads it. It is the figure that moves first as the platform grows.

Every Scheduled Task Is Accounted For

A scheduled job that quietly stops running is indistinguishable from one that runs and finds nothing. The schedule is therefore held against a registry from two directions.

From the declarations. A test in the suite walks every key in the schedule. Each one must either be a registered gate or appear in an allowlist carrying a written reason for having no gate. A new scheduled task that is neither fails the test.

From the database. A runtime probe reads the enabled periodic task rows and compares them to the same registry. A row that is enabled and that nobody declared is a zombie and fails. A declared gate with no enabled row also fails.

The two planes see different things. The test sees only what the code declares. The probe sees only what the database contains. Together they close the gap where a task exists in one and not the other.

The current split:

Coverage Count
Scheduled tasks with a gate watching them 24
Scheduled tasks allowlisted with a written reason 55
Scheduled tasks unaccounted for 0

Every task is accounted for. Not every task is watched — 55 of the 79 carry a written reason for having no gate rather than a gate. The registry records which is which.

The mechanism exists because the failure it prevents has occurred. Two entries were once duplicated inside the same schedule dictionary; Python keeps the later definition, so the earlier copies were dead configuration that read as live. They were removed in July 2026 rather than left in place.

A Check That Has Not Run Is Not a Check That Passed

Each gate declares a cadence and a grace multiplier. A daily gate is expected within a day, with a grace factor applied before it is judged late. The muster marks a gate stale when its artifact ages past that window, and stale is a distinct state from pass.

Gates are registered across seven categories, 85 in total:

Category Gates
Scheduled 28
Test suite and build checks 16
Structural 20
Corpus 9
Skill 6
Release 4
Repository 2

Release itself is a chain. The release phase runs migrations, seeds the legal corpus from source, audits the dynamic compliance forms, and runs the report verification harness. The four are joined so that a non-zero exit from any step aborts the deploy rather than letting the next step mask it.

The last step in that chain, the report verification harness, runs in enforce mode. A failed A/B check aborts the release. It has done so since September 10, 2026; before then the harness read a release mode that defaulted to warn, and a failed check printed the failure and let the deploy proceed.

The control catalog holds 16 controls, each carrying an enforcement status. Fifteen are active. One — the parity hard-fail control — is in shadow: it logs mismatches and does not block on them.

What the Measurements Do Not Cover

The limits are part of the specification.

  • The scaling record is two runs on developer hardware. Ten thousand postings in August 2026, five hundred in February. Nothing here reports production load, because production load at that volume has not occurred.
  • No load test runs on a schedule. The harness is a command someone invokes. It is not in the release chain and it is not in the nightly chain, so a change that made posting slower would not be caught by a gate.
  • The nightly verification is serial. One scheduler, one worker per queue, associations scanned in sequence. Parallel verification is not implemented.
  • Report queries have no upper bound on history. A balance as of a date sums every posted line in that account up to that date. The index keeps the read within one account; nothing keeps it within one year.
  • Entry-number allocation grows with the book. The scan that assigns the next number is the one O(entries) step in the posting path, and the measurement above did not cover it. It is the first thing to re-measure on a mixed ledger.
  • One control is in shadow. The parity hard-fail control observes and does not block. Fifteen of the 16 controls in the catalog enforce; that one does not yet.
  • The stored balance is checked nightly, not continuously. A drift introduced at 02:01 is found the following night, not on the next read. Reports are unaffected, because reports do not read the stored value.
How CommunityPay Enforces This
  • Every journal entry posts through one interface and one enforcement choke point — 18 guards, 12 of them non-overridable
  • Report figures are recomputed from posted journal lines on every request and are never served from a cache
  • The stored account balance is compared against a full recompute every night; a mismatch is a CRITICAL finding
  • 79 scheduled tasks, each either watched by a registered gate or carrying a written reason for having none
  • A gate that has not run within its cadence reports as stale rather than as passed
  • Measured posting latency of 22.83 ms average across 10,000 enforcement evaluations, last measured August 2026
Login