
Every church has the same conversation about fifty times a year. The sanctuary lights are flickering. Someone mentions it to whoever they see first. That person means to pass it along. Three weeks later the lights are still flickering and nobody is sure whether anyone ever heard about it.
The problem is not that people are careless. The problem is that a request with no front door has no state. It cannot be assigned, it cannot be overdue, and it cannot be finished, because nothing ever recorded that it started.
The first two things I built for Mosaic were the front doors. Facilities Flow, for anything physical. Tech Flow, for anything with a screen.

Current Facilities Flow Dashboard
Two Apps, One Fork
Facilities Flow came first, on January 18th. By January 24th it was running in production. On February 5th I copied the whole thing, renamed the schema from facilities to tech, changed the work order prefix from WO- to TT-, and had an IT ticket system by February 16th.
That sounds like laziness. It was. It was also correct. The two problems have the same shape: someone reports a thing, someone triages it, someone does it, and everyone can see where it stands. The nouns differ. The workflow does not.
What I didn’t realize is that the fork would split, permanently. Tech Flow grew. A credential vault for shared service accounts. An asset catalog wired to our device management system.
So did Facilities. A key log. An Exchange-backed room booking interface, which is not remotely an IT concern. Today they share very little product surface.
The roadmap has an explicit line saying I will not merge them. The duplication got solved a different way: everything genuinely common got pulled out underneath both apps into shared libraries for authentication, database access, HTTP handling, file uploads, telemetry, and time zones. The apps stayed separate. The plumbing converged.
If I had merged them into one configurable "request system," I would be maintaining a configuration language instead of two small apps. I am fairly confident that would have been worse.
The Status Flow Is the Product
Both apps run the same lifecycle: submitted, research, planning, scheduled, in progress, completed, with blocked and canceled available throughout.
The interesting part is that it did not start that way. The original flow had a single "triage" state. Twelve days after launch I split it into "research" and "planning," because the maintenance team does not experience those as one step. Figuring out what is actually wrong with the HVAC unit and deciding what to do about it are different weeks of work, and collapsing them meant the board could not show where anything really was.
That is the whole lesson of a status model. The states are not a technical detail. They are a claim about how the work actually happens, and if the claim is wrong the board lies to everyone who looks at it.
In April I also made every transition legal. Any status can move to any other status. The original design enforced a strict forward progression, which is tidy and which real work violates constantly.
What a Security Audit Found
Ten days after Facilities Flow went live I ran a security pass on it. It found seventeen problems.
Not seventeen nitpicks. An authentication bypass. An access control failure where any authenticated user could read every work order by guessing an ID. Attachment filenames generated with a random number function that is not built for that purpose. No validation that an uploaded "image" was an image, so an executable renamed to .jpg went straight through. Missing security headers. Personally identifying information leaking out of a lookup endpoint that had no business returning it.
I built that app fast and I built it competently, and it still shipped with seventeen holes in it. That gap is the thing I most want church leaders to understand about internal tools. The functionality was fine. Functionality is the easy half.
The fixes were not exotic: an authentication layer every route passes through, an admin check that actually checks, magic-byte validation on uploads so the file has to be what it claims, and masking on the fields that leak. But I would not have written any of it if I had not gone looking, and nothing about using the app would have told me.
The Review Queue, and a Bug That Was Not Logic
By August a different problem had surfaced, and the design spec states it plainly: staff were submitting work orders straight into the maintenance team's queue without any facilities or technology sign-off.
So requests now land in pending_review and route by category. Audio-visual and IT come to me. Everything else goes to Lauren. The reviewer can approve, decline with a required note, or request modifications, which sends it back to the submitter to edit and resubmit.
Emergencies are fast-tracked, not bypassed. A priority-one request still enters the queue, pinned to the top, with an immediate notification to the reviewer. I did not want an emergency flag that skips review, because then every request becomes an emergency.
The bug worth telling happened the day after launch. I found it myself, using the app normally.
Declined work orders were invisible to the people who submitted them. The data was all correct. The status was set, the note was saved, the audit trail was complete. But the item dropped out of the submitter's default list and the badge said "Rejected" in gray, which reads like a system state rather than a decision someone made about your request.
The fix was not logic. It was legibility. A solid red badge reading Declined, a red mark on the card, a red filter chip, and the item stays in your list where you can see what happened to it.
A system that records a decision correctly and communicates it badly has not actually made the decision.
Keys, and Putting the Rule in the Database
The key log spec opens with the best problem statement in the whole repository:
There is no record of who holds a physical key to the building. Today that lives in someone's memory. When a key goes missing there is no way to answer who had it, when it was issued, or which doors it opens, which is also the information needed to decide whether to rekey.
So the system tracks key types, the stamping on the physical blank, how many copies exist, which locations each opens, and who holds which numbered copy. Master keys resolve through, so "who can reach the safe room" is an answerable question. Holders are permanent or temporary, temporary terms offer thirty, ninety, or end-of-year, and lost is a real status rather than an absent return.
One design choice I would repeat everywhere. The rule "one physical copy can only be out to one person at a time" is enforced by a filtered unique index in the database, not by application code. The database will refuse to record a second open issuance of entryway key number four. It is not a validation that a future bug can route around. It is a fact about the data.
Similarly, handing over five keys at once runs inside a single transaction, all or nothing. A conflict on the third key rolls back the first two, rather than leaving a key log that disagrees with the ring it exists to describe.
Room Booking, Where I Chose Not to Own the Data
Facilities Flow also books rooms, and the design rule is the part I am most pleased with:
State lives in Exchange. Mosaicops is a skin. Outlook is the fallback.
There is no booking database. The room table stores capacity, buffer times, and whether the room needs approval, and that is all. Every action the app takes through Microsoft Graph is an action a person could take in Outlook. If my app is down, booking and approvals keep working, because the actual system of record is the one the church was already paying for.
Reality still pushed back. The spec's original design read pending requests out of a shared mailbox, and that turned out to be impossible to build: the forwarded messages expand to an empty event with no usable identifier. I rewrote it mid-build to read the room calendars instead.
Then Exchange taught me something I did not know existed. A recurring meeting with no end date pinged our approvals channel ten times in nine days. Microsoft runs a repair process that re-sends a meeting request whenever the organizer's copy and the room's copy disagree, and a series with no end date is permanently truncated on the room side, so they never agree. Accepting does not stop it. The loop only ends when the recurrence gets an end date.
And a shipped feature I threw away in twenty-four hours: I built the availability view on a full calendar library on July 2 and replaced it with plain layout cards on July 3. The library was heavier than the problem.
What I Would Do Differently
I would run the security pass before launch instead of ten days after. Nothing about the audit required the app to be live.
I would not have shipped a single shared service key. Every app in the fleet posts bug reports into Tech Flow using the same credential. It is convenient, I can rotate it from a settings screen in about ten minutes, and it also means one leaked configuration value exposes roughly ten applications. There is a written plan to split it per caller. It is still written rather than done.
And I would have put admin management in the product on day one. For the first month, granting someone admin access required editing environment variables in the Azure portal, which is a fine way to make yourself the only person who can administer anything.
The Takeaway
A request system is not really software. It is an agreement about what happens when someone notices a problem, made explicit enough that the agreement survives people forgetting.
The parts that mattered most were not technical. The status names had to match how the team actually works. A decline had to look like a decision. The rules that must never be violated belong in the database, where code cannot forget them.
The parts that bit me were all invisible from the inside: the security holes, the shared credential, the missing admin screen. None of those showed up in using the app. All of them showed up when I went looking on purpose.
Go looking on purpose.
Next: three tools that got better by doing less.
