Getting a hundred-year-old cemetery off paper

Member records, plot assignments, and a financial ledger for a volunteer cemetery association · Azure Static Web Apps, Azure Functions, Azure SQL

What was needed

The cemetery has been operating for more than a hundred years, and for all of that time it ran the way cemeteries run: on paper. Ledger books, plot maps, index cards, and eventually a few spreadsheets that were emailed between board members.

That works, in the sense that the cemetery has been continuously operated for a century. It has one specific failure mode, though, and it isn't losing the records. It's that the records only exist where the records are. A board member standing in the cemetery, on the phone with a family asking whether their grandmother's plot has space beside it, cannot answer that question. Someone has to go find the book.

The association is run by volunteers, and volunteers turn over. Every transition means the person who knew where things were is replaced by a person who doesn't. What was needed was not a database. It was for the answers to be reachable by the people who get asked the questions, at the moment they get asked, on the phone in their pocket.

What I built

Two applications sharing one database. A public site for families and the community, and an administrative portal for the board — member records across four categories, plot and burial-position assignments, a financial ledger, and role-based access so the treasurer, the secretary, and the officers each see the parts they're responsible for.

The burial model is the part that took the most care, because it is where the paper system carried knowledge that no schema captures for free. A plot isn't simply occupied or empty. It can hold a full burial, a full burial with a cremation at the foot, or up to four cremations in defined positions — and which positions remain available depends on what is already there and where. Getting that wrong doesn't produce a bad report. It produces a family being told there's room when there isn't.

So plot status is derived from the actual assignments rather than maintained alongside them. There is no field for a volunteer to update and forget. The system computes availability from what is recorded as being in the ground, and burial records permanently block the positions they occupy — no soft delete, no archive, no path by which an administrative action can free a space that isn't free.

Everything else is soft-deleted with archive and restore, because volunteers make mistakes and a hundred years of records should not be one wrong click from gone.

What it does today

It runs the association. Board members work from phones, which is what the whole thing is for — the mobile layout isn't a scaled-down desktop view, it's the primary case. Member records, plot lookups, burial positions, and the ledger are reachable in the cemetery, in a meeting, or on a call with a family, by whoever happens to be holding the question.

The public site takes contact from families and the community and delivers it to the board.

And when the next set of volunteers takes over, the answers will still be in the same place.

How it was made trustworthy

I audited the whole system end to end and remediated it in two days, running the work through eleven parallel AI workers under a verification harness — each task scoped to files it alone owned, each result validated by an executed command before anything merged. A worker's claim that it finished counted for nothing; the check either passed or the task was retried with the failure output attached.

The audit found real things, and they're worth listing because a records system for a cemetery is not a place to be vague about correctness.

The public contact form logged submissions instead of sending them. Every family who reached out got silence, and nobody knew, because the page said thank you.

The permission model disagreed with itself in three places — documentation, API, and interface each described a different set of rights. Board members clicked buttons that were never going to work.

Deleting a member was permanent, and the ledger's foreign key had no cascade rule, so removing anyone with financial history returned a server error. One path caused irreversible loss, the other was simply broken, and which one you got depended on whether that person had ever paid for anything.

Sessions expired hard at thirty minutes, with a refresh endpoint that existed in the API and had never been wired to the front end, so volunteers lost work in the middle of data entry.

Underneath it, half the production schema was not in version control, and real member data had been committed to the repository.

All of it closed: nine guarded migrations, a rebuilt permission model that agrees with itself, soft-delete throughout, derived plot status, a nineteen-test integration suite, and mail delivery that actually delivers. Nineteen of nineteen tests passing against production, with verification evidence attached to every issue rather than a checkbox.

The test suite then found a defect the audit had missed — a constraint on plot type holding values from an entirely different domain, in production the whole time.

Neither is findable in code review. Both came out of querying Application Insights after deployment, which is the point: the job is not done when the deploy succeeds. It is done when there is proof.

Field notes

  1. Derive the state you cannot afford to have wrong. Any status a volunteer has to remember to update is a status that will eventually be wrong. Compute availability from the records instead of storing it beside them.

  2. A handler that returns 200 while silently failing is worse than one that crashes. Look for the absent side effect — the null timestamp, the empty queue, the message that never arrived. There is no error to find.

  3. Azure SQL's firewall stalls connections from unlisted addresses rather than rejecting them. Your migration script does not error. It hangs, indefinitely, with no message.

  4. Never stash authentication state on the request object in an Azure Functions handler. It has its own user property; the assignment is a silent no-op on the deployed worker and works fine locally. Use a local binding.

  5. Write the tests even when you are certain the audit found everything. It didn't.