309
requirements
1034
edge-case clauses
94
domain invariants
95%
coverage, 2534 tests
Why it exists
I wanted one place that answers where Mass is. And adoration, confession, retreats, whatever the parish has on this week — for every Catholic parish, from wherever you happen to be standing. That answer does exist today, scattered across an office phone line, a poster on a door and a Facebook page nobody has touched since 2019, which in practice means it does not exist at all.
Three things sit around it. A priest can reach the people who follow his parish. A parish keeps its own information current. And giving to it is a detail you copy and paste, not a phone call you have to work up to. What the platform is actually for is proximity: putting the faithful, the priests and the parishes on one surface that is open to everyone.
The problem
Someone wants to know what time Mass is. Today that means calling the parish office, reading a poster on the church door, or finding a Facebook page last updated in 2019. The schedule exists — it just isn't anywhere you can look it up.
The hard part was never publishing a schedule. It is publishing the correct one: the Sunday it was cancelled, the one moved to 10h, the holy day that added a service. That single requirement drove most of the architecture.
Architecture
Four repositories, three trust domains, one Django backend that owns every rule.
The public API needs no credential and is read-only. The parish dashboard authenticates with a Supabase JWT and is capability-gated. The superadmin control plane sits behind a password, TOTP and Tailscale, with a Caddy IP allowlist that runs in middleware before DRF authentication — so a leaked token is inert off-network, and a valid dashboard superuser JWT carries zero privilege there.
The parish is the tenant, but there is no tenant column and no schema-per-tenant. Isolation resolves the set of parishes a user manages and filters every query against it. The acting parish always comes from the URL, never the payload.
Decisions worth explaining
- 01
Mass occurrences are computed, never stored. One expansion engine serves the public API, the dashboard calendar, the iCal feed and the app — so a schedule change cannot be correct in one surface and stale in another.
- 02
Out-of-scope reads return 404, not 403. A bishop probing identifiers learns nothing about which parishes exist in another diocese.
- 03
Every rule is enforced three times: serializer, model.clean(), and a database CheckConstraint. A rule that lives only in a serializer is not an invariant, it is form validation.
- 04
The documentation repository doubles as an MCP server, and its verifier exits non-zero if any link, identifier, file citation, line number or constraint name stops resolving. Documentation that can go stale silently is worse than none.
- 05
Check-in and event RSVP shipped, then were deliberately removed after a privacy review. What survives is a tombstone migration that drops the tables. Rebuilding it is a product and compliance decision, not an engineering one.
Stack
Status
Live and still in active development. Backend, dashboard and the public site are in production, and are still being built on — what is described here is the current shape, not a finished one. The superadmin console runs on a private network. The mobile app has release signing wired but was never shipped to a store, and the pilot is deliberately a single parish — there are no adoption numbers to quote.