MSR Master Application

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.

Scale
90+ departments
Shape
One app, many parts
Identity
One person, one account
Record
Every action logged
New here? Read this first.

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.

How it's built · 1
The Foundation

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.

1

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.

Workspace
your home — every module you can open, and your role in each
Vehicle Passes
Stalls
Requirements
  each module stands on  
Foundation
login · one People directory · participations (who holds which role where) · activity trail
Log in once → land in the Workspace → see only the modules you've been given a role in → click to enter.
2

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
The Foundation stores that you hold a role. The module decides what the role means.
3

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.

person
who
module
where
role_key
which role
acting_for
for which dept
enabled
on / off
That's the whole thing — who, where, which role, which department, and on/off. It doesn't record what the role is allowed to do; the module decides that. Keeping this row tiny is what keeps the system flexible.
Why the role sits on this row — not just "is a member". The real reason is the jobs only the common place can do: your Workspace (all your roles in one screen), "who's an admin across every department?", and "cut all of Jaya's access — she left" — each a single, fast lookup, for people and the AI. "Who is what, where" is a whole-event fact, not one module's business, so it belongs in the shared place — and it has to be fast, which one indexed row makes it. (A module could tell an editor from a viewer on its own — enforcement would work without the Foundation; but since the one word already lives here, the module just reads it and keeps no table of its own.) Drop it and every module re-grows its own who-does-what notebook, and those cross-event questions become a 90-module hunt. (ADR 0017.)
✓ Role on the badge (here)
One word, one place. The module enforces from it; leaders & the AI see everyone in one look; someone leaves → removed completely in one step. New modules inherit it free.
vs
✗ Role only inside each module
Enforcement still works — but there's no cross-event view, the AI goes blind, offboarding can leak, onboarding becomes two writes that can disagree, and all 90 modules re-grow their own user tables.

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.

4

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.

1 · Sign in
Prove who you are (SSO). On its own this gives you nothing.
2 · Be given a role
The only way into a module. No participation → you don't even see it.
You can prove who you are and still be denied everything until someone onboards you.
5

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.

TermPlain meaningOwned by
RoleYour title — Approverthe participation row
PermissionWhat you can do — approve, issueModule
ScopeWhich slice — South Gate onlyModule

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.)

6

Several roles add up — union

What happens when one person wears more than one hat in the same module.

Approver+Issuer=can approve and issue
Hold several roles in one module; the module simply adds up what they allow. No pre-baked "super-roles".
7

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.

Module
screens · roles · permissions · scope · data
Foundation
identity · participations · the record
1
Register — "I exist, here are my roles."module → foundation
2
Who is this, and what role(s) do they have here? (plus a shared "add & manage people" screen the module reuses)foundation → module
3
This just happened — note it in the record.module → foundation
A module connects to the Foundation through only these three things — nothing else. It never has to ask "is this person allowed?" — it already has what it needs to decide for itself.
8

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.

Personone human
— holds many →
Participationrole_key · dept · on/off
← in a —
Moduleone operation
Departmentacting-for
tagged on each participation
Activity-trailwho · what · when · for whom
Only five things to keep track of: a Person, a Module, the Participation that links them (carrying a role and a department), and the Activity trail that records what happened. Everything else lives inside each module.
9

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.

duplicate
Person B
merged_into →
redirects on every lookup
survivor
Person A
The Foundation redirects B → A on every lookup, so access & history consolidate automatically — and modules never re-point their data. Reversible; nothing is deleted.
10

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.

One shared base underneath; each ashram and each year is a clean, separate slice on top — so nothing old is lost, and nothing new is hidden from the people who need the whole picture.
Past years are read-only — and who sees them:
  • 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.)
11

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.

Access levelManage peopleSettingsManage modulesMerge / disable accountsView peopleActivity log
Technical team · can do everything
Back-office · day-to-day
Auditor · look only
An example, not the final list — the exact powers per level get pinned down when we build the admin area. The idea is what matters: the risky controls (managing people, settings, turning parts of the system on and off) stay with the technical team; back-office handles the everyday work; auditors can only look.
These levels are built in a screen (ADR 0018). Access levels — for the admin area and for every module — are created and adjusted in a role-builder UI, not hard-coded, so a new level needs no developer and no release. The building blocks each level is made of (the individual powers) stay developer-defined — you compose a level from them, you don't invent new powers in the screen. And nobody can grant more access than they hold themselves. This is the same approach big sign-in systems like Google and Microsoft use.
12

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.

React + TypeScript Fastify (Node) PostgreSQL Prisma Isha SSO · OIDC + PKCE Hosting — not yet decided
One rule keeps it healthy: before adding anything to the Foundation, ask — does every module need this, or is it one module's thing? If one module can own it, it lives there. That's what stops the shared base from getting stiff.
How it's built · 2
System Architecture

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.

Hosting open
Two parts settled, one still open. How the code is organised and how the data is stored are both decided. What's not yet decided is where it all runs — on Isha's own machines or a cloud provider — and that choice doesn't change anything above it. It'll be settled separately once we have the inputs.

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.

1

Two views of one system

Two settled views of a single decision — plus one question that's still open.

Code

How the code is organised

Folders in one shared codebase — the Foundation, and one folder per department's part.

Data

How the data is stored

One database, with each department's part kept in its own section.

Two settled views — how the code is organised, and how the data is stored. Where it all runs is a separate question, still open.
2

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.

One app = Foundation + Modules
one codebase, shipped as one app — the modules are walled-off parts inside it, not separate systems
foundationvehicle_passesstallsrequirements
The simplicity of one app to build and run, with clean internal walls between each department's part — the structure of separate systems, without the cost and complexity of actually running dozens of them.
  • 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
One codebase, modules side by side with hard walls; inside a module, code is grouped by feature (a vertical slice), not by technical layer. The docs mirror the same split — Foundation design + ADRs at the root of 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.
3

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.

foundation
Person · Participation · Activity-trail
vehicle_passes
→ links to the shared people list
stalls
→ links to the shared people list
requirements
→ links to the shared people list
Because it's one database, a question that spans departments is a single lookup against the shared people list. Each module still keeps its own tables to itself.

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
Every module keeps its own tables in its own section, but they all link back to the one shared list of people. Because it's a single database, "who did what across every department?" is one simple lookup — and there's just one thing to back up and look after.
Data layoutVerdictWhy
One shared section (all tables together)✗ noNothing keeps the parts apart — every module's tables pile into one shared space and can touch each other freely. The same tangle, down at the data level.
A section per module, one database✓ oursEach module owns its own section, yet one database keeps the shared people list that everything links to — so an event-wide question stays a single lookup. One thing to back up, one place to manage, one shared history of changes.
A separate database per module (same server)✗ noKeeps the parts apart, but the database can't answer a question across separate databases in one go — the event-wide view has to be stitched together in code. More to back up and maintain.
A separate database per system (the separate-systems approach)✗ noFull separation, but every cross-department question becomes a call between separate systems, and the shared thread linking people across departments is gone. Only worth it at very large scale.
Same idea as the code side: enough separation to keep modules tidy and independent — but never so much that we lose the shared thread that makes whole-event reporting easy.
4

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.

ArchitectureVerdictWhy
One app, no structure✗ noNothing keeps the parts apart, so over time everything tangles together.
One app, split by technical layer✗ noDivided the wrong way, so a single feature is smeared across every layer.
One app, split by department (ours)✓ oursClear boundaries per department, one app to ship, one database, the shared people-thread intact — and it can still evolve later.
Many separate systems✗ noLots of separate systems to run, with no real payoff at our size — and it breaks the shared people-thread.
On-demand functions✗ noWrong fit for a system that's all about stored, connected data; slow first response; cost climbs under load.
Message-driven / spread out✗ noMore machinery than we need, and timing problems we'd have to solve for no reason.
The rule: start as far left (simple) as your structure allows; move right only when real pain forces it — never the reverse.
5

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 one rule that keeps it solid: a module may use the shared Foundation, but must never reach directly into another module. An automatic check blocks the build the moment anyone tries. That single guardrail is the whole difference between a tidy system and a tangled one.
How it's built · 3
Schema — built (Slice 3)

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.

Built · Slice 3
What the build settled beyond the shapes below — five concrete choices (written up as an artifact for an optional review, no longer a gate):
  • Real Postgres enums for edition.status and scope_level (not text+CHECK) — stricter typing, settles ADR 0013's "how scope_ref is typed".
  • Role tables keyed by role_key/privilege_key alone — the schema-lab's module_key composite dropped, since the context is the schema (truer to ADR 0018's per-context layout).
  • scope_ref is a polymorphic uuid, no FK — it points at an edition or an ashram, guarded by a scope_guard CHECK (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_id is nullable + unique (admin-first onboarding), and the department↔ashram consistency is actually enforced (via trigger), not deferred.
1

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.

foundation.person
person_idPKuuid
what everything links to — the join spine
sso_idUQtext?
Isha SSO subject — the identity anchor · nullable (admin-first onboarding), unique when present
emailUQtext
one active, editable — a lookup key, not identity
phoneUQtext
one active, editable — a lookup key
full_nametext
display (from SSO / IPRS)
merged_intoFKuuid?
→ person · null unless merged; resolved on every lookup
sign_in_disabledbool
global offboarding — history stays
created_at · updated_attimestamptz
foundation.department
department_idPKuuid
ashram_idFKuuid
→ ashram · the ashram it belongs to
nameUQtext
a standing org/operational unit — an "area" is a department
event_only_fortext?
null = permanent · else the event it exists only for (e.g. msr) — the "MSR area" marker; generic, never hardcoded. "exists-for", not "participates-in".
is_back_officebool
the reserved "Back-office" attribution
created_attimestamptz
foundation.module
module_keyPKtext
opaque, stable — e.g. vehicle_passes
nametext
display label
is_activebool
no owning-dept column — ownership is derived from the appointed admin participation(s) (ADR 0014)
created_attimestamptz
foundation.ashram
ashram_idPKuuid
the tenant — the isolation boundary
keyUQtext
stable — e.g. iyc, ssb
nametext
display — Isha Yoga Center
created_attimestamptz
one row seeded now — iyc. Data must never cross ashrams.
foundation.edition
edition_idPKuuid
event × ashram × year — the operational container
ashram_idFKuuid
→ ashram
event_keytext
neutral — default msr; "MSR" never hardcoded
yearint
e.g. 2027
statusenum
open / closed — a Postgres enum; prior years read-only
seeded now — MSR·IYC·2027 (open) + last year MSR·IYC·2026 (closed, holds imported history). Every module's data hangs off an edition.
foundation.participation
participation_idPKuuid
the one row all access hangs on
person_idFKuuid
→ person · who
module_keyFKtext
→ module · where
role_keytext
opaque key — validated by the module, never its meaning. No Foundation role registry; the human label lives in the module's own role table (ADR 0016 → now data, ADR 0018). Why it's here: the role is the grant — it distinguishes an admin from a read-only viewer so the module can enforce permissions; without it every module re-grows its own user table (ADR 0017).
acting_for_department_idFKuuid
→ department · always set — attribution
scope_levelenum
edition · ashram · global — a Postgres enum; how far the grant reaches (never a null wildcard)
scope_refuuid?
polymorphic — points at an edition or ashram, no FK; a scope_guard CHECK keeps level ↔ ref in agreement; null only when global
enabledbool
per-role on/off — soft, per-module offboarding
created_attimestamptz
UNIQUE (person_id, module_key, role_key, acting_for_department_id, scope_level, scope_ref) — one row per role per scope. A grant reaches its level and everything beneath it (Global → Ashram → Edition).
foundation.activity_log
activity_idPKbigint
actor_person_idFKuuid
→ person · who did it
acting_for_department_idFKuuid
→ department · for which dept (from the participation)
module_keyFKtext
→ module · where
edition_idFKuuid
→ edition · which event-year (per-year rollups)
actiontext
what happened — e.g. participation.granted, pass.issued
target_refjsonb
the module's own ref to the thing acted on
occurred_attimestamptz
append-only (a DB trigger blocks UPDATE/DELETE) — the audit log, the reporting data, and the one event spine are the same table
A person holds many participations, each into one module carrying one role key + one acting-for department; every meaningful change writes one activity_log row. The participation is the hub; person_id is the shared thread every module hangs off.
In plain words — what 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.
(Worked example + full reasoning: "How access works" above, and ADR 0017.)
Every module row also carries its edition — and through it, the ashram and the year. That's the tenancy grain: one shared Foundation, but each ashram's and each year's data stays cleanly separable, and cross-ashram / cross-year reporting is still a single query. The participation's scope_level (edition · ashram · global) says how far a grant reaches — never a blank "everything".
2

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.

requirements.requirement_row
row_idPKuuid
edition_idFKuuid
foundation.edition · which year this row belongs to. "Last year" is simply rows stamped with last year's edition — there is no second table.
department_idFKuuid
foundation.department · whose row it is. The department is the "area".
category_keyFKtext
requirements.category · Materials, Transport, … kept as data, so a new one is a config edit
positionint
the row number the coordinator sees — kept gapless, and re-checked only when the save finishes so renumbering after a delete is one clean step
valuesjsonb
the category's own columns (item, quantity, unit…) in one bag, keyed by field name — this is what lets a department's columns change year to year without rebuilding the database
created_at · updated_attimestamptz
vehicle_passes.pass
pass_idPKuuid
person_idFKuuid
foundation.person · the join spine
gateSCOPEtext
module-owned — South Gate vs North Gate
vehicle_notext
statustext
issued_attimestamptz
A module's tables live in its own section and reach the Foundation only through its shared lists — 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.
The admin area is a module too. The tool used to run the platform follows this very same pattern — its own section of the database, with its own tables for the access levels shown earlier (see Foundation → "Who runs the system"). Those levels live in the admin area's own section, not in the shared Foundation — so giving the back-office different levels of access adds no new tables to the Foundation tables above.
2b

Roles as data — the role-builder (ADR 0018)

Accepted (ADR 0018) — supersedes ADR 0005. Roles, privileges and scope are authored in a UI at runtime instead of fixed in code. Crucially, the participation row above does not changerole_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.

«context».privilege
privilege_keyPKtext
the vocabulary — developer-defined & seeded (e.g. budget.write). Roles are composed from these; capabilities are never invented in the UI.
labeltext
«context».role
role_keyPKtext
matches participation.role_key — resolved by join
labeltext
display name — now data (was resolved in-process)
scope_leveltext
granularity this role works at
is_privilegedbool
"admin tier" marker for cross-module oversight/AI · open: move onto the grant for speed
enabledbool
«context».role_privilege
role_keyFKtext
→ role
privilege_keyFKtext
→ privilege · tick a box = one row. A role change edits these rows only — never a participation.
«context».member_scope
participation_idFKuuid
→ the grant · hangs off the existing participation
scope_valueSCOPEuuid
the concrete slice (e.g. an area) — stores no identity, no role
«context» = a module's schema, incl. admin_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.
Where these four tables sit — and why not one shared set:
  • One copy per module, in that module's own sectionrequirements.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_key on 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.
3

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.
The one exception: whole-event reporting may read across every module's tables at once — that's the whole point of a single shared database. Everyday work always goes through the front door, never straight into another module's tables.
Still-open refinements (on a built spine, not a gate): the Foundation shape above is built and applied, but a few finer points can still be revisited — the person lookup columns (how 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.
How it's built · 4
Paths & speeds

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 firstDirectionTablesR/WDB timeEnd-to-end*
Read participations — "who + roles here"Module → FoundationparticipationR0.18 ms / 0 cached~2–4 ms
Resolve privileges — can() (enforcement)Module (internal)role_privilegeR0.49 ms~2–4 ms
Change a role's privilegesModule (internal)role_privilegeW0.85 ms · 0 grants touched~3–5 ms
Oversight/AI — "who can X anywhere"API → Foundationparticipation + markerR13–15 ms~15–20 ms
Log activityModule → Foundationactivity_logW~1–2 ms~4–6 ms
Module data readBrowser → API → Modulemodule tablesR6–33 ms~8–35 ms
Workspace loadBrowser → API → FoundationparticipationR0.18 ms~3–5 ms
Role label (display)Module → its role tableroleR~0.1 ms~1 ms
Write / change participationModule → Foundationparticipation + activity_logW~3–5 ms~8–12 ms
Register module + rolesModule → FoundationmoduleWdeploy only
* End-to-end = DB time + ORM (~1–2 ms) + one co-located round-trip (~0.5–2 ms) + app logic. A split/serverless host (API far from DB) adds 10–80 ms per round-trip.
What the numbers say: the most frequent call is the cheapest (Workspace read, 0.18 ms, index-only). A role change is 2–3 Foundation rows in one transaction with zero module-table churn — modules read the role live, so nothing downstream updates. Cross-module reads are fast because everything hangs off the shared 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.
Full numbers + reproduction: sandbox/schema-lab/README.md · decisions: ADR 0018 (roles as data), ADR 0011 (hosting / pooler).
How it's built · 5
Requirements — the first module

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.

Scale
90+ areas
Collects
8 categories
Feel
Like a spreadsheet
Ends in
Frozen numbers
1

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).

My Requirements
Cluster
Admin
Cycle Management
  all four read & write one shared core  
Shared core
the category list · everyone's requirement rows · which areas a coordinator may touch · frozen-or-not · a permission check on every read and write
A person sees a view only if a role unlocks it — and that role is an ordinary Foundation participation, exactly like any other module.
WhoTheir viewWhat they can reachFirst deliverable?
Area CoordinatorMy RequirementsEnters and edits only their own area(s)
Cluster CoordinatorClusterReads every area; who's submitted; exportlater
Module AdminAdminSets up areas + coordinators; freezes — cannot edit anyone's datasetup only
Cycle ManagerCycle ManagementHands out and takes back actual cycleslater
2

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.)

Last year · 2025  read-only
#ItemQtyNeeded on
1Folding chairs20012 Feb 25
2Steel barricades6014 Feb 25
3Floodlights1814 Feb 25
☑ select rows → copy to this year ▸
This year · 2026  you're editing this
#ItemQtyNeeded on
1Folding chairs25010 Feb 26
2Steel barricades60
3Floodlights18— drag to fill ↓
4+ add row
An illustration of the layout, not the finished screen. On a phone the two panels stack, last year on top and collapsible. Cycles is the one category with no last-year panel — its rules say it starts fresh each year.
The grid is borrowed, not built. Coordinators live in Excel, so the right-hand table behaves like a spreadsheet — paste straight from Excel, drag a value down a column, edit any cell, delete a row and the rest renumber themselves. We adopt a proven grid library for this rather than hand-coding a spreadsheet, which is a notorious time sink. Uploading and downloading whole files is deliberately left for later.
3

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.

ADR 0004. This is why drag-to-fill is a hard requirement on the grid and not a nice-to-have — it's what makes setting dates by hand painless enough to be the right call.
4

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.)

When someone asks to…What happensDatabase change?
Add a category or a columnA new entry in the listnone
Rename oneChange its label — its hidden identity stays put, so old data still lines upnone
Remove oneIt's retired, never deleted — it vanishes from this year's entry screen but stays intact in every year it held datanone
Hard-deleting anything with history behind it is forbidden — that's what would orphan or destroy a past year's numbers. Retiring gets the same result safely, and is reversible.
The module's own nine tables — built, in its own section of the databaseWhat it holds
category · fieldThe list above — what we collect, and each column's type, rules, and whether copy-forward carries it. Written from the team's version-controlled list, never by hand.
requirement_rowEvery area's actual numbers, for every year. One table, not one per year.
area_registrationWhich areas take part this year. Added later than the rest, when admin setup was built: the admin registers areas before anyone is assigned to them, so "registered, nobody assigned yet" has to be a state the system can hold — it is the admin's to-do list.
coordinator_scopeWhich area(s) each coordinator may touch — the module's own business, invisible to the Foundation.
freeze_stateBuilt now, used later: the per-category lock, so the freeze phase needs no rebuild of storage.
role · privilege · role_privilegeThe module's four roles and what each may do — the same pattern the admin area uses, kept in this module's own section.
Eight of the nine landed together in one step, deliberately: the shape of stored data is the one choice that is genuinely expensive to change later, so it was settled before any screen was built on it. (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.
Two things this is not. It's not a screen where stakeholders invent their own fields — the list is edited by the team and shipped, which keeps it reviewed and version-controlled (a self-service builder stays open as a later option if it's ever genuinely needed). And it's not the "generic engine" the platform forbids: we're parameterising one module's own repeated shape — eight lists of requirement rows — not trying to generalise across different departments' apps.
5

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
ADR 0003. Every read and write is checked on the server, so this isn't just a hidden button — there is no path to it. An "area" here is simply a Department in the coordinators' own vocabulary (ADR 0015); which areas a coordinator may touch is the module's own business, and the Foundation never sees it.
Now proven, not just intended. With the save path built, "the admin cannot edit" stopped being a design statement and became a tested one: an admin who also holds a scope row on an area is still refused, because the check asks whether the caller holds the "write my area's rows" permission — which the admin role simply does not carry — rather than asking what their job title is. That test is the one that would fail first if someone ever tried to make admin editing "just work".
6

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.)

Read-only comes free. A past year is already closed at the platform level (see Foundation · 10), and a closed year can't be written to by anyone. So "last year is locked" needed no separate archive table and no extra code — it falls straight out of a decision the Foundation already made.

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.

Still being decided at a finer grain: within a category, whether an old year shows its exact columns or today's columns with blanks where a field didn't exist yet.
7

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.

A later phase, not in the first deliverable — but the data is shaped for it now, so adding it won't mean restructuring anything.
8

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
Out of this module altogether: WhatsApp campaigns, requirements that come up during the event, generating official documents, and any screen for authoring categories or roles at runtime.
Still open, and deliberately so: the column-level history question above, the field-by-field validation rules for the categories still under stakeholder review, and how the old app's columns map onto the new field list for the categories beyond Materials. Each is tracked in the module's DESIGN under Open Design Questions. The grid library is no longer open — it was settled by a live spike on React 19 (decision 0006 below).
Reference
Decisions

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 eventOnlyFor marker 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_console schema, not the foundation core (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 truth app-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 from roleKeys, 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 roleKey is derived from the module's own MODULE_ADMIN_ROLES declaration (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-appoint 500. Back-office-only UI; a 403 locks the surface (ADR 0009). Extends ADR 0009 §4 and 0012Accepted
  • 0029A module admin can see the module's admin setsupersedes 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 as onboardMember) 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 holds module_admin" is a Foundation grant fact and a new admins.read privilege 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 screensupersedes 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: onboardMember now validates the granted roleKey against the target module's declared role set (403 otherwise), closing the twin of the hole ADR 0026 dec. 2 closed on the other grant path — role_key is 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-code ROLES (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): ModuleView gains an optional icon — 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 badges roleKeys ∩ 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 manifest group is display-only (ADR 0021). Frontend-only: no schema, contract or endpointAccepted
  • 0030Search-and-confirm before minting a Person — restores DESIGN.md mechanic 1 ("find first, then add"), which the code has never implemented: every add-or-reuse-by-email path did an unconditional upsert since 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: Person has 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: person gains a nullable display_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 seam searchDirectory (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, and lint:boundaries covers only apps/web so CI would not have), because a new directory.search privilege 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 — sadhgur vs sadhguru — 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) xor newPerson (asserting a new human); a bare email is a 400, because the confirm cannot live in the UI (ADR 0009) — the idiom EmptySubmissionError already set. merged_into resolved 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 shared PersonPicker; 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 2027 open + 2026 closed (the legacy *_curr tables run to Feb 2026, so _curr is MSR 2026). Supersedes only the year labels in ADR 0013 — its rule, "current open + last year closed", is unchanged. The seed keeps update: {} 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 modulesupersedes 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 value and no onValueChange; 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 an editionId as 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 — and DisplayEdition drops ashramKey, 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: getCurrentPerson resolves a bearer token straight to a person_id (a JWT under SSO, still no server state), and getAppContext runs 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-edition refuses 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 that grilling settles 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 :80 directly, 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-dev ships 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), executing apps/api/deploy/deploy-uat.shreset --hardnpm cidb:generatedb:deploydb:seed → restart → health-check gate. Migrations use a DDL-capable URL pulled from SSM Parameter Store; the app keeps its least-privilege rw_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 — mirrors apps/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.

Module decisions

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_date change. 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 offeredreverses 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 is overflow-hidden and 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/editors both 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 shadcn Select, 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, the applyOnClose gap) 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 grilling after 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 both Ctrl+Z/Ctrl+Y and 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 to grid-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, because dirty was 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 fillsupersedes 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 form useClipboard={{ rangeFill: true }}, not the bare true default 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 (rowClass names a property on each row), so source became a new array on every click → dataSourceChangedsetData → the open editor re-rendered, and RevoGrid's default TextEditor is controlled, so it re-applied the stored value to the live <input>; applyOnClose then read that reset input and saved the OLD value back. Every step is ordinary library behaviour — the defect was entirely in driving source from view state. (B) "I don't know if I have selected it or not." Not the selection code: --revo-grid-focused-bg was themed primary 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 the data-rgRow index 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 pin source as 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) into INFRASTRUCTURE.fields too, tagged with a new field-level entryMode: 'panel' display mode there. Closes the open question the prior session's handoff carried: not an either/or — electrical sits sibling to infrastructure in CATEGORY_REGISTRY (zero migration per ADR 0002), and a shed row can independently capture the same detail when its services_needed includes 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 a readonly prop, mirroring toGridColumns's existing convention, with a category-agnostic "Service details" title. The Sheet UI primitive is hand-rolled from dialog.tsx's own structure rather than installed via the shadcn CLI, which wanted to overwrite button.tsx on a version mismatch. Verified live: re-seeded msr_dev, Electrical's own tab renders as a plain grid and saves a real row; Infrastructure's "Service details" doorway does too; the services_needed multiselect'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.