One platform for the whole event.
Mahashivratri is run by 90+ departments and thousands of volunteers. This is their shared operations platform — one sign-in, one list of people, and one common base that every department's app is built on, so no team has to rebuild logins and access from scratch.
This page is a plain-language map of how the MSR platform is built — what it is, how the pieces fit, and the decisions behind it. The Overview and Foundation sections need no technical background; Architecture and Schema go a level deeper for engineers. A handful of words have a precise meaning here — Person, Module, Foundation, role — and each is explained the first time it appears. A number like 0004 links to a one-page record of a decision. Nothing here is the final software; it's the shared picture the whole team can point at while we build.
The Foundation is the shared groundwork under every part of the system: signing in, the single list of people, who's allowed into what, and a record of every important action. A Module is one department's app that sits on top of it — Vehicle Passes, Stalls, Requirements. The one idea to hold onto: the Foundation only records that you have a role somewhere; each Module decides what that role can actually do.
In one picture
Before any detail, the whole system in one glance — the parts you work in on top, and the shared base they all stand on underneath.
Who owns what — the whole idea
The one split to get right — because everything else in the system follows from it.
Foundation stores
- Login — one sign-in (Isha SSO)
- People directory — one record per human
- Participations — who holds which role where
- Activity trail — the record of what happened
Module owns
- Its screens & workflow
- Permissions — what each role can do
- Scope — which slice of data a role sees
- All its own data
All access is one tiny row — the participation
Every bit of access in the platform comes down to one small record — a participation. Here's everything it holds.
A 20-second example
Jaya is Admin in Requirements; Ravi is a read-only Coordinator. Both click "Freeze budget." The module reads each one's role_key — Jaya's says admin → allowed; Ravi's says cluster_coordinator → blocked. Same button, different outcome — because of one word on the row. Drop role_key and both rows look identical: the module can't tell them apart, so it must let everyone freeze the budget or no one. And "show me every Admin across all 90 departments" stays one quick lookup on that same column.
Signing in ≠ getting in
Worth stressing, because it's the heart of how access works: proving who you are and being allowed in are two separate steps — one without the other gets you nowhere.
Role, permission, scope
Three words that sound alike but mean different things — and mixing them up is the usual source of confusion. Here's each in plain terms, and who owns it.
Two Approvers, same permissions — one sees South Gate, the other North Gate. That difference is scope. (Like Google Drive: permission = view vs edit; scope = which folder.)
Several roles add up — union
What happens when one person wears more than one hat in the same module.
How a module talks to the Foundation — just 3 connections
A module doesn't wire into the Foundation a dozen different ways. There are exactly three connections between them — no more — and here's what each one carries.
The data behind it all — five things
Strip the platform to its bones and there's remarkably little to track. Here's how the core pieces fit together.
Two records for one person → merge
People sometimes end up with two accounts for the same person. Here's how the Foundation quietly resolves that.
redirects on every lookup
Built to run every year — and beyond one ashram
Two things the platform handles from day one: the event coming round every year, and running at more than one ashram. Both are kept cleanly apart without splitting the system in two.
Every year is kept
Mahashivratri comes round every year. Each year's work — every pass, request and record — is kept as its own edition, sitting right next to last year's, so you can always look back at what happened last time instead of it being wiped to start over.
More than one ashram, one system
It's built for the Isha Yoga Center today, but the same system is ready to run the event at other ashrams later. Each ashram's data is walled off from the others — while the people who oversee everything can still see across all of them in one place.
- The current year is open; past years are frozen. When a year ends it's closed — nobody can edit it, whatever their role. "Read-only" comes from the year being closed, not from the person.
- Who can see a past year is set by each person's grant scope: a grant reaching that year (its own year, or an ashram / whole-system grant that covers it) can view it; someone with no grant reaching it doesn't see it at all.
- When each is decided: the freeze happens when an admin closes the year; who-can-see is set at onboarding (the grant's scope); the module renders a closed year view-only on every screen. (ADR 0013.)
Who runs the system — and how much they can touch
Not everyone who runs the platform needs the same powers. The people who manage the system work in an admin area, and access to it comes in levels. Someone on the technical team can do everything; a back-office person handles the day-to-day but can't reach the sensitive parts — managing people, changing settings, or turning parts of the system on and off. A level is just a checklist of which areas it's allowed to open, like the one below.
Built with
The main tools the platform is built with. One line is deliberately still open — where it's hosted — which the Architecture section picks up.
How the software itself is organised. In plain terms: it's built as one app — not dozens of separate services — but cleanly divided inside into the shared Foundation plus one walled-off section per Module, all running on one database. (The industry name for this shape is a modular monolith.) Where the app will be hosted isn't decided yet.
The rest of this section goes a level deeper — the shape of the code and the shape of the database — and is written for the engineers reviewing it. The plain version is the paragraph above.
Two views of one system
Two settled views of a single decision — plus one question that's still open.
How the code is organised
Folders in one shared codebase — the Foundation, and one folder per department's part.
How the data is stored
One database, with each department's part kept in its own section.
The choice — a modular monolith
The shape we chose: one app holding the Foundation and every module together, with hard walls between them — a modular monolith.
- Hard walls between the parts. Each module lives in its own folder and its own section of the database. It can't quietly read another module's data or borrow its code — the separation is a real wall, not just a naming habit.
- They only talk through set doorways. A module reaches the shared Foundation through the three connections shown earlier, and reaches another module only through a small, agreed "public menu" — never by reaching into its internals.
- Each feature kept in one place. Everything for one feature — its screen, its rules, its data — sits together in one folder, instead of being scattered across the codebase. Easier to find, easier to change.
- Ready to split off later, if ever needed. Those clean walls mean a module could be pulled out into its own separate service down the line if it ever truly has to — but we start simple and only add that complexity under real pressure, never the other way round.
What it looks like in the repo
msr/ ├─ CLAUDE.md root rules — govern the whole repo ├─ apps/ │ ├─ web/ React SPA — one build │ │ └─ src/ │ │ ├─ foundation/ login · shared member-mgmt component │ │ └─ modules/ │ │ ├─ vehicle_passes/ │ │ ├─ stalls/ │ │ └─ requirements/ │ └─ api/ Fastify API — one deployable │ └─ src/ │ ├─ foundation/ identity · participation · activity trail │ └─ modules/ │ ├─ vehicle_passes/ │ │ ├─ CLAUDE.md module-only rules (added only when needed) │ │ └─ passes/ ← one vertical slice (a feature folder) │ │ ├─ route.ts │ │ ├─ handler.ts │ │ ├─ schema.ts (validation) │ │ └─ data.ts │ ├─ stalls/ │ └─ requirements/ ├─ packages/ │ └─ shared/ published DTOs / types — never internals ├─ docs/ │ ├─ DESIGN.md Foundation design (STABLE) │ ├─ adr/ cross-cutting decisions · 0001–0012 │ └─ modules/ │ └─ requirements/ │ ├─ DESIGN.md module design (Living) │ └─ adr/ module decisions (own numbering) └─ .dependency-cruiser.cjs CI boundary rule — build fails if a module reaches in
docs/, each module its own docs/modules/<m>/; and CLAUDE.md carries the rules, root-level plus a per-module one only where a module needs its own.Data view — one database, a shelf per module
The same choice seen from the data side — one database, but each module keeps its tables in its own section.
Inside the one database — a schema per module
msr_dev ← one PostgreSQL database │ ├─ schema foundation ← the shared join spine │ ├─ person person_id (PK) · sso_id · email · phone │ ├─ participation person_id → module · role_key · dept · enabled │ ├─ department │ ├─ module │ └─ activity_log who · dept · module · what · when │ ├─ schema vehicle_passes │ ├─ pass FK person_id → foundation.person │ └─ vehicle │ ├─ schema stalls │ └─ stall FK person_id → foundation.person │ └─ schema requirements ├─ requirement FK person_id → foundation.person └─ requirement_item
How it stands against the rest — the code shape
Every sensible way to structure the code, side by side with why we landed where we did. The others aren't wrong everywhere — they just don't fit an app this size with a shared people-thread at its centre.
Why it fits — this app specifically
Beyond the general case, the specific reasons this shape suits MSR — the product, the small team, and the once-a-year burst.
- Our product literally is "Foundation + Modules." The architecture names the design you already have — code layout mirrors the mental model 1:1.
- Transparency needs one database. "Who did what across all departments?" is a single lookup in one database — not a stitching-together of data from separate systems.
- Small team, tight timeline. One app, one build, one database. Every hour goes to features, not plumbing.
- A burst once a year, not constant scale. Turn capacity up for MSR, down after — no rebuild, whatever the host.
- "Nothing meaningful is ever destroyed." One database means one backup, one restore, and the ability to rewind to any point in time.
- Grows without regret. A new module is just a new folder, its own section of the database, and the same three connections to the Foundation; the automatic boundary check guarantees it can't break the others.
The exact tables and columns the database starts with. A database's shape is the one choice that's genuinely hard to change once it's built, so it was drawn on paper first; it is now built, test-driven, and applied — a single migration on a foundation schema, 37 invariant tests green, seeded on a fresh DB. This is the most detailed, engineer-level part of the doc; the plain idea behind it is in the Foundation and Architecture sections above. Shape changes from here go through a new ADR, not an edit.
- Real Postgres enums for
edition.statusandscope_level(nottext+CHECK) — stricter typing, settles ADR 0013's "howscope_refis typed". - Role tables keyed by
role_key/privilege_keyalone — the schema-lab'smodule_keycomposite dropped, since the context is the schema (truer to ADR 0018's per-context layout). scope_refis a polymorphicuuid, no FK — it points at an edition or an ashram, guarded by ascope_guardCHECK (level ↔ ref must agree), not a foreign key.- Append-only enforced at the database — a trigger blocks UPDATE/DELETE on
activity_log, not just app convention (defense-in-depth, ADR 0004). - Two locks post-date the schema-lab:
person.sso_idis nullable + unique (admin-first onboarding), and the department↔ashram consistency is actually enforced (via trigger), not deferred.
The Foundation tables — core tables + the tenancy dimensions
The shared tables every module is built on. In plain terms: person is the one list of people; department, ashram and edition are the context each action belongs to — which team, which campus, which event-year; module and role register what exists and the roles each module offers; participation is the single row that grants someone access; and activity_log is the record of everything that happened. Each card below is one table with its columns.
msr) — the "MSR area" marker; generic, never hardcoded. "exists-for", not "participates-in".vehicle_passesiyc, ssbiyc. Data must never cross ashrams.msr; "MSR" never hardcodedMSR·IYC·2027 (open) + last year MSR·IYC·2026 (closed, holds imported history). Every module's data hangs off an edition.scope_guard CHECK keeps level ↔ ref in agreement; null only when globalparticipation.granted, pass.issuedperson_id is the shared thread every module hangs off.role_key is, and why it lives in the Foundation:
- What it is: the one word that says what someone is here — "admin", "approver", "read-only". The Foundation stores only the word, never what it means (the module decides that).
- Why the Foundation needs it (the real reason): "who is what, where" is a whole-event fact — not any one module's business. Only the common place can answer what leadership actually asks: your Workspace (all your roles in one screen), "who's an admin across all 90 departments?", "cut all of Jaya's access — she left", and the same for the AI. A module knows only its own corner — it can never answer across all of them.
- And it has to be fast. Because it sits in one indexed place, those answers are a single sub-millisecond lookup (measured), onboarding is one write, and offboarding is one switch — not a 90-module hunt.
- Yes — a module could enforce on its own. It could keep its own role and still allow/block an editor vs a viewer; that part works without the Foundation. We keep the word here for the whole-event jobs above — and since it already lives here, the module just reads the same word and needs no table of its own. Enforcement comes free; it isn't the reason.
scope_level (edition · ashram · global) says how far a grant reaches — never a blank "everything".How a module hangs off it — the first real one, plus one mock
The left-hand table is real and built — the heart of the Requirements module's own section of the database (its full set is below, under Requirements). The right-hand one is made up, to show the same pattern would hold for any other module. Notice each links back to the shared person / department lists and carries its edition, but keeps everything else to itself.
person, department, edition. Everything else — scope, status, workflow — is the module's own; the Foundation never stores or interprets it. Requirements is built (its five tables, plus its own three access tables, landed together in one step); vehicle_passes.pass is still an illustration.Roles as data — the role-builder (ADR 0018)
role_key stays opaque; these tables just give it something to resolve against. Validated in the schema-lab (resolution sub-millisecond; a role change stays O(role), touching zero participations). Built and applied in Slice 3.Each role-owning context — every module, and the Foundation for its own roles — keeps four small tables, and can() becomes a join instead of a hardcoded check. They live in the context's own section, never a shared registry.
budget.write). Roles are composed from these; capabilities are never invented in the UI.participation.role_key — resolved by joinadmin_console for the platform's own operator roles (platform_admin, back_office_operator, auditor). Same four tables everywhere — the Admin Console is just another module-context (ADR 0012/0020). Measured in the schema-lab at 50k grants: resolving privileges 0.49 ms; a role change re-empowers hundreds of grants in 0.85 ms with zero participation writes. Full numbers in sandbox/schema-lab + ADR 0018.- One copy per module, in that module's own section —
requirements.role,stalls.role, … Never a single global set, and never in the core Foundation. - The Foundation's own operator roles (
platform_admin,back_office_operator,auditor) sit in the admin_console module's schema — the admin console is itself a module (ADR 0012/0020). - The core Foundation gains no new tables — it still keeps only
role_keyon the participation (the assignment). - Why per-module, not global: one module's privileges (
budget.freeze) mean nothing to another; a single shared table would couple all modules and let one bad change break the rest. Separate copies keep each module's blast radius to itself. - "But cross-module oversight?" Already covered by the global participation table (+ an admin-tier marker) — you get "who's admin anywhere" without making the definitions global.
Two rules the joins obey
With the tables drawn, two rules govern how they may link to one another — one for how a module reaches the Foundation, one for how modules reach each other.
Modules → Foundation
A module may link into foundation.person and foundation.department — the one connection every module is allowed. It reads its own participations to build a screen, and adds one line to the activity trail on every meaningful change. It never touches another module's tables.
Module ↔ Module
Never a direct link into another module's tables. A module reads another's data only through its published "public menu" — a small, stable, agreed set of things it offers — or through announcements it broadcasts. One-directional, no loops, and checked automatically.
materials.line_item──▶requirements.requirement
one module reaching straight into another's tables — not allowed; the automatic check stops the build.
materials──asks──▶requirements through its public menu
through the front door — the small, stable public menu. One-directional; if the reverse is ever needed, the other module sends an announcement instead.
email/phone uniqueness and blanks are handled), how the merge redirect is enforced, the role registry (labels stored here vs read live from each module), whether a module row copies the department it's acting for or works it out, and how the ashram + edition scope sits on the participation and on module rows (denormalised vs derived). Refinements, not a blank page.What actually happens when someone logs in, opens a module, and does something — which tables are read and written, and how fast. Measured on a mock of the real schema at above-real scale (1M activity rows · 200k guests · 500k requirements · 50k role grants resolved live). Foundation↔Module calls are in-process (one app), not network hops. Full sequence diagrams, table-updates and reproduction live in docs/performance.md (+ sandbox/schema-lab/) — this is the high-altitude summary.
| Call · most frequent first | Direction | Tables | R/W | DB time | End-to-end* |
|---|---|---|---|---|---|
| Read participations — "who + roles here" | Module → Foundation | participation | R | 0.18 ms / 0 cached | ~2–4 ms |
Resolve privileges — can() (enforcement) | Module (internal) | role_privilege | R | 0.49 ms | ~2–4 ms |
| Change a role's privileges | Module (internal) | role_privilege | W | 0.85 ms · 0 grants touched | ~3–5 ms |
| Oversight/AI — "who can X anywhere" | API → Foundation | participation + marker | R | 13–15 ms | ~15–20 ms |
| Log activity | Module → Foundation | activity_log | W | ~1–2 ms | ~4–6 ms |
| Module data read | Browser → API → Module | module tables | R | 6–33 ms | ~8–35 ms |
| Workspace load | Browser → API → Foundation | participation | R | 0.18 ms | ~3–5 ms |
| Role label (display) | Module → its role table | role | R | ~0.1 ms | ~1 ms |
| Write / change participation | Module → Foundation | participation + activity_log | W | ~3–5 ms | ~8–12 ms |
| Register module + roles | Module → Foundation | module | W | deploy only | — |
person_id (the person-360 query spanning all three schemas is 5 ms). The single biggest speed lever is keeping the API co-located with the database — bigger than schema or ORM. Full detail: docs/performance.md.Stress test — data-driven roles + load (schema-lab, at scale)
- Enforcing a permission is essentially free. Resolving a role → its privileges (the new data-driven
can()) is a 0.49 ms index-only join; a single yes/no check is 0.045 ms. Moving roles from code → data costs nothing at the database. - Changing what a role can do stays O(role), not O(members). Editing a role's privileges wrote one row in 0.85 ms and instantly re-empowered 683 people — with zero participation writes.
- Cross-module oversight / AI ("who can freeze anywhere", "who's admin anywhere") runs in 13–15 ms over 50k grants — fine for oversight; indexable if it ever goes hot.
- Concurrency: a small pool of ~20–30 connections sustained ~6,600 queries/sec with zero failures — enough for 10,000+ users (each query is 1–11 ms, so a small pool cycles for thousands). The only wall hit was raw connections (Postgres caps at 100) → solved by a connection pooler (PgBouncer), not a schema change.
- Scaling is infrastructure, never the schema: a pooler + a few app copies behind a load balancer + keeping the API co-located with the DB. The data model doesn't change from 1,000 → 10,000+ users.
sandbox/schema-lab/README.md · decisions: ADR 0018 (roles as data), ADR 0011 (hosting / pooler).The first real department app built on the Foundation. Its job: collect what every area needs for the event — materials, transport, volunteers, furniture, signages, infrastructure, sevadhars, cycles — into one trusted list the budget can actually be built from. Today that lives in scattered emails and spreadsheets, so nobody can see the whole picture or tell who has and hasn't submitted. Design in full: docs/modules/requirements/DESIGN.md · what & why: docs/modules/requirements/PRD.md.
One module, four views — not four modules
Everyone works on the same data; what changes is the lens their role gives them. That's why this is one module with role-gated views, rather than several separate ones (ADR 0021 reserves "separate module" for genuinely different lines of work).
The entry screen — last year beside this year
The one screen that decides whether this tool gets used. Coordinators plan by looking at what they actually used last year — so last year sits right next to the table they're filling in, not behind a tab. Left is last year, locked; right is this year, editable; the divider between them drags, so a coordinator reviewing a long list can widen the left, and one who just needs a glance can shrink it. (ADR 0001.)
Copy last year forward — the values, not the dates
Starting from a blank page is why coordinators under- and over-plan. So they can tick last year's rows and copy them over as a starting point. But we deliberately do not carry the dates across.
What comes across
The descriptive things that genuinely repeat year to year — the item, how many, where it comes from. Each field is individually marked as "carry this forward" or not, so operational leftovers (what was already issued, return dates, remarks) never travel.
What doesn't — and why
Dates. We considered shifting them automatically to match this year's festival date and rejected it: not every date tracks the festival, so a blanket shift would quietly fill the sheet with dates that look right and aren't. A wrong-but-plausible date is worse than an empty one. The coordinator sets them — with last year visible alongside and drag-to-fill making it quick.
Nothing about the eight categories is hardcoded
The old app baked each category and its columns into the code, so every rename or new column meant a developer change in several places plus a release. But these will keep changing — several categories' fields are still under review right now. So the categories and their fields are a list the system reads, not code. (ADR 0002.)
area_registration came with the admin setup screen, once building it showed the year's area list had nowhere to live.) The tell that it worked: the admin's inability to edit an area's numbers is stored as data — the admin role simply holds no "write" permission — rather than relying on a check a developer could forget.Only the area's own coordinator edits its numbers — not even the admin
This data feeds the budget, so its value rests entirely on it being what each department itself declared. So there is no admin edit screen at all — a deliberate departure from how the old process worked.
Area Coordinator can
- Enter and edit their own area's rows — and only theirs
- Work through several areas separately, if they hold several
- Read their own past years
Module Admin cannot
- Edit any area's numbers — at all
- Their job is set-up (register areas, assign coordinators) and freeze
- A genuine emergency correction goes through break-glass — logged and attributed, never a quiet edit
Last year's data, and why each year looks like itself
Last year's numbers have to be there on day one, or the copy-forward flow has nothing to stand on. They're brought over from the old app once, by a script, and then live here as ordinary rows stamped with their year — so after that load, nothing depends on the old app any more. (ADR 0005.)
Each year shows the categories it actually had. Categories come and go, so we show a category for a given year only if it held data that year — plus, for the year you're working in, everything currently active. A category introduced this year doesn't clutter last year's view; a retired one still appears in the years it was really used. No per-year snapshot of the structure is stored anywhere — this reads straight off the data we already have.
Freeze — where the whole thing is heading
The point of collecting all this is a number the budget can rely on. Once a category is complete enough, the Module Admin freezes it: from then on that category stops moving — for everyone, through every path, enforced on the server rather than by hiding buttons. Categories freeze one at a time, so materials can be locked while volunteers is still being filled in. Undoing it is a deliberate unfreeze, and re-freezing is required to lock it again — so every post-freeze change is a conscious, traceable act.
What ships first
The first deliverable proves the whole entry loop end-to-end on one category — because once the engine is driven by the category list (mechanic 4), the other seven are entries in that list, not seven more screens to build.
In the first deliverable
- The entry screen + the category-driven engine
- Materials proven end-to-end, then the rest as list entries
- Coordinators scoped to their own areas
- The admin set-up that makes the above possible
- Last year's data loaded in
Later phases, same module
- Freeze
- The Cluster view + who's-submitted tracking
- Email campaigns to coordinators
- A richer admin oversight dashboard
- Cycle Management
The significant decisions behind the platform, each written up as a short one-page record — an ADR ("Architecture Decision Record"). This is the index; the full reasoning for each one lives alongside the code. The list only grows: when a decision changes we don't rewrite the old record, we add a new one that replaces it — so this history always matches what we actually built.
- 0001Shared Foundation, bespoke modules — no universal workflow engineAccepted
- 0002The Foundation is a participation registry, not an authorization engineAccepted
- 0003Modules are run by one department, used by manySuperseded by 0014
- 0004Schema designed for downstream analytics and AIAccepted
- 0005Module roles are static (defined in the module) — no runtime role-builderSuperseded by 0018
- 0016No role registry in the Foundation — role definitions/labels live in module code, resolved live in-process (refines 0005, 0014)Accepted
- 0006Multiple roles per module; permissions are the unionAccepted
- 0017The participation carries the role (role_key) — the grant is the role, not bare membership (why role_key exists)Accepted
- 0018Roles/privileges/scope authored as data — a runtime role-builder, module-owned; Foundation is its own role context (supersedes 0005; validated by the schema-lab)Accepted
- 0007Modular monolith, one DB (schema-per-module), AWS hostingSuperseded by 0011
- 0008Modules communicate through published contracts, never by reaching inAccepted
- 0009Security is enforced on the server, every request — never in the UIAccepted
- 0010Documentation governance & doc-sync workflow (ADRs append-only)Accepted
- 0011Modular monolith + one DB (schema-per-module) settled; hosting deferredAccepted
- 0012Foundation admin is a capability-based console module — tiered operators, seeded roles, runtime builder deferredAccepted
- 0013Multi-tenancy (ashram) & yearly editions — one shared Foundation, hierarchical scope on the participation; multi-event kept open, unbuiltAccepted
- 0014Module ownership is emergent from the appointed admin participation, not a stored owning-department; modules may be jointly runAccepted
- 0015Areas are Departments; a nullable
eventOnlyFormarker distinguishes permanent departments from event-only areas (generic across events)Accepted - 0019Department is flat — no stored hierarchy; grouping is naming-convention only, rollups via participations & scoping via member_scope (additive escape hatch if a real hierarchy ever surfaces)Accepted
- 0020Operator-tier role tables live in the Admin Console module's
admin_consoleschema, not thefoundationcore (reaffirms ADR 0012, clarifies ADR 0018; corrects the Slice 3 placement)Accepted - 0021App shell + module mounting: a thin Workspace launcher over participation rows; each module declares its routes in code (a frontend manifest —
key·name·icon·routes·group?) mounted under/m/{key}via React Router; separate modules + display group (Vehicle Pass / Name Tags = separate; "Access Control" = grouping); server registry unchanged (ADR 0016). Amended by 0025 — the launcher now expands a module into its role-unlocked views; the other nine points standAccepted · amended by 0025 - 0022App-shell chrome (additive to 0021): a persistent centered operating-context switcher pill (
edition · ashram, always visible; switching visual-only/deferred per ADR 0013, source of truthapp-config.ts) + a ⌘K/Ctrl-K command palette for jump-to-module; Workspace is labelled "Your Dashboard" in the UI (concept unchanged)Accepted - 0025The Workspace expands a module into its role-unlocked views (amends 0021's one-tile-per-module). The module stays the unit; under it sit the views the person's roles unlock (Requirements: My Requirements · Cluster · Admin · Cycle, ~1:1 with roles), shown directly — no menu page. A single-view module collapses to one tile; it expands into a labelled block only for someone with several. Modules declare their views in the manifest (
name·route·unlockedBy) so the Foundation renders them generically (ADR 0016/0008 intact); client-side fromroleKeys, no backend/contract/stored-data change; server still authorizes each view (ADR 0009). Landing is the Workspace; "Dashboard" reserved for the story-28 reporting screenAccepted - 0023Versioning & rollback: version the whole monorepo by git SHA (one matched frontend+backend+contract per commit), immutable SHA-tagged artifacts, roll back both apps as a unit; DB is never rolled back with the code — migrations stay forward-only but backward-compatible via expand/contract. Artifact/deploy mechanics ride on the deferred hosting ADR 0011Accepted
- 0026Appointing a module admin: the client sends a module and an email, nothing else. The admin
roleKeyis derived from the module's ownMODULE_ADMIN_ROLESdeclaration (ADR 0016) and an explicitly-named one is validated against it — closing write-any-role-into-any-module; the acting-for department defaults to the reserved Back-office the operator's console grant already carries (mechanic 7, no dropdown); scope defaults to the edition appointed for (ADR 0013), level+ref as a pair. A module admin may appoint a peer admin — stated and tested, not an accidental pass-through: a single-admin module can't survive that admin being unreachable. Both grant paths are idempotent on(person, module, role, dept), fixing a re-appoint500. Back-office-only UI; a403locks the surface (ADR 0009). Extends ADR 0009 §4 and 0012Accepted - 0029A module admin can see the module's admin set — supersedes ADR 0028 decision 8 ("the screen lists no admins yet"), which said so only because no read existed. ADR 0026 dec. 6 and ADR 0028 both exist because a module with exactly one admin is stranded the moment that admin is unreachable; an admin cannot act on that risk without seeing it, so the mitigation shipped without the instrument that says whether you need it. The list renders beside the appoint card, so the reason to appoint is visible where appointing is possible. Three settlements: the read is a Foundation seam (
listModuleAdmins, same context shape asonboardMember) not a Requirements one, because every module hosting the member seam has the same failure mode and two implementations of an authorization-bearing read is how they diverge; it is authorized exactly as the paired write is — the caller holds one of the target module's admin roles — not by a Requirements privilege join, since "who holdsmodule_admin" is a Foundation grant fact and a newadmins.readprivilege would only restate an existing authority; and it shows active grants only, for the active edition — a disabled grant is not a backup, and who was removed is the activity trail's record, told better there. Not the generic members screen ADR 0028 rejected: that rejection was about a screen covering every role, and a seam answering one question does not commit us to itAccepted - 0028A module admin appoints peers from the module's own screen — supersedes ADR 0026 decision 8 ("appointing UI is back-office-only"), which had left ADR 0026 decision 6's peer-delegation capability with no surface a module admin could actually reach. Requirements' Admin setup gains an Appoint another admin card (email only; edition-scoped, department defaulted to the appointer's own). The back-office Module Admins screen stays and is not a duplicate: it bootstraps a module with no admin (
modules.manage, no participation), where this extends an existing admin set (authorized by holding an admin role there) — different authority, precondition and seam. Second half:onboardMembernow validates the grantedroleKeyagainst the target module's declared role set (403otherwise), closing the twin of the hole ADR 0026 dec. 2 closed on the other grant path —role_keyis opaque text with no FK, so an unrecognised key persisted a grant that authorized nothing and appeared nowhere. The whitelist is read from each module's own in-codeROLES(module-roles.ts, composition root → module), never restated, so it cannot drift. Admin roles stay grantable — bounding which module's roles, not how privilegedAccepted - 0027A view-tile owns its icon and badges only the roles that unlock it (extends ADR 0025):
ModuleViewgains an optionalicon— falling back view → module → initials monogram — because two views of one module both drew the module's clipboard and stopped telling the tiles apart; and a view-tile badgesroleKeys ∩ unlockedBy, so "Admin setup" is no longer labelled Area Coordinator. The badge is the only place the UI says why a surface is open to you, so it must not say something untrue. Chrome corrections in the same pass: the group header renders only when there is more than one group (one group drew the literal fallback "GENERAL" above the module's own label), and the counts pill counts modules only — the group count is dropped rather than relabelled, because an Area IS a Department (ADR 0015) and a manifestgroupis display-only (ADR 0021). Frontend-only: no schema, contract or endpointAccepted - 0030Search-and-confirm before minting a Person — restores
DESIGN.mdmechanic 1 ("find first, then add"), which the code has never implemented: every add-or-reuse-by-email path did an unconditionalupsertsince slice 7, so a well-formed but wrong address silently minted a phantom Person holding a real grant. Supersedes nothing — the decision was already written; the code was out of step. Running the gate before designing found the thing that shaped it:Personhas no name and no phone, so an email-only search cannot prevent the harm (the admin searches the address they believe, finds nothing, confirms anyway) — matching on a name is what lets the search disagree with the admin. So:persongains a nullabledisplay_name(admin-supplied, SSO-authoritative at first login; never backfilled from an email local-part, which would fabricate the field the search must trust); the read is one Foundation seamsearchDirectory(per ADR 0029 dec. 2), authorized as the write it precedes — each of the five callers runs its own existing gate and then calls the seam, which does not re-run it (the first draft had the seam re-run the caller's gate; a Foundation seam re-running Requirements' privilege join would import from a module — an ADR 0008 cycle, caught at implementation planning before any code was written, andlint:boundariescovers onlyapps/webso CI would not have), because a newdirectory.searchprivilege would be a synonym for that exact set (ADR 0016 / 0029 dec. 3); matching is a case-insensitive substring of email or name (the wrong-address case is a middle difference —sadhgurvssadhguru— which a prefix match misses), min 3 chars, capped at 25, unpaginated on purpose — paging turns a lookup into a browse. The create path states its intent:personId(reuse, picked from the directory) xornewPerson(asserting a new human); a bare email is a400, because the confirm cannot live in the UI (ADR 0009) — the idiomEmptySubmissionErroralready set.merged_intoresolved on reuse and merged-away rows hidden from search (mechanic 1); a sign-in-disabled Person does appear, marked, since their record is the one that must be reused rather than duplicated. Five API sites + three forms change together via one sharedPersonPicker; an exact email match is still picked, never auto-reused. Breaking API change. Closes prevent only — detect / confirm / merge stay unbuiltAccepted - 0024The target edition is MSR 2027: seeded editions corrected to
2027open +2026closed (the legacy*_currtables run to Feb 2026, so_curris MSR 2026). Supersedes only the year labels in ADR 0013 — its rule, "current open + last year closed", is unchanged. The seed keepsupdate: {}and so cannot self-repair a mis-yeared edition: opening/closing stays an audited admin action, never a redeploy side-effectAccepted - 0031A module's single-view Workspace tile is labelled by its view, not the module — supersedes ADR 0025 decision 3's tile-labelling clause only (the collapse-to-one-tile behaviour itself is unchanged). A person holding one role saw a tile named after the whole module (e.g. "Requirements") that opened one specific screen ("Admin setup") — label and destination disagreed. Now the tile takes the view's own name/icon, matching the multi-view case ADR 0025 already got right. A module declaring no
views(Admin Console) is unaffected. Frontend-only, no schema/contract/server changeAccepted - 0032Entering an edition is grant-scoped; the Workspace owns the switch, the header only says where you are. Amends ADR 0013 §6 (a closed edition is no longer "fully visible" to whoever can reach it — entering one needs a grant; its read-only half stands and is now shown on screen, not merely refused on save) and ADR 0022 dec. 1 (the pill shows, it no longer switches). The pill has listed reachable editions since 0022 and switching has never worked — a radio group with a
valueand noonValueChange; its follow-up ticket is marked complete with the switching half of its brief undone. Nine decisions. Two layers is the load-bearing one: the Foundation gates which editions you may enter, a module still decides what past data it shows inside your context — so Requirements' read-only last-year panel stays area-scoped reference and copy-forward survives (the single-layer alternative would have deleted module ADRs 0001/0004/0005 for every ordinary coordinator, and its "fix" was re-granting every coordinator on the closed edition — the same access spelled out row by row). Active edition is server-held, validated against grant scope on the guard shipped at roadmap row 21; a module may still name aneditionIdas a read TARGET, authorized by the same seam — two verbs, enter needs a grant, reference is the module's own scoped read. One list grouped by location: an Edition is event × ashram × year, so a separate Location control could only ever express states that do not exist — andDisplayEditiondropsashramKey, so a second ashram would draw two indistinguishable "MSR 2027" rows. Archive access is an ordinary grant scoped to the closed edition, the role deciding the view (cluster_coordinator= every area read-only) — no new privilege, no "archive viewer" concept. Header shows, Workspace switches — a deliberate deviation from GCP/AWS/Slack, all of which keep the switcher in the header, taken so a mid-entry switch is structurally impossible rather than warned about; the accepted cost is a trip to the Workspace and a label that looks clickable. Sticky for live years only, so rollover self-corrects and nobody wakes up parked in a closed year. Prior art for the archive half is accounting software, not the cloud consoles: Xero / QuickBooks / NetSuite "close the books" — a locked period is read-only for everyone regardless of role, and prior-period access is a separate explicit permission. Two of its nine decisions have since been amended — see 0033. Dec. 7 was corrected a second time the same day: no separate Workspace switcher exists — the header pill IS the control, live only on the Workspace path and only when more than one open edition is reachable (built as a distinct card first, dropped on sight for repeating the header with nothing to click).Accepted · part built - 0033An archive is a URL, not an operating context. Amends ADR 0032 decisions 2 and 9. 0032 dec. 9 said a live edition a person chose is restored "next session" and a closed one is not — but there is no session:
getCurrentPersonresolves a bearer token straight to aperson_id(a JWT under SSO, still no server state), andgetAppContextruns on every page load, not once at sign-in. So "not restored" and "pressed F5" were the same event, and a coordinator reviewing last year would be thrown back to the open year on their next navigation — deleting the archive access 0032's own decisions 4 and 5 exist to provide. Found the same way 0032's earlier self-contradiction was: reading the ADR against the code before implementing it. The fix keeps the intent and drops the mechanism — the active edition is a live year and only a live year (POST /api/me/active-editionrefuses a closed one with 409, deliberately distinct from the 403 for reach, because someone holding a real grant on last year has not done anything unauthorized and must not be sent chasing access they already hold), and a closed edition is reached by naming it in the request, authorized per request by the same reach guard, so nothing is persisted and nothing has to expire. Leaving an archive is closing the tab. The rejected alternative was a timestamped preference that decays — a clock invented to simulate a session we deliberately do not have, with a constant that is too short for careful reading and too long for coming back the next morning. Rollover still self-corrects, now for free. Named cost: this is 0032's second correction in one day — both caught before code, which is the process working, but worth stating plainly thatgrillingsettles decisions and does not verify them against the system. Decision 1 is built; the URL half is web work, not startedAccepted · part built - 0034UAT hosting: API on AWS EC2 behind an ALB (targets the box on
:80directly, no reverse proxy), self-managed Postgres 16 on the same instance, web on Netlify; a plain Node process (no Docker), deployed over SSM Session Manager (no SSH keys). UAT-only — partially answers ADR 0011; production hosting and its data-governance question stay open. Trade-offs named as UAT-acceptable: no managed backups/PITR, single box/AZ (app+DB share fate), manual deploysAccepted - 0035Automated UAT deploy: a push to
msr-devships the backend via a GitHub Actions workflow that runs one AWS SSM command on the box (no SSH; role assumed by OIDC, no stored keys), executingapps/api/deploy/deploy-uat.sh—reset --hard→npm ci→db:generate→db:deploy→db:seed→ restart → health-check gate. Migrations use a DDL-capable URL pulled from SSM Parameter Store; the app keeps its least-privilegerw_role. Delivers ADR 0034's automation follow-up. UAT-only; gating the deploy on CI-green and the prod guardrails of ADR 0023/0011 are named follow-upsAccepted - 0036The live doc is a static multi-file site, not one self-contained file. Supersedes the "one self-contained HTML file" property of the live-doc convention (ADR 0010's sync rule is unchanged). Split into
msr-live-doc.html(structure) +styles.css(theme — mirrorsapps/web/src/index.css) +app.js(sidebar/scroll/theme toggle), same folder, no build; the tiny no-flash theme script stays inline. Filename kept so the ~23 references (CLAUDE.md, ~14 ADRs, two skills, the doc-guard, both preview servers) stay valid — hosting names the entry document, the repo renames nothing. Trade: no longer one portable file, but it's served, not emailedAccepted
Updated 2026-08-04. New ADRs are added here as part of the same change that makes them (the Definition-of-Done gate). Tentative = not yet final; read the ADR before relying on it.
Each Module keeps its own ADR series, independent of the Foundation numbering above. First module: Requirements — its full design is in docs/modules/requirements/DESIGN.md, the what/why in that folder's PRD.md.
- Requirements0001Area Coordinator entry screen: resizable side-by-side year comparison (previous year read-only, left; current year editable, right) over a draggable divider — not a fixed 50/50 split or a full-table expand toggle; current-year grid on an adopted Excel-like library, not hand-coded; copy-from-last-year is the default on-ramp; file upload/download round-trip deferredAccepted
- Requirements0002Categories & their fields are a config-driven registry with stable keys — remove = retire (status flag), rename = relabel, add = append; never hard-delete anything with history behind it. Config-as-code (dev-owned, not a runtime form-builder); JSONB values so field churn needs no migration. Settles "one category vs all 8": build the engine once, the rest are configAccepted
- Requirements0003The Area Coordinator is the sole editor of area data; the Module Admin cannot edit it (oversight/setup/freeze only) — a deliberate deviation from legacy §10, to protect attribution. Emergency fixes are logged break-glass, never a routine admin edit surface; MVP builds no admin-edit path. Coordinator scoping reuses existing Foundation primitives (ADR 0015/0017/0009)Accepted
- Requirements0004Copy-forward copies the values, not the dates — the coordinator sets dates for the current year, aided by last year shown alongside. No auto date-shift (a blanket shift injects plausible-but-wrong dates; not every date tracks the festival) and therefore no Foundation
event_datechange. A targeted per-category shift stays a future option only if real usage demands it. The drag-to-fill mechanism is superseded by ADR 0010 — a Fill-down button now reaches the same outcomeAccepted - Requirements0005Last year's reference data is loaded once from the legacy app's prev/current tables (2024 + 2025) into the new DB and kept native — stamped by edition, in the config-driven shape — not a live legacy integration. Read-only comes free from the closed edition (no archive table). Load 2025 for the MVP, 2024 if cheapAccepted
- Requirements0006Entry grid library is RevoGrid, not react-datasheet-grid or Glide Data Grid — settled by a live browser spike on React 19 (the app's actual version), not docs alone. The other two each have a live-reproduced React 19 problem (a crash, or an install-time failure); RevoGrid has neither, is the best-maintained by a wide margin, and (at the time) was the only candidate with live-wired touch support for the drag-to-fill gesture — that last reason is moot as of ADR 0010 (drag-to-fill was replaced by a button); the library choice itself stands on the other reasonsAccepted
- Requirements0007Which areas take part in a given year is recorded, not inferred — a small per-year list the admin edits. The tempting shortcut was to read it off the "exists only for MSR" marker, but that marker describes what a department is, not whether it takes part: Traffic is a year-round department and an MSR area. The other shortcut — treating "has a coordinator" as "takes part" — would make it impossible to register the areas before naming who runs them, which is the actual order the work happens inAccepted
- Requirements0008A past year is shown with today's columns, not its own historical set — blank where a field didn't exist yet; a retired field's old values stay in the data (and in analytics) but aren't shown in the entry grid. The grid is reference for entering this year, not an archive viewer: seeing last year in this year's shape is the point, and it keeps the two side-by-side panels reading straight across. True per-year fidelity would need a stored yearly snapshot the module deliberately avoids — it belongs to the later oversight surface if ever neededAccepted
- Requirements0009The entry screen's controls are one sticky row of menus, and the year is stated rather than offered — reverses the layout half of the 2026-07-29 roadmap position ("the switchers stayed visible") while keeping its requirement: the screen still says where you are, and now keeps saying it while you scroll. Five stacked blocks (title, "Your areas" pills, "Viewing" pills, a wrapping row of eight category buttons, a sticky breadcrumb) pushed the grid most of the way down a 720px viewport, and the category row already wrapped at eight with more coming by PR (ADR 0002). Now: area + category are menus whose trigger shows the current value (plain text when a coordinator holds one area — a menu with one option is a click that cannot change anything), the page title goes because the Workspace tile is itself named "My Requirements" (root ADR 0031), and the breadcrumb goes because the row it duplicated is now sticky itself. The in-module year switcher is retired: last year is not hidden by it — it is the permanent read-only left panel (ADR 0001 §5) — and entering a past edition is a grant-scoped Foundation concern, not one module's toolbar. The row must sit outside shadcn's
Card, which isoverflow-hiddenand so silently makes any sticky child inert. The accepted cost: you can no longer see the other areas/categories without opening a menu — revisit this first if a coordinator reports losing their place. A left rail was the honest alternative and was rejected for spending ~200px of width, the axis the 10–12-column categories need most (ADR 0001 §4). Frontend-onlyAccepted - Requirements0010The grid gets real cell editors for date/select/multiselect, and a Fill-down button replaces drag-to-fill. R8 declared column types RevoGrid was never actually configured to honour — confirmed live on the running grid (
columnTypes/editorsboth empty) — so every cell, whatever its declared type, has always fallen back to plain text. Multiselect could never be saved through the UI at all (proven against the live API: a string is rejected, a real array is accepted) — the roadmap's R10 claim that this was "verified in the running app" did not hold for coordinator entry, only for legacy-loaded data; corrected in place. Dates had zero format validation anywhere — proven by saving a nonsense string, accepted clean. Fix: native<input type="date">(a real calendar picker, always ISO format, no new dependency), the shadcnSelect, a checkbox list for multiselect's three options, and the same validation shape phone numbers already have, extended to email and dates. Decisions 1, 2 and 4 (editors, validation, theapplyOnClosegap) stand as built. Decision 3 (the Fill-down button) is superseded by ADR 0012 — see belowAccepted · built - Requirements0011Undo/redo in the entry grid is one shared, labeled, linear history stack, settled by
grillingafter Ctrl+Z was found to do nothing — there was no undo implementation at all, not a bug in one. Covers every mutating action uniformly (cell edits, paste, delete rows, copy-forward, add-row — fill-down folded into paste at ADR 0012), one undo step per action (a 40-row paste undoes in one step), unbounded depth for the session, and clears on save or on switching area/category/year — so undo can never put the screen and the database quietly out of step. Triggered by bothCtrl+Z/Ctrl+Yand toolbar buttons (disabled empty or mid-save); redo is standard linear (a new edit after undo drops the redo branch); every undo/redo names what it reverted via the existing save-style notice. Implemented as a new pure module,grid-history.ts, sibling togrid-model.ts, tested with no RevoGrid involved. A follow-up bug found live the same session — the Save button's "unsaved" label stuck after an undo restored the exact last-saved state, becausedirtywas a manually-toggled flag — is fixed by deriving it from a real comparison (sameRows) insteadAccepted · built - Requirements0012The Fill-down button is replaced by RevoGrid's native copy-paste fill — supersedes ADR 0010 decision 3 only (its real-cell-editor and validation decisions stand). Monish asked whether copy-paste would be more natural than a button. Reading RevoGrid's compiled source (not just its docs) found
getRangeFillClipboardData: copy one cell, select a range, paste, every cell fills with the copied value — gated behind the OBJECT formuseClipboard={{ rangeFill: true }}, not the baretruedefault the grid was quietly running on. Confirmed live in the running preview before deciding, and again after removal. One prop added;fillDown(), the Fill-down button, and its handler/state all removed — less of the module's own code, and the ordinary Excel gesture coordinators already know instead of one to discover. Mobile is not worse here (unlike drag-to-fill's real touch problem): OS copy/paste fires the same native clipboard eventsAccepted · built - Requirements0013Rows are marked for delete with Ctrl/Cmd+click, and grid selection state is never written into RevoGrid's
source. Closes a roadmap entry corrected three times in one day as each mechanism failed live — range-only → plain-click-selects → drag → Shift+click → Ctrl/Cmd+click (drag never fired a range event through a real pointer: ~150 focus events, zero range; Shift+click's range event isn't reachable from the public event surface). The last round shipped two live-reported defects with different causes that looked like one. (A) Typed values vanished on click-away — real data loss, suite green throughout. The highlight rode on the row data (rowClassnames a property on each row), sosourcebecame a new array on every click →dataSourceChanged→setData→ the open editor re-rendered, and RevoGrid's defaultTextEditoris controlled, so it re-applied the stored value to the live<input>;applyOnClosethen read that reset input and saved the OLD value back. Every step is ordinary library behaviour — the defect was entirely in drivingsourcefrom view state. (B) "I don't know if I have selected it or not." Not the selection code:--revo-grid-focused-bgwas themedprimary 18%and RevoGrid spends it on the whole row under the cursor — the same colour the selection used, so the two were pixel-identical. Now focus and selection differ in kind, not strength: neutral wash vs brand wash + a 3px left bar. Highlight moved to a generated stylesheet keyed on thedata-rgRowindex RevoGrid already stamps (survives row virtualisation for free; every value a token, no colour in the component). A plain click now only clears — a destructive action needs a deliberate gesture, and undo (ADR 0011) still covers a mistake. Tests pinsourceas the same array across selection gestures, not merely deep-equalAccepted · built - Requirements0014Electrical is a 9th category, own tab, own real grid columns (like Signages' 18) — and the same ~20-field content is separately spread (
.map()-derived, not hand-duplicated) intoINFRASTRUCTURE.fieldstoo, tagged with a new field-levelentryMode: 'panel'display mode there. Closes the open question the prior session's handoff carried: not an either/or —electricalsits sibling toinfrastructureinCATEGORY_REGISTRY(zero migration per ADR 0002), and a shed row can independently capture the same detail when itsservices_neededincludes it — two doors, two separate rows, nothing linking them at the data level. Revised live, same session: the first build put BOTH homes behind the panel, reasoning "~20 fields is too many columns" independent of category — reconsidered once tried: that cost (a row growing dozens of blank columns) only exists on Infrastructure's shed rows, not on Electrical's own, where every row genuinely is about this detail; Signages' 18-column precedent settled it back to a plain table there.panel-mode fields (Infrastructure's copy, plus new IT/Telecom 2-field sets) render behind one appended summary cell instead of real columns, grouped by section in a reusable side panel, opened by a plain click/keydown rather than a RevoGrid editor (the read-only previous-year pane never enters RevoGrid's edit mode at all, so an editor-based trigger could only ever work on one side). Same panel component serves both panes via areadonlyprop, mirroringtoGridColumns's existing convention, with a category-agnostic "Service details" title. The Sheet UI primitive is hand-rolled fromdialog.tsx's own structure rather than installed via the shadcn CLI, which wanted to overwritebutton.tsxon a version mismatch. Verified live: re-seededmsr_dev, Electrical's own tab renders as a plain grid and saves a real row; Infrastructure's "Service details" doorway does too; theservices_neededmultiselect's own pick step could not be driven through this session's automation — the same class of pre-existing RevoGrid-automation gap ADR 0006's drag-fill follow-up already documented, not a defect hereAccepted · built
Updated 2026-08-02 — the Requirements module's first deliverable is built end to end: entry screen, save, all eight categories, last year's data loaded, and copy-forward. Year switching was built and then retired at ADR 0009; last year remains permanently on screen as the read-only comparison panel. The module's design narrative is the Requirements section above.