← all posts

COMET: a case-management system one law firm actually runs

Firm, clients, and infrastructure identifiers are deliberately absent.

I built a case-management and invoicing tool for a small litigation practice in India. Solo project, in production since 2025, used daily.

This post is about the decisions, because the features are boring: cases, contacts, activities, invoices. Every firm tool has those. Most still get the important parts wrong.

The unit of truth is not the case

I assumed it was. It isn’t.

Watch a small practice work for a while: the thing that actually needs recording is the work done for a client. Advice given, a hearing attended, a document reviewed. Sometimes that work belongs to a case. A surprising amount doesn’t, and the firm still needs to log it and eventually bill it.

So COMET is contact-centric. Every activity attaches to a client; a case is optional context. Invoicing sits on the activity log, not on cases. This one modeling decision did more for the firm than everything else combined.

Deadlines and document storage got deferred entirely. The log had to be right first.

Postgres is the backend

A Next.js frontend talks directly to Supabase: Postgres plus auth, no API server of my own. That makes the database the security boundary, so I treated it like one.

Auth is enforced in middleware on every request, revalidating the token against the auth server instead of trusting the session cookie. The client-side auth listeners exist for UX and are documented as exactly that.

Row-level security is on for every table, with a deliberately simple single-tenant policy: one firm, a handful of admin-provisioned users, no per-matter scoping. I wrote the trade-off down instead of building role hierarchies nobody asked for. The schema leaves room for scoping later. It doesn’t pretend to need it now.

The part I’d defend hardest: which writes get wrapped in a database transaction was decided by asking what a half-finished save costs.

An orphaned contact from a failed save is harmless; someone reuses it later. A case that lost its party links is silent data corruption. So contact creation happens client-side, and party links are written inside a single transaction along with the case row. Same for activities: the chronology entry an activity references is inserted in the same transaction, so the pair can’t half-save.

Destructive paths get guards, not trust. Deleting a contact goes through a function that refuses while anything still references it, because the naive cascade would quietly strip party lists. Issued invoices are frozen by trigger; you fix one by voiding and reissuing.

Voiding an invoice releases its activities back to unbilled. Writing it off keeps them billed. That sounds pedantic until you see what it does: the activity log stays honest about what was actually invoiced.

Discipline that survived contact with production

Eighteen migrations, dev first, prod after sign-off. One full production restore through a temporary legacy schema, preserving historical case IDs. When the firm stopped billing GST I left the columns in place, zeroed: a destructive migration to save four columns is a bad trade.

112 unit tests and six end-to-end specs run in CI. Two of the six exist because of bugs I shipped, which is a hell of a way to grow a test suite. One was an UPDATE branch that silently dropped a column the INSERT had. The fix came with a written rule (“any column added to INSERT but not UPDATE”) and a pinned regression test, so I can’t ship it twice.

That’s most of my testing philosophy: the suite grew out of real defects, and the invariants live in the docs, not in my memory.

Still broken or missing: the mobile layout overflows in places, and a couple of list queries fetch more than they should. There’s no per-user audit trail either: a reasonable trade for one firm with a handful of trusted users, and unreasonable the moment a second firm signs on. It’s written down as exactly that. Production software has a to-do list. Anyone who says otherwise is selling something.

The other system: a wiki an LLM maintains

The same practice has a second thing I built: a private knowledge base over its client files, written and maintained by an LLM agent, browsed in Obsidian.

The idea is roughly the opposite of RAG. Instead of re-deriving answers from document chunks per query, knowledge gets compiled once into interlinked pages (matters, people, authorities, issues) and kept current. Contradictions surface when a document is ingested, not when someone happens to ask.

Legal files made the constraints non-negotiable, and the constraints are the interesting part:

The rulebook is mature. The corpus is not: roughly one client’s material in, one matter at a time by design. About 11,000 files in the source folder say “ingest everything” would be easy to promise. The schema forbids it.


Stack: Next.js, React, TanStack Query, Supabase (Postgres, RLS, plpgsql), Vitest, Playwright, GitHub Actions. The wiki is markdown, Obsidian, and a rulebook the lawyer and the agent co-evolve.