Angel Campa
Colour theme

WorkThe Cloudflare pivot

Lextract

Read a commercial lease PDF and handed back 126 structured lease terms, each with a confidence score, a citation to the source text, and a red-flag review of the clauses that tend to cost tenants money.

Ran on
Railway until June 2026, then Cloudflare Workers
Ran
March 2026 to August 2026
Domain
Commercial lease abstraction
Source
lease-abstraction-saas-lextract-snapshot

Retired

Built with

  • Next.js 16
  • React 19
  • TypeScript
  • Cloudflare Workers
  • Workflows
  • Queues
  • Neon Postgres
  • Stripe

Hosted services

  • Railway
  • Cloudflare Workers
  • Workflows
  • Queues
  • R2
  • Hyperdrive
  • D1
  • Neon Postgres
Lease review UI: a 151-page source lease PDF beside the extracted fields, an overall HIGH 88% confidence badge, per-category confidence averages, and a red-flag panel explaining a missing CAM cap
The review UI: the source lease on the right, the extracted fields on the left, a confidence score on the lease and on each category, and the red flags the terms produced. The three dashes in the summary bar mark fields with no value: every unknown renders as a dash rather than an invented one, scored not_found and dropped from the confidence denominator, which is why the lease still reads HIGH 88%.

The decision

On 2026-06-12, the entire backend moved from FastAPI, Celery, and Redis on Railway to Cloudflare Workers in a single day: 29 commits in the private development history, executed by coding agents against a 1,091-line written plan agreed in advance, with the Celery chain remapped onto Cloudflare Workflow steps and the API contract frozen so the frontend needed no changes.

The Railway backend had worked, but it billed for every hour it sat idle against traffic that was bursty by nature: a lease arrived, minutes of work happened, then nothing for hours.

The Python design it replaced ran a second, adversarial extraction pass that hunted for errors rather than confirmed them, a dual-extract judge that arbitrated genuine disagreements, a per-model pricing table maintained in the repository, and explicit prompt-injection defence around lease text an attacker could author. The Cloudflare orchestrator ran the extraction prompt twice against the same model instead, and the Python stack stayed in the repository, unported, so the two designs could be read side by side.

The new design ran only when a lease came in, matching the traffic pattern that had cost money to sit idle against on Railway.

Architecture

Production ran entirely on Cloudflare, with no container, VM, or always-on process anywhere in the request path. A Next.js 16 Worker served the app and reverse-proxied /api/v1/* to a second Worker holding the API.

Extraction and export ran as Cloudflare Workflows, so a transient model error retried only the failing step rather than the whole document. Extraction takes minutes and calls an unreliable third party, which is the whole reason it cannot live inside a request. Neon Postgres sat behind a Hyperdrive binding, since serverless functions and a fixed connection limit do not mix.

The extraction workflow was eight durable steps, with one compensating step catching a throw from any of them:

load PDF, run extraction
  persist extraction result
    mark scoring
      score confidence
        persist confidence
          detect red flags
            persist red flags
              mark complete, enqueue email
                                              -> on any throw: mark extraction failed

Each pass inside that first step was a model chain rather than a single model, and the third pass required both conditions at once: a field holding a non-null value scoring below the escalation threshold, and headroom still available under the cost ceiling.

The score itself did not take the model’s word for it. Cross-field validators re-derived what they could and penalised every field involved when the arithmetic disagreed, and fields genuinely absent from a lease were scored not_found and excluded from the overall denominator rather than counted as failures.

The design standards were agreed in writing before any code and were short enough to hold in mind: routes parse and delegate and do nothing else, SQL lives only in repositories, state transitions go through one module, R2 key construction goes through one module, and every external API sits behind an adapter.

The first Playwright end-to-end pass, on 2026-03-24, found the entire authenticated API returning 401 for every registered user: dashboard, profile, credit balance and upload all broken. The cause was the auth SDK’s session cache: it stored the session response in a signed cookie for five minutes, and a cached response carried no headers, so the header carrying the JWT was absent for the whole cache window after every login. The fix bypassed the cache entirely and asked a token endpoint directly.

By the numbers

Each figure carries where it came from and when it was measured. Open one to see the command behind it, where the repository recorded a command.

Code and tests
567 source files / 106,161 lines; 347 test files / 91,084 lines; 4,731 declared test cases
Provenance

Source portfolio/METRICS.md § What each number's command actually is

python scripts/repo-metrics.py

Measured

Extraction schema
126 fields across 16 categories (44 required, 19 CAM-relevant)
Provenance

Source portfolio/METRICS.md § Extraction schema

Measured

Frontend tests executed
2,282 tests across 190 test files
Provenance

Source README.md § Testing

Measured

Coverage gates
Backend and extract-sdk Python: --cov-fail-under=95 with branch coverage; Frontend: lines 88%, functions 80%, branches 80.3%, statements 85.6%
Provenance

Source README.md § Testing

Measured

Ground-truth extraction harness
20 leases with ground-truth cases, drawn from public SEC EDGAR filings
Provenance

Source README.md § Testing

Measured

The Cloudflare rewrite
Python/Celery/Railway backend replaced in a single day (29 commits against a 1,091-line written plan)
Provenance

Source README.md § The rewrite: Python to Cloudflare-native, and what it cost

Measured

Screenshot sweep
618 public routes captured, both light and dark theme
Provenance

Source README.md § Screenshots

frontend/scripts/capture-archive.mjs

Measured

Commit history
1,252 commits between 2026-03-03 and 2026-08-07, including 201 merges: 421 fix, 285 chore, 212 feat, 67 docs, 26 test, 16 refactor
Provenance

Source portfolio/METRICS.md § Commit history

Measured

Screens

Lextract landing page: "Get 126 lease fields in minutes" hero with a $15-per-lease, no-subscription pitch
The landing page, captured from a local production build. Both light and dark themes were first-class, and the capture sweep shot every page in each.
Result preview page for a sample Austin office lease: ten key lease terms, a confidence distribution bar chart, a "3 red flags detected" badge, and a blurred locked panel offering to unlock the remaining 116 fields
What an anonymous user saw before paying: ten of the 126 fields, the confidence distribution across high, medium, low and not-in-lease, and a red-flag count, with the remaining 116 fields gated behind an unlock.
Dashboard listing four seeded extractions grouped by day, each with a status badge for uploading, extracting, failed, or complete and paid, beside counter tiles and a credit balance panel showing 13 credits remaining
The dashboard, on demo data. Four extractions in four different states (uploading, extracting, failed, complete-and-paid) because the states are the interesting part of the screen.
Billing page from a seeded local run: a payment history listing a $120 ten-credit pack and a $65 five-credit pack, above a credit transactions list where each grant and each spend shows a signed amount and the balance after it
The balance after each row is computed at insert time and surfaces directly in the UI: the same column an append-only trigger refuses to let anything update.
Read the repositorylease-abstraction-saas-lextract-snapshot

Its portfolio/ directory holds the architecture, metrics and testing documents these figures were taken from.