# Perpetuity

> A deal platform for real-estate investors. Drop in a rent roll and a P&L, and get back a model you can underwrite from.

Real estate · Document AI · Multi-tenant SaaS · [Walkthrough on request](https://cal.com/abhishek-kolge-yed7dg/intro)

Case study from https://abhishekkolge.dev. Canonical page: https://abhishekkolge.dev/work/perpetuity

- **Role:** Lead engineer, from the empty repo to the deploy pipeline
- **Team:** 2 engineers
- **Stack:** React 19 and Vite, Express 5 on Prisma, FastAPI with Inngest, Claude through LangChain structured output for extraction and Mastra for chat, Postgres with pgvector, ECS Fargate, Terraform

## Problem

Underwriting a property deal starts with a pile of documents: a rent roll, a P&L, a balance sheet, a draw schedule, each in whatever format the last owner used. Someone reads them, retypes the numbers into a spreadsheet, and hopes every line item landed in the right bucket. It takes days, and a miscategorised line is invisible until it has already moved a decision.

## Approach

You create a deal and drop the documents in, and each one comes apart on its own track. A model reads them, but it can only pick a category from a fixed list in the schema it answers in, and plain code checks its answer again. Nothing reaches the underwriting model until a person approves each module, and once a month’s categories are approved every later month has to line up with them.

## By the numbers

- **31 models**: behind deals, documents, extraction jobs, scenarios and per-deal chat
- **5 modules**: rent roll, income statement, balance sheet, draws and assumptions, each approved on its own
- **26 workflows**: durable functions doing extraction, rebuilds, revalidation, ingestion and spreadsheet recalc
- **8 roles**: across three tiers for the platform, the owner team and the tenant org
- **161 resources**: of Terraform in one root module I wrote by hand without off-the-shelf modules
- **1 tag push**: ships three services, the migration and the web app, with no static cloud keys

## Architecture

- **Interface:** React 19 and Vite with TanStack Router, ag-grid for every financial table, a shared component library
- **API:** Express 5 on Prisma and Postgres, Better Auth sessions, one response envelope, Zod on every request body and query
- **Extraction:** FastAPI and Inngest, single-shot Claude calls with categories picked from a fixed schema, pdfplumber and openpyxl for the parsing
- **Models:** LibreOffice daemons recalculating the deal workbook, with versioned assumption scenarios
- **Chat:** Per-deal agent over its own documents: Postgres full-text and pgvector fused with RRF, in-process ONNX embeddings, page-level citations
- **Access:** Invite-only Microsoft sign-in, three-tier roles, tenant scope as a where clause on every deal read
- **Platform:** ECS Fargate behind an ALB, RDS, S3 and CloudFront, secrets in Parameter Store, GitHub OIDC
- **Hardening:** A KMS key per store and a permission boundary on every task role; WAF at the ALB and at CloudFront, GuardDuty, Security Hub, CloudTrail, Config and VPC flow logs written in and switched on per environment
- **Delivery:** Trivy over the files and the secrets, a report-only Trivy pass over the Terraform, a harden-runner egress audit, every Action pinned to a commit SHA, a release-age floor on new packages, CODEOWNERS on the dependency files

## Decisions

### Starting again instead of patching the prototype

There was a working proof of concept when I arrived, and building on it looked like the fast path. I opened an empty repo instead. The prototype had already answered the product question, but its shape would have cost more to bend than to replace, so I kept its ideas and left its code behind.

### Categories come from a fixed list

Early versions asked the model for JSON and then tried to make sense of whatever came back. Now category mapping answers in a schema whose categories are a fixed list, so a line item cannot land in a category that doesn’t exist. The second pass now has almost nothing to fix, where it used to rewrite bad output without telling anyone.

### A document a stranger wrote talks to the model

Row labels pulled out of a document are capped in length and in count before they go near the categorisation prompt, and control characters are stripped, so a stranger’s file cannot slip in text nobody can see.

### Nothing counts until a person approves it

Each module is reviewed and approved on its own, and approval won’t go through without the exported artifact behind it. The first approved month locks how categories map, and every later upload inherits that instead of being re-guessed. After an edit, the recalculation runs with no model involved.

### Refusing a month that doesn’t match

If a new month shares less than 35% of its line items with the baseline, the platform refuses to merge it instead of averaging two different charts of accounts together.

### What I’d change about approval

Editing an approved module moves it back out of approved instead of refusing the edit. The screen makes approval look like a lock, but it only works as a checkpoint, and that is what I’d change.

### The wizard I shipped and then took back out

I shipped a rebuilt deal-creation flow with a wizard, draft recovery and the team picker. It broke creating a deal in ways the review had not caught. I patched forward for a few days, then parked the work on its own branch and took the whole feature back out, keeping the unrelated polish that had ridden along with it.

### Nine days to a revert

It was nine days from shipping it to reverting it. The wizard itself was fine. It had gone out without the tests that would have caught the breakage, and it came back properly once creating a deal was safe.

### Two spreadsheet engines kept warm

The financial model is a real workbook, so recalculating it means running LibreOffice, and per request that meant a cold start every time on the slowest thing in the system. It now runs as a small pool of daemons started with the container and handed work in turn, with hard recalculation forced and a cold spawn as fallback.

### One root module

The whole estate is a single Terraform root module covering network, services, database, CDN, keys, alarms, threat detection and budget. Task roles carry a permission boundary that denies the calls making a breach worse, the pipeline holds no static cloud credentials, and the recovery runbook names its own gaps.

### Only dev was ever applied

Only the dev environment was ever applied. The others are written and validated but were never stood up, so I don’t claim a production estate.

### Writing the rules before writing the code

There were two of us across five services in two languages, and agents did a real share of the typing. So the repo carries the rules: an orientation document, the contracts that must not drift, and task-specific instructions for the parts that are easy to get subtly wrong.

### Where a careless merge reaches everything

Anything crossing a service boundary was planned in writing and approved before an agent could edit, and a second model reviewed the risky changes before I did. I made myself the only reviewer on the dependency and build files.

## Screens

- Workspace dashboard: portfolio totals, active deals grouped by fund with their review status, and the property mix
- Deal onboarding: the source package, each category with a slot per month and bulk upload that reads months off the filenames
- A deal’s extracted draw schedule: budget against funding to date, read-only once the module is approved

## Outcome

Six months took it from an empty repo to a multi-tenant platform where a deal goes from raw documents to a recalculated model: five extraction modules with their own review and approval, versioned assumption scenarios, a per-deal chatbot that cites the page it read, and the AWS estate as code. Dev runs the whole pipeline end to end. Staging and production are written but never stood up, so it has been proven in dev and has no users behind it yet.

## Next

- [The Food Maestro](https://abhishekkolge.dev/work/food-maestro.md)
