The Origin
My son O was at a poorly-supported school. When he was little, he loved being naked. If his mom or I didn’t feel like getting a treat for him, we’d threaten to make him wear clothes: “We don’t have any — we’d have to go to the grocery store after you get dressed.” In his mind, that was the most vehement no possible. So any time he truly didn’t want to do something, he’d say, “We don’t have any.” We don’t have any school work. We don’t have any horseback riding.
It was probably the 12th time I had to explain the “3-year-old wants treats” story to someone on his support team that I realized: this wasn’t a communication problem. This was an institutional knowledge failure across the adults in his world.
Parents, SLPs, teachers, aides — they all see different slices of an autistic kid’s language. But nobody shares context. Texts get lost. Paper logs go home in backpacks. The SLP gets 1-2 hours a week. The other 166 hours produce language that never makes it into therapy planning.
No existing tool fit. Paper logs aren’t searchable. Shared Google Docs have no structure and HIPAA concerns. EMR systems lock parents out. AAC apps are for communication, not documentation. So I decided to build it.
I didn’t know JavaScript.
The Method: 50 Days, Solo
The tools: Three Stack Overflow answers about multi-team membership in Firestore. ChatGPT3. A 180-page graph paper notebook.
The SO answers covered: Custom claims and user tokens as a mechanism for moving memberships between teams, and team membership as a document with various permission patterns for invites and movement. Not how to wire up the functions — that I had to figure out.
The process: I’d sketch UI flows on index cards and put them in order like a comic book page. Stick them over graph paper, number each card, and write out what had to be done underneath. GPT3 and Stack Overflow were still competing with each other for dominance among coders looking for quick answers, and they had similar capabilities when it came to extending ideas. So the notebook was the real architecture tool.
It was painstaking. It was where I first learned to model the smallest atomic change possible and extend it gracefully until it could work for the whole app. Microservices because of microcapability.
The schedule: 4-5 hours a day. 50 days. Solo. During the summer, while being a full-time dad to the kid who needed it.
The Technical Challenge: Many-to-Many in a Document Database
The core problem: a speech-language pathologist working with 12 families needs one login, 12 isolated team contexts, and the ability to switch between them. In a relational database, that’s a join table. In Firestore, there are no joins.
The threat model, in plain English: You get a new security guard every day and he writes up your ID badge based on what he heard you say, and someone else has to check all of the security guards’ badges for the right info.
What I built:
Team membership as subcollections. Each team has a members subcollection. Each member doc stores the user’s UID, email, team ID, and admin status. Collection group queries let you find all teams a user belongs to.
JWT claims as the switching mechanism. When a user switches teams, a callable function verifies membership, updates a user_tokens document, which triggers a Firestore function that sets custom claims on the JWT. The next token refresh carries the new team context. Every security rule reads from these claims.
Audit logging. Every mutating operation — team creation, invite, join, promote, demote, revoke — writes an immutable audit entry via the Admin SDK. Clients have zero access to audit_log. Reads go through Cloud Functions with admin-only pagination and CSV/JSON export.
Scoped search. Algolia search keys are generated per-team with a 1-hour expiry. When you switch teams, your search key invalidates. You can only search within your active team’s data.
16 Cloud Functions. Batch writes for atomicity. Lazy Admin SDK initialization for cold start performance. Firebase Functions v2.
The architecture is genuinely sound. Not because I’m a backend engineer — I’m not. Because the constraints forced it. Document databases punish bad data modeling immediately. You can’t hack your way past a missing subcollection or a wrong security rule. The shape of the data is the architecture.
The Midnight Phone Call
My wife had already gone to bed. I called her — I only text.
“It took me forever to get this localhost version up. I need you to write down 12 digits, go in there and sign up. Then you have to switch to Gambol’s team” — that’s our dog, the name I used for testing.
Five seconds later: “Yeah, it took forever” — cold starts — “but it switched from O to Gambol.”
I ran around the block.
What I’d Change Now: Two Lines in the Security Rules
The app works. The architecture is correct. But looking at the Firestore rules with more experience, two things stand out:
1. user_tokens is readable by any authenticated user.
match /user_tokens/{document=**} {
allow read: if isSignedIn();
allow write: if false;
}
Any logged-in user can read any other user’s token data — their active team and admin status. The data is low-sensitivity, but it’s broader than necessary. The fix: scope reads to the user’s own document.
match /user_tokens/{userId} {
allow read: if isUser(userId);
allow write: if false;
}
2. Dead code that looks like a safety net but isn’t.
match /users/{userId} {
allow read, update: if isUser(userId);
allow write: if false;
}
This looks like it prevents writes. It doesn’t. In Firestore, write encompasses create, update, and delete. But allow rules are OR’d — the update permission from line 1 still works. The allow write: if false is dead code. It reads like a lock on a door that’s already open from the other side.
If the intent is “users can read and update their own doc but never create or delete it,” the rules work — by accident. The fix: be explicit about what’s allowed and drop the misleading line.
match /users/{userId} {
allow read, update: if isUser(userId);
allow create, delete: if false;
}
These aren’t bugs. The app has been secure the whole time. But they’re the kind of thing you learn to see after you’ve written more security rules — the difference between “it works” and “it communicates intent.”
The Launch: 200 Users, and Why That Wasn’t Enough
The app launched in a 10,000-member Facebook group for GLP families. 200 signups in a day.
The idea of what it could do — a shared phrasebook where parents capture moments and SLPs review them before sessions, with multi-team isolation and GLP stage tracking — shocked people. Nothing like it existed.
Then they opened it. And it was… a blank screen with a form.
An app like this is hard to explain to someone, because it starts mostly empty until people make it their own. Without sample data — without a way to show what a populated phrasebook looks like — users hit a blank page and bounced.
I could have solved this. Faker.js could have generated sample gestalts. But in 2023, no one really understood what a gestalt was well enough to generate realistic examples programmatically. And it was an emotionally taxing project: I was trying to help my son be heard. The technical problem was solvable. The emotional blocker — creating fake versions of my kid’s real language patterns for a demo — was harder than any Firestore rule.
His SLP loved it. Thought it was great. But without internal champions who could show other families what a populated team looked like, adoption stalled. The staff at his school were not technology enthusiasts. An organizational tool that requires organizational buy-in is a chicken-and-egg problem.
The lesson: Wanting to build something and building something are only the first two steps. Buy-in. Internal champions. Sample data that shows the vision before users have to create it themselves. Those are what separate a good product from a successful launch. I learned as much from launch day forward as I did during the entire 50-day build.
Why It Matters
This isn’t a story about a content strategist who learned to code. It’s a story about constraints producing architecture.
I didn’t know JavaScript. I didn’t know Firestore. I had three Stack Overflow answers, a chatbot that was roughly as capable as those answers, a graph paper notebook, and a kid who needed the adults around him to share context.
The constraints — document database with no joins, Firebase’s JWT-based auth model, the need for multi-team isolation that an SLP with 12 families could actually use — those constraints produced the architecture. Not expertise. Not experience. The shape of the problem forced the shape of the solution.
And the launch failure produced something the build never could: the understanding that technical solutions don’t adopt themselves. That the blank screen problem is a content problem. That internal champions matter more than feature completeness. That building for someone you love changes the stakes in ways that make some problems harder, not easier.
The security rules I’d change are two lines. The product lessons I learned are the ones I use every day.