Skip to content
Abhishek KolgeSenior AI Product Engineer
Index

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 (opens in a new tab)

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
Workspace dashboard: portfolio totals, active deals grouped by fund with their review status, and the property mix

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.

Deal onboarding: the source package, each category with a slot per month and bulk upload that reads months off the filenames

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 prototypeThere 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 listEarly 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 modelRow 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 itEach 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 matchIf 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 approvalEditing 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 outI 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 revertIt 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 warmThe 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 moduleThe 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 appliedOnly 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 codeThere 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 everythingAnything 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.
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.

Résumé (opens in a new tab)