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 (set at first sign-in under login-first, ADR 0038; nullable only for back-office-created or legacy rows), 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 and only login-match key · set at first sign-in (login-first, ADR 0038); nullable only for back-office/legacy rows, unique when present
emailUQtext
search + contact, never a login-match key (ADR 0038) · SSO-authoritative — refreshed each sign-in, not admin-editable (ADR 0040) · the UQ is a pre-SSO leftover, open for S4 (ADR 0041)
phonetext?
contact + duplicate detection, never a login-match key (ADR 0038) · not unique — families and shared desks do share a number · nullable, /userinfo may not return one
display_nametext?
display + the name ADR 0030's search matches on · SSO-authoritative — refreshed each sign-in, not admin-editable (ADR 0038/0040)
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 change — 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.

«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 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_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, three views — not three 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). Cycle Management used to be listed as a fourth view and is exactly that reserved case — fulfilment during the event is a different line of work, so it became its own module (ADR 0098), reading this module's cycle requests through a published contract.

My Requirements
Cluster
Admin
  all three 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 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 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 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: 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 · dec. 3 superseded by 0052
  • 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 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 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.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-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
  • 0037Sign-in is Isha SSO; the session token carries identity only — person_id, sso_id, iat, exp, nothing more. No roles in the token: authorization stays resolved per request from participation, so a coordinator offboarded at 10am loses access on their next request, not whenever a token expires. Diverges from the incoming IT spec, which put role_id in a 3-day JWT and then needed a blacklist to compensate — its endpoints and flow are authoritative, its token shapes are not (the captured spec says so itself). A flat role_id also cannot express ADR 0006's union of several roles held per module. No blacklist: signInDisabled already revokes harder, with no TTL and no storage. Logout is global (RP-initiated, id_token_hint) — with local-only logout on a shared event terminal, the next person clicking "sign in" lands inside the previous person's account, because Isha SSO still holds their session; that failure is silent, global logout's is visible and harmless. The spec's "clear server-side session data" is recorded as a deliberate no-op (ADR 0033: there is none). The getCurrentPerson seam does not move. Decision 2 (1-hour token, no refresh token, silent SSO re-auth) is provisional — it turns on Isha SSO's own session lifetime, which is not in the spec and is with IT; if that session is short, refresh tokens come back. Decision 2 now resolved by ADR 0039 (SSO session is one week → 4-hour token, silent renewal, no refresh token)Accepted · dec. 2 resolved by 0039 · amended by 0042
  • 0038Onboarding is login-first — the first sign-in creates the Person. Amends ADR 0030 decision 5 and answers DESIGN Open Q#3 in the negative. An admin no longer mints a Person; the human signs in through Isha SSO, a row is created from their claims with no participation, and an admin grants them a role afterwards. sso_id is the only match key — email and phone become attributes, never identity, so a person whose Isha email changes keeps working. This deletes the failure ADR 0030 was written to mitigate rather than mitigating it again: a mistyped-but-well-formed address can no longer mint a phantom holding a real grant (0030 cites a live one), because nobody types an identity that arrives signed from the provider. 0030's search half stands and matters more — every result is now a verified human. Named cost: nobody can be pre-provisioned before their first sign-in, so bulk pre-setup becomes bulk assignment from a list; and the directory becomes "everyone who visited", not "everyone who works on MSR". Person gains nullable phone; name and phone are SSO-authoritative, refreshed each login, not admin-editable, so the 0030 search can trust them. Unonboarded and fully-offboarded people see one identical wall — no information leak. Rejected but worth revisiting: capturing sso_id at onboarding from an Isha people-search API, which would be strictly better if such an API exists — question is with ITAccepted
  • 0039SSO session mechanics — 4-hour token, silent renewal, 1-hour idle logout, cookie-held PKCE verifier. Resolves ADR 0037 decision 2 (was provisional) and its open items 6–7. Isha SSO session is one week (per Giri anna, to confirm with IT), which makes a short token painless: it renews silently off that week-long session on the next request after expiry, so an active person is never interrupted and types a password ~once a week — no refresh token. The one security control is a 1-hour idle logout that is global (ends the Isha session too, not just ours) — because on a shared event terminal, clearing only our token leaves Isha's week-cookie alive and the next person silently re-inherits the previous person. Idle-based beats a fixed timer: it only ever closes a session nobody is using. The PKCE code_verifier + OAuth state live in a signed, httpOnly, Secure, SameSite=Lax cookie (~10-min, deleted at callback) — no Redis, no in-memory map, no server state (matches ADR 0033 statelessness, scales across ADR 0034's ALB). The cookie is a handshake artifact only; the session stays Bearer/JWT. The idle timer is client-tracked — a safety layer, not the authz boundary (ADR 0009); a closed tab still relies on explicit Sign Out + kiosk config. ⚠️ The "one week" premise was never confirmed and is now contradicted — Isha's id_token and access token both measured one hour on UAT (6 Aug), so the silent renewal this ADR planned would have failed constantly. ADR 0044 replaces it with a server-to-server refresh grantAccepted · dec. 1 superseded by 0044 · dec. 4 superseded by 0042 · dec. 2 superseded by 0047
  • 0040Isha SSO owns every contact attribute; pre-SSO Person rows are reset, not adopted. Amends ADR 0038 decision 5 — email joins display_name and phone as SSO-authoritative, refreshed on every sign-in. 0038 named only the latter two, contradicting DESIGN.md:65; freezing email at creation would leave the directory holding an address the person has abandoned, and email is what ADR 0030's admin search matches on. Matching is untouched — email is written from the match, never used to make it (0038 dec. 2 stands). Second half: every existing person row was typed by an admin and carries no sso_id, while email is UNIQUE — so at S2 a real sign-in would find no sso_id match, try to create, hit the taken address and lock the person out (0038's trade-offs anticipated a duplicate, which assumes the addresses differ; when they're identical it's worse). Resolved by clearing the directory before S2 — safe only because it holds seed and demo data, no real users. Rejected: adopting an unclaimed row on email — the standard SSO-migration move, and it works, but it makes email load-bearing for authentication again, which is the exact failure login-first was built to delete. Re-check at S2: if MSR takes on live users first, decision 3 is invalid and adoption returns to the table. Whether email stays UNIQUE is deliberately left open for S2 — moved to S4 by ADR 0041, and settled by ADR 0050: it stays UNIQUEAccepted · Consequences amended by 0041 · dec. 4 settled by 0050
  • 0041SSO S2 stays additive; the session token arrives in the URL fragment. Amends ADR 0040's Consequences — the three retirements (devLogin's email lookup, resolvePersonRef.newPerson, addPersonToDirectory) and the email @unique question move from S2 to S4; the directory cutover stays at S2 (it is a deploy step, not code), and 0040 dec. 3's "no real users" precondition was re-checked and re-confirmed on 5 Aug. Retiring devLogin at S2 had only two outcomes and both were worse: point it at login-first and UAT mints a Person per sign-in for two slices, or delete it and S4 has nothing left to gate. It also expires on its own — the cutover empties the directory, so the email lookup matches nobody from the moment S2 deploys — and it is the only sign-in path on a machine with no Isha credentials (every laptop, and CI). The session token reaches the web client in the URL fragment (/#token=), read once and scrubbed with replaceState: fragments are never sent to a server, so it stays out of our access logs, the ALB's, and any Referer — unlike ?token=. Not a cookie either (ADR 0037/0039 dec. 4: the session stays Bearer/JWT, and a session cookie reopens CSRF on every write). A /userinfo missing sub or email fails as a named 401, not a Prisma 500 — which claims Isha guarantees is DESIGN Open Q#1, still unanswered, so the path is real. SSO config is read per request, never at import, so a box without credentials still boots and only /api/auth/login answers 503. Cost: person.email stays NOT NULL UNIQUE through S2, and the dev-login form is still on the UAT login screen — gated only by an empty directory until S4Accepted · dec. 2 superseded by 0042 · dec. 1 amended by 0043
  • 0042The session is an httpOnly cookie, not a Bearer token the page can read. Supersedes ADR 0039 dec. 4 ("we do not become a cookie-session app") and ADR 0041 dec. 2 (the #token= fragment); amends ADR 0037 dec. 5 and its id_token trade-off. The token itself is unchanged — same signed identity-only JWT, same 4-hour life — only where the browser keeps it: an httpOnly, Secure, SameSite=Lax cookie, with the id_token in a second one scoped Path=/api/auth/logout. Why now: localStorage is readable by every script on the page, so one XSS bug is a stolen session and 0037's care to keep authorization out of the token buys nothing. And the CSRF objection that ruled a cookie out rested on a false premise — netlify.toml rewrites /api/* with status = 200, a proxy not a redirect, so the browser sees the API as same-origin. Cookies were already proven across that path by the PKCE handshake cookie. The client now learns it is signed in by asking /api/me, not by reading a token — one source of truth instead of two that disagree exactly when the session ends. Logout goes global via end_session_endpoint + id_token_hint (confirmed present in Isha's discovery doc), which is what finally makes 0039 dec. 2's idle logout implementable — it was blocked on holding no id_token. CSRF is closed by three independent locks: SameSite=Lax withholds the cookie on every cross-site write, a cross-site HTML form cannot send application/json, and a scripted cross-origin write is preflighted against an API that loads no CORS plugin. ⚠️ Trip-wire: that defence is a deployment property — the day the web client calls the API on another origin, a CSRF token becomes mandatory before that ships. Deviation stated: textbook BFF keeps provider tokens in Redis; we stay stateless per ADR 0033/0039, so we still cannot end a session server-side — already accepted in 0037 dec. 3 (signInDisabled is the revocation path). No sliding renewal: Isha's session length is still unknown (0039's "one week" is second-hand and IT's answer made it less certain), and a fixed 4 hours cannot drift past a bound we have not measured. Cost: a real loading state before first paint, and dev-login must set the same cookie or laptops run a different auth path from UATAccepted · dec. 7 superseded by 0044
  • 0043Dev-login is off unless an environment opts in. Amends ADR 0041 dec. 1 — devLogin no longer stays "exactly as it is" until S4; S4's scope is untouched and this removes nothing. POST /api/dev-login takes an email and mints a real session token with no password, and it had no environment gate of any kind — verified: no NODE_ENV check anywhere in apps/api. On a public UAT host now holding real PII, and carrying a seeded global platform_admin (root@msr.local, the placeholder in the form's own input), that was one guessed address away from full admin. ADR 0041's trade-off called it "gated by the directory being empty" — the directory was never empty. Now: disabled unless MSR_ALLOW_DEV_LOGIN=1, read per request, so toggling is a restart not a redeploy. 404 when disabled, not 403 — a 403 tells anyone scanning a production host that the route exists and is worth revisiting. The web form asks the server which sign-in methods exist rather than using a build flag, because a build flag hides the form while leaving the endpoint live: the failure that looks fixed. The point is the asymmetry — forgetting used to produce the dangerous state; now it produces the safe one, and the dangerous one needs someone to deliberately type a variable. Rejected: NODE_ENV !== 'production' — tooling writes it for unrelated reasons; a password-free admin route should not depend on that. Rejected: delete it now — it is the only sign-in on any machine without Isha credentials (every laptop, and CI). Stated cost: UAT stays open (closing it removes the escape hatch if SSO breaks — revisit after S3), and a flag that can be set can be set wrongly, which is why S4 deletion is still mandatorySuperseded by 0049
  • 0044The server renews the session with Isha's refresh token. Supersedes ADR 0039 dec. 1's "no refresh token" and ADR 0042 dec. 7 (no sliding renewal); 0039 dec. 2–3 and 0042 dec. 1–6 stand. S3's fixed 4-hour session interrupts an actively-working person, and because the bounce unmounts the page, a 401 on a write discards unsaved data — ordinary during all-day Requirements entry, not an edge case. Three things were measured, not assumed: Isha's /authorize returns X-Frame-Options: SAMEORIGIN so a hidden iframe is refused (the hosts are same-site but framing is judged on origin), prompt=none is ignored (it redirects to their own login page instead of returning error=login_required), and — the one that decided it — Isha does issue a refresh token, on the ordinary authorization-code grant, despite offline_access being absent from scopes_supported. One log line on one real sign-in settled what three documents could not; their claims_supported is literally ["TODO"]. So renewal becomes a server-to-server POST with grant_type=refresh_token — no browser, so nothing X-Frame-Options can block. Held in a third cookie, __Secure-msr_refresh, path-scoped to /api/auth/refresh alone. Proactive, not reactive: renewing after a 401 means the write has already failed, which is the thing that loses the data. A refused renewal signs the person out — that is what makes it safe: Isha decides the session may continue, so an offboarded person is refused at the next renewal rather than the next 4-hour boundary, i.e. sooner. Both reversed decisions were right when made: 0039 rejected a refresh token when the session sat in localStorage, which is exactly what RFC 9700 warns against — 0042 removed that condition and nobody re-opened it; and 0042 dec. 7's objection was to extending without asking Isha, which a refresh grant does not do. ⚠️ Accepted cost: a refresh token is worth far more than a 4-hour session cookie, so theft costs more — mitigated by httpOnly/Secure/__Secure-/path-scoping/rotation, not eliminated. Two unknowns that must be measured at implementation: the refresh token's lifetime (no refresh_expires_in is returned) and whether global logout invalidates it — the second is blocking, since a refresh token surviving sign-out would undo ADR 0037 dec. 3. Rejected: Isha IT's own spec's self-issued 7-day refresh token — it never contacts Isha, so it is blind extension with a longer window; and a server-side store (the textbook BFF), deferred on its own merits rather than smuggled in hereAccepted · dec. 4 superseded by 0046
  • 0045Renewal has a twelve-hour ceiling, and "cannot renew" is not "signed out". Extends ADR 0044 — reverses nothing; answers two questions that building it forced. Amends the effective ceiling of ADR 0039 dec. 1 / ADR 0042 dec. 1: the token still lives 4 hours, but a continuously-active person is now renewed across 12. Why a number was needed at all: maxAge sat on a SHARED constant fixed at 4 hours, and inheriting it would have closed the renewal window at exactly the moment renewal exists to get past — shipping the slice inert, with every test green, no log line, and the interruption still there. The most dangerous option precisely because it looked like the smallest. The ceiling is absolute, not sliding: the 12 hours run from sign-in, not from the last renewal — re-extending on each renewal would be the blind indefinite extension ADR 0042 dec. 7 refused, arrived at one renewal at a time. Not a weakening, and the direction matters: the 1-hour idle logout is untouched, and every renewal re-asks Isha — so an offboarded person is cut off sooner (next renewal, not next 4-hour boundary), while only the person actively typing gets the longer window. A third state nobody had named: signed in, working, holding no refresh token — Isha may never have issued one (offline_access is absent from scopes_supported), or the cookie hit its ceiling while the session did not. That answers 409 and clears nothing; 401 is reserved for Isha refusing and clears all three cookies. Conflating them would either sign out a working person early — the exact data-losing interruption this slice exists to remove — or keep a dead session alive. Renewal requires a live session AND a refresh token, so a stolen refresh cookie cannot resurrect an ended session; because renewal is proactive the old session is always still valid, so demanding both costs nothing. ⚠️ Accepted cost: a stolen refresh cookie is worth up to 12 hours of minted sessions, not 4 — bounded, not eliminated. And 12 is a judgement, not a measurement, unlike almost every other number in these slices. Rejected: inheriting the 4-hour default (ships inert); a browser-session cookie with no maxAge (attractive, and needs no invented number — but on a never-closed shared terminal the only bound left is Isha's refresh-token lifetime, the one thing ADR 0044 says we have not measured); 24 hours (more exposure than the problem needs); an env var (policy belongs in an ADR, identical on every box)Accepted
  • 0046A failed renewal stops renewing; it does not sign you out. Supersedes ADR 0044 dec. 4; 0044's other decisions and all of ADR 0045 stand. The problem with dec. 4: Isha answers invalid_grant for a revoked token, an expired one and an offboarded account — one code for three causes — and dec. 4 treats every one of them as the third. Two measurements on 7 Aug changed its weight: everything Isha issues is short (expires_in and id_token exp − iat both 3600s) and the refresh token's own lifetime is unmeasurable (no refresh_expires_in, and the token is opaque not a JWT) — so "the token simply expired" is the case to expect, not the edge. Isha also does not rotate, so the token ages from sign-in toward a deadline we cannot see. The cost was also mis-stated during the build: renewal is attempted at ~3.5h into a 4h session, not continuously, so a short-lived refresh token does not sign someone out hours early — it fails the first attempt and costs the ~30 minutes left on the session. Small, but paid by exactly the person the slice protects, for a reason that has nothing to do with them; and once the first renewal succeeds, every later failure leaves them better off than S3. So: a refused renewal stops the timer and changes nothing else, and 401 and 409 now behave identically on the client (kept distinct in the API and logs, because they mean different things to anyone debugging). Sign-out has exactly three causes — the button, the 1-hour idle timer, or the session expiring into the existing 401-anywhere re-auth. The server still clears all three cookies on refusal: a token Isha rejected is worthless. ⚠️ Withdrawn claim: ADR 0044 said renewal made offboarding bite sooner than S3 — it now bites at the same time, up to 4 hours. Not a new risk (S3 does exactly this today), but an improvement given up. Offboarding in MSR still bites on the next click via signInDisabled, which never consults Isha. Also clarifies ADR 0045: twelve hours bounds renewals, not the session — a renewal granted just inside the deadline still issues a full 4 hours, so a session may run to ~16 hours. Behaviour accepted; the wording was wrong. Rejected: measuring the refresh-token lifetime first (takes a day of elapsed time, must be redone whenever Isha changes anything, and answers a question this decision removes); distinguishing the three causes (there is no way to); signing out on 401 but not 409 (harsh treatment for the common case, gentle for the rare one — backwards); a warning banner (machinery to make an interruption tolerable, which 0044 already rejected)Accepted
  • 0047Renewal follows activity, and the idle logout goes. Supersedes ADR 0039 dec. 2 (the 1-hour global idle logout). Its premise was never MSR's: dec. 2 named shared event terminals as the threat — the next person at the keyboard inheriting a session through Isha's still-live one. Confirmed 7 Aug that operators use their own devices, so what remained was pure cost: step away for an hour, come back to a full sign-in, all day. But deleting the timer alone would have been worse than keeping it. Before S3b an abandoned tab died at 4 hours by itself; after S3b the renewal timer fires on a schedule and cannot tell whether anybody is there, so an open laptop renews itself to the 12-hour ceiling — a ~16-hour unattended session. The idle logout was the only thing preventing that. So the thing to fix is not the timer, it is renewal extending a session nobody is using — always a defect, since ADR 0044 exists to stop interrupting someone who is working. Now: the idle logout is removed, and renewal only happens if there has been interaction since the last one (pointerdown · keydown · scroll · touchstart — scroll and touch because reading a long grid on a tablet involves neither a key nor a click). An abandoned session is not renewed and expires on its own 4-hour clock: unchanged from this morning, and twelve hours better than deleting the timer alone. Global logout stays on the Sign out button (ADR 0042 dec. 5 untouched); only the automatic global logout goes. This is also the honest form of the "sliding renewal" ADR 0042 dec. 7 rejected — activity decides only whether to ask Isha, never whether the session may continue; Isha still answers that. ⚠️ Accepted: nothing automatically ends an Isha session now, so offboarding there bites within 4 hours rather than 1 (the same as S3 today); and a stolen unlocked laptop keeps a session for up to 4 hours — judged moot, since an attacker holding a live unlocked machine already has the browser and everything on it. ⚠️ Trip-wire: this rests entirely on personal devices. If a shared ops desk, registration counter or kiosk ever appears, revisit before it ships. Rejected: lengthening to 4h (does nothing the session's own expiry doesn't, once renewal is activity-gated); making it local-only (the appearance of a control — one click undoes it, which is worse than none because it reads as protection in the code); deleting the timer and leaving renewal alone (the 16-hour session above); asking "is this a shared computer?" at sign-in (disproportionate for a device type MSR does not have)Accepted
  • 0048An expired session is a prompt over the page, not a redirect away from it. Supersedes ADR 0042's lazy re-auth behaviour — the mechanism stands, the navigation does not. Closes SSO S3b. S3 made any 401 navigate to /login, which unmounts the page and takes every unsaved keystroke with it — a defect introduced by the fix for a different problem (before S3 there was no 401 handling at all, so a failed save left the data on screen), and one ADR 0044 named at the time and left as required work. Renewal removes the common case, so what reaches here is a session that has genuinely ended: away longer than four hours, a refused renewal (ADR 0046), or the 12-hour ceiling. The session ending is correct. Destroying the person's work on the way out is not — and it was never forced on us: nothing about an expired cookie requires unmounting a React tree. Now the guard keeps rendering its outlet and overlays a sign-in prompt: same route, same scroll position, same half-entered grid. Somebody who merely ARRIVES signed out still goes to /login — different event, nothing on screen to protect — which is why this gets a fourth session state (expired) rather than a conditional buried in the guard. Not a security boundary and does not pretend to be: the data behind it was already fetched and rendered, and the server refuses every further request on its own (ADR 0009) — the same exposure as any tab left open on stale data. ⚠️ Honest limit: if Isha's session has also ended, signing in is a full-page navigation and the work is lost anyway; sign-in opens in a new tab where the browser allows it, but popup blockers make that best-effort. This narrows the window; it does not close it. Rejected: persisting drafts to sessionStorage (every form needs a schema, restore path and migration story — not unmounting achieves the same for the cases that matter); silently refreshing and retrying on a 401 (after ADR 0046 that question was just answered — it would hide a real sign-out behind a spinner); a pre-expiry warning banner (machinery to make an interruption tolerable, which ADR 0044 rejected — and unworkable now, since with renewal the client cannot know the expiry time); accepting the loss (the one defect the whole slice was opened to fix)Accepted
  • 0049Dev-login retires to a command; the deployed sign-in surface is Isha SSO alone. Supersedes ADR 0043 — the flag is deleted with the route it gated. 0043 is discharged, not reversed: it was explicitly insurance until S4, and this is S4. It said in principle that "a flag that can be set is not deletion"; building S4 measured why. Both env templates ship MSR_ALLOW_DEV_LOGIN=1 enabled and uncommented (.env.example:18 — "Shipped ENABLED and uncommented on purpose"; .env.uat.example:32), and the runbook instructs re-adding it after every git reset --hard deploy. A production .env will be made the way every other one was — by copying a template — so the copy carries the hole, and the only thing between that and a password-free admin route on a public host is a comment someone must read during a cutover. 0043's central claim was that forgetting produces the safe state; measured against the actual deploy process, forgetting produces the dangerous one. The route goes; the function stays. devLogin survives as a seam with no HTTP caller, keeping its tests — 0043's own reasoning had already located the danger at the route. Deleted: the route, the flag, GET /api/auth/methods, the web form, DevLoginResponse/SignInMethodsResponse. Local sign-in becomes npm run dev:signin plus a Vite dev-proxy cookie rule, which cannot ship — vite.config.ts is executed by vite dev and nothing else, so there is no flag to forget because there is no flag. The CI-fixture problem everyone expected does not exist — measured: the suites mint through test/helpers/auth.ts, and of 76 test files only 9 mention dev-login, 6 being tests of it; one line is the whole dependency. UAT's escape hatch gets stronger, not removed — the same command over SSH, so knowing an email address is replaced by holding shell access, the boundary the Isha client secret already sits behind. That also closes 0043's open "UAT stays open — revisit after S3". Rejected: keep the route, gated (the property it was valued for is not one the deploy process has); asking Isha to register a localhost redirect URI — highest fidelity, but it puts the client secret on every laptop, the exact condition 0043's argument rested on being absent, and blocks a mandatory security close on an external ask; a fake OIDC provider in dev (puts an HTTP sign-in surface back in the repo). Accepted cost: nobody exercises the real handshake locally — already true, named so it is not later blamed on this ADR. Built 7 Aug — the script became a seam plus a thin runner, because dec. 4's --create guard is a security control and a guard only reachable by running a command on a laptop is one nobody tests. It is now proven directly, including that a host merely containing localhost (localhost.evil.com) is refused — exact match against an allow-list, never .includes(), which is the classic way this check is written wrongAccepted · amended at implementation
  • 0050Nobody is pre-provisioned: a Person is only ever created by a sign-in. Settles the question ADR 0040 dec. 4 parked for S4 and enforces ADR 0038 dec. 1/2/4, which the code has contradicted since S1. An admin could still mint a Person by typing an email — resolvePersonRef's newPerson branch, reachable from five seams and three screens through PersonPicker's "Add as a new person". Measured 7 Aug: that path is a trap, not a convenience. resolveOrCreatePerson upserts on ssoId alone (correctly, per 0038 dec. 2); a pre-created row has none, so its owner's first real sign-in takes the create branch, hits person.email @unique, and fails — and sso-handshake.ts does not catch it. The admin's shortcut silently disables the person it was used on, and neither of them learns why. Removing a broken path needs no product justification; keeping it would need one. Removed with addPersonToDirectory and POST /api/admin/people — under login-first the directory is not something an admin writes to. Confirmed with Monish (7 Aug): coordinators and module admins can and should sign in first, then the admin assigns; no workflow needs a list prepared in advance. email KEEPS @unique — the open question from 0040, now answerable: once nothing creates a Person by email, a collision can only mean Isha holds two identities on one address, which we want loud. Dropping it trades into a worse failure — two rows with the same email and display name, in the one directory an admin searches by email to pick a person (ADR 0030 dec. 4): a grant made to the wrong human, silently. Prefer the visible failure to the quiet one. The collision now returns a real message instead of a dead end. Rejected: adopting a pre-created row on first sign-in (0040's reasoning stands — it makes email load-bearing for authentication again); refusing addresses Isha does not know (we have no endpoint to ask, and a check we cannot perform is not a safeguard). Accepted cost: nobody can be set up in their absence, and the directory cannot be pre-populated from a spreadsheet — a bulk import would need sso_ids from Isha and its own ADR. Built 7 Aug, with one trap worth carrying forward: under the PrismaPg driver adapter a P2002 does not populate meta.target — the shape every example shows — the columns sit at meta.driverAdapterError.cause.constraint.fields. Matching only meta.target compiles, runs, and silently falls through to the generic failure dec. 4 exists to replace. The test caught it; nothing else would haveAccepted · amended at implementation
  • 0051The first platform admin is granted from the box, and the seeded root account goes. Replaces the mechanism ADR 0018's bootstrap guardrail relied on without changing its purpose. A deadlock found while building S4, in nothing's reading path. The only global platform_admin is root@msr.local, seeded with no sso_id. With ADR 0049 removing the route and ADR 0050 removing every by-email creation path, that row is unreachable by construction: no sign-in resolves to it (matching is on ssoId), no route can mint a session for it, and everyone who signs in through Isha lands with no participation. So nobody holds users.manage and nobody can grant anybody anything — and there is no first-user-becomes-admin mechanism anywhere in apps/api. A fresh environment deadlocks, production above all, where the seed is the only thing that will have run. Fix: sign in, then grant from the box. The intended admin signs in with Isha (creating their Person); someone with shell access runs npm run grant:platform-admin -- <email>, which resolves an existing Person and refuses to create one — a grant command that could also mint an identity would be dev-login with extra steps. Everything after that happens in the Admin Console UI as designed. The seed stops creating people entirely — root@msr.local, its grant, and the demo rows all go: every seeded row is a pre-SSO row, i.e. precisely the hazard ADR 0040 dec. 3 named and ADR 0050 just took away from admins. Removing root@msr.local also closes ADR 0043's exposure at the source rather than gating the door in front of it. The bootstrap grant is idempotent and recorded in the activity trail, marked as a bootstrap — there is no acting operator, which is exactly why it must appear. Rejected: MSR_BOOTSTRAP_ADMIN_EMAIL — the obvious alternative, and it needs no shell access, but it is a standing privilege grant living in a config file for the life of the environment, and config files are copied between environments: the exact failure ADR 0049 exists to stop. Replacing one dangerous env var with another, in the slice whose point is deleting one. Rejected: first sign-in becomes admin (on a public host the first sign-in is not guaranteed to be ours); giving root@msr.local an sso_id (a shared super-admin whose sign-ins attribute to nobody). ⚠️ Stated cost: the first grant needs shell access, so a non-technical operator cannot do it — correct for a once-per-environment action, and if it ever becomes routine that is a broken grant model, not a reason to make this a route. Built 7 Aug, and dec. 4 could not be implemented as written: it wanted a trail entry with no actor, but activity_log.actor_person_id is NOT NULL with an FK and the ADR also claimed "no schema change". Settled — the entry names the granted person and the action key platform_admin.bootstrapped carries the "no operator" fact, an action no other path writes; making the column nullable was rejected as weakening "every action has an actor" for every row to accommodate one. Also found: removing the seeded people would have broken every local, UAT and CI seed (the Requirements demo threw when its volunteer was absent), and the fixture rework this ADR expected was not needed — of 517 API tests exactly one depended on the seed creating peopleAccepted · amended at implementation
  • 0052A declared group header renders however few groups there are; only the "General" fallback is withheld. Supersedes ADR 0027 dec. 3 (0027 dec. 1/2/4 stand) and closes an assumption ADR 0031 leaned on. Found by dogfooding: a person holding one role in Requirements saw a single tile labelled "My Requirements" under no heading — the word "Requirements" appeared nowhere on the screen. Two correct decisions collided. ADR 0031 relabelled a collapsed single-view tile after the view, saying plainly that the module name "moved from the tile to the section it sits in"; ADR 0027 dec. 3 withheld the section header at one group. The name was handed to a heading that is never drawn. The fix is to notice what 0027 dec. 3 actually diagnosed: a useless string, not a lone group. Its rejected alternative argued only against the literal fallback "GENERAL" — the manifest's absence of a group made visible — and group count was a proxy that held only while one grouped module existed. So the test becomes group.declared: a bucket named by a manifest draws its header at any count, and the fallback draws none, ever. 0027's objection is narrowed, not overturned — Admin Console declares no group and still sits in an unlabelled block. Nothing on screen gains a heading it did not have: where a group header newly appears over a multi-view module, that module's own duplicate label is suppressed, so it is one heading before and one after — the count beside it changing meaning from views to modules, which is what every other group header already counts. Rejected: always rendering the header (brings back "GENERAL", rejected on evidence in 0027); putting the module name back on the tile as a prefix (ADR 0031's own rejected alternative — undoing that ADR by the back door rather than fixing the condition it assumed); giving Admin Console a group so every module has one (fixes today's screen with data, leaving the next ungrouped module to reopen the hole silently). Honest limit: verified in the running app for the two-role case only — the dogfooding account holds both Requirements roles, so the single-role and Admin-Console-only cases are pinned by tests, not by eyesAccepted
  • 0053The sign-in screen is a statue on the canvas, not a card. Frontend-only, root-scoped; ADR 0049 dec. 1 (one button, one door) is untouched — this changes what the screen looks like, never what it offers. Three compositions were built and rejected on sight first, and the rejections are the reasoning: photo-in-a-rectangle with text over the face (the title's strokes crossed the eyes — clutter, not depth, at every wash strength); the face extracted into a small vignetted box (read as a sticker); the photo washed far back under a ghost title (the face disappeared — the very complaint the wash was meant to fix). What all three share is a photograph whose background fights the canvas, which is the problem actually solved here: the statue is a cutout (rembg/isnet-general-use, run offline; the 3.9 MB source is not committed since the model name + Pexels provenance re-derive it), so the figure sits directly on the theme's own canvas with no baked-in background left to fight. A duotone pass — grayscale luminance plus an alpha-masked color-mix(in oklch, var(--primary) 15%, var(--background)) layer in color blend — re-maps every stone tone into the palette with a faint indigo lean; this is what makes a cutout belong to the page instead of floating on it ("it feels out of place" was the report). Both sides token-derived, so the earlier .signin-canvas force-light scope is deleted and a ThemeToggle sits in the header — dark darkens the stone so the white tilaka and crescent become the brightest points. New --font-display token (Fraunces, standard/standard-italic imports so opsz stays variable at 144); --font-heading untouched. The page never scrolls — h-svh + every vertical measure svh-proportioned, statue and text sized from the same unit so they cannot drift together. ⚠️ The event window (6–7 Mar 2027) and 94 departments were given directly by Monish in conversation, not from any doc — this screen is their only copy, and if either is wrong that file is where it is wrong. A refused sign-in renders in an Alert above the button in the server's words, with no advice added: only a message reaches the client, never a code, so the screen cannot tell SsoEmailAlreadyHeldError (where "try again" must NOT be said, ADR 0050 dec. 4) from the rest. A test was re-pinned rather than relaxed — sso-login.test.tsx's "one button on the whole screen" (the ADR 0049 no-second-door guarantee) broke on the theme toggle; counting to 2 would have gutted it, so it now counts sign-in controls. Rejected: a WebGL depth-map shader (no depth map, no mesh — the seam takes one later); an AI-generated stand-in statue (a real sacred monument is not something to fabricate); text over the face at any wash strength; blur/opacity as anti-sticker measures (read as cheap); a gold ghost year (reverted twice); an all-caps roman title; one ghost-year alpha for both themes (measured: 6% whispers in dark and vanishes in light — 13%/6%)Accepted · dec. 3/4 revised by 0055 · dec. 4 retired by 0056
  • 0054The access wall is the second screen of the product, and is dressed as one. Extends ADR 0053 dec. 4's font scope; ADR 0038 dec. 3/7 are intact — what the wall says, and who sees it, is unchanged. Raised straight after 0053 and correctly: for a brand-new person the product is exactly two screens long — the sign-in hero, then (because login-first onboarding creates the Person with no participation) the access wall. So the wall is not an edge case, it is the second screen every new person sees, and it was a plain grey Card arriving directly after the most considered screen in the app. Nothing broken; inconsistent — which on a first impression is its own defect. Now: the Card goes, replaced by the page's own canvas with the shared grain, a faint duotoned statue and a staggered entrance. The load-bearing decision is the restraint, not the styling — the statue sits at ~15% opacity and about a third of the hero's size, because the wall renders inside the app shell's sticky header (a full-bleed figure would fight chrome the login page does not have) and because it is a blocked state, where hero treatment would celebrate the wrong thing. Named explicitly so the next person does not "finish the job" by scaling it up. --font-display reaches "Namaskaram." — the greeting ADR 0038 dec. 7 called "a voice precedent for every user-facing screen", which makes it the right second home for the token; --font-heading still untouched. Grain extracted to a shared grain-overlay.tsx (two copies of the noise URI = two places to tune it). Rejected: leaving it for a separate task (defensible, but it is the highest-traffic path in the product and the fix reused what already existed); minimal consistency, keeping the card (leaves the generic box that was the complaint); copying the hero wholesale. ⚠️ Not visually verified — reaching this screen needs a signed-in session holding no participation; typechecked, linted and suite-covered, but look at it before UATAccepted · dec. 3 reversed by 0056
  • 0055The year returns behind the statue, and the figure is sized by the door. Revises ADR 0053 dec. 3 and dec. 4 plus the button-zone trade it stated; 0053 dec. 1/2 (cutout + duotone), 5, 6 and 7 stand, and ADR 0049 dec. 1 is still untouched — one button, one door. Live iteration produced four reports that were all the same class of problem: a value chosen by eye that a measurement contradicts. In three of the four the fix was not where the complaint pointed. The hero title goes — the page is the statue and the door, and the footer's date line is now the only place naming the occasion; --font-display keeps its token and its access-wall home (0054) but has no user on this screen. The ghost year returns as a rule, not a preference: the digits must span wider than the viewport so the figure cuts the middle and the outer two crop at the page edges — a number that fits on screen reads as a heading, one the statue interrupts reads as scenery. ⚠️ That width comes from TRACKING, not font-size: reaching viewport width on size alone needs ~430 px glyphs, but the figure is only fully opaque for its top 50%, so a number that tall runs 44% of its height into the dissolving chest and shows through the stone — not a paint-order bug, the order was always right. The figure grows 114svh → 130svh, and that is set by the BUTTON, not the face: frost is a relationship with a backdrop, and backdrop-blur over empty canvas returns the canvas unchanged, so at 114svh the dissolve finished at y=485 while the button starts at 476 and ~90% of the control sat over nothing — no fill tuning could ever have made it read as glass. Now 33%/22%/11% stone behind its top/middle/bottom. Two measured constraints the size must keep: the fade must complete on screen or the figure ends in a hard cut at the image edge, and must still start above the button. Light theme drops brightness() for a gamma curve (SVG feComponentTransfer, exponent 0.7, color-interpolation-filters="sRGB" — the default linearRGB lightens the stone far past where it belongs): brightness(1.55) is a multiply tuned for stone (37→63) and anything over 164.5 clips, so the tripundra rendered at 244 across all 2,397 of its pixels, a range of exactly zero — "flat white, not natural", a region with no gradient has no form. Gamma leaves lit stone at 83.3 against 83.8 and gives the mark back a 39-step gradient; dark never clipped and keeps the plain CSS functions, so there is no second set of values to sync. The fade mask is eased, not linear — a straight ramp sits between 40% and 70% opacity for a long stretch and over a near-black canvas that is fog, not dissolving stone; the curve drops fast through the plateau then flattens into a thin veil across exactly the button's band. ⚠️ Fog above the door and frost behind it are the same pixels — only the curve separates them, so flattening the haze further undoes the figure-size decision. Rejected: splitting "2027" into two groups either side of the head, and shrinking it to 210 px/1.14em (measured as the best achievable balance, all four digits ≥39% visible) — both rejected on sight, leaving the "0" at ~18% and a full size×tracking search confirming nothing does better without sacrificing another digit; moving the button down 40–60 px (only 33 px of footer clearance, and it already sits 95% through the dissolve — what reads as "chest" is faint shoulder mass); Fraunces for the year; a weight-700 year; per-highlight masked layers before the curve was found (right diagnosis, wrong altitude); converting the statue to SVG (asked directly — it would not have helped, going behind is paint order and that was already correct). ⚠️ Known limitation: rembg/isnet-general-use deletes the crescent moon — only ~13 opaque pixels survive, and the faint arc on screen is the sliver touching the head. Recoverable (it separates on chroma, not brightness: crescent r−b=+1 vs sky −20 at the same luminance, which is also why grayscale-100 flattens them together) and it was recovered once, then reverted on sight as looking worse. rembg zeroes the RGB as well as the alpha there, so an alpha-only restore paints a black crescent. ⚠️ Not visually verified: light theme at full size, and 375 px mobile (unchanged gap from 0053)Accepted
  • 0056The door is tinted glass, the display serif retires, and colour is judged after alpha. Revises ADR 0054 dec. 3, retires the token ADR 0053 dec. 4 created, edits copy under ADR 0038 dec. 3/7 (what the wall says, not who sees it) and adds a dev-only route against the grain of ADR 0049; 0049 dec. 1 — one button, one door — is untouched. The sign-in door leaves --primary for a TINT of it: brand indigo at chroma 0.195 read as odd on a page this quiet, so the glass now composites to 0.020 at rest / 0.035 on hover against a 0.005 canvas — about a fifth of brand strength, in the canvas's own hue family. ⚠️ Frosted glass reads from a DIFFERENT token per theme and they are not interchangeable — frost LIGHTENS what is behind it, so the fill must sit at or above its canvas: --background in light (0.9), --foreground in dark (0.985). Using --foreground for both produced a black pill in light, where that token is oklch(0.24 …). --font-display and Fraunces retire completely — the token had exactly two users, 0055 deleted the title, and a lone serif line on an otherwise all-Geist screen reads as a leftover rather than a voice; token, both imports and the dependency go, verified against a production build (zero Fraunces files shipped). The wall loses its padlock chip (a grey chip with a lock is the 403 idiom, and this is the second screen of the product, not an error — it was also pushing the stack 68px down) and its text moves BELOW the face, over the dissolve, the rule the hero settled twice. Copy reads APP_NAME, never a literal — app-config.ts is the declared single source of truth for display identity and says so in its own header; the wall hardcoded "MSR" twice, which is how it drifted from the product's actual name "MSR Apps". The days-remaining count re-derives itself instead of reading Date.now() once per render (reported from a screenshot: the clock had rolled over and the footer had not) — ⚠️ the tick is not local midnight, ceil drops a day when the REMAINING ms cross a whole-day boundary, i.e. at the event's own clock time, so the timer comes from the remainder; visibilitychange covers tabs whose timers were deferred while asleep. A dev-only /dev/access-wall preview route is added, gated on import.meta.env.DEV — the wall renders from workspace state, not a route, which is exactly why 0054 shipped never visually verified; ⚠️ the gate is the whole safeguard (the route is unauthenticated and outside RequireAuth, and 0049 retired dev login FROM a route TO a command so there would be "no flag to forget and no route to probe"), statically replaced by Vite and verified absent from the bundle. ⚠️ Three measurement traps recorded, all the same shape — an attenuation applied twice with only one factor measured: (1) ALPHA DILUTES CHROMA — the dark glass at chroma 0.035 behind alpha 0.12 composited to 0.0042, BELOW the canvas's own 0.005: there was no blue on screen while every number said there was; judge the composite, never the paint. (2) Mixing a tint in oklch rotates hue the SHORT way, and a chroma-0 token has hue 0 — --foreground in dark is oklch(0.985 0 0), so mixing toward primary's 277° travelled 0°→341°→277°, through magenta, and produced a pink button; these mixes are in oklab. (3) Stacked attenuations multiply — the wall's crown was invisible because a fade-in to 22% and the image's own 0.2 opacity gave ~3%; neither value looked wrong alone (same shape as 0055's crescent). Rejected: a fully neutral door (built — lost the tie to the palette, read as grey plastic); keeping --font-display "for later"; switching the local dev session to a no-participation account instead of the preview route; removing the wall's top fade entirely (measured — the cutout carries only 2.1% of margin above the crown, so the head hard-cuts; shortened 22%→10% instead). ✅ Caveat discharged 2026-08-10 — checked inside AppShell with a live session, and it found two defects the preview route structurally could not show, both fixed: the page scrolled ~160px with nothing to scroll to (values tuned in a shim that has no 61px sticky header, no footer and no main padding), and the grain — absolute inset-0, so bounded by a box main insets with px-6/max-w — drew a visible card rectangle, re-creating the exact thing ADR 0054 exists to remove. Grain and glow are now fixed (viewport-anchored, no edge) while the figure stays absolute so top-0 still means the content box; the figure is capped at 50svh because its size is bounded by the TEXT, which sits below the face. The lesson: a component verified outside the shell it ships inside has not been verified. Remaining and measured: chin-to-text gap 15px with 20px of slack, and below ~520px of viewport height the screen scrolls (content genuinely not fitting — correct there). Mobile caveat narrowed 2026-08-10 — the sign-in half is MEASURED (not seen), the wall's is still open. ⚠️ "Measured" is the honest word: everything here came from reading geometry out of the running page, because the preview pane would not composite — no mobile state was looked at. Numbers prove overflow and occlusion; they cannot say whether a composition reads well. Sign-in, defect 1 — the composition collapsed. ⚠️ Which term of min(130svh,110vw) binds FLIPS WITH THE VIEWPORT'S ASPECT: on desktop 130svh wins, but on a 375×812 phone 110vw is 412px against 130svh's 1055, so the figure collapsed into the top third while the door, positioned in svh, sat far below — statue gone by y=301, button at y=625, 324px of empty canvas (40% of the screen) and 0% stone behind the door, so backdrop-blur had nothing to frost. Same defect the figure was grown to 130svh to fix (ADR 0055), returning because the aspect changed which cap applied. Fixed with two mobile-only values — figure min(130svh,260vw) and stack top-[70svh], both restored at sm: — giving 30%/21% stone behind the door and no scroll. Stated cost: the figure is 975px wide on a 375px screen, 300px cropped each side; that crop is what carries the dissolve down to the button. Unjudged by eye. Sign-in, defect 2 — the door was clipped: it was min-w-88, a hard 352px MINIMUM, and min-width beats both width and max-width in CSS while the page is overflow-hidden, so the overflow was clipped rather than scrollable: measured at 320px the button ran x=−16 to x=336, 32px sliced off its ends with no way to reveal them, the only action on the screen. Now w-full max-w-88 — 352px at 1100 (unchanged), 272px at 320, inside the padding, neither axis scrolling. ⚠️ The wrapper needs w-full too or the door renders 221px on every screen (a regression this fix introduced, then corrected). ⚠️ Access wall: still open — checked in the same pass and clean (no scroll, greeting clearing the face by ~50px, header fitting) but the preview pane clamped at 427 CSS px, so those numbers are at 427+, not 375; the wall's layout is fluid with no fixed minimums so it should hold, but that is reasoning, not measurement. Noted, not this ADR's: at 427px the app shell's own header reaches exactly the right edge and will overflow below thatAccepted
  • 0057The sign-in hero ships as WebP and is preloaded, so the figure arrives with the screen. Extends ADR 0053 dec. 1 (the statue is a cutout) with how that cutout is encoded and delivered; supersedes nothing — no earlier ADR chose a format. Reached from a question about the performance docs, not a complaint about the screen: measuring UAT in a real browser found the hero was the largest asset on the site, 936 KB of uncompressed PNG, 555 ms — and behind it a worse defect. The image is a module import, so its URL does not exist until the bundle has parsed: the real first-visit chain was HTML (296 ms) → 217 KB of JS (2.19 s) → React mounts → <img> created → only then a 936 KB download starts, while the scene's animations had already fired on mount. So the figure's 1.6 s fade played out on an empty box, finished, and the statue popped in at full opacity whenever it landed — reported independently as "the image loads separately from the text". ⚠️ That is a loading-order bug wearing the costume of a motion problem: the animation was never mistimed against the text, it was timed against an image that was not there. Now WebP q92 → 171 KB (18%), saving 765 KB across both screens that use it. ⚠️ alpha_quality=100 is load-bearing, not a default to leave to chance — the matte IS the figure's density here: 667,390 px sit at alpha 200–254 and only 1,572 at exactly 255, because rembg's soft matte never reaches full opacity, so alpha drift would thin the statue itself rather than fray its edge. WebP keeps that channel bit-exact (max diff 0) at every RGB quality. AVIF is rejected on that measurement, not on size — at comparable sizes it moved alpha by up to 29 levels; the better codec in general, the wrong one for this image. RGB is allowed to be lossy because the stone is greyscaled, gamma-lifted, duotone-recoloured and half-dissolved before anyone sees it (interior error 1.98/255 mean, 13.7 max). The tripundra is the canary, and it survives — ADR 0055 records the brow mark flattening to a range of exactly zero once already, so it was probed BEFORE anything was touched: 2,395 px, 38-step gradient, independently reproducing 0055's own 2,397/38, and 216..254, 38 steps unchanged afterwards. ⚠️ Verified in the BROWSER's decoder, not only in Python — a format swap can carry a different colour profile, and that failure mode is invisible to the tool that wrote the file. The 936 KB PNG stays in src/assets, unimported, as the master (the rembg output is expensive to regenerate and lossy to redo), verified absent from a production build. The preload needs no plugin: Vite rewrites link[href] to the hashed URL and emits the asset, so preload and import cannot drift — both resolve to one emitted file — and the image downloads in parallel with the bundle. ⚠️ It is UNCONDITIONAL, a stated trade: a deep-link into an authenticated page fetches 171 KB unseen, accepted because without a session every route redirects to /login and with one the figure is already cached. Rejected: q88/q82 (39–88 KB more saved, but q88 nearly doubles interior max error for a saving imperceptible in load time — wrong trade on an asset rejected on sight twice); lossless WebP (586 KB, not enough); downscaling (source is 1400×1035 and min(130svh,260vw) can render near that, so no headroom — the win had to come from the codec); a build-time conversion plugin; a transformIndexHtml plugin to inject the preload (~15 lines re-implementing a built-in). ⚠️ HTML comments ship to every visitor — the first preload carried its full reasoning inline and roughly doubled a 0.9 KB document; source comments compile away, index.html does not. ⚠️ The pop-in is NOT fully fixed — the image is small and early, but the entrance is still not gated on decode, so the ordering defect is mitigated, not closed; the remaining half (hold until img.decode(), then one orchestrated entrance folded into the text stack's existing stagger) is deliberately left rather than rushed alongside a codec change. The three scene timers (glow 2.4 s, year 1.8 s, figure 1.6 s) still resolve after the text's ~1.5 s so nothing lands together — a probable cause of the "easing feels off" report, and most of it may dissolve once things arrive in the right order. ⚠️ Not visually verified — the preview pane would not composite, so nobody has looked at it in either theme; the figure was confirmed to render and measured identical, but measured is not seen (0056's rule), and light carries the higher risk since its gamma 0.7 curve amplifies whatever error the encoder left. ⚠️ The preload's effect is unmeasured on UAT — the 555 ms it exists to beat was measured on the deployed site, and the comparison is not real until this deploysAccepted
  • 0058The session handoff is injected by a hook, not remembered — and it arrives with live git state beside it. Makes one rule from ADR 0010 mechanical; supersedes nothing — no earlier ADR chose how HANDOFF.md reaches a session, and the rule itself (read it first) is unchanged. CLAUDE.md has carried "before anything else, read HANDOFF.md" since the handoff existed; it was skipped again on 2026-08-10, opening a session with the previous one's branch, its two ADRs and its unfinished PR decision unread. ⚠️ That is not a lapse to correct but the predictable behaviour of the mechanism — CLAUDE.md is context, not enforcement, so whether the file gets read depends on how task-shaped the first message is: "continue the sign-in work" pulls toward it, "hi" does not, because answering conversationally looks correct. Over enough sessions that is a coin flip, and the failure is silent — a session that skipped the handoff is indistinguishable from one that read it until it commits onto the wrong branch or re-derives an ADR that already exists, leaving the developer as the only detector. A SessionStart hook (tools/hooks/handoff-inject.mjs, on startup|resume|clear|compact) now puts the text in context before the model generates anything — not a reminder to read it, which fails identically by leaving the same decision to get wrong. ⚠️ It injects LIVE GIT STATE in the same payload and the pairing is load-bearing: the handoff is hand-written at the end of a session, so it is routinely stale — at the moment this ADR was written it stated feat/signin-visual-redesign, tree clean, against a live chore/repo-cleanup with 7 changed files and no upstream. Injecting it alone would make the agent MORE confidently wrong, supplying a false branch with the authority of an automatic mechanism; branch/upstream/dirty/HEAD sit beside it so the contradiction is visible in the same breath. Scope is HANDOFF.md only — WORKFLOW.md, DESIGN.md, CONTEXT.md and knowledge/ are excluded deliberately, not as a first increment: those are large, stable and discoverable from the task itself, while nothing in any task ever points to the handoff, which is exactly why it needs a mechanism and they do not. A larger payload would reproduce the failure it fixes — CLAUDE.md is ignorable partly because everything in it is marked important. Follows the pattern doc-sync-reminder.mjs states outright: the machine guarantees the trigger, not the judgement — the hook guarantees the handoff is present, whether it is true still depends on /handoff being run, and that half is named here as still open. Non-blocking, always exit 0, every git probe independent. ⚠️ Two silent implementation traps, found only by running it: (1) --format=%h %s unquoted makes git read %s as a pathspec and throw — and because the git block was one try, that single slip discarded branch, upstream and dirty files too, fail-open turning a formatting error into a silently absent section; (2) trimming a command's output shifts porcelain's columns — status is padded to two chars (" M path"), so .trim() ate the leading space of the first line only and a fixed slice(3) beheaded one filename (M pps/web/index.html). Rejected: a hook that prints a reminder instead of the text; injecting the wider doc set; putting it in settings.local.json (fixes one machine, leaves every other clone on the coin flip); gating injection on the first message looking task-shaped (that judgement is the very thing proven unreliable). ⚠️ Not yet observed in a real session — verified by direct run (payload correct, exit 0 inside and outside a repo, biome ci clean), but SessionStart had already fired for the session that wrote itAccepted
  • 0059CI is scoped by change and by branch — every push keeps the fast checks, the database suite runs where work integrates, and the secret scan is never scoped at all. Retains the every-branch push trigger while narrowing what that trigger pays for; complements ADR 0035 (UAT deploys from msr-dev), which is what makes the branch axis land where it does; leaves deploy-uat.yml untouched. Prompted by a GitHub Actions billing report naming two causes — that ci.yml runs twice per commit because push and pull_request are both active, and that the UAT deploy fires "from random branches" — and asking for both triggers to be narrowed. The spike is real; neither cause is. ⚠️ Measured before acting: all-time this repo has 152 push-triggered CI runs against 11 pull_request ones, so duplicates could never have exceeded ~11 runs; and deploy-uat.yml's last 30 runs were all on msr-dev and all skipped (its paths-ignore: docs/** working), consuming zero billable minutes — which is why it does not appear in the usage metrics at all. Narrowing push is rejected on that evidence: it would not remove duplicates that are barely occurring, and would leave feature branches with no CI at all, since PRs are rare here — feat/sso-login alone ran 17 times in four days with no PR, and all 17 would have checked nothing. The real driver is push volume, which no configuration change removes: 72 runs in four days across six branches (msr-dev 26, feat/sso-login 17, requirements 14, feat/signin-visual-redesign 11) at ~7 billable minutes each. So a new scope job decides two things and the expensive jobs need both: did anything outside docs/, knowledge/, .claude/ or *.md move (code), and is this a ref worth paying full price for — a PR, msr-dev or main (deep). checks is never gated — Biome, module boundaries, doc-guards, migration guard, typecheck run on every push to every branch, because that job is under a minute and catches most breakage. ⚠️ The code axis fails OPEN and that direction IS the decision: a new branch with no before commit, a force-push whose old tip is gone, an empty diff — all resolve to "code", because a wrong docs-only is a merged regression nothing tested while a wrong code is three spent minutes. ⚠️ The ref axis deliberately does NOT fail open — an unrecognised branch is a feature branch, which is the trade itself; its safety net is that reaching msr-dev or a PR runs everything before the work integrates. ⚠️ THE SECRET SCAN IS NOT SCOPED, and that is the load-bearing line: gating the whole security job on "code changed" is the obvious implementation and would have blinded gitleaks to the likeliest way a secret actually enters this repo — a key pasted into a runbook, a .env example or a log excerpt in an ADR (all docs-only by this workflow's own rule), or a key committed to a feature branch and forgotten — precisely where the branch axis would have removed it. Only the dependency audit is scoped, with the npm ci it needs. The concurrency key now states the push/PR split instead of arriving at it by accident — github.head_ref is set only on pull_request, so the old key already produced two groups (CI-refs/heads/x vs CI-x) that never cancelled each other, beneath a comment claiming "the newest run per branch"; ⚠️ separate streams are CORRECT and must stay, because whichever run started second would cancel the first and the PR run is the superset (it alone runs the doc-guard diff checks, the migration guard against the base, and the ClickUp-ref check). Saving: ~30–41%, and the honest version of that number matters — the 72 real runs were replayed through the rule by classifying each run's actual commit (16 deep, 56 cheap). ⚠️ GitHub bills each JOB rounded up to the whole minute, so a run costs at least as many minutes as it has jobs, and a first pass that modelled runs rather than jobs produced a much rosier 55%. Measured: checks <1→1, integration-tests 3, security 3 = 7/run now; a deep run becomes 8 and a cheap run 3–4 (the gitleaks-only security job is unmeasured — that range is the whole uncertainty), so ~504 min becomes ~296–352. ⚠️ A deep run now costs one minute MORE than before, because scope is a whole extra billed job making a twenty-second decision and repeating a checkout checks already does — the saving is bought on cheap runs only. Folding the classification into checks is the obvious follow-up (a billed minute off every run, and a failing checks would stop integration-tests starting at all); left out deliberately so the gating lands as one reviewable change. Docs-only commits are 20 of the last 60 on msr-dev (33%) and the branch axis does the rest; the figure assumes today's PR habits — opening more PRs correctly raises it. Context the report understated: the "massive spike" is ~440 min/month against a 2,000 allowance. Rejected: narrowing the push trigger to msr-dev + PRs — considered seriously and rejected on one fact: ⚠️ it saves ~64% against this decision's ~30–41%, and that gap is wider than it first looked (a feature-branch push under that option costs nothing at all, no job running), so the rejection knowingly spends ~25 points of saving to keep first notice upstream of the UAT deploy trigger — deploy-uat.yml fires on push to msr-dev and ADR 0035 leaves "deploy waits for CI-green" an open follow-up. If that follow-up ever lands, re-open this trade; gating the whole security job; a whole-workflow paths-ignore on ci.yml (would skip the doc-guards exactly when an ADR changes); unifying the concurrency groups; dorny/paths-filter (a third-party action holding a token, for a git diff and one grep); adding tools/ to the allowlist. ⚠️ Verified by replaying real commits, not by reasoning about the regex — e35ff39 and b7ee16a → docs-only; 270a636 → code, on tools/hooks/; ff28966, 8eb1c06 → code. ⚠️ Not yet observed in a real CI run — the first docs-only push is the test, and the thing to check is that checks and the secret scan still ranAccepted · dec. 1 revised by 0060
  • 0060The change classifier moves inside checks, because GitHub bills whole jobs — and the secret scan survives a red run. Revises ADR 0059 dec. 1; the two-axis rule, the fail-open direction and 0059 dec. 3 (the secret scan is never scoped) all stand, and this strengthens dec. 3. 0059 put the classifier in a job of its own and named this as the obvious follow-up, deliberately left out so the gating landed as one reviewable change. ⚠️ The arithmetic is the whole argument: GitHub bills each job ROUNDED UP to a whole minute, so a run costs at least as many minutes as it has jobs — and scope spent a full billed minute making a twenty-second decision, while repeating the full-history checkout checks performs moments later. The saving 0059 bought on cheap runs was partly handed back on every run, and a deep run ended up costing MORE than before it (8 minutes against 7). The classifier is now the first step of checks — before setup-node, since two jobs wait on its output — with code and deep exposed as job outputs; the logic is unchanged. ⚠️ security declares needs: checks with if: !cancelled(), and the second half is not decoration: a bare needs: skips a job whenever its dependency fails, which would have stopped the secret scan on exactly the runs where a branch is least tidy and most likely to be carrying something it shouldn't. The audit steps still read checks's output — empty on a failed run, so they skip, which is correct. integration-tests keeps a plain needs: checks and the short-circuit is deliberate: if lint or typecheck is already red the Postgres suite never starts, so a doomed run stops costing money at the first failure instead of the last. Measured on the same 72 replayed runs: cheap runs 3–4 min → 2–3, deep runs 8 → 7 (the minute 0059 added is repaid), total ~504 → ~224–280, a 44–56% cut against 0059's 30–41%. ⚠️ On the free plan this is the difference between under and over the cap — GitHub Free gives an org 2,000 minutes/month on private repos, the four-day sample runs at ~126/day (~3,800/month if sustained), 0059 alone lands at ~2,220–2,640 (still over) and this lands at ~1,680–2,100; the sample is four days of unusually heavy parallel sessions and the billing page is the authority, not this extrapolation. Accepted cost is wall-clock, not coverage: the two jobs now wait for checks instead of starting beside it, so a healthy deep run takes ~1 min longer end to end — nothing runs less often, and one thing (the secret scan on a red run) runs more often. Rejected: keeping scope with a shallower checkout (the job itself was the cost, not its checkout); putting the classifier in security instead (a gitleaks failure would then block the test suite for no reason); if: always() on security (would also run on a cancelled workflow); accepting 0059 as shipped (on the measured pace it does not get under the cap, which was the actual goal). ⚠️ Still not observed in a real CI run, and there is more to check than 0059 left because the job graph changed rather than just the gating — confirm a feature push shows two jobs with the secret scan green, an msr-dev push shows all three, and a run where checks FAILS still shows security having run. That last is the regression dec. 3 exists to prevent, and the one nobody will notice if it breaksAccepted
  • 0063Every surface paints a bare --background, so the entry screens' glows retire. Frontend-only. Supersedes the "effectively monochrome" accounting in ADR 0056 and removes one of the three scene timers ADR 0057 tracks; ADR 0053's duotone figure and ADR 0055's sizing both stand. Reported as "the blue on the sign-in page does not match the blue on the rest of the app" — accurate, but not where it appeared to be. ⚠️ --primary was never the problem and the first reading chased it: the sign-in blue computes to chroma 0.063 where a button computes to 0.195, so "different blue" looked true at the token level while the canvas tokens already agreed exactly. What differed was a layer on top that only the two entry screens had — a radial --primary wash, 8% on the sign-in hero and 6% on the access wall, where every other surface paints a bare --background and nothing else. Both glows are removed; at 8% the wash was not strong enough to read as a designed light source and was strong enough to make the canvas a different colour from every other screen. The grain overlays stay — grain is achromatic, so it was never what made the two screens differ. ⚠️ The statue's duotone is explicitly NOT part of this: it recolours the figure, never the canvas, and taking it along would have made Adiyogi visibly blue — the opposite of every colour decision from 0053 through 0056. A colour report on this product has now been misdiagnosed twice by reasoning about tokens instead of about what is painted over them. Verified by measurement in both themes — sign-in canvas identical to --background, radial-gradient layers found in the document: 0. ⚠️ Measured, not seen — the Browser pane would not composite, and the access wall was checked only through its dev-only route, i.e. outside the shell it ships inside⚠️ Superseded in full by 0071 the next day — the app's canvas now IS a radial wash and the sign-in page's canvas is deliberately a different colour from the app's, which is the exact deviation this removed. Decisions 3 and 4 (grain stays, the duotone is not part of this) still holdSuperseded by 0071
  • 0064The Admin Console is a sidebar of screens drawn by job — granting is one screen, not one per module. The console's first design pass: Slices 8–9 built its endpoints and gave it two screens without designing the surface. Extends ADR 0012 (which named the console's jobs, never its shape) and fires the extraction trigger module ADR 0023 named. ⚠️ The inventory is the argument — three working endpoints have no surface at all, and the person the tiers were invented for has nowhere to stand: back_office_operator holds directory.read + identity.merge + activity.read and can open no screen that uses any of them. Granting becomes ONE person-first screen — find a person, see everything they hold in every module, grant or remove there — because splitting the grant by which module it lands in is the code's shape showing through; to the operator it is one action with a different target. An Access overview keeps the reverse read (who holds console access, who runs each module), since a person-first screen can never answer "who are all the platform admins?", which is an audit question. A screen is a job; the buttons inside gate individually — mirroring the server, where people-search is deliberately gated on "any capability that can add a Person" (ADR 0030 dec. 3) with the dangerous verbs behind their own permissions. ⚠️ You land on the first screen you can SEE, which is NOT module ADR 0023 dec. 2's fixed redirect — the tiers see different screens, so a fixed redirect would land a back-office operator on a refusal. Activity becomes its own Workspace tile, because an auditor holding only activity.read would otherwise reach the trail through a console whose every other screen refuses them; ⚠️ the trail has a table and a write path and no read endpoint, so that view needs one built. ⚠️ Emergency access gets NO screen, because break-glass grants nothing — measured, not assumed: the seam writes a trail entry and returns viaBreakGlass: true, and nothing reads it; module access is decided solely by enabled participation rows. A screen over it would report success and leave the operator locked out, discovered in an emergency. The sidebar component moves to components/ and stays presentational — required regardless of taste, since the module boundary is CI-enforced and the console cannot import it where it stands. Accepted cost: granting a console tier stops looking special (ADR 0012 dec. 4's escalation guard), mitigated by a filtered target list and its own confirm — the server guard is unchanged and remains the boundary. Rejected: four screens with granting still split by module; two screens dropping the overview (loses the audit read); restructuring the two existing screens and stopping there (the scope originally agreed — it spends the whole sidebar cost on two items); building emergency access now; teaching the shared sidebar about permissions; a merge screen (no endpoint — Slice 10 is parked). ⚠️ Nothing here has been seen on a screen — a design pass written before implementation, and jsdom renders no pixelsAccepted
  • 0065A module's own surface gates on its own permissions, never on the role key it was granted. A rule for every module, reached while designing the Admin Console. Extends ADR 0025 by drawing the line its unlockedBy sits on — 0025's role-key gating for Workspace view-tiles is correct as built and not reversed; reaffirms ADR 0002 and ADR 0009. ⚠️ Without gating the console is unusable for two of three tiers: back_office_operator can use one screen, auditor none. The obvious fix is the one already in the codebase — copy ADR 0025's role keys one level down — and asking why that is wrong produced a distinction the estate had never written down. ⚠️ There is no Foundation permission table: foundation.participation holds a role_key it never interprets, and each module carries its own privilege/role/role_privilege set (ADR 0020). So the two levels are not two implementations of one idea — they are different facts in different places, and which one a surface may reason about depends on whose surface it is. A Foundation surface decides by role key, because a grant is all the Foundation has. A module's surface decides by its own permissions, served by its own endpoint in its own vocabulary — AppContextResponse is NOT extended, per ADR 0008. ⚠️ The clinching fact is in the schema: admin_console.role already carries the comment "a named tier, authored at runtime (the role-builder writes these)", and ADR 0012 dec. 3 defers that builder on this very table — so the day a tier is authored rather than shipped, a client-side tier→screen map is not merely stale, it is UNKNOWABLE, because the client cannot learn a new tier's permissions. Permissions are the closed code-defined set; tiers are the open composition. ADR 0027 dec. 2 already set the precedent that a UI stating something untrue about access is the defect that matters most. This changes what is shown, never what is allowed — every server guard stands (ADR 0009). ⚠️ Accepted cost, and it is the sharp one: two gating vocabularies now exist — tiles by role key, screens by permission — and the tile layer stays coarse and CAN drift, since a module's views list role keys by hand. The failure that permits is seeing a console tile and finding no screens inside, which is exactly why ADR 0064 dec. 5 lands on "the first screen you can see" and must show a real refusal when that set is empty. Rejected: role keys via unlockedBy (the cheap answer, seriously considered); extending AppContextResponse (puts one module's vocabulary in the Foundation's shared contract); show-everything-and-403 (never unsafe under ADR 0009, but makes click-and-be-refused normal for two tiers); shipping a copy of the seed to the client; a generic Foundation "what may this person do" read (would reverse ADR 0002 outright). Requirements is deliberately not migrated — its sections sit behind one role, so nothing hides today. ⚠️ Not implemented; the behaviour is unverified until each of the three tiers has been signed in as on a running appAccepted
  • 0066The console's permission list settles — reads split from writes, and an auditor can see who holds the keys. Closes the ADR 0012 follow-up that said the capability catalog "settles when the console is built" — this is that moment — and refines ADR 0012 dec. 2; its dec. 1, 3 and 4 stand. Two named permissions were never built: the seed ships seven, while ADR 0012 also named settings.manage (no settings surface exists) and account.disable (disabling is enforced with users.manage). Both are dropped — docs-only, since nothing exists to delete; carrying them invites someone to compose a tier around a permission the console never checks, the one thing ADR 0012 dec. 3 forbids. ⚠️ And a read was locked behind a write: listOperators — the answer to "who holds console access?" — is gated on console.grant, so only the people who can hand out console access are allowed to see who has it. A back-office operator cannot; nor can an auditor, the tier that exists for oversight. Seeing who holds access moves to directory.read; changing anything still needs the write permission it always did and the escalation guard is untouched. ⚠️ The auditor tier gains directory.read, reversing ADR 0012 dec. 2's log-only auditor, deliberately and on the record — an auditor who reads the trail but cannot see who holds the keys sees that a grant happened and never the resulting state. breakglass stays in the catalog, unresolved: it is seeded and enforced and grants nothing (ADR 0064 dec. 7), and removing it here would pre-empt its own task. Worth knowing where each half lands: which permission guards an action is hardcoded at the call site (a code change with tests), while which tier holds which permission is seeded data — tier composition is cheap to revisit, guard placement is not. Accepted costs: dec. 3 widens privilege by migration — an auditor grant made before this ADR confers more after it, which is why it is an ADR and not a seed tweak; directory.read's name is now narrower than its reach (it covers seeing people and what they hold); and the catalog is declared settled with one entry knowingly inert. Rejected: leaving the guard as-is (makes the auditor tier decorative); adding an access.read beside it (over-modelling — nothing wants one read without the other); keeping settings.manage reserved (a reserved permission is the phantom entry); giving auditor every read including merge (merge is a write); dropping the auditor tier altogether (would hand auditors identity.merge). ⚠️ Not implemented — the tier matrix is computed from the seed, not observedAccepted
  • 0067The DEVLOG holds only the live unit, and the alignment gate refuses instead of reminding. Process/tooling. Supersedes nothing — the gate hook's non-blocking behaviour was chosen in a code comment dated 2026-08-04 and never recorded as a decision, which is part of what this fixes; complements ADR 0010 by making gate 1 mechanical and stays inside ADR 0058 dec. 4's fail-open principle. ⚠️ The finding that forced it: the hook RAN, on target, and was ignored. Its once-per-session marker is on disk (/tmp/msr-alignment-gate-833ff9ae-…, written 13:22 — the same minute as the session's first test file), printing exactly the right thing; four behaviours of code were then built and the gate was reported as passed when it had never been run. So the defect is not a missing check — nothing mechanically connects "the gate ran" to "code may be written", and a warning nobody must acknowledge leaves no trace, so the false claim had to be interrogated out. Two findings alongside: docs/DEVLOG.md was three units stale, still naming a branch nobody was on, and caught nothing — which is the evidence it was not load-bearing, while its own header says it exists because "three workflow mistakes traced back to there being no recorded stage anywhere"; and stage-advisor.mjs exists wired to nothing. The DEVLOG now holds the ACTIVE unit and nothing else — a closed unit's section is deleted, not summarised, and the Prior units table goes with it, because everything a finished unit knew already has a home (ADRs, roadmap, ClickUp, git log) and a summary table is accretion by another name: four rows now, forty later, read by nobody. Prompted by Monish directly — "why do we have to record every unit in the devlog, its waste of time" — and the file's own founding argument supports him: recording where we are NOW is the point. What survives is the one thing living nowhere else: the live stage, and the deliberate skip record (ADR 0010 permits skipping a stage, never silently). The active entry must name the current branch, which is what turns the file from a diary into a machine-checkable artifact — and gives it a consumer, answering the objection that nothing reads it. The hook then REFUSES the first apps/ write when the DEVLOG names a different branch than the one checked out — mechanically decidable, and exactly the condition that held all day. ⚠️ It refuses only on positive detection and fails open on everything else (no DEVLOG, unreadable file, detached HEAD, any throw → allow): 0058's fail-open is about a SessionStart hook that must never stop a session starting, while a PreToolUse hook refusing one write is a different risk, and the escape is always available — make the log true. The escape is honest work, not a flag: the 2026-08-04 comment rejected blocking partly because an escape hatch "gets overused", which is right and is why the way out is correct the DEVLOG (~10 seconds) rather than --skip, since stating what is true cannot be done by reflex. ⚠️ Accepted limit, stated rather than oversold: a hook cannot verify judgement, only the artifact. Nothing stops the branch line being updated without the gate running — what changes is that a silent walk-past becomes a deliberate falsification in a tracked file, which is meaningfully different from enforcement and is not the same thing. Also accepted: this is the first hook here that refuses anything (the other four warn), it will interrupt legitimate short-path fixes, and the DEVLOG loses its history including the SSO unit's five build findings — already held in ADRs 0044–0051 and roadmap S0–S4 in more detail, so a second copy is the duplication ADR 0010 forbids. Rejected: keeping it advisory (refuted by the marker file); deleting the DEVLOG outright — Monish's instinct and close to right, rejected only because the live stage and the deliberate skips live nowhere else; having the hook read the transcript (impossible — a hook receives tool input, not the conversation); a --skip acknowledgement flag; a separate gate-output file (a second place recording the unit); folding in a fix to make the doc-guard check the live doc's content rather than that it was touched — worth doing, since that is how 0063's missing row slipped through, but bundling it here would hide it. ⚠️ Exercised in BOTH directions, and the allow paths were tested first — because a blocking hook that misfires stops all work. Verified: a docs/ file is ignored; an apps/ write with a matching branch allows and reminds; a second write in the same session is silent; malformed stdin and a missing file_path both fail open; and a mismatched branch refuses with exit 2, naming both branches — produced by really pointing the DEVLOG at another branch and restoring it, not by unit-testing the comparison. stage-advisor.mjs was also run against the shrunk DEVLOG before wiring (decision 1 changed the file's shape it parses) and correctly reported the live stage. Still unwalked: detached HEAD, a repo without git, and a DEVLOG with no **Branch:** line — all three coded as allow, none run; plus the first real session with UserPromptSubmit firing every turn, where a wall of text is the very failure stage-advisor warns aboutAccepted
  • 0068The People directory is browsable, not only searchable. Extends ADR 0064 dec. 2, which described People as find a person, see everything they hold — a lookup; this adds the list that makes it a directory. Reaffirms ADR 0030 (find-first before granting) and ADR 0050 (nothing here creates a Person); neither is touched. Raised on the running screen — "where is the directory?" — and the answer was that there wasn't one: People opened to a search box and nothing else. ⚠️ Measured, and the gap is older than it looked: the only directory reads were a search with a 3-character minimum and a hard cap of 25, plus one-person-by-id. No list has ever existed — checked across every branch's history: no listPeople, no listDirectory, no GET /api/admin/people. The POST /api/admin/people that once existed was a create path, deleted by ADR 0050 because an admin-created Person carries no sso_id and is locked out of their own first sign-in — so this is a gap never filled, not a regression. The vocabulary had drifted too: CONTEXT.md calls it the People directory and the capability is directory.read — "Read the People directory" — with nothing behind it that read the directory. And search fails exactly the case that needs help: you must already know the name, so somebody half-remembering a volunteer's surname cannot start, and 3 characters of a name they are unsure of returns the wrong people or a length error. A paged list, newest first, behind directory.read; paged with a SERVER-side cap, never unbounded (the directory is every human who has ever signed in and grows for the life of the platform — the same rule the activity trail follows: the caller asks, the seam decides the maximum, and asking beyond it returns the maximum rather than an error). The screen leads with the list and search narrows it, so "who is in here?" is answered before anything is typed. A Foundation seam with a console wrapper doing authorization only — the split ADR 0030 dec. 2 established and person-access.ts already follows. ⚠️ Accepted cost, stated rather than waved through: this WIDENS EXPOSURE deliberately. A 25-result search demanding you already know part of a name is meaningfully narrower than a browsable list of every person on the platform. Same capability, internal staff only, every response bounded by paging — but the shape of what one operator can enumerate has changed, and that IS the decision, not a side effect. Also accepted: offset paging drifts if rows are inserted mid-read (the directory is append-mostly, and a drifted page shows someone twice rather than answering wrongly about access); and two routes to a person, so a reader must grasp that search is a filter, not a different dataset. Rejected: renaming the screen "Find a person" and staying search-only — honest, free, seriously considered, and rejected because it makes the product's vocabulary retreat to fit the code while "who is in here?" still goes unanswered; showing only recently-added people (half a directory, inventing a ranking nobody asked for — the person you want is the one you cannot see); reusing the search endpoint with an empty query (would silently turn a deliberate 3-character floor into an unbounded read, which is what the floor exists to stop); putting the list on Access overview (a different question — "who holds what" — leaving People a lookup with no home for the directory). ⚠️ Verified on the running app, not in jsdom: the directory lists every person, clicking one loads their access, and a Requirements grant rendered as "Requirements / Area Coordinator" — the server returning a role KEY and the browser naming it from that module's own manifest, i.e. ADR 0016 holding across a real module boundary, which no unit test in either app could show. NOT SEEN — the Browser pane would not composite, so layout and dark mode are uncheckedAccepted
  • 0069The console removes only what it can grant, and never your own console access. Completes the remove half of ADR 0064 dec. 2 ("grant or remove, right there"), whose grant half shipped first. Respects ADR 0028 (a module admin manages their own module's members) and ADR 0051 (the bootstrap that breaks a lockout); offboarding stays soft — mechanic 5, nothing is deleted. Taking access away raises two questions giving it does not. ⚠️ The existing route cannot be reused: the per-module disable authorizes the caller as an admin OF THAT MODULE, and a console operator generally is not one — that is the whole point of the console bootstrapping a module's first admin. And the console can SEE more than it should touch: People lists everything a person holds, including a Requirements area_coordinator that the Requirements admin manages from inside their own module. Showing it is right; being able to revoke it from here is a different claim. So the console removes only what it can grant — a console tier (console.grant) or a module-admin grant (modules.manage) — and a module's own roles are shown but not removable here, carrying a "managed in …" note instead of a button. ⚠️ And nobody removes their own console access — refused SERVER-side, not merely hidden. console.grant lives only on platform_admin, so self-removing the last one locks the platform out of granting anything; recoverable from the box (ADR 0051) but that is an SSH-to-recover outage one click away. Explicitly not claimed as a security control: someone holding console.grant can already remove every OTHER operator. It stops the accident that actually happens. The capability required is resolved from the TARGET ROW, not the request, and each grant now reports its own removableWith — computed server-side because deciding it in the browser would need each module's admin-role list, which is server config, and a client copy is the same mistake ADR 0065 rejects for the tier→capability map. Accepted costs: decision 2 will read as a gap to an operator who can see a coordinator grant and not remove it (the alternative dissolves the module boundary the estate is built on, so the screen names where it IS removable); an operator genuinely leaving cannot remove their own console access, which for the most-privileged grant in the system is a feature, not a bug; and the last remaining platform_admin can still be removed by ANOTHER platform_admin — decision 3 stops self-removal, not the two-operator version. Rejected: letting the console remove anything it can see (the intuitive reading, and it makes the console the effective owner of every module's membership — break-glass is the sanctioned exception, and it grants nothing today); reusing the module-admin disable route (two authorization models in one endpoint); a hard "last admin" lock — rejected on precedent, since ADR 0012 deliberately has no last-admin lock for modules and ADR 0051 gives the console the same escape, and the lock's own failure mode is refusing a legitimate removal; hiding the control on your own row and stopping there (client-only is not a rule — ADR 0009); hard-deleting the participation (loses the attribution history the trail depends on, and breaks re-grant-reconnects-the-same-row). ⚠️ Verified live, and the REFUSALS are the substance: self-removal → 403 "you cannot remove your own console access"; a coordinator grant → 403 "managed inside that module"; someone else's tier → 200, enabled=false, trail entry grant.revoked attributed to Back-office. Driven through the UI too — the confirm step fires for a console tier, the server's refusal is shown verbatim rather than swallowed, and the grant survives. Every test grant was removed afterwards and the database checked back to its exact prior stateAccepted
  • 0070A message lives with its control, and a failure carries a kind. The first decision anywhere in the estate about how the app tells someone that something worked, failed, or is empty — nothing in DESIGN, CONTEXT, apps/web/CLAUDE.md or ADRs 0001–0069 covered it. Builds on ADR 0039 dec. 1 (a 401 from any request is handled globally) and ADR 0009 (the UI reflects a refusal, it never IS the boundary). The ask was "we don't have anything" — measured, that is half true, and the missing half is the one that matters. Eleven screens DO render a loading state and several empty states are genuinely good; but there is no snackbar at all, 27 places print red text against exactly 1 role="alert", eleven screens carry five different load-state shapes between them, and the requirements grid's empty state is a dead end. ⚠️ Corrected 14 Aug: this entry first said the grid had no empty state at all — wrong. The guard is on draft.length, not rows.length, so the survey's grep missed it. What was actually wrong is that it was a dead-end sentence naming neither on-ramp (copy-forward, Add item) — the same flaw as the other three empties. Step 5 improved it rather than creating it. ⚠️ One problem is a LIVE defect, not a risk this change introduces: both API clients put three incompatible things down one Error channel — 48 throws, of which ~21 are code words used as signals ('forbidden'), ~12 are real sentences, ~13 are developer strings ("Activity load failed (403)") — and four files tell them apart by comparing err.message to a literal, passing the string straight to the screen when the comparison misses. "Directory load failed (500)" reaches people today. So a failure becomes two fields, not one string: { kind, message } — kind is what code branches on, message is always a sentence fit to show. A code word can no longer leak because it is not in the field screens read: a discipline problem becomes an impossibility. The load-bearing rule: a message lives next to the control that caused it, and the snackbar is only for messages whose control is gone, or never existed. That derives the familiar split instead of asserting it — a removal that succeeds takes its row away (snackbar); a removal that is refused leaves the row standing (inline); a background refresh has no control at all (snackbar). It also explains a bug already in the tree: success is what destroys the control holding its own confirmation, which is why the grant confirmation vanished and is still patched around by a quiet flag. Deliberately NOT snackbar-for-everything — the intuitive reading, and it would fade refusal copy like "you cannot switch off your own sign-in — ask another operator" after four seconds, far from the button, unreadable twice: worse than the red text it replaces. Also settled: five state shapes collapse to three (loading/ready/failed — forbidden and missing were failure kinds wearing a different coat, and idle was never a load state at all); two kinds of nothing — nothing yet (carrying the next step, or WHO TO ASK when you may not take it) and no matches (offering to clear the search) — which the shared component makes you declare, since that is what stops them drifting back together; skeletons stay bespoke because one should resemble what it replaces (only the rows skeleton duplicated across four screens is extracted); a retry button appears for kind: 'failed' and nothing else, decided by the component from the kind rather than remembered per screen, because a retry on a refusal will fail identically every time it is pressed; unauthenticated renders nothing — ADR 0039 dec. 1 already covers the screen, so six per-screen session messages currently sit behind that overlay as dead weight; and in the snackbar a success auto-dismisses while a failure does not, the same asymmetry ADR 0069 chose for the sign-in toggle: ceremony belongs on the direction that costs something. Accepted costs: ⚠️ the typed-failure conversion is the largest piece of the change and moves zero pixels (accepted — it is what makes the visible half safe); the "is this message's control still on screen?" call is not mechanical, so it can be got wrong (the mechanical alternatives are wrong in known cases, which is worse); loading will never be perfectly uniform; and unauthenticated stays in the union although nothing renders for it, because collapsing it into failed would put a retry button on an expired session. Rejected: snackbar for everything; successes-in-snackbar/failures-always-inline (breaks when a write has no form open); sanitising at the snackbar (only the client knows what a failure means, and every inline error would still leak); fixing the 13 bad strings and keeping one channel (leaves string-matching as control flow, invisible to the type checker); converting screen-by-screen (two live conventions is precisely the complaint being fixed); one component swallowing all four states; and a third kind of nothing — "data exists but none is yours" — which no case in this app actually is. Six sequenced steps, each green on its own so the app is never half-converted at a commit boundary. ⚠️ sonner is treated as suspect until it compiles — shadcn's sidebar did not work as delivered here — and its next-themes import is stripped, since this project's .dark class already flips the tokens. All six steps built and green (14 Aug) — CI green including integration-tests on PR #19, the first CI run of the API suite on this branch (583 tests / 66 files) — the typed failure: both clients hold zero raw throw new Error (48 → 0), seven caller files branch on the kind. ⚠️ The suite stayed GREEN through a real regression — 285 → 289 → 291 passing while a refusal rendered red, prefixed "Could not load the directory:" — because this app's screens have almost no coverage of their own failure branches, so a client↔screen contract is untested by construction. Steps 2–6 not startedAccepted
  • 0071The app's ground goes near-white, and the door keeps the blue. Supersedes ADR 0063 in full — both of its decisions reversed one day after acceptance, on instruction after seeing the result. The palette read as purple and measurably was: the neutral ramp sat at hue 288, the violet arc, where the red channel lands above green — and red-over-green is what the eye names purple. Rotating to 264 is contrast-neutral. That exposed the real problem: with a tinted canvas, tinted cards, tinted chips and tinted borders, nothing anchored either end of the value range, so the app read as dull — the blue was never the fault. So the ground goes near-white and neutral with white cards above it, which knowingly breaks the 2026-08-08 "nothing is white any more" rule at both levels; contrast went up throughout (foreground on card 16.44). The canvas becomes a soft two-blob wash rather than a flat fill, because a flat field of one value reads as dull however well chosen — and a warm third blob was shipped and removed on sight, so the wash stays single-hued. The sign-in page keeps the old blue, scoped and light-only: the statue is that composition and a near-white ground leaves it nothing to sit in. Two grounds on purpose — the thing 0063 existed to prevent. The --sidebar family becomes aliases of the app tokens (shadcn's stock palette had been missed by two retunes, and its own comment predicted exactly this); a selected nav item differs in kind, a primary tint rather than a lighter grey; and --canvas-hover is added because --muted cannot serve two altitudes — tuned for the card it measured 1.03 against the canvas, an invisible hover. The reusable finding is not a hex value: six cues failed the same way in one unit — a cue that signals itself by a hair of lightness dies when a neighbouring surface moves, while every cue that differed in kind survived. Also recorded: --primary is one value in both themes, which works for fills and fails for text (dark measured 1.92:1 against an AA floor of 4.5 before it was caught). ⚠️ Measured, not seen for most of this — the Browser pane would not composite; the sign-in page has not been looked at since its canvas was scoped. ⚠️ Open: the app header still paints a flat fill over the wash, and dark has no wash at allAccepted
  • 0072The light sign-in reads as stone and ink, not as tint. Frontend-only, light theme only — dark is untouched. Re-tunes ADR 0053 dec. 3 (the duotone figure) and ADR 0055 (the ghost year's ink); every geometry decision in 0053/0055/0057 stands. A direct consequence of ADR 0071, which scoped a blue canvas to this one page: the statue's fill was mixed out of that canvas, so the moment it became the only blue surface in the product, the figure inherited it — and the dial in login-scene.tsx could not fix it, because the canvas's own chroma is the floor. Reported on sight in three passes — the statue has a blue hue → it's not black like dark mode, it's grey warm → at zero chroma, feels unnatural. ⚠️ Three complaints, three different causes, and only the first was about colour. The tint becomes a token (--statue-tint) because the two themes want different answers and an inline style cannot branch; light is chroma 0.012 at hue 264, a third of the old value and deliberately not zero — 0.040 read blue, 0 read as unnatural, since real stone is never perfectly achromatic and a fully neutral figure reads as a rendering rather than rock. The "grey warm" report was lightness, not hue: measured over the cutout's 667,355 opaque pixels, the old gamma 0.7 lift put the figure at 56/86/119 (p10/median/p90) where dark's own treatment sits at 26/49/77 — a mid grey on a pale blue canvas is what the eye names warm. Gamma 0.7 → 1.1 lands it at 23/46/77, dark's blackness in light, and darkening cannot re-open the clipping ADR 0055 recorded (that was brightness() hitting the 255 ceiling; a gamma above 1 moves away from it — the tripundra's 200–253 becomes 195–248, gradient intact). The ghost year's ink moves to neutral black at 16% in one token carrying its own alpha — it was --foreground/16%, and that token leans violet at hue 285.9, so it was tint again from a different source. ⚠️ Solid black was asked for, shipped, and reverted on sight: the occlusion and the crop survive full opacity, but the number stops being scenery and competes with the door — the ghost is the effect. ⚠️ Measured, not seen — the Browser pane would not composite for the entire unit; every value here was accepted or rejected by eye on Monish's own screen, which is the only reason that is safeAccepted
  • 0073One base hue, a flat ground, and cards that rest on a shadow. Frontend-only. Supersedes ADR 0071 dec. 2 (the near-neutral ground) and dec. 3 (the two-blob wash) — the day after they were accepted; 0071's white cards, --canvas-hover split, sidebar aliasing and its cues must differ in KIND finding all stand, and this ADR exists mostly to apply that last one. Reached from a plain report on the Workspace screen — the background is good but there's room for improvement — behind which sat three defects, only one of them colour. The light theme had FOUR hue families, each from a separate tuning pass: canvas 247.9, the grey ramp 264, --primary 277.4, the text ramp 285.9 — 0071 rotated the greys 288→264 and the canvas and text ramp never came along. All now sit on 277.366, the primary's own hue. ⚠️ The rotation is invisible, and that is the finding: the largest channel move on any surface is 1 of 255, because at chroma 0.003–0.03 there is almost no hue to rotate — the value is structural, in that a future retune of --primary now carries every surface with it instead of leaving three strays behind. The ground is tinted AND darkened (L 0.9722 / chroma 0.0034 → L 0.955 / chroma 0.010) — ⚠️ the LIGHTNESS is what mattered, and it took longest to find: the tint landed at 0.978 first and the page was still reported as boring, with the tiles offered as the suspect and correctly rejected, because at 0.978 a white card cleared the ground by only 1.07 — a white sheet carrying white cards. Every earlier step moved chroma, which cannot fix that; 0.955 takes card-over-ground to 1.14, safe because what made the old palette dull was that every level was tinted — the cards stay pure white, so 0071's anchor is intact and only the ground moved. ⚠️ At this lightness the chroma ceiling is ~0.011, and it is a hard wall: the blue channel reaches 255 and stops, and pushing further pulls red and green DOWN, turning a tint into a grey-blue cast — to go bluer the ground must get darker, not more saturated (the same wall the sign-in canvas meets at 0.040, one lightness step down). Landed after walking 0.012 → 0.008 → 0.005 → 0.011 by eye, with two step-backs. The two-blob wash is removed — added by 0071 one day earlier; its job is now done by the ground's own colour and by the tile shadows, two sources of relief where the wash was one. It is kept verbatim as a comment: both blob positions and strengths, background-attachment:fixed, and the standing rule never to give it a second hue. Workspace tiles rest on a SHADOW, border at 50% — they were shadow-none over a ground only 0.028 L from the card, so the outline carried 100% of the separation and the page read as boxes drawn on paper. Depth differs in KIND and survives a canvas retune; a hairline is a lightness cue and dies with one — exactly what removing the wash and moving the ground would have done to it. Dark keeps the outline (a shadow over a near-black canvas is invisible). The header's fill went 60%→30% so the wash's stronger blob (at 102% 18%, directly behind the bar) could show through, and dec. 5 then made that moot — left in place rather than reverted, since over a flat canvas the two look identical and legibility under a scrolled page comes from the blur and the bottom border. Contrast measured at every step: foreground on background 15.41, on card 16.48 (0071 recorded 16.44), muted-foreground 6.13, border 1.43, card-on-background 1.07 — all hold or improve, and the canvas stays R<G<B so 0071's purple test still passes. ⚠️ Measured, not seen — the Browser pane would not composite for the entire unit; every value was accepted or rejected by Monish on his own screen. ⚠️ Left drifting on purpose: --border now clears the ground by only 1.34 and --muted (L 0.95) sits almost exactly ON it, so a chip on the canvas has nearly no fill — both were tuned against a near-white ground and want a nudge, deferred so the next report stays attributableAccepted
  • 0074Production hosting — two EC2 boxes in their own AWS account, PostgreSQL 18, and an app role that cannot alter its own schema. Infrastructure only. Answers for production what ADR 0011 deferred and ADR 0034 settled for UAT alone — 0034 said in as many words do not treat this UAT topology as the production answer just because it exists and works, and is not superseded: it still governs UAT. The environment was already provisioned and undocumented, found by listing EC2 in account 700392259328, a different account from UAT's — which is why nobody had seen it. Two t3a.small instances on Amazon Linux 2023, not UAT's Ubuntu, so UAT's setup steps cannot be pasted across. ⚠️ App and database are on SEPARATE boxes, where UAT co-locates them and its URL says localhost: the connection now crosses a network, which turns TLS, a pg_hba rule and a security-group boundary into required work UAT never faced. Upgraded 15.18 → 18.4 on the day it was found, while the database was still empty — 7.6 MB, zero tables — because that is the only moment the upgrade is free. ⚠️ The skew was silent and forward-facing: CI and local dev run 18, UAT 16, production 15, and nothing tested against 15. Not broken — participation_grant_uq needs NULLS NOT DISTINCT, which arrived in PG 15, so production sat exactly on the floor — but a migration using any 16+ feature would have passed CI, passed UAT, and failed at migrate deploy, the one place ADR 0004's forward-only migrations give no rollback. ⚠️ On AL2023 the 15 and 18 packages cannot coexist (same unversioned paths), so it is remove-then-install; the old cluster is kept as the rollback. The app's database role holds no DDL — the provisioned build had granted it ALL ON DATABASE, which includes CREATE, so a compromised app process could have rewritten its own schema beneath the layer ADR 0009 guards. Now CONNECT only, with DML arriving through default privileges from the owning role. Deploys are manual: ADR 0035's workflow exists but has never executed once, skipping every run on its role-ARN guard, so there is no proven automation to promote. ⚠️ What it deliberately does NOT settle: backups (none exist, and 0034's data is disposable reasoning does not survive the move to production), TLS mode on the app↔DB link, which git ref ships, where the migration credential lives, whether 1.9 GB and no swap is the right size for a box that runs npm ci every deploy, and the 11.10.0.0/16 addressing — public space used privately, unlike every other instance in either accountAccepted
  • 0075The Admin Console owns the department directory — create, rename, mark, retire. Closes the gap module ADR 0024 opened deliberately: since that removal a Department could be created by nobody through the UI, and 56 of 59 rows carry event_only_for = 'msr' when most should not — correcting one is a shell, adding a 60th is a shell, and event-only units are discovered during setup week. ⚠️ One of 0024's stated reasons does not survive the schema and is corrected here rather than by editing an append-only record: it argued an admin could add a second "Security" and nothing would object, but @@unique([ashramId, name]) would have refused — the ownership argument stands without it. A Departments screen joins the sidebar last (People leads; org structure is set up a handful of times an edition) and is called Departments, not Areas, because area is one module's word for a department it enrolled. New capability departments.manage gates the writes, directory.read the read — users.manage means disable a sign-in, and folding org structure under it makes "may fix a wrong marker but must not touch anyone's sign-in" unsayable; ⚠️ it differentiates nothing today, buying expressiveness now and restriction later. Ashram is derived server-side from the caller's grant — no picker, no client ashramId: the operating context is already on screen in the header pill, so a form field would be a second answer that can disagree. eventOnlyFor is a two-way checkbox worded as the department's nature, the event key taken from context so the neutral column stays neutral (ADR 0015 dec. 4) — not a one-option dropdown, not free text. Retire, don't delete: a new nullable retired_at, hidden from choosing, never from history, and retiring is not a de-enrol. Rename is an UPDATE on the one row, so past trail entries display the current name — accepted over snapshotting names onto the largest table in the system. The table lists every department, unfiltered, with the count, because the screen's job is finding the rows that are marked wrong. No request/approve workflow — back-office updates manually, decided explicitly against building it. The 56 bad rows are fixed by a script, not 56 panel-opens. Rejected: keeping users.manage; an ashram picker now; hard delete when nothing references it; name snapshots on activity entries; defaulting to enrolled-only; returning enrolment to Requirements (0024 dec. 4 reversal, raised in the grill and not taken). ⚠️ Nothing is built — a stage-2 design recordAccepted · not yet built
  • 0076A module declares its own per-edition enrolment — the console never names a module. The enrolment half of 0075, separated because it is an extension point every future module inherits. ⚠️ The problem the task called "the real design work": enrolment writes requirements.area_registration, the module's own schema, and the Admin Console is itself a module — so the console calling it is module-to-module coupling, which ADR 0008 permits only through a published contract and lint:boundaries would be right to fail. ModuleManifest gains an optional enrolment declaration; the console loops over MODULES and renders a toggle per declaring module, learning that a module enrols and never what an area is — the same inversion ADR 0021/0025 already use for routes and views, and a module that cannot enrol simply omits the field. Requirements declares its existing seam (registerArea/deregisterArea), which ADR 0024 dec. 4b kept alive for precisely this. The enrolment targets the edition in the header, derived not picked — the third application of 0075's rule. Why not the cheap way: hardcoding Requirements makes module #2 an edit to the console, and splits the job ADR 0024 insisted on keeping in one place. Accepted cost: one consumer today; the console cannot explain a failure in module terms; a module could declare enrolment and not implement it (same as a views entry pointing at a missing route). ⚠️ Nothing is builtAccepted · not yet built
  • 0077A module registers enrolment on the server, and says who may enrol on its behalf. Amends 0076 dec. 1/2 and answers what 0076 never asked. ⚠️ The hole was found by building it: Requirements' authorize() needs a Requirements participation holding areas.manage, and a console operator has none — so the enrol toggle would have 403'd for exactly the people the screen exists for. The obvious repair is worse than the hole (a module reading admin_console's role tables is the coupling ADR 0008 forbids), so the module states in its OWN code that it accepts platform authority: the console proves departments.manage and hands the authorizing grant over, and the module honours it because it chose to. The declaration moves to the SERVER, beside the seam and its authorization — a browser manifest would have carried only a label while the interesting half sat elsewhere, which is how two homes for one fact drift. Attribution follows the caller, so an enrolment made from the console attributes to the operator's own Back-office grant and the trail answers who and on whose behalf. ⚠️ Platform authority is NOT a second door: the module's own endpoint still demands areas.manage from anyone arriving over HTTP, and that has its own test — if it ever passes, the console's capability has quietly become a way into every module's API. ⚠️ A claim in this ADR was wrong and was corrected before it shipped: lint:boundaries scans apps/web/src ONLY, so the server has no boundary enforcement at all — the rule the whole design leans on is upheld by review, not CI. Captured as its own task. Accepted cost: one consumer today; a module could register a provider and honour no authority; a real widening — a back-office operator can change which areas take part while holding no role in the module. Rejected: the module checking the console's capability (ADR 0008 violation); granting operators a real module_admin role in every module (defensible, and it carries every other power that role bundles); returning enrolment to Requirements' screen (0024 dec. 4, declined again)Accepted
  • 0090Master data is Platform Admin only — departments.manage leaves back-office. ✅ BUILT 2026-08-22 (local only; a feature-branch push does not run the database suite). Supersedes ADR 0075 dec. 9 (a module admin asks back-office, who edits departments by hand) and ADR 0088 dec. 6 — written the day before, recording that back-office finally differed from auditor by exactly this capability. The capability itself is unchanged and still enforced at seven call sites; only who holds it changes. 🔴 Measured in production BEFORE changing anything, because the cost depends entirely on who holds what: platform_admin 3 holders (Monish, Morya, Sowmya), back_office_operator 0, auditor 0. Nothing in production holds either non-admin tier, so this strands nobody and the argument is about what the tiers MEAN rather than anyone’s access. (UAT is noise — the same human holds all three tiers on two accounts, one a personal gmail address that also holds platform_admin there.) Why: a department is master data — it outlives the edition, every module reads it, and Requirements no longer enrols them per edition — so changing one is not day-to-day work, and the tier named for day-to-day work should not carry it. ⚠️ Consequence taken deliberately: back_office_operator is now IDENTICAL to auditor, both holding directory.read + activity.read. That is the shape ADR 0088 itself objected to — a name claiming something it does not have — accepted on ONE ground only: nobody holds either tier anywhere that matters, so the duplication is invisible in practice. Retiring one is deliberately DEFERRED, not declined: 36 files reference auditor (it is the fixture the whole API suite uses to mean a console holder with reads and no writes), and the seed does not yet reconcile ROLES — only the privileges inside them — so removing a tier from the code would leave its row in every existing database and silently do nothing. This ADR does not decide which of the two goes. 🔴 And the part that would actually have shipped wrong was the COPY, not the capability. No test failed because somebody lost a power — nobody had it. Three screens named “back-office” as who to ask; all three now name a platform admin, and ⚠️ the third was already wrong before this change (appointing a module admin needs modules.manage, which back-office has never held). An empty state pointing at somebody who cannot act is a dead end wearing a hint. ⚠️ Two costs recorded rather than waved away: every department change now routes to three people, and departments get discovered DURING setup week — if that bottlenecks, the answer is another platform_admin or reviving the request-flow question, not quietly putting the capability back. And a forward hazard: when merge is built it belongs to back-office, so the moment identity.merge returns there, anyone holding that tier for read-only oversight silently gains a write — exactly what ADR 0066 named when it refused to collapse auditor into back-office. Whoever builds merge must re-check holders or give it its own tier. ⚠️ This change only works because of yesterday’s reconcile: before ADR 0088 added it, removing a capability from a tier was a no-op on every already-seeded database.
  • 0089A role reference lives in the part whose roles it describes, and reads the tables that enforce them. ✅ CONSOLE SCREEN BUILT 2026-08-22 (731 API / 466 web, local; CI pending). 🔴 And the Requirements screen dec. 1 also decided is DEFERRED, not cancelled — appended the same day. Monish: “do we need it for modules? What will the module admin do with that?” Half of that does not change the design: the platform admin does NOT need Requirements’ roles, since they never assign a coordinator and the only Requirements role they touch is module_admin when bootstrapping a module with none — no choice among four. But the other half retires my own justification: the module admin IS the one choosing, and nobody asked for that screen. ⚠️ That is dec. 2’s own argument against a provider registry, turned on this ADR’s output — a screen with no user who asked for it is the same bet in a different shape. Deferring costs nothing, which is why it is safe: dec. 1’s each-part-owns-its-own ruling means the Requirements screen adds no seam and no change to the console screen whenever it lands. Had dec. 1 gone the other way — one console page fed by a publishing seam — this deferral would have been impossible, because the seam would have had to exist first. 🔴 What the build made true that was false before: sections.ts carried the line “this filter hides NOTHING today”, and Roles being gated on console.grant ends that — an auditor sees three sections where a platform admin sees four. The partial case is real for the first time and is now covered both ways; before this the filter had only ever been exercised all-or-nothing. The comment is corrected rather than left to mislead. Completes what ADR 0012 dec. 3 left open on the READ side — that decision deferred the runtime builder and said nothing about looking, so the role→capability mapping has never had a surface. Asked plainly: “how do I see which privileges which role is getting?” 🔴 Measured first: it is written in THREE places and two of them currently disagree — the seed constant, the module’s own roles file, and the role_privilege tables that actually enforce it. Dev still grants identity.merge to two tiers; ADR 0088 removed it from the code the same day and nothing re-seeded dev, and no surface anywhere reveals the difference. ⚠️ The absence has already cost something concrete: answering “what can back-office do that an auditor cannot?” required grepping the seed by hand — and that grep is what found the defect ADR 0088 exists to fix. A question a screen would have answered in five seconds instead surfaced a capability granted to two tiers and enforced nowhere. Three questions were being asked as one, and separating them is most of the decision: A what does this role get? (nowhere — this ADR), B why can this person do X? (largely exists on the person panel), C does the deployed database still match the code? (nowhere, bit us today, and not a screen — a check nobody has to remember to look at). 🔴 TWO screens, each owned by the part whose roles it describes — not one page in the console. ⚠️ This reverses my own proposal and the reversal is the point: I proposed one console page fed by a per-module publishing mechanism; Monish’s counter — “for modules we can put it in the modules, module admin we can show” — dissolves the problem instead of solving it. No seam, no contract, nothing wired at startup: ADR 0008 is not worked around, it stops applying because nothing crosses. It also puts the reference where the audience is (a module admin assigns coordinators; a platform admin does not) and fits the sidebar-of-sections shape module ADR 0023 already built. And explicitly NO provider registry — ADR 0080 dec. 3 applied before the mistake rather than after it: enrolment’s registry was built and torn out because “unused machinery is worse than absent machinery”, and a generic mechanism with one user invites the next module to adopt a shape nobody proved. Both screens read the TABLES, never the source constants — a screen built from the seed would have reported identity.merge as removed while the running database still granted it, wrong in exactly the moment somebody would open it; reading code tells you what was intended, reading tables tells you what will happen. ⚠️ Accepted as the point rather than a flaw: the screens show drift as bare fact without explaining it. Console screen gated on console.grant — Platform Admin only, argued and conceded (“why do back-office operators and auditors need to see?” — correct for back-office, who cannot grant anything). ⚠️ But the auditor case is strong and is declined KNOWINGLY: an auditor reads in the trail that somebody got back_office_operator — a role name with no meaning attached — seeing that a grant happened and never what it amounted to, the same gap ADR 0066 dec. 3 widened the tier to close one level up. ⚠️ And “platform admin and auditor but not back-office” CANNOT BE EXPRESSED: auditor holds exactly activity.read + directory.read, back-office holds both plus departments.manage, so no capability exists that the first two share and the third lacks — expressing it means inventing one whose only purpose is opening a read screen, which ADR 0088 dec. 3 now has a test against. So the real choice was all-three or platform-admin-only, and this is the strict end with the gap recorded as chosen rather than overlooked. Every row shows the plain label AND the code name (labels alone cannot be tied to a refusal message; keys alone make it a developer’s page). A grid, capabilities down and roles across — the question that started this was a comparison, and it is the layout the decisions are already written in, so screen and record can be checked by eye; one combined grid was rejected because the two capability sets do not overlap at all and blank-by-design is indistinguishable from blank-because-broken. ⚠️ Trade-off taken with eyes open: nobody can see the whole platform’s permissions in one place, and a Platform Admin cannot see Requirements’ roles at all without a Requirements grant — not a side effect but a pinned behaviour (module-privileges-are-its-own.test.ts proves the console’s departments.manage is refused there). ⚠️ And this is the FIRST console screen not visible to everyone with console access, which makes load-bearing a filter that has never done anything — sections.ts says so itself, “this filter hides NOTHING today”, and its tests cover only all-or-nothing. The partial case is created by this screen and is untested. Rejected: a generated Markdown table in docs/ (genuinely cheaper, but generated from the code so it inherits the flaw, and it goes stale silently — the failure this area keeps producing).
  • 0091The seed holds exactly the roles it declares, and refuses to remove one somebody holds. ✅ BUILT 2026-08-22 (727 API tests, local; CI pending). Closes the gap ADR 0090 recorded as knowingly open the same day — dec. 2 deferred retiring a tier partly because the seed could not remove a role at all. Extends ADR 0088 dec. 4 (a tier holds exactly what it declares) one level up. No schema change, no migration. 🔴 And it found the same defect still live in a module nobody was looking at: the Requirements seed reconciled nothing at all — its only deleteMany is for coordinator scopes — so removing a privilege from ROLES ran, reported success and changed nothing. That is the identical hole ADR 0088 fixed on the console side, which had been live in production for weeks, still open one module over and on nobody’s list. It was found by reading the file next to the one being fixed. ⚠️ The load-bearing half is the REFUSAL, not the removal. participation.role_key has no foreign key to either role table (the Requirements table says so in its own doc comment), so deleting a role somebody holds does not fail — the grant survives pointing at nothing, the holder silently loses every capability, and nothing anywhere raises an error. The database will not stop this, so the seed does: it throws, names the role and the grant count, and deletes nothing. A blind delete is one line shorter and passes every test that happens to have no holder; what it does in production is revoke access quietly. Each module reconciles its own table (admin_console.role and requirements.requirements_role live in different schemas per ADR 0020, and a shared helper would be the cross-module reach ADR 0008 forbids); holder counts are scoped by module_key for the same reason. Every removal logs a line, because silence is what let the privilege version survive. ⚠️ One stale schema comment is now recorded as wrong: Role is described as “authored at runtime (the role-builder writes these)”, which would make this reconcile delete every admin-created role the day such a screen shipped. Confirmed with Monish: no screen for creating roles is planned, so nothing but the seed writes these tables and the simple reconcile is correct rather than merely convenient — but if that ever changes, roles need provenance before the screen ships. Why: a guard the database cannot provide has to live in the code that would otherwise cause the damage, and refusing is recoverable where cascading is not — a refused seed is a message and a decision, a cascaded delete is somebody’s access gone with no record it happened. Retirement itself is still declined (ADR 0090); this only makes it possible.
  • 0092A crashed screen is not a failed request, and it keeps the chrome it crashed inside. ✅ BUILT 2026-08-22 (479 web tests, local; CI pending). 🔴 Three findings from the build itself: a test asserting “puts the crash on the console” passed with our own logging deleted, because React logs a caught error by itself — it was testing React, not us, and only mutation found it; nothing exercised the real route tree, so dec. 2 (the boundary sits INSIDE AppShell) was untested by every behaviour test, since each builds its own memory router — a boundary mounted in the wrong place fails exactly the way having none does, and moving it onto the shell route now turns a test red; and the no-leak assertion failed on first run because vitest runs with DEV true and the development trace rendered, so it is now pinned in BOTH directions rather than only the production one. 🔴 Reported from PRODUCTION 2026-08-22: the People screen threw and a signed-in human was shown nine stack frames and a minified chunk name on a black page with no way out. That screen is not ours — it is React Router’s built-in developer fallback, shown because nothing in apps/web catches a render-time throw: measured zero occurrences of errorElement, ErrorBoundary, componentDidCatch or useRouteError in the whole source. So this was never a People-screen defect — any render throw in any route has always done this. The trigger was a version skew (production’s web had a screen reading row.grants while production’s API did not send it), but the skew is the trigger, not the bug. ⚠️ And the estate had every ingredient except this one: ADR 0070 built a five-kind failure vocabulary, a shared Failed component and a rule that what a failure offers is decided by its kind — all of it about REQUESTS. A crash in our own rendering sat outside that frame and stayed uncovered because the vocabulary looked complete. 🔴 Departs from ADR 0070 dec. 5 deliberately, and the reason is the interesting part: that rule grants a retry only to kind: 'failed' because for every other kind we know retrying fails identically (“a retry button on a refusal is a lie”). A crash is the case where we do NOT know — the production one was deterministic, a one-off bad response would not be — so the reload is offered without being promised, with Back to workspace primary so nobody is stuck behind a button that cannot help. TWO boundaries, and neither per-screen: an inner one on a pathless route inside AppShell (it must be inside, or React Router replaces the header and sidebar and Back to workspace has no chrome to go back through) and an outer one at the root for a crash in the shell itself or on /login. ⚠️ Per-screen is REFUSED on a pattern this week supplied twice: twenty-plus routes would each need wiring and a new screen would silently get none — the same shape as the sidebar filter that hid nothing for months and the seed that reported success while deleting nothing. Two boundaries cover every screen by construction. Trace to the console, on screen in dev only, and NO reporting endpoint here — an unauthenticated POST needs payload limits, rate limiting and an answer to log-spam, which is a different question from what the user sees. ⚠️ Cost named rather than waved off: this crash was found because Monish screenshotted it, and MSR is an event with a hard date and users who will not file bug reports — a volunteer crashing at 2am tells nobody. Spun out as its own task. A NEW component, not Failed: ApiFailure is defined as what a failed request is, so reusing it means manufacturing a false kind and hollowing out the rule that component enforces — and Failed renders inside a screen where a boundary replaces it. The copy names the human channel because the trace decision made it the only one, mirroring the access wall’s existing sentence rather than inventing a support desk (same rule ADR 0084 dec. 3 followed) — and ⚠️ says nothing about whether their work was saved, because a crash mid-write may have lost something and a reassurance we cannot verify is worse than silence. Reload is window.location.reload(): a same-path navigate can be a no-op, leaving the boundary mounted and the button apparently dead. ⚠️ This is presentation, not prevention — it does not make crashes less likely, and the production crash that prompted it is still live until the API catches up.
  • 0093A panel uses the sheet it was given, and a row’s state never wears its action’s position. ⚠️ NOT BUILT — a stage-2 design record. Reported from the running console: “the side panel, the UI and the placement is so bad… it’s bad in all panels.” 🔴 Measured first, and it reframed the whole task: five panels, one primitive, and the primitive is barely used. SheetFooter already carries mt-auto so it pins to the bottom by construction — one panel in five uses it, and that panel has none of the reported faults. SheetDescription: two of five. So the drift is panels ignoring the shape, not the absence of one — which means the fix is adoption, and the shared PanelSection/PanelRow components the task proposed are refused: they would wrap a primitive that already works, for a seventh panel that does not exist. Same ground as ADR 0080 dec. 3 (the enrolment registry, built and torn out) and the Requirements roles screen deferred the same day. 🔴 And the reported “ragged two-line rows” are not a layout: each grant is a SINGLE flex line with flex-wrap, and in a narrow column the wrap gives up and drops the action to a second line. The markup intends one line; the container is too narrow for it. 🔴 THE actual defect, and it is not styling: RemoveGrant renders <span className='ml-auto'>managed in {module}</span> — that ml-auto puts an explanation in the action slot, where a button sits on the row above. Three kinds of thing — a button, a state, an explanation — shared one position at similar weight, so the ABSENCE of an action looked like a DISABLED action. That is ADR 0070 dec. 5 one layer out. Fixed by two zones: state left (including “managed in Requirements”, which moves), action right in a fixed slot, justify-between rather than flex-wrap. The footer takes the panel-level primary and the destructive, nothing else — Give access primary, Switch off sign-in secondary, which fixes two faults at once: the primary stops being below the fold and the destructive stops sharing a line with the email it acts on. Three things stay out of it: inline fields (the event-only checkbox is a field), per-row actions (a footer Remove would have to say which of four grants it meant — ADR 0070 dec. 1), and forms (so Give access becomes a button revealing its form, the shape Departments and Coordinators already use). ⚠️ Accepted cost: granting is one click longer, taken because the common case is looking somebody up and the arrangement it replaces cost the primary action entirely. No border per row — four bordered boxes for four one-line facts are what push the footer out of view. ⚠️ An operator who merely lacks the capability is told nothing, and that is CHOSEN: “you are not allowed to do this” is the disabled-button problem written out in words, and the console spent the same day hiding controls people cannot use. Cost named: an empty action slot means either “nobody can” or “you can’t” — acceptable because both resolve to not your job here. No bespoke heading: HOLDS is uppercase, letter-spaced, with a border-primary bar — styling that appears nowhere else in the app. ⚠️ Person panel FIRST, alone; the other four follow — and the reason is verification, not effort: jsdom renders no pixels, so no test can see any of this, and six panels in one pass is a review where a wrong call is repeated five times before anyone sees it. Checked before committing to a shape drawn from one panel: the other four hold 0–5 repeated-row lists and 1–3 actions, and department-sheet’s three resolve to one inline field plus a form plus a destructive — the shape fits all five without amendment. ⚠️ Four panels stay inconsistent until the follow-up lands, named rather than pretended away. 🔴 Correction 2026-08-24: dec. 2's stated REASON is wrong; the decision is not. "flex-col-reverse … so the primary lands under the thumb on a phone" describes the opposite of what the CSS does — column-reverse lays children out bottom-to-top, so the DOM-FIRST child is the one under the thumb, which in every panel here is the destructive action. Measured at 426px with the footer built as decided: Archive renders below Save name, and the person panel's Give access is not under the thumb either. No code change — primary-at-top-of-stack is what shadcn's footer does deliberately, and overriding it to chase thumb position would be working around the primitive dec. 1 exists to adopt. It was false because it was reasoned about and never measured. The other four panels are now built (coordinator, Requirements department, console department; service-detail already complied) — still unseen by a human, which stays the gate.
  • 0094Back-office corrects the department directory; archive stays with Platform Admin, and a mistake is removed rather than archived. ⚠ BUILT the same day — 802 API + 539 web tests, biome ci clean. HALF-SEEN: a platform admin watched both controls appear; the back-office half, which is the reason the unit exists, is unlooked-at, and removeDepartment has never run outside a test. 🔴 And getting a human to it found what no test could: the dev database had never been seeded since before ADR 0088, so a platform admin had silently lost the Archive button they had the day before — a control gated on a capability that does not exist renders as ABSENCE, with no error and nothing in the log. A capability split is a DATA migration wearing a code change; db:seed runs on every deploy so UAT and prod heal themselves, but a long-lived local database does not. Asked directly: “give me capability to add and manage departments but remove archive button for them… we need to properly have UI guards and capabilities.” Reverses ADR 0090 dec. 1 two days after it was accepted, and the substance is not that back-office writes departments again — it is which single write is held back. The line: back-office may CORRECT the directory, never REMOVE from it. Create, rename and the event-only marker leave a department available to every module; archiving takes it out of every place a department can be chosen. 🔴 A reason argued earlier in the same session was wrong and is recorded as wrong. The first draft held archive back because “archiving isn’t back-office’s job”; asked whether the tier fills the list before the event or keeps it tidy all year, the answer was all year, which makes closing a department squarely their job. The decision did not move; its justification did — what survives is rare plus consequential earns a second pair of eyes, constant plus cheap does not. 🔴 And the unit grew a feature nobody asked for, because the gap was real: NOBODY can remove a mistaken department today. A Platform Admin can only archive it, leaving a dead row in the console table wearing a Bring back button. So removing a mistake becomes its own write, a real delete, held by both tiers — remove means “this should never have existed”, archive means “this existed and is finished”. The “nothing has used it” test is Postgres’, not ours: measured in the migration SQL rather than the schema file, five foreign keys point at a department and every one is ON DELETE RESTRICT — so the delete either succeeds because nothing pointed at the row, or the database refuses it. No module queried, no usage projection, boundary not crossed, which is the only reason it is affordable. ⚠ One of the five is dead: nothing has written area_registration since per-edition enrolment retired, so it is four live references, not five — stated because the count was wrong once already in the same session. Three keys, because the label is the disclosure: departments.manage (add and rename) and departments.remove (delete a department nobody has used) to both tiers, departments.archive to Platform Admin alone. Archive splits OUT — one of four enforcement sites changes, against four the other way for the same outcome. Remove gets its own key although both tiers hold it: a hard delete hiding inside a permission called Manage departments surprises whoever granted it. The panel asks on open whether a department is removable and offers the control only if it is, with the server re-checking at delete time; always showing it and letting the server refuse was REFUSED because most departments are in use, so the button would fail on nearly every row and train people to ignore refusals — on the one control with no undo. The count stays OUT of the list (four counts across forty rows, none read until a panel opens). Back-office sees no archive button and no archive paragraph, but hiding alone leaves a dead end, so an archived department gets a sentence instead: “Archived on 12 Jan. A platform admin can bring it back.” Remove follows references, never archived status — one rule, and an archived row nobody used still holds its name against the unique constraint. ⚠ Accepted cost, and it is the sharp one: the foreign-key test means “nothing has been recorded against it”, NOT “nobody has seen it.” A department can sit in every picker for days with nothing pointing at it, so a visible, discussed department stays removable until somebody is assigned or enters data. Taken because the repair is to type the name again — the name frees the instant the row goes and nothing is lost but an id and a created date. ⚠ Industry practice cuts against which write we hold back: Xero, QuickBooks, Stripe and Salesforce all treat deactivate/archive as an ordinary edit and reserve only the hard delete. We are ordinary in having a delete-only-while-unreferenced rule and unusual in reserving archive, taken on one shared directory, three Platform Admins and one week where a missing department is a support call. ⚠ This ends the deliberate back_office_operator / auditor duplication ADR 0090 recorded — expected, since that ADR called back-office the tier that grows — and it makes the standing rule load-bearing: for read-only oversight grant auditor, never back-office, because anyone holding back-office has just silently acquired three department writes. Also found while checking, and deliberately not folded in: Requirements’ own same-named permission is labelled “Register and edit departments” and registers nothing — it gates a single read. 🔴 The build corrected dec. 7 and three assumptions. The panel does NOT count references to decide whether to offer Remove — counting means reading coordinator_scope and requirement_row, which are Requirements’ tables, and ⚠ the dependency check would NOT have caught it because it runs over imports, not Prisma models. It would have passed CI while doing the exact thing the boundary rule exists to stop. So the read attempts the delete in a transaction and rolls back — it cannot disagree with the real delete because it IS the real delete, and a future module adding a foreign key is covered without anybody remembering to update a count. Measured, not assumed: ON DELETE RESTRICT raises SQLSTATE 23001, not 23503, so the first refusal came back as a 500 instead of a 409; and the error arrives as a DriverAdapterError inside a transaction where it is a P2003 outside one. ⚠ The first refusal test passed while proving nothing — it pinned the coordinator’s participation to the same department it scoped, so participation_..._fkey refused first and the scope constraint was never exercised. ⚠ And mounting the panel turned 11 tests red in three files, none about this change: each mocks the api module with a literal factory, so the new read returned undefined and the panel crashed on .then. A panel that fetches on open is a contract with every test that mounts it.
  • 0095A filter control names itself on screen, and one control undoes them all. ⚠ BUILT, NOT SEEN. Reported from the running app: six dropdowns side by side on the cluster view, every one reading “Any”, with no way to tell what any of them filters. 🔴 The labels were NOT missing. ListFacetSelect took a label and put it on aria-label only — a screen reader announced “Sevadhar type”, a pair of eyes got a box saying “Any”. ⚠ It survived because the control had one other user: the console’s Departments screen renders ONE dropdown whose options (Year-round / Exists only for MSR) say what it is. One is guessable; six identical boxes are not — the defect shipped with the control and the second user is what exposed it. The general shape: an accessibility attribute is not a label. aria-label satisfies the test, the linter and a screen reader while the screen says nothing — and getByLabelText passed before and after the fix, so the suite was green on a screen nobody could use. The test that turns red is getByText: the name is on the SCREEN, not merely on the element. Now named by association (htmlFor/id, not aria-label) so the visible name and the accessible one cannot drift apart, and clicking the word opens the list. Plus one Clear that resets the search AND every facet, shown only while something actually narrows the list and removing itself again — a Clear that clears nothing is the disabled-button lie ADR 0070 dec. 5 already refuses, and on a bar of seven controls an eighth that does nothing is worse than absent. “Narrowing” is judged by EFFECT, since a facet put back to Any by hand leaves an empty string that narrows nothing. Clear stays in the SCREEN, not the shared control — the control knows one facet, only the screen knows the set, and it is not generalised for a second user that does not exist (the refusal ADR 0093 made about wrapping a working primitive). ⚠ Accepted cost: every screen using the control gains a visible label, including the console’s Departments screen, which did not need one — a control that names itself SOMETIMES is a worse rule than one that always does. ⚠ The Browser pane cannot check this one: the screen sits behind Isha SSO and the pane holds no session. jsdom renders no pixels, so a human’s eyes are the only evidence that the label is legible and unclipped.
  • 0096The filter bar is one row, and a panel holds the filters. ⚠ NOT BUILT — a stage-2 design record. Reported from the running app with the toolbar circled: “the search box for department will be different right… it should not take too much vertical space.” Amends ADR 0095, does NOT supersede it — a filter still names itself; the label moves inside the panel with the control it names. 🔴 Measured in the running app at 1206×643 before designing anything: 428px of chrome above the first data row — TWO THIRDS of the screen, four stacked blocks with 72px of pure gap, and a heading repeating the pill directly above it. 🔴 The filter bar is 58px on Materials (one dropdown) and 128px on Volunteers (six), where it wraps onto THREE rows — 1376px of controls into 1127px of space. A bar whose height depends on how many closed lists a category happens to declare is not a bar, it is a surprise. 🔴 And the layout was hiding a real defect: one search box, searching ROW VALUES. It works on By item by accident (the item name is a row value) and cannot find a department by name on By department, which lists 62 of them. So the box now searches whatever the TAB LISTS — departments / rows / items — with the label saying the subject, and clears on tab change because its subject changed underneath it. ⚠ This does NOT break the not-filled list: module ADR 0035’s rule is that a ROW FILTER never hides a department (the zeros ARE the list of who has not filled in) — a search whose subject IS the department is a different control with a different job, and the dropdowns still narrow rows on every tab. Four blocks become two rows, the category heading goes, and ~120px comes back — four more rows of data on a 643px viewport. The dropdowns move into a Filters popover with set values as removable chips: one row on every category, 36px instead of 128. No chip for the search text (it is visible in the box) and no count badge on the button (the chips ARE the count) — both would be the screen repeating what the reader can already see. Applies immediately; Apply was rejected on evidence — it batches re-renders, and 148 rows re-filter in the browser with no round trip, so the cost it buys does not exist. The screen also finally states both numbers (ADR 0086 dec. 3): ⚠ it was the ONLY filtered list in the app without a count line, while six others use the shared helper. ⚠ ACCEPTED COST, stated rather than discovered: you can no longer see which filters EXIST without a click — a step back from ADR 0095, whose subject was six boxes reading “Any” nobody could identify. The chips preserve what is SET; discovery costs a click, taken because the three-row bar costs every reader on every visit. ⚠ CLUSTER VIEW ONLY — seven other screens keep the bar they have (ListFilterInput has 8 call sites). One screen, seen by a person, then the rest — ADR 0093’s sequencing, for its reason: jsdom renders no pixels, so a wrong call would ship nine times before anybody saw it once. No new primitives — popover.tsx and badge.tsx both already exist and are already used. 🔴 The three-row wrap that prompted all of this was invisible to 32 passing tests and was found by a person looking.
  • 0097Production runs main, and a green health check is not a landed release. Written from a real deploy, not a plan. Closes a question ADR 0074 left open and the runbook asked a reader to settle deliberately: its deploy step said git checkout msr-dev with its own warning attached that “main is the better long-term answer”. Asked mid-deploy and settled: production runs main, UAT runs msr-dev, and the box moved onto it the same day. A release now reaches production one way: PR into msr-dev, then PR into main, both green including the Postgres suite (which only runs on pull requests). 🔴 THE finding: production ran new code against OLD permissions and a MISSING table while reporting systemctl active, /api/health 200 and /api/auth/login 200. All three were the checks the runbook had; all three passed; the release had not landed. Cause: db:deploy && db:seed in one chain — the migrate failed, && stopped the chain, the seed never ran. Running the seed alone later made the PERMISSIONS correct and left the TABLE missing: a half-applied release no signal reported. The API does not touch a new table at boot, so nothing failed — cluster_scope was absent all session and would have failed only when somebody opened the Cluster Coordinator view. 🔴 And a capability lives in a ROW, not in the code: gate a control on one that does not exist and it renders as ABSENCE — no error, nothing in the log, a button gone for people who had it yesterday. The same failure hit a developer’s machine that morning. The transferable sentence: every signal this deploy had was about the PROCESS, and every failure was in the DATA. systemctl reports a process; /api/health reports a process; neither knows what the schema contains or which privileges a tier holds. New runbook §3d asks the database by name after every deploy — the table the release adds, and each tier’s privileges for any release touching a capability. 🔴 Also fixed: §3a’s backup command could NEVER have worked — it handed pg_dump the Prisma URL, and schema= / uselibpqcompat= are Prisma’s parameters which libpq rejects outright. It failed twice on this deploy. The rule is now written down: $MIG for npm run db:*, $PGURL for pg_dump and psql. ⚠ The screens in this release still have not been looked at by a person, in production or anywhere else.
  • 0098Cycle Management is its own module, not a view inside Requirements. Amends ADR 0025's Requirements example (the reserved Cycle Management view will never ship there) and corrects the Requirements PRD and DESIGN, which carried fulfilment as a later phase of that module. Requirements collects what departments will need before the event and freezes it for the budget; fulfilment — assigning numbered bicycles, hand-over, return, live inventory — runs during the event, by a different person, on data Requirements has no business holding. Kept inside, every freeze rule would need an “except cycle fulfilment” carve-out forever. So: a top-level module, peer of Requirements and Operations. The Cycles requirement category stays in Requirements — collecting requests was never in question. cycle_manager and cycles.fulfil leave Requirements' role list and are declared by the new module when built — closing the ADR 0088 violation (seeded since R2, zero enforcement sites). The new module reads cycle request rows through a published Requirements read contract, scoped to the Cycles category only — tighter than the rows.read.all the old role held. No events, no synced copy. Wanted for MSR 2027: legacy had working fulfilment (cycles_inventory), so shipping without it takes a tool away. Placement only — the module's internal design goes through the pipeline.Accepted
  • 0114Cycle Management is unlisted on main, because every production user is an admin. ⚠ Withholds the module ADR 0098 placed, without unbuilding it. The module was promoted to main in PR #47 and deployed to production on 27 Aug; it should not have been reachable there yet. Revoking the grant was never a control — every production user holds platform_admin, which carries modules.manage, so the module sat in their Give-access menu and any of them could grant cycle_manager to themselves (onboarding.ts only stops you removing yourself, not granting). So: cycleManagementModule is not listed in the web manifest. One list feeds the Give-access menu, the Workspace tiles and the router, so absence removes all three together — and both consumers skip a module they cannot resolve, so an existing grant holder sees nothing rather than a crash. Not an access boundary: the server still mounts and still authorizes the routes on cycles.fulfil (ADR 0009 owns the refusal). The schema, the migrations, the API and every grant are untouched; restoring it is adding the import back. 🔴 main only — msr-dev deliberately keeps the module, because UAT deploys from it and it has to stay testable. The branches now disagree about one line and nothing enforces it. The danger is NOT the next promotion (msr-dev has no competing change, so the removal survives) but the routine catch-up in the other direction: merge main INTO msr-dev and the module vanishes from UAT, somebody re-adds the line to fix UAT, and that re-add descends from this removal so the next promotion applies it cleanly, with no conflict — back in production with nobody having decided it. A build-time flag (VITE_MSR_WITHHELD_MODULES, set per Netlify site) was written and rejected as too much machinery for a one-line change; it is the answer if this bites. Until then the guard is a comment in the file and the ADR.Accepted · built 2026-09-01
  • R-0037An item is a name, a unit, and nothing clever — the By item tab. ⚠ NOT BUILT — a stage-2 design record. Asked from the running app: “I want to view in terms of items, like A4 print sheet total, and it breaks down how many departments asked.” The two existing cluster views answer who has filled what in; neither answers how much of a thing the event needs, which is the buying question. Completes the amendment module ADR 0031 made to ADR 0029 — that Materials could NEVER total by item, “too pessimistic once names are standardised”. 🔴 The first measurements were wrong to reason from and were thrown away. They came from a stale dev database holding pre-standardisation names and 64 rows whose “unit” was a date, a bare number or an off-list spelling. Monish said the problem looked local; it was — the current loader already remaps aliases and blanks a quantity typed into the unit column. Re-measured on a fresh load into a scratch database, dev row count checked before and after (2,951 both times): 2,787 rows, 41 departments, 1,332 distinct item names, 1,106 after lowercase+trim, units spotless. 🔴 And the re-measure found what no decision had accounted for: 404 rows have NO quantity while quantity is required, min 1 — the loader wrote rows a coordinator could never save, which the registry permits in as many words (“required is a rule for entry, not for the load”). Four states, not two: 2,177 total cleanly · 206 quantity-no-unit · 192 unit-no-quantity · 212 neither — 610 rows, 22%, cannot contribute to any total. THE decision: an item in several units is a FACT, not a mistake, and its total SPLITS per unit — 1,004 Nos · 12 Bundle, never one summed number. 87 items / 636 rows / 23% arrive in more than one unit, and that survives standardisation almost untouched. A single total across Bundle, Packet, Piece and Set is a number no buyer can act on that looks authoritative — the same failure ADR 0035 refused when it banned paging. ⚠ Not the same problem as a DUPLICATE unit: ADR 0029 dropped Number because it and Nos were one unit spelled twice — duplicates die in the option list, genuine differences are shown, and the view never merges units. Blank units get their own labelled subtotal (45 no unit given), never folded into the commonest — that bucket is the setup-week chase-list, and it is now a CLOSED legacy set because materials.unit becomes required (split out as its own task: a rule that bites on save should not ride inside a viewing feature). Names group on lowercase + trim + collapsed whitespace and NOTHING further, merging 226 variants (BAll point PEn, MArker-Permanent-Black — six spellings of one pen). ⚠ gada cloth off white and gada cloth offwhite stay TWO rows, deliberately: removing spaces would join them and also join pairs where the space matters. A wrong merge misreports a purchase order and is INVISIBLE; a missed merge is two adjacent rows a buyer fixes in seconds. Displayed name = the commonest spelling, always one somebody actually typed (Title-casing the key gives A4 back only by luck) — accepted cost: it can flip when a row arrives. A third TAB, not a screen, and not generalised beyond the cluster coordinator: a buyer asking the same question is a second reader and a real decision about who may see every department’s numbers. Every rule chooses a visible imperfection over an invisible error. ⚠ Open before building: what the tab shows for Cycles, the ONE category with no number field — and it has no item-like column either, so it may simply not get this tab. 🔴 This row said “Infrastructure and Cycles” for an hour after publication and was wrong: Infrastructure has ELEVEN number fields (quantity, light_count, ac_tonnage, area_sqft …) having absorbed Electrical’s columns, 39 in all. The claim has now been published wrong THREE times in this repo, each time copied from prose rather than read off the registry — and this one was copied into an ADR whose own subject is that mistake. totalColumns derives from the registry, so the screen has always been right; only the sentences were not.
  • 0088A capability nobody enforces leaves the tiers — identity.merge comes out until merge exists. ✅ BUILT 2026-08-21 (local only — a feature-branch push does not run the integration tests, so CI has not seen it). 5 tests, both guards mutation-tested; the rule test reports “seeded in a tier but enforced nowhere: identity.merge”. ⚠️ Production and UAT still hold the stale grant until their next deploy — db:seed runs every time, so the reconcile clears it with no manual step. Supersedes ADR 0066’s tier table (one row, and the row count); 0066’s decisions 1–4 and all its reasoning stand. No behaviour change — nothing checks this permission today, which is the entire point. Found by answering a plain question — “what can back-office do that an auditor can’t?” — from the seed rather than the docs, then grepping the enforcement sites: departments.manage 7 · directory.read 5 · modules.manage 4 · console.grant 4 · users.manage 2 · activity.read 1 · breakglass 1 · identity.merge 0. 🔴 ADR 0066 contradicted itself in one document, and that is the finding. It states “the catalog is only useful if every row in it is enforced”, drops settings.manage and account.disable on exactly that basis (“phantom permissions are worse than missing ones”), then prints a table two sections later granting identity.merge to two tiers. Same defect, same document, and the exemption was never argued because nobody noticed it was one — CONTEXT.md inherited it and told readers the list was “settled at seven” for weeks while the seed shipped eight. ⚠️ What it actually cost is the part worth carrying: the devlog explained why back_office_operator had never been driven — “its set differs from auditor only by identity.merge, which has no surface” — a sentence that made the tier look untestable and was already false, since it also holds departments.manage with seven enforcement sites. A phantom permission did not merely mislead a reader; it talked a real, enforced difference out of being tested. So the capability leaves both tiers but STAYS in the vocabulary, and that distinction is the whole decision: settings.manage and account.disable were wrong ideas, this is a right idea, unbuilt — the tier is where the lie was, the vocabulary is where the intent lives, and deleting the name would lose the only marker that the gap is deliberate. Now a live need, not hypothetical: on the same day a human held two Person rows in production under two Isha accounts, and with no merge path the resolution was deleting one row by hand against a database with no backups. The rule is restated as a TEST over the seed, not prose — a capability may appear in a seeded tier only if some call site enforces it — the third time it has been stated and the first time anything checks it. breakglass stays, named explicitly so the rule does not sweep it up: one enforcement site, grants nothing useful yet, and dec. 3’s test is enforced, not useful. ⚠️ The way this change can appear to work and not work: the seed is idempotent by upsert, and an upsert that stops mentioning a role_privilege leaves the existing row in place — so an environment seeded before today keeps the grant unless the removal is explicit, and production is already in that state. A fresh-DB test cannot see it. Two existing tests assert the current set and must be turned around, not deleted — which is good news: the behaviour is covered, so this is visible to the suite rather than silent. ⚠️ Accepted and worth knowing: tier composition now lives in three ADRs (0012 dec. 2, 0066’s table, 0088 dec. 5), so a reader who stops at either of the first two gets a stale answer — the real fix is one living home for it, which the append-only rule deliberately does not provide. Consequence: back_office_operator now differs from auditor by exactly departments.manage — one screen, seven call sites, and finally worth testing.
  • 0087A person’s row says what they hold, and carries their phone where there is room. ✅ FULLY BUILT 2026-08-21 — all nine decisions, two slices, local only. Phone landed on seven surfaces and is refused on the two badge sites, where a badge already cannot hold a name and an email together. ⚠️ The ABSENT case is the common one, not an edge — measured on production, 2 of 3 people have no number, and it comes from an OIDC claim optional by spec, so missing is permanent rather than a backfill gap. Nothing stands in for it: no placeholder, no em-dash, because an invented value in an identity field is worse than a blank one and most rows would carry the invention. Displayed, never matched — mutating a box to match phone numbers reds the test that pins it. 🔴 And listOperators never selected a name at all, which is why Access could only ever render an address — fixed at the source, not in the component. ⚠️ The blast radius was SEVEN reads, not the six estimated at the gate, and the contracts rippled into ten test files whose fixtures had to gain the field — the suites stayed green while tsc failed, which is the split worth remembering. ✅ People’s rows now say what each person holds — badges labelled from the owning module’s manifest exactly as the panel labels them, “No access” in words where there is nothing, live access only, and sign-in state beside it as an independent fact. One grouped read, counted not assumed: five people cost three queries, and mutating it to a query per person reports “expected 7 to be 3”. 🔴 Two findings while building it. (a) The People box does NOT match role text and CANNOT — role labels live only in the web app’s module manifests and the server holds keys, so a server-backed box could match platform_admin but never “Platform Admin”. Recorded as a second exception to ADR 0086 dec. 6, alongside phone, with a structural reason rather than a practical one; the Requirements tables are unaffected because their boxes filter locally and DO hold the labels. (b) A searched row had to carry grants too, and the TYPECHECKER found it — the search returns PersonMatch, which has none, so typing into the box would have hidden the Access column that browsing shows. Searching showing less than not searching is worse than not having the column. Fixed where Requirements already had the pattern: the console wrapper attaches grants on top of the Foundation’s matches, so the seam stays a matcher and the two pickers do not pay for a read they never use. ⚠️ grantsByPerson is now the single implementation of “live grants for these people” — a second copy is how the list and the search would start disagreeing about who holds what. Lifts the DirectoryRow exclusion of grants that ADR 0068’s contract imposed, extends ADR 0086 dec. 6 (with its one deliberate exception), and applies Requirements module ADR 0026 dec. 5 (name ADDED, never substituted) to two console tables it never reached. Asked for on the running production screen: “add a role column also so that we know what role they have or not” and “show phone number in all places… alright if its available”. Both asks were measured before being designed, and the measurements changed the answers. 🔴 Phone: 2 of 3 people in production have one — so the column is worth building and the empty case is a third of the live directory, permanently: phone comes from Isha’s phone_number claim, optional by OIDC spec, so it is not a backfill gap. Hence shown where it exists, nothing standing in for it where it does not — no placeholder, no em-dash — because an invented value in an identity field is worse than a blank one and a third of rows would carry the invention. 🔴 “All places” turned out not to be literal: a person renders in nine places and three are badges, which already pick one of name and email because they cannot hold both. So phone lands on seven surfaces and is refused on the two badge sites — the detail lives in the panel a click away. 🔴 And listing those nine sites found an unreported defect: Console → Access renders console operators by EMAIL ONLY, in every branch — the one people-table in the console with no name at all, on the most-privileged list in the system, where two live rows are shared mailboxes whose addresses identify no human. The identical fault was fixed on Requirements’ Admins table months ago; this one was never revisited. Fixed here. The Roles column reverses something written on purpose — DirectoryRow is documented as deliberately thinner than PersonAccess because “a list of everyone must not fan out into everyone’s grants — that is a query per row”. ⚠️ That reasoning is sound and its premise is wrong: it assumed N+1. One grouped read over foundation.participation returns every person’s grants in a single query, so the cost was forced by an implementation nobody had to choose, not by the feature. “Do not fan out” survives as a constraint on the implementation; “therefore no grants on the list” falls. The column counts LIVE access only — a switched-off grant does not appear, though the person panel deliberately still shows it. ⚠️ So the list and the panel can disagree about one person, accepted deliberately: the panel is the surface that can explain itself, the list is the surface that has to be scannable, and a row reading Requirements · Module Admin for someone offboarded is actively misleading on the very screen used to check. Rejected: greyed-out removed grants — that puts the panel’s nuance in a table cell, where nuance is not read. One badge per grant, labelled from the OWNING module’s manifest exactly as the panel labels it, so both say the same words about the same grant; a count (“2 roles”) was rejected as unreadable at a glance, which is the only thing the column is for. No access is said in WORDS, never an empty cell — it is the row an operator scans for, and blank reads as not loaded. Sign-in state and access are independent facts side by side: an offboarded person shows their badges and Sign-in: off, because blanking roles for anyone who cannot sign in hides exactly what needs acting on — a locked-out person still holding a grant nobody removed. ⚠️ Two of the eight decisions are refusals, and the sharper one is that phone is DISPLAYED, NEVER MATCHED — the single deliberate exception to ADR 0086 dec. 6. +918825575293, 08825575293 and 8825575293 are one number and three strings, and the column is not unique by design because families and shared desks really do share one: a box that finds two people for one number, or none for a number that is right, is worse than a box that does not look. ⚠️ First things to verify: a person holding nothing reads “No access” (a bug here is a blank cell, indistinguishable from a load failure); a switched-off grant is absent from the list AND present in the panel — if the panel hides it too, a real defect has been introduced looking like consistency; and the grants read issues one query, not one per person — count them, do not infer it from the page feeling fast.
  • 0086A list is filtered in the screen where it arrives whole, and searched on the server where it does not. ⚠️ ✅ FULLY BUILT 2026-08-21 — all ten decisions, six slices, local only (a feature-branch push does not run the integration tests). Activity’s box landed last: server-backed, matching Who and Acting-for and REFUSING the action code and module key — nobody types person.signin_disabled, and matching it means “disabled” hits rows about something else. ⚠️ An empty query means NO FILTER here, not no results — the trail is one read with an optional filter, where the directory is a list read and a search read, so an empty directory search returning everything would be a second route to the list. Same screen behaviour, different mechanics, and worth knowing before “fixing” either to match the other. The filter and the count share ONE where object: built separately they agree on any small fixture and drift the moment the filter changes, and a total the rows contradict is worse than no total — mutating the count to ignore the filter reds three tests. Everything else is built, including amendment A: the “Find a person” card is GONE and People is one server-backed box over one list, reporting “1 of 312 matching” — what was searched, not what was loaded. ⚠️ Amendment B named the wrong cause and the ADR now says so: the picker’s input already had autocomplete="off" when the password-manager panel was reported, because managers ignore the attribute. What markup can still change is the type — an untyped input defaults to text and reads like a username field — so both the shared filter input and the picker are now type="search". Deliberately NOT recorded as fixed: jsdom has no password manager, and the only real check is a browser with one installed. ⚠️ That change also flipped the picker’s accessible role from textbox to searchbox, which broke a test that used “no searchbox” to mean “no filter box” — it now asserts the stronger thing, that the one search-shaped field on Requirements → Admins is the one for appointing somebody. Boxes on Requirements → Departments and → Coordinators, and on Access’s console-access list; the shipped Departments box now also matches its nature and status columns. The two SERVER-backed boxes (People, Activity) and dec. 7’s query rules are still unbuilt. The matching, the count line and the input live in one shared control in src/components/ — where the area rules put a component two modules need — and the count helper exists precisely because dec. 3 is the only thing keeping the two mechanisms honest: mutating it to a bare count reds five tests across all three screens. ⚠️ On Requirements → Departments the work-queue count deliberately stays over ALL departments while the filter count is separate: how many still need a coordinator is a fact about the edition, and narrowing what you look at must not change what the screen reports. Activity now carries the edition’s total and states its truncation, and the console’s directory search is re-gated on directory.read — DIRECTORY_SEARCH_CAPABILITIES is deleted, and an auditor can now search the list they were always allowed to browse. ⚠️ That route had NO positive test of any kind — only 401 and 403 — so nothing proved anybody could search it, which is how the mismatch survived. Dec. 7 landed last and was the largest: the 3-character floor and the 25-result refusal are gone, and the seam returns { matches, total } — the third use of that shape, deliberately not a fourth idiom. ⚠️ It was split out of the authorization fix at the gate and then turned out to BLOCK slices 5 and 6, which the task graph had backwards: a box built before this would have 400d on a two-character query and on any common surname, and its count line could not have said “50 of 312” because the endpoint returned a bare array. Three callers share the seam — console, module-admin member search, Requirements coordinator-assign — so their response shapes moved together while their gates stayed untouched, and each is pinned by its own endpoint test. The picker now reads “Showing 25 of 312 — keep typing” where it used to refuse; the client’s 400 branch and both error classes are deleted, not orphaned. ⚠️ An empty query returns an empty page, not everyone — it narrows nothing, so it matches nothing, and answering with everyone would make the search a second route to the browsable list. Both decisions mutation-tested: returning everyone for an empty query reds one test, and counting the total unscoped reds the merged-away test — which is the right one, since an unscoped count reports a match nobody can see. The read returns a page in the same shape DirectoryPage already used, rather than a second idiom. The screen had NO test at all before this — which is how it came to present its newest 50 as the whole trail; the client↔screen contract was untested by construction. Local only, CI unseen. Supersedes ADR 0030 dec. 3 and dec. 4, amends ADR 0068 dec. 3 (“search remains exactly as it is” — it does not), and extends ADR 0075 dec. 8’s shipped filter into a rule for every list in the console. Asked for as “search boxes in all the admin console and requirements admin setup”; reading the code first turned one request into one shipped box, three real gaps, one trap and three lists that get nothing. 🔴 The trap: People and Activity are truncated by the server at 50 rows, and Activity does not say so — DIRECTORY_DEFAULT_LIMIT and TRAIL_DEFAULT_LIMIT are both 50 (max 200), and the trail response is { entries } with no total, so the screen presents the newest 50 as though it were the trail. A plain filter box there would filter the 50 rows that happen to be loaded and answer “nobody matches Ravi” while Ravi sits at row 180 — not a slow search but a confident wrong answer, from the one control whose entire product is trust. Hence the rule, which is about the data and not the screen: whole list → filter locally; truncated → ask the server, checkable in one line by whoever adds the next box. 🔴 And an authorization mismatch that would have shipped invisibly: DIRECTORY_SEARCH_CAPABILITIES is ['users.manage', 'console.grant', 'modules.manage'] — three writes — while the directory LIST on the same screen sits behind directory.read. So an auditor browses the list and is refused the moment they type, on a screen they are entitled to read: exactly the fault ADR 0066 dec. 2 corrected once already here, applied then to listOperators and the list and never to the search. Found by reading two capability constants side by side, not by using the screen — 200s all the way, no test broken, the 403 visible only to a role nobody on the team holds, the same shape as ADR 0085. So searching the directory becomes a READ on directory.read, one endpoint not two (a read-gated twin beside the write-gated one was rejected: two queries over one table differing only in who may call them is a pair that drifts, and the day they disagree the quieter one is the security bug). It widens nothing that matters — every row it returns is a row the list already hands the same caller. The 3-character floor and the 25-result refusal go too: ADR 0068’s own Context had already measured the cost (“3 characters of a name they are unsure of returns the wrong people or a length error”) and then left them in place. ⚠️ Removing the floor removes a disclosure control deliberately — ADR 0030 called it that — but that argument was already settled the other way by ADR 0068 dec. 1, which gives the same capability a browsable list of everyone; the floor was guarding an open door, and paging is the control that survives. Over the cap the surface returns a page and says so, which is ADR 0068 dec. 2’s own rule (“asking for more returns the maximum rather than an error”) finally applied to the search that was violating it; the picker says “25 of 312 — keep typing” where it used to refuse. Every box says both numbers (“12 of 61”, or “50 of 312 matching” when the server searched) — the only thing stopping two identical-looking mechanisms from being their own trap. Activity states its truncation whether or not it gets a box (a correctness fix, owed today) and its box matches Who and Acting-for only — not moduleKey, not the action code, because nobody types person.signin_disabled and matching it means “disabled” hits rows about something else. A box matches the text the row visibly shows, case-insensitive substring, so a reader can always see why a row matched; that extends Console → Departments’ shipped box to the year-round/event-only marker. ⚠️ Restraint was asked for and granted: three lists get NO box — who runs each module (two rows today), Requirements → Admins (a handful, and it already carries a person field whose own code comment records why a second one is a hazard). Not cost: a box over two rows teaches people the box means nothing, and then they distrust it where it matters. Query text lives in the screen, not the URL (every keystroke would be a history entry, or need replace-not-push machinery for a benefit nobody asked for); sections stay addressable as they are. Accepted knowingly: a one-character substring cannot use an ordinary index — fine at thousands of people and a handful of operators, and if it stops being fine the fix is a trigram index, not an error message aimed at the person typing. ⚠️ First thing to verify, because it can silently not happen: an auditor can type in the People box and get results — no existing test exercises a role holding directory.read without a write capability. Then: a person past row 50 is findable by name (the gap is by definition invisible in the first 50, so the fixture needs a row 51).
  • 0085The API proxy target comes from the site, not the branch. ⚠️ BUILT, NOT YET PROVEN ON A DEPLOY. Supersedes the mechanism in commit ab52acc — which carried no ADR, its reasoning living only in netlify.toml and a commit message, and that is part of why it went unchecked. 🔴 The production hostname was authenticating against UAT’s SSO client and writing to UAT’s database — measured: msrapps.sadhguru.org handed back issuer uat-login.sadhguru.org and client msr-apps-alxdag3z0bcppp. 200s all the way, nothing looking broken; the exact failure ab52acc existed to prevent. It had already bitten us twice and been misread both times — a Person row created by testing landed in UAT (which is why grant:platform-admin refused with “not in the directory”), and “after clicking sign in it’s going to the UAT” was reported and explained away. One cause, three symptoms. The previous mechanism cannot work, and it was not a misconfiguration: the site DOES build from main, and the .invalid placeholder can never resolve — so had [context."main"] been in effect, production would have failed loudly instead of reaching UAT. Branch cannot separate these environments at all: both sites build from a branch, one file describes both, a production deploy ignores the branch key, and [context.production] was already ruled out because it fires for UAT too. So the target now comes from API_PROXY_TARGET, set per SITE, with no default — an unset variable FAILS THE BUILD, because the bug being replaced was a fallback quietly resolving to UAT. ⚠️ Both redirects had to move to the generated _redirects: netlify.toml is evaluated first, so leaving the SPA fallback there would have made /* swallow every API call and answer it with index.html. ⚠️ The transferable lesson: a verification note in a file is not a gate. ab52acc wrote the exact curl to run — nobody ran it, because no production site existed yet and the trigger later passed to a different workstream. A build that fails is a gate; a comment is not. ⚠️ Not proven until the production hostname returns msr-apps-s6kvfcaszwk1.
  • 0084A refused sign-in carries a kind, and the door to another account is an Isha logout. ✅ BUILT 2026-08-20 (local only, CI has not seen it). 🔴 Switch a person’s sign-in off and their next attempt dead-ends: the refusal fires after the token exchange, so they hold a live Isha session — pressing the button again re-authorizes the same person silently, forever, with no path back to Isha’s form. ⚠️ CORRECTED before building: the claim that seven sites print internals into the URL was wrong — SsoHandshakeError keeps the detail on .cause and sets ONE fixed message, “sign-in could not be completed — please try again”, so the detail is logged and never rendered; nothing leaks and the code comment’s stated intent is implemented. The real defect is worse in the case that matters: six failures share that one sentence, and for the offboarded person “please try again” is false advice — the one thing ADR 0050 dec. 4 says an unretryable sign-in must never say. So splitting by kind stands on a better reason, the seven-message scope shrinks to two sentences, and dec. 4 changes character: today the screen discloses nothing, so speaking plainly introduces a disclosure deliberately rather than repairing a leak. Invisible because no test asserts what that screen says — only that a failure lands on /login, the ADR 0070 coverage gap one screen further out. So the callback now carries ADR 0070’s kind (refused | failed) and the screen derives what it offers; failed gets no new control because the sign-in button is the retry. Measured, with approval: Isha accepts prompt=login, select_account, none and prompt=totally_bogus_value identically — and does not validate client_id either — so the parameter is safe to send and almost certainly not read. Hence no prompt is sent at all, and the way to another account is an opt-in Isha logout with its cost in the label (“Sign out of Isha and use a different account”), shown only for refused — never automatic, which would infinitely bounce if prompt is ignored. ADR 0038 dec. 3’s non-disclosure is scoped, not overturned: only an offboarded person can reach this state, so the message’s existence discloses it whatever it says — vagueness buys nothing and costs the reader their next step — and they have just authenticated as that account. So it says plainly that sign-in is switched off, pointing at “the person who invited you”, the access wall’s existing sentence. ⚠️ Two accepted holes, both stated: a second Isha account is a new sso_id → a new Person on the access wall, so the door delivers an honest “no access yet” rather than getting anybody back in (and mints the duplicate slice 10 defers); and if Isha sends no id_token the logout degrades to local-only (ADR 0042 dec. 5) and the dead end survives — the first thing to measure at build time. Rejected: letting them in to the access wall (the most dec.-3-consistent answer) — it needs a session minted for a disabled account, dismantling slice 11’s teeth. ⚠️ Three things the tests could not see. (a) The browser caught a clipped exit: the door’s bottom landed 7px OUTSIDE a 1536×644 viewport — a real laptop, the one ADR 0083 was derived on — on a page that is h-svh overflow-hidden, so the one control whose purpose is being an exit was unreachable with all nine tests green (jsdom has no layout). Capped at min(77svh, 100svh - 190px) only while the door shows, so tall screens keep the approved composition — the same relative-unit cap ADR 0083 landed for the ghost year — and the copy lost “If you are expecting access, please” because those words cost the third wrapped line that pushed the door off-screen. (b) \b made a guard unfalsifiable: /\brefused\b/i can never match a real leak, because textContent concatenates adjacent elements with no separator (refusedSign out of Isha…) — rendering the code word deliberately left nine tests green. Plain substrings now. (c) Typecheck caught what tests did not: an id_token typed string | undefined where the source returns string | null compiled everywhere except one call site, every test passing. Every guard mutation-tested — including one mutation that was itself invalid (broken JSX → vitest reports “no tests”, which is not a pass). ⚠️ NOT verified: the screen’s appearance. The preview pane is never displayed here, so screenshots time out and motion never starts — everything sits at opacity: 0 with its initial transform applied, which skewed the first five measurements by 8–16px until the computed styles gave it away. ✅ But the Isha round trip PASSED on UAT the same day — the blocking unknown is closed. A refused handshake does carry an id_token: the account was refused, the door pressed, and the next attempt was met by Isha’s own credential prompt — only possible if its session had ended, so dec. 7 holds and the door works end to end. ⚠️ An inference, not a direct read (an id_token lives 3600s, so a long gap would also prompt; here it was minutes). ⚠️ The first report was “the error is the same after signing out — we’re stuck”, and that was the design working — the same switched-off account is meant to see the same message. A working exit and a broken one both look like “I saw the error twice”; the credential prompt is the only thing that separates them, so re-testers must be told to watch for the prompt, not the message. Dec. 6 is vindicated rather than merely accepted: no prompt was needed at all.
  • 0083The ghost year is sized as a fraction of the frame, not in pixels. ✅ BUILT 2026-08-20. 🔴 The sign-in year was min(28vw, 340px) — a fixed pixel in a scene whose every other measure is relative, so past 1214 px of width it rendered identically on a 1536 px laptop and a 4K monitor while the figure (min(130svh,110vw)) kept growing. The diagnosis is the two-screen table: the same 340 px puts the ink at 91% of the frame on 1536×644 ("looks great") and 72% on 1920×959 ("not scaling properly") — the second being the "reads as a second heading" composition ADR 0055 dec. 2 exists to prevent. Now min(22vw, 53svh), where 22 is that approved look re-derived (340 ÷ 1536). Because ink width is always 4.094 × font-size, ink ÷ viewport = 0.901 with the viewport cancelling out — the framing is identical at every size by construction, measured at 93% across 375, 1100, 1536 and 1920 px. 53svh is only a guard past 2.41:1. ⚠️ Four attempts were rejected on sight and are recorded so they are not re-derived — 57svh put 8% of the ink through the translucent stone (exactly the 0 and inner 2, reported as an opacity change when no alpha had been touched); tighter tracking read "too tight"; a silhouette occluder worked (8% → 0%) but was withdrawn as unnecessary; and 53svh as the primary anchor held the year against the STATUE, which on a differently-shaped screen ran the outer 2 and 7 off both edges — proving the invariant is the frame, not the figure. ⚠️ The process failures cost more than the fix: the reference viewport was inferred from screenshots four times and was wrong every time (125% display scaling means a screenshot’s pixel size is not the viewport), F12 shrinks the window so a console reading measured the wrong thing, and a wedged Vite cache served stale code through reloads while a probe reported it as verified. Measure the viewport on the page; never infer it.
  • 0082An admin grant authorizes only the editions it covers. ✅ BUILT 2026-08-19. 🔴 authorizeModuleAdmin filtered person / module / role / enabled and not scope, so an admin of a past edition could act on the current one — implementing what ADR 0013 §3 had already decided. ⚠️ Why it stayed invisible is the transferable part: the one seam anybody would test (onboardMember) was safe incidentally, via a check on the WRITE's scope rather than the caller's; removePeerAdmin merely looked safe because its last-admin guard fired by coincidence. Both genuinely exposed paths REVOKE access.
  • 0080Per-edition enrolment retires; a module owns its own use of departments. ✅ BUILT 2026-08-19. ⚠️ Two corrections on implementation: 61 offered / 57 unassigned, not 62 / 58 — back-office needs filtering too, which registration hid by accident — and module ADR 0032 dec. 3's claim that the de-registered-but-scoped hazard is deleted is wrong: archiving reproduces it, so that handling stays. Retires ADR 0077 AND ADR 0076 — the whole generic enrolment mechanism, whose only user is withdrawn — and supersedes the premise of module ADR 0007 (module-side removal: module ADR 0032). Reached by asking the same question three times and not accepting the first answer — "what's the use of this?", "why is it required, doesn't it make any sense?", "why should the Foundation care about this?" — then the reframe that settled it: "the department table is common, all the modules use it. They will use it however they need... the Foundation need not be involved in it." What the numbers said: ⚠️ 60 of 62 departments were ticked — a flag true 97% of the time distinguishes almost nothing; it gated no access (listMyDepartments resolves from coordinator_scope and never consults registration, so a coordinator sees their departments whether or not anyone ticked the box); it gated no data (rows are per-edition already, so "which took part in 2026" is answerable from the rows); 4,296 requirement rows against 60 registration rows; and one provider was ever registered, with every read inside requirements/. So: show every department. Whether one takes part is answered by whether it has a coordinator and whether it has data — both already recorded, both truer than a tick somebody may have forgotten. A module owns how it uses departments and the Foundation does not mediate it: foundation.department stays shared and module-agnostic; what a module DOES with one is its own business in its own schema. The generic mechanism goes with its only user — unused machinery is worse than absent machinery: untested by real use, it invites the next module to adopt a shape nobody validated, and reads as load-bearing. 🔴 AND THE PLATFORM-AUTHORITY EXCEPTION CLOSES WITH IT. ADR 0077 dec. 3 was the ONLY place a module accepted the Foundation's authority instead of demanding its own privileges — allowed because ADR 0024 had moved department-creation out, leaving no other path to set enrolment. With enrolment gone that justification evaporates: every module surface authorizes on its own privileges, no exceptions. This is the decision's biggest structural win. Nothing replaces it — no auto-enrol on first assignment, no "take all departments on" button, no derived table; both were live options and both keep a record always derivable from another record. ADR 0015 is untouched and eventOnlyFor is NOT a substitute — it records a department's NATURE, and reading it as the participation list is the exact conflation 0015 exists to prevent. Why: the Foundation should host what every module needs, not what one module wants — a directory of departments is genuinely shared, a per-edition opt-in list is one module's workflow, and putting it in the shared layer meant the Foundation carrying a concept it could not interpret, which is how ADR 0079's label bug became visible. It removes a CONTRADICTION rather than a feature: access ignores registration, so a coordinator could be scoped to an unregistered department and still work in it — two records that can disagree, where the loser is the one maintained by hand, and ADR 0022 and ADR 0023 both spent effort papering over exactly that. ⚠️ The work queue survives, which was the strongest argument for keeping it: "registered but nobody assigned" is 56 of 60; "department with no coordinator" is 58 of 62 — two departments' difference for a table, a screen and a manual step per department. And setup cost was about to become real: the demo load ticks all 60 silently, production skips that load, so a human would have ticked 60 boxes before anyone could open a screen. Accepted costs: you can no longer declare "Dining is in this year" before finding the Dining person (registration's original purpose, genuinely lost); "deliberately NOT taking part" stops being expressible — two departments today, and an absence cannot be told from an oversight; a future module rebuilds rather than wires up. Rejected: auto-enrol on assignment; a take-all button; keep-the-table-drop-the-checkbox (the cost was never the UI); keeping the mechanism unused for module #2 — considered seriously, since 0076/0077 existed to make it free, and refused because it leaves the authority exception open with nothing behind it. ⚠️ Sequencing: PR #24 and #25 both touch this code and #25 renames the very table this deletes — both land first or the rename and the removal conflictAccepted · not yet built
  • 0081Cross-module insight reads the activity trail, not a projection. ⚠️ NOTHING IS BUILT — a decision about where a dashboard reads from, taken before building so the answer is not improvised. Asked directly: "for dashboards we might need to store it, but I think the participation table will give us the info... if it has to be fetched fast across the tables, how do we do it?" The instinct is half right, and the wrong half matters. participation answers WHO — its own schema comment says "nothing else lives here (no permissions, no module data)" — so it will say Dining has two people in Requirements and cannot say they entered anything. activity answers what happened, when, per department, per module, and is already indexed for exactly that: [actingForDepartmentId, editionId, occurredAt DESC]. Measured before deciding: 62 departments, a per-(department, module) rollup in 7ms, and "which departments have done nothing in Requirements this edition" in 7ms → 59 of 62. So: read activity + participation + department, all Foundation-owned, and NO projection, cache or materialised view — at 62 departments a read model would be inventing a performance problem in order to solve it, and a projection is a second copy of the truth that can drift. ⚠️ Completeness is OUT OF SCOPE, asked and answered ("not required") — which is what makes the rest affordable: no module publishes a summary seam, nothing defines "done", and there is nothing to fetch across module tables at all. ⚠️ The trail records ACTIONS, NOT AMOUNTS — rows.saved six times means six saves, not six rows, so every figure from it is an engagement figure and a dashboard printing an activity count beside the word "items" would be lying. ⚠️ And the most useful insight is an ABSENCE: "59 have done nothing" cannot come from activity alone, because a department that did nothing has no rows there — it needs department LEFT JOIN activity. Both Foundation tables, so nothing is crossed, but a query written over activity alone shows the three busy departments and silently hides the fifty-nine that matter, which is the whole point of the screen. Its failure mode is a plausible-looking page, so that query ships as the dashboard's first test. Why the trail is a better basis than anything we would have designed: it reflects what HAPPENED rather than what somebody remembered to record — the same reason ADR 0080 could delete the enrolment tick. Attribution being the product (ADR 0003) built the oversight spine as a side effect. Accepted costs: no "40 of 60" anywhere; targetRef is JSON so richer detail is queryable but unindexed (the first thing that would get slow); ⚠️ recordActivity becomes a REPORTING dependency, not only an audit one — a write path that forgets it goes invisible to every dashboard, silently; and nothing distinguishes "did nothing" from "had nothing to do", a gap registration used to cover. Rejected: reading module tables or a cross-schema materialised view (dissolves ADR 0008, the boundary the architecture rests on); a shared summary table every module writes into (the honest answer IF completeness were needed — held in reserve); per-module summary seams fanned out (also held in reserve, and preferable, since each module keeps its own definition of done); deriving insight from participation alone, as originally suggested — a dashboard built on it would report staffing as though it were progressAccepted · not yet built
  • 0079One word: department. "Area" retires as a synonym. Reverses the NAMING half of ADR 0015 — its model is untouched and reaffirmed — and supersedes module ADR 0007's naming, whose table and per-edition reasoning stand exactly as written. Deletes the CONTEXT.md glossary rule that told us to keep both words. Reported while reading the Admin Console's department panel: "I don't think it makes any sense, why is everything departments suddenly? Areas sometimes we call it, but it means departments only, and we have event-only area too." ⚠️ The complaint was about the WORDS and it was right; the model was never the problem. There is one Department directory, no Area entity, and eventOnlyFor still means exists-for and never participates-in. What was wrong is that both words reached the same person on the same panel — a heading saying Departments above a checkbox saying Areas — which is our own rule ("say area to Requirements users, Department for the data model") broken on a back-office screen where one operator sees both registers at once, with nothing saying they are one thing. A sound rule for prose that cannot survive a shared surface. Nine decisions. Our system says department everywhere — screens, labels, roles, routes, types, seams, tables, activity actions — retired as a synonym rather than scoped to an audience. Stored identities move by migration, not dual-write: area_coordinator → department_poc, areas.manage → departments.manage, rows.{read,write}.own_area → own_department, area_registration → department_registration. The role reads Department POC (Monish's pick from three offered) — it matches the knowledge base, which has called this person their department's Point of Contact all along, and stays unambiguous once Cluster Coordinator and Cycle Manager ship, where a bare "Coordinator" would not. 🔴 THE LINE WITH NO SAFETY NET: foundation.participation.role_key is opaque text with NO foreign key (ADR 0016/0017) because the Foundation deliberately does not interpret it — so role_privilege cascades on update but participation does not, and omitting that one UPDATE would leave every coordinator's grant naming a role that no longer exists: no error, no constraint violation, just a person who can suddenly see nothing. Verified against the database rather than assumed — 3 grants carry the new key, both privileges still linked to it, 60 enrolments survived the table rename, zero stale keys after a reseed, and a real coordinator's read path still returns their 2 departments. ⚠️ Acceptable ONLY because nothing is live: production holds zero grants and zero requirement rows, so no human loses access and no running release reads the old names — after go-live this same rename needs expand/contract (ADR 0023), and both the migration header and its guard marker say so, so the file cannot be copied later as a precedent. A module still names its own concepts — the console prints the label the module publishes (ADR 0077) and learns no module vocabulary; the fix was at the module, which now publishes Departments. ⚠️ legacy/ keeps the OLD SYSTEM's words, and that is not an exception: materials_requirements_curr.area_id is that column's name in the portal's export, so renaming it would make the code claim a column that does not exist and anyone debugging a load would grep for department_id and find nothing. Quoting a foreign system accurately is not inconsistency — legacy/ is where the translation happens, and the compiler confirmed that boundary unprompted when renaming areaName broke extract-demo-dump.ts against the legacy types. Two other senses stay and are now commented so the next sweep knows: Excel's "multi-area copy" (a discontiguous selection) and geometry's "NOT Square Feet, an area"; likewise the area_cluster/area_sqft registry fieldKeys (ADR 0002). Five activity-trail rows keep the old actions (area.registered ×3, area.deregistered, area.created) — rewriting an audit record is a judgement about the record, not a refactor, so it waits for its own decision rather than riding along inside a rename. Collisions forced better names: listAreas could not become listDepartments — the console already owns fetchDepartments and it answers a different question (every department in the ashram, not the ones enrolled this edition with their coordinators), so a blind rename would have collided AND conflated; it is listEnrolledDepartments, and AreaSummary becomes EnrolledDepartment. Accepted costs: 61 coordinators have said "area" for years and the product now says department — their word replaced by ours, a real imposition rather than tidying; legacy/ reads differently from the rest of the codebase; the trail briefly holds both vocabularies; and a !-marked breaking change means any environment must run db:deploy AND db:seed, since a database migrated without the code (or the reverse) authorizes nothing. Rejected: relabel-only, keeping area in the code — my own recommendation, overruled, and he is right that half a rename leaves the next reader the same two registers just further from the surface; keeping both words with an on-screen explanation (makes the reader carry a translation the software could remove); expand/contract anyway for the practice (ceremony against zero grants, and a dual-read path nobody needs is one nobody tests); renaming legacy/ too; rewriting the five trail rows in the same migration. ⚠️ TWO SWEEP MISFIRES, both caught, both worth carrying: the exclusion guard tested /legacy/ while the directory path ends /legacy with no trailing slash — so it silently did nothing and the legacy folder was swept (reverted in full, byte-identical). And the sweep inverted an assertion written to prove the old word was gone: not.toMatch(/area/i) became not.toMatch(/department/i), which asserts the opposite and failed loudly only because it was a test. A regex over prose cannot tell a word being tested from a word being used — which is why every remaining occurrence was read, and why DESIGN.md's prose is left saying "area" behind a banner rather than sweptAccepted · built
  • 0078A confirm popup is for a write that lands now and cannot be undone in one step. A rule for every module, from a plain report — "for all the important actions we need an alert dialog box, we don't have one for any" — which was correct and worse than it sounded. ⚠️ Measured: remove a peer admin, retire a department, de-enrol an area, switch off a sign-in and de-register an area were all one click, written to the server, with no second thought, and four of them were built the same day. ⚠️ There is no AlertDialog primitive installed at all — components/ui/ ships dialog, sheet and alert, which is part of why every confirmation so far was hand-rolled inline. The rule is two questions — does the click write immediately, and can it be undone in one step — and both must be true, stated as a rule rather than a list so a new button answers it on its own. ⚠️ The word "delete" is not the test: the grid's Delete item looks like the most dangerous button in the app and is the safest — it goes through commit() into the undo stack and nothing reaches the server until Save — so it gets an Undo snackbar instead, because a dialog there would interrupt the 200 intentional deletions to catch the one that was not. The dialog names the act and puts the VERB on the button (Remove Ravi Kumar, never OK) with a required consequence line, so somebody who clicks the right-hand button reflexively still performs the act they meant. A refusal keeps the dialog OPEN carrying the server's own sentence — both of module ADR 0026's guards refuse after the confirm — closing only on success, where the message goes to the snackbar under ADR 0070's existing rule. Extends ADR 0070 rather than contradicting it: 0070 governs results and is right that they must not interrupt; a confirmation is the question before the act and must. One shared component in src/components/, because two modules need it today and every future module will. ⚠️ Accepted cost: two confirmation patterns still exist — a modal for a button press, the existing inline panel for a save-time refusal. ⚠️ Not a security control: every one of these actions is authorized server-side and the guards refuse independently — this is about accidents. Rejected: inline everywhere (scrollable past on a 61-row table); a modal on every write (would make the 56-wrong-marker fix unusable); a modal on grid delete (argued for by Monish, dropped once undo-and-unsaved was established); type-the-name-to-confirmAccepted · not yet built

Updated 2026-08-14 (ADRs 0055–0060, 0063–0066, 0071–0073 — the sign-in hero's title goes and the ghost year returns behind the statue; the door becomes tinted glass, the access wall is redressed and the display serif retires; the hero ships as WebP and is preloaded so the figure arrives with the screen; the session handoff is injected by a hook rather than remembered; CI is scoped to the change but the secret scan never is, and its classifier then moves inside checks because GitHub bills whole jobs; every surface paints a bare background so the entry screens' glows retire; the Admin Console becomes a sidebar of screens drawn by job, a module's own surface gates on its own permissions rather than the role key it was granted, and the console's permission list settles with reads split from writes; the DEVLOG holds only the live unit and the alignment gate refuses instead of reminding; the People directory becomes browsable rather than only searchable; 0053/0054/0056/0057/0059 revised, 0012 dec. 2 refined, 0064/0065 stamped where ADR 0066 made their auditor tables stale; the app's ground goes near-white while the door keeps the blue, superseding 0063 in full one day after it was accepted; the light sign-in then reads as stone and ink rather than tint, the statue's blue being the last surviving reader of the canvas 0071 rescoped). 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 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 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 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 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 → dataSourceChanged → setData → 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 · amended by 0015
  • Requirements0015Infrastructure retires its shed/barricade shape for a plain five-field intake form (Purpose, Start Date, End Date, Items/Amenities/Utilities Needed, Remarks), replacing MSR 2027's proposed spreadsheet columns. contact_name/contact_mobile/contact_email/shed_type/shed_type_other/quantity/location/barricade_type/services_needed and the ~24 ELECTRICAL_PANEL_FIELDS/IT_FIELDS/TELECOM_FIELDS spread into Infrastructure all retire (ADR 0002 pts 3–4) — 2026's 39 rows keep their values, just stop rendering as columns. No contact fields survive at all, typed or derived: every row write already lands a recordActivity entry with the actor's personId, so "who filed this" needs no new column. Grain is one row per purpose + date range, coordinator's discretion, not enforced. showsPreviousYear: false (Cycles' convention) since this shape has no 2026 precedent. items_needed is an open, ~55-option multiselect seeded from a new MSR 2027 intake document's examples, Other paired with a free-text companion field (the shed_type_other pattern); Electrical/IT/Telecom are now three ordinary options with no special handling. Amends module ADR 0014 decisions 1 and 4 — Infrastructure stops hosting the Electrical/IT/Telecom detail panel; ServiceDetailSheet stays in the codebase unwired for future reuse, and the standalone Electrical category is untouched. The multiselect cell editor is rebuilt on the shadcn Command component for search, the first multiselect with enough options to need one. Two rejected alternatives worth noting: a new category per area-group tab (Parking/Registration/Seating/Utilities/Stage/Accommodation/Access Control & Security), dropped once the source document's tabs were confirmed illustrative, not structural; and the source document's own Y/N-dropdown-per-item shape, dropped for the same reason ADR 0014 originally collapsed three booleans into one multiselectTentative · not yet built
  • Requirements0019A number cell takes numbers only — typed or pasted. Supersedes ADR 0010's scoping of number (not a numbered decision — the reasoning recorded beside them in grid-model.ts: "number stays out of this pass, since the server already validates it correctly"); 0010's decisions 1–3 stand. Asked for directly. The server's validateRow has always had a number branch, so values were never unvalidated — but it answers a different question: whether a save is allowed, not whether the typo can be made. A coordinator filled a whole table and was then handed complaints about work finished minutes earlier. The two facts that shaped it more than the request did. (1) Paste is how a bad value actually arrives — ADR 0012 removed our own fill code in favour of native copy/paste, so pasting a column out of a spreadsheet is the designed workflow, and RevoGrid's clipboard writes straight past every editor. An editor alone would have policed the careful path and left the likely one open, so the rule is enforced in onAfteredit, the one place a typed cell and a pasted range both land, on the same numericCellValue both call. (2) Decimals are legitimate — quantity is min: 1, which admits 2.5, and it sits beside a unit field where kg is an option; blocking them would be a NEW restriction on real data, not enforcement of the existing one. Negatives are refused: every registry rule is min: 0 or min: 1. A rejected character never appears — discarded, not shown-and-flagged. The number editor alone commits on Enter/Tab/blur, not per keystroke: every prefix of 120 is itself valid, so per-keystroke saving would cost three undos (ADR 0011) to take one typed cell back. A refused paste keeps the previous value and says so — silence is indistinguishable from success on a 40-row paste. The helper is a gate, not a converter: a passing value keeps its own primitive, because the bag genuinely holds both numbers (from JSONB) and strings (from the text editor), and normalising would rewrite the stored type of every cell touched — a data change hiding behind an input restriction. That was written the wrong way first and the existing save/paste/undo tests caught it, all three failing on a quantity turned from 150 into '150'. Accepted: '1,200' is refused rather than repaired to 1200 — repairing a value is a different act from refusing one, and stripping separators would need its own ADR. Rejected: <input type="number"> (a raw HTML control never stands in for a primitive — and it silently admits e/+/-, reporting a bad value as empty, turning a typo into a silent blank); flagging inline instead of preventing (warns rather than restricts, which is what was asked for); whole-numbers-only. Verified in the running app, not only jsdom: 5 accepted, a refused with value and cursor intact, 5.2 committedAccepted · built
  • Requirements0020The year is named on the pane it describes, not restated in the control row. Amends ADR 0009 dec. 3's FORM only — the switcher stays retired, entering a past edition is still a Foundation concern, listMyYears still feeds the last-year panel. Pointed at on screen and asked: "can we remove this — not needed." Counted on the running page rather than assumed: MSR 2027 appeared three times — the app-shell chip, this label, and the grid panel header MSR 2027 · this year (with MSR 2026 · read-only over the left pane). ADR 0009 retired the year switcher reasoning that "two controls a few hundred pixels apart both reading MSR 2027 invite the question of which one governs the save" — an argument that condemned the replacement label too, and was not carried that far. Dropping the switcher removed the ambiguity about which control governs the save; it left the duplication in place, one word shorter. The panel header says the year and what that year is for, attached to the grid it describes; a bare year floating in the control row said the same word and explained nothing. That row is sticky, so everything in it is paid for on every scroll of a long table — and ADR 0009 folded five blocks into it precisely to buy vertical space. ADR 0009's inherited requirement, the screen says where you are and it stays said, still holds twice. viewedYear goes with the label (its only reader); the endpoint does not. Said plainly: this is the second amendment to dec. 3 in a week — the first pass removed the control and kept its caption out of caution, and the caption was the part nobody needed. Rejected: dropping the app-shell chip instead (Foundation chrome on every screen — one module does not remove it for everyone to tidy its own toolbar); qualifying the label ("Entering: MSR 2027") — better than a bare year, still a third copy in the one row always on screen. A test now pins that both panel headers still name their year, so the label cannot be removed and the information quietly lostAccepted · built
  • Requirements0022An emptied area set removes a coordinator — the grant is disabled, never left scopeless. Extends the R4 decision (one grant, many scope rows, acting-for frozen at creation); nothing in it is reversed. Since R4 the module has had no way to take an area away from a coordinator — mechanic 5 always said "removing an area means unticking it", but nothing ever asked the seam what an empty set meant, and the screen disables its button at zero. Drift against CONTEXT.md → Offboarding, which already made per-module removal the admin’s soft, reversible job. The load-bearing decision is that a scopeless grant is not persistable: areas come from coordinator_scope while authorization comes from the privilege join, so an enabled grant with no scope would pass every check and return an empty screen — access that reads as granted and behaves as revoked, the worst state to debug. So emptying the set disables the grant, keeping "is this person a coordinator" one boolean instead of two that can disagree. Disabled, never deleted, so history survives and a later assignment re-enables the same row. An empty set for somebody holding no grant is a 422, not a no-op: the grant it would have to create could only take the admin’s acting-for department and would then be frozen there for good. Removal earns its own trail action, coordinator.removed — attribution is this module’s product, and "assigned to 0 areas" reads as a mistake rather than a decision. Rejected: splitting the write into add + remove (recommended mid-session and withdrawn on reading R4 — it reverses a settled contract to solve a problem the client creates); leaving the grant enabled with no scope; deleting the row. ⚠️ The silent-removal half is NOT fixed — a partial set still drops the areas it omits, unseen, because the form never shows what the person currently holds. That is UI work, deferred to the screen’s redesign. ⚠️ Written, not run: no PostgreSQL on the dev box and prisma generate blocked by a self-signed certificate, so six new tests and the typecheck could not execute locally — CI is the first run.Accepted · server built, UI deferred
  • Requirements0023Admin setup becomes a sidebar of sections, led by the work queue. The screen’s first design pass — R4 built it as a stopgap and every layout ADR in this module (0009, 0020, 0021) governs the entry screen, so this one had never been designed at all. Reported directly: "it’s all in one place and it’s not looking nice… the user needs proper visibility and accessibility." Areas · Coordinators · Admins, each with its own address, Categories joining when freeze ships; Areas leads, because registered-but-unassigned is the state an admin checks constantly while the three setup forms are used a handful of times per edition — the original column had that order inverted, and DESIGN mechanic 5 already called that state "the admin’s work queue". An area is managed in a slide-in panel, so the table stays visible behind the thing editing against it. Four accessibility defects fixed in the same pass, all read from the code: the page had one heading (CardTitle renders a div), area selection was colour-only (no aria-pressed), nothing was announced (no live region anywhere in the module), and the area-set label labelled nothing. shadcn’s sidebar was installed, not hand-rolled — a plain <nav> was proposed and rejected: the rule is "never hand-roll a primitive shadcn provides", and the exception offered for it ("a sidebar is layout, not a control") was invented for the occasion rather than found in the rule, which had already caught one real drift. The Base UI build arrived, reusing our own Button/Input/Sheet; button.tsx was offered for overwrite and refused. The correctness fix hiding inside the redesign: the panel and the directory search both show the areas a person already holds, read from coordinator_scope directly — not by inverting the areas list, which starts from area_registration and so cannot see a scope row on a deregistered area; three ordinary steps produce that state, and replace-semantics turns "missing from the list" into "deleted". Search results carry those areas too, so two people called Ravi stop being distinguishable only by an email address on the one screen whose job is granting access. Parked, not dropped: the menu’s shape when cluster_coordinator and cycle_manager ship — scoped by categories rather than areas, and one person may hold several roles here, so a section per role would list the same human twice; a mid-session proposal to rename Coordinators to People was withdrawn for reversing a settled decision over roles months away. Also parked: bulk setup before sign-in is impossible, and MSR 2027 is exactly that scenario. ⚠️ Nothing seen — the screen is behind module_admin, and the box has no PostgreSQL and cannot run prisma generate, so the API will not start.Accepted · in progress
  • Requirements0024A module does not create departments — Requirements enrols, it no longer mints. Removes the admin screen's whole add-area surface. Creation went first, on the argument below; enrolling followed the same day after being argued against twice and overruled — half a job on one screen and half on another is worse than one job in one place. The enrol SEAM survives where the create one does not, and that is the point: creating writes Foundation data every module reads, so a hidden seam is a live capability; enrolling writes this module's own area_registration and reaches nothing else. What prompted it: the reworked screen showed "event-only" on 20 rows out of 20 — measured at 21 of 24 departments, with Dining, MSR Security and Parking all flagged as existing only for the event, because R5's legacy load had to create them and creating means event-only. The badge was noise and has gone; the create path is why the data is wrong. The decision behind it: a Department is Foundation data — every module reads it, the activity trail attributes to it, and it outlives the edition — so a Requirements admin mid-setup could add a second "Security" beside the real one and nothing would object. Creating and enrolling are different decisions with different blast radii, and one endpoint made them look like one act. Removed from the API, not merely from the screen: a seam kept behind a hidden button is a capability that still exists and is only harder to see — the exact shape root ADRs 0049 and 0050 each had to undo. ⚠️ Accepted gap, raised BEFORE the change and taken deliberately: nothing in the product creates a department now — the Admin Console has no department surface at all, so an event-only unit like "East Gate Parking" needs an engineer with a shell until that screen is built. It lands where it hurts most if it is late, since event-only units are discovered during setup week. Rejected: removing the button and keeping the endpoint; removing the whole add-area surface including registration (per-edition, per-module work — centralising it puts back-office in the loop for ~90 enrolments a year); gating creation behind a new Requirements privilege (does not change who owns the row); fixing the data and leaving the mechanism. The endpoint test is rewritten to pin the refusal — a 400, and the department it would once have minted does not exist — so the removal is proven rather than assumed. Verified against a real PostgreSQL: 522 API tests, 263 web, typecheck clean.Accepted
  • Requirements0025A row holding a changed cell is washed gold, and the row cues stay three. ADR 0016's per-cell text cue answers which value moved and cannot answer which rows did — R17's own verification found exactly 1 highlighted cell in 84, precise and unscannable. Asked for directly: wash the whole row, subtly. ⚠️ The obstacle was that row cues here are already spoken for, and the governing rule was learned painfully — a neutral wash for where the cursor is, a brand wash plus left bar for delete-marked rows, made to differ in kind after an identical pair drew "I don't know if I have selected it or not" (module ADR 0013). So the third cue is deliberately the weakest: --chart-3 at 7%, gold because the cell-text cue for the same fact is already gold — one fact, one hue — emitted before the selection rule at identical specificity so selection wins the cascade, and not clearing .rgCell backgrounds so the focus wash still reads through. Built as a generated rule per row index, never a field on the row: rowClass needs row data, which replaces RevoGrid's store mid-edit and is documented as having caused silent data loss — and this cue recomputes on every keystroke, not every click. The derive is a pure changedRowIndexes(rows, fieldKeys) returning indexes, because the only consumer paints by index. Seen in a real browser, and unit-tested with 7 cases chosen for what would ruin the cue at row scale — chiefly that a sheet with no snapshots yields [] (a bug there washes every row gold) and that a never-travelling field like a date is ignored (ADR 0004, the same failure 0016 guards). ⚠️ Not verified: a row that is changed AND focused, or changed AND delete-marked — reasoned from the cascade only, and that combination is exactly what ADR 0013 was written aboutAccepted
  • Requirements0026A module admin removes a peer — guarded against self and against the last admin. Completes root ADR 0028, which gave a module admin the power to appoint a peer without back-office and left the opposite direction unbuilt. ⚠️ The styling was the symptom, not the defect: Admin setup's Admins section is a bare list of email addresses with no per-row action, where Areas and Coordinators are tables with Manage — and underneath, a module admin cannot remove a peer admin anywhere in the product. That power exists only in the Admin Console behind modules.manage, so an admin who has left can only be removed by finding a back-office operator — the exact round-trip 0028 removed for appointing. Admins becomes a table with a Remove action, and two guards make devolving it safe: you cannot remove yourself (it locks the module for the person fixing it, and it is the accidental click) and you cannot remove the last admin (the set-of-one warning already printed above the form exists to prevent that state). All three tables gain name AND email — added, never substituted: Person.displayName and PersonMatch already carry it, only the table row types do not, and today two live rows are shared mailboxes whose addresses identify no human. Coordinators' empty state — "Assign someone from the areas above" — is corrected: stale copy from before ADR 0023 split the screen, and there are no areas above. Accepted cost: this widens what an admin can do to peers (down to two, then one) — the same asymmetry appointing already has, bounded by the guards and recorded in the trail; and ⚠️ displayName is nullable and never backfilled, so some rows show email alone — how many carry a name was not measured. Rejected: restyle only (fixes the screenshot, leaves the gap); self-removal behind a confirm; replacing email with name. ⚠️ Nothing is builtAccepted · built
  • Requirements0027An undo snapshot comes from a copy RevoGrid has never been given. Amends module ADR 0013 dec. 2 and repairs module ADR 0011, whose decisions were all correct and whose implementation never worked at all. Reported live: "undo and redo using the keyboard doesn't work properly, and when I make changes in a cell and do undo nothing happens" — true of the buttons too, of every action, however many times pressed, for the two weeks since it shipped. ⚠️ RevoGrid mutates the row objects in source IN PLACE before it reports the edit, so the "before" snapshot pushed for undo already held the post-edit state; undo faithfully restored what was already on screen. Proved by instrumenting the running app — entering 42 over a 5 logged beforeQty: 42 … mutatedInPlace: true — after two plausible theories were disproved by test rather than argument (the keyboard guard: the button failed identically; StrictMode alone: the same test wrapped in StrictMode passed). The library was checked first, at Monish's suggestion: RevoGrid 4.23.24 has no built-in undo/redo anywhere in its event list or types, the mutation is deliberate (dataProvider.setCellData carries an internal mutate flag with no public prop to disable it), and beforeedit/beforecellsave do fire pre-mutation but cover only the edit path — not paste, delete, copy-forward or add-row. Four rules, each pinned by a mutation-checked test: the snapshot comes from a pristine clone held in a ref the widget never receives; a mutation applies to that pristine base, never to the mutated draft; only draft is ever handed to the grid, always as a clone, so a stored snapshot cannot be rewritten in place and redo stays honest; and setHistory moves outside the setDraft updater, because StrictMode double-invokes updaters and every action was landing in history twice, so one action needed two undos. A cheaper option was rejected — handing the grid a clone as source would also have worked, but it puts a fresh source array into the exact mechanism ADR 0013 records as having caused silent data loss, i.e. new risk in the most dangerous part of the component for no extra correctness. ⚠️ Two lessons larger than the bug. (1) This is ADR 0013 dec. 2 read in the other direction: the real rule is that source is shared mutable memory with a third party, in both directions, so anything we mean to keep must not live there. (2) 351 passing tests never caught a completely dead feature, because the test double invoked the edit handler without mutating the row as the library does — a double gentler than reality is a blind spot with a green tick on it, and that is now a rule, not a one-off fix. Consequence: root ADR 0078 dec. 3 gave grid delete an Undo snackbar instead of a confirm dialog on the premise that undo works — so that safety net was imaginary until now. Verified in a real browser on the sequence that failed (copy last year forward → edit a cell → Undo), not in jsdom alone, because a green suite is precisely what hid itAccepted · built
  • Requirements0028Clicking the row number selects the row, and ONE selection serves every verb. Amends module ADR 0013 dec. 1 (extended, not reversed — a plain click on a CELL still selects nothing) and closes the checkbox column. Three reports over a day and a half — "users are confused how to delete", "I can't select multiple rows", "if I select multiple rows and paste, it pastes only one row" — were one design hole: selectedRows drove Delete while RevoGrid's own rectangular range drove copy/paste, nothing joined them, so a selection could not be copied and the only way to copy a row was dragging across all twelve columns. ⚠️ A checkbox column was built, tested, seen working — and withdrawn. Monish's call and the right one: a checkbox looks like "this row is selected for whatever I do next", so it promises row copy/paste the clipboard cannot see; making a broken thing discoverable is worse than leaving it hidden. The library was checked before anything was designed — setCellsFocus(start,end) writes a real range into the very store the clipboard reads (confirmed live: {x:1,y:2,x1:6,y1:2}), that range is rectangular, and paste is clamped to the last existing row (endRow = Math.min(rowLength-1, …)) so RevoGrid never creates rows. Seven decisions. Clicking S.No selects the whole row (Shift+click a block, Ctrl+click a gappy set) into the SAME set Delete uses, so the wash appears, Delete counts, and copy takes it. The copy range is DERIVED from the selection in one direction — setting both at the click site meant reading state from a render closure, and two Ctrl+clicks in one tick silently dropped a row. A contiguous run copies in full; a gappy one falls back, because a bounding box would quietly copy the rows the coordinator did NOT pick (Excel restricts multi-area copy the same way). A paste running past the last row grows the table by exactly the shortfall, in one undo step, while a paste that fits still overwrites — that boundary is the whole feature, and the same defect was silently eating columns pasted from Excel into a short table. The date floor is 1 Jan of the current year, derived from the clock (hardcoded it would block the wrong year every January) and enforced on the calendar and on typing, since a bound on one of two paths is not a bound; the floor is the year rather than today so a needed_by since passed stays savable. Rejected: the checkbox; a Duplicate button (does not fix the Excel paste); owning the clipboard ourselves; a bounding-box range; and switching grid library — AG Grid Enterprise and Handsontable ship all of this but are paid, neither decides what "selected" means for us, and the grid is load-bearing across nine module ADRs, so that is a post-MSR-2027 evaluation, not a swap under deadline. 401 web tests, every decision mutation-checked. ⚠️ Two carried gaps, neither absorbed silently: the date floor is client-side only (the server's validateRow does not enforce it, and paste bypasses it), and a test double may never be gentler than the library — that bit three times in two days (a stub that did not mutate rows hid a totally dead undo behind 351 green tests; one that did not fire beforecellfocus hid a double-handled Ctrl+click; userEvent.click(el,{shiftKey}) silently drops the modifier), which belongs in apps/web/CLAUDE.md as a rule. ⚠️ Not browser-verified by the author — the preview pane was not compositing, and RevoGrid virtualises so a zero-height viewport renders no rows at allAccepted · built
  • Requirements0029A field becomes a closed list only where the DATA proves the list. Applies module ADR 0002 rather than amending it — every change is a registry edit plus a re-seed, no migration, no screen code. Constrains the unbuilt Cluster view / oversight dashboard (DESIGN mechanic 9): what those can ever group by is decided here, years before they are built. Monish asked how to filter and build a dashboard across all eight categories; the answer is not a screen, since you cannot filter or total free text and 30 live fields are free text. His framing set the constraint — "not everything needs to be a dropdown, some things can be left out". ⚠️ Every field was decided against the 4,051 loaded rows, not against intuition, and the census overturned two assumptions we would otherwise have shipped: volunteers.skills was never free text (the source workbook held a list, and per-skill counts like "Basic IT - 3 :: Volunteer Coordination - 5" — the load flattened it; split back apart, 232 rows contain 9 skills covering 229 — eight obvious plus Driving, the one real value in any of the four fields that the derived lists missed, surfaced as an open question rather than swept into an Other bucket and then added, so nothing real is discarded anywhere in the migration), and signages.location looks like a place and is not (its 71 values are placement instructions — "double sided with b3 left b2 straight" — while zone and sub_zone already carry the structured place data). The census also found an unreported defect: materials.unit holds other columns' data — "MSR Store"×21, "31-Jan-2025"×20, bare numbers ×34. Four free-text fields convert — plus volunteers.days_before_event, asked for separately and the one exception to the reasoning, since it was already a number and so never unfilterable: 265 rows use exactly eleven lead times, and ⚠️ a select stores a string, so anything later ranking areas by lead time must sort numerically. ⚠️ It migrates all 265 rows that look like they need nothing — every value is already an option, and every one is a JSONB number where an option is a string, so the grid would have written "28" beside a stored 28 and a GROUP BY would have split one bucket in two; found by asking what type is stored, not whether it validates. Originally — materials.unit, transport.vehicle, volunteers.skills, signages.frame_size — and 26 stay free text, each for a stated reason. ⚠️ materials.item stays free text and the consequence is written down, not discovered later: 1,896 distinct values over 2,700 rows (two-thirds of the module), so Materials totals by area/unit/source/date and never by item — a catalogue with Other was offered and declined. No Other escape hatch on a converted field: a missing option is a one-line registry request, and Other becomes the dumping ground the conversion exists to end (measured need is near zero — vehicle and frame size are covered 100%). Where a list is authoritative elsewhere we take it whole — the unit list is the back-office master materials sheet, the vehicle list the transport system's own Vehicle Type master; our own data-derived list of 11 vehicle models was rejected by Monish ("the old 11 dropdowns are not good") in favour of 10 vehicle classes. ⚠️ Number was dropped from the supplied unit list — it and Nos are the same thing, Nos is what 1,927 rows (81% of Materials) already say, and keeping both splits every countable-item total in two. Migration is part of the conversion, not a follow-up: the server already rejects off-list values and copy-forward hands this year's rows last year's text, so an unmigrated value is a row a coordinator cannot save. Unit: 91% map cleanly, Unit/units×47→Nos, pack/Packs×37→Packet, Tin/Feet/Sack added, ~95 blanked and every one reported. Vehicle: 185 of 194 confident, 407 Mini lorry→new Mini Lorry, and 6 rows deleted across 5 areas (45 cycles, 2 e-bikes, 10 buggies) on Monish's domain call "we don't give them" — positions renumbered. 2027's copied rows have inheritedValues migrated alongside values, or the gold changed-row wash (ADR 0025) fires on every row nobody touched. signages.board_category retires (1 row in 601, and that value is two place names). materials.source stays text until the back-office answers — unanswered since 27 Jul, and options are the one thing that cannot be safely renamed. Accepted costs: no item-level Materials total, ever, until dec. 2 is revisited; 19 rows lose a vehicle model (Car - Innova→SUV); the record that 5 areas wanted cycles is gone, so next year it looks like a new ask; a coordinator can be blocked by a missing option; per-skill counts dropped. Rejected: the item catalogue; an Item category beside free-text item; deriving the vehicle list from our own data; keeping both Nos and Number; defaulting the ~95 wrong-column units to Nos; moving that text into remarks first; and converting after entry opens. ⚠️ Timing is the whole risk and it is not an engineering one: production holds zero requirement rows today, so the migration touches only the legacy load — after entry opens, every conversion becomes a rewrite of data a coordinator typed. Nothing is builtAccepted · built
  • Requirements0030A consent field that only ever says Yes is not consent. Retires cycles.agrees_to_compensation for MSR 2027. Applies module ADR 0002 — one registry line, no migration, no screen code — and retires in place like ADR 0018 (Electrical) and ADR 0029 (board_category). Asked directly while reading the Cycles form: "is it a good idea to ask people?" — not whether it validated. Three measured facts answered it. ⚠️ 128 of 128 rows last year said Yes — not one No, in any area — so it collected keystrokes, not information. ⚠️ The person agreeing never sees the form: Area Coordinators enter cycle requests (domain §6 stage 1) while the same document says the requester must agree, so a coordinator ticks Yes on behalf of somebody absent. ⚠️ And there is no clause — no compensation text exists anywhere in the product, so a Yes is recorded against nothing. The validation bug that surfaced this was a trap, and that is the transferable part. The field carried no validation at all, so a row saved with No or with nothing — but required: true does not fix it, because required only asks that a value EXIST and "No" is a value. And pattern: '^Yes$' is silently ignored on a select: the validator's select branch continues before pattern runs, so the zero-code fix would have shipped as dead config that reads as working. Reaching that dead end is what turned a validation bug into a design question. Retired, never deleted — cycles has no legacy load so zero rows in any edition hold this key, and the exception was still refused: the key stays resolvable so a re-run load or stale client cannot raise an error, because making the exception is how the rule stops being reliable. Responsibility attaches at HAND-OVER instead (domain §6 stage 2), where Cycle Management gives a named person a numbered cycle, records it and tracks the return — the person is present, can be shown the clause, and is accepting a specific object on a specific day. agrees_to_return_by_due_date is deliberately left alone: same shape, 120/128 Yes, and it asks agreement to a due date the product never states (last year's column was literally named return_cycle_by_feb_20th_2026, so the date lived in a column name and broke by construction) — but only the compensation field was asked about, and bundling an unasked change into an approved one is how a decision stops being traceable to the person who made it. Accepted costs: nothing records that a requester accepted responsibility until hand-over does; a reader will fairly ask why one field went and its twin stayed; and if a clause exists off-system, the product no longer gestures at it at all. Rejected: required: true (enforces nothing); a new mustEqual rule (~5 lines, implements §9 rule 6 as written — rejected because it makes the WRONG record stricter, the coordinator still answering for the requester); options: ['Yes'] (a dropdown you cannot decline — removes the false choice, keeps the meaningless record); keeping it WITH the clause text on screen (the honest version, and the right answer if the clause existed and the requester filled the form — so it stays available rather than chosen); deleting the field. ⚠️ Open, and deliberately NOT resolved by editing it: knowledge/requirements-and-management.md still states in three places (§5, §6 stage 1, §9 rule 6) that the requester must agree. That document records what the BUSINESS specified and a decision of ours is not a licence to rewrite their intent — the question for Kathiresa Mani and Krishna Kumar is what is the compensation clause, and who is meant to be agreeing to it? Until they answer, code and doc disagree in writing, on purpose. ⚠️ The wider Cycles gap is untouched and is bigger than one field: 6 of 8 fields carry no required rule, and the name pattern §9 rule 6 specifies exists nowhereAccepted · built
  • Requirements0031Materials come from the workbook alone, under its updated names. Reverses the 2026-08-15 source rule ("one source per area, whichever holds MORE items wins") and overrides the deliberate choice in materials-sheet.ts not to substitute the workbook's UPDATED MATERIAL NAME. Amends ADR 0029 dec. 2. Asked as a naming question — "did we use the updated materials from the excel data?" — and the answer was no: only the unit list had come from that sheet. The column is 2,956 of 2,963 rows filled, changes 2,099 names, collapses 2,137 distinct → ~1,360, and does better than tidy spelling: it takes quantities OUT of item names ("Tissues: 30 Nos" → Face Tissue, "Pens - 20 nos" → Ball point pen). ⚠️ Then checking "we are not using portal data for any areas right?" found the real defect. Materials came half from each source (29 areas sheet / 28 portal), and 455 workbook items were loaded NOWHERE because their banner matched no area. 🔴 Police Coordination had 109 items in the workbook and loaded ZERO — its coordinator would open an empty previous-year table while the workbook held their whole list; Safety Team 12 → zero; Accommodation → zero. The cause cannot be fixed by a cleverer parser: isAreaBanner cannot tell an area banner from a sub-heading, because both are a non-numeric column A with nothing else on the row — so ten sub-headings inside Accommodation's run (Sadhguru setup, Miantenace, …) were each read as a separate area, which is exactly why GENTS SHED(Accomodation) reported 0 items while its rows sat under headings nobody claimed. Eight decisions. Materials now come from the workbook and nowhere else — an uncovered area loads NO materials rather than falling back, on the instruction "don't add anything from portal", because a per-area fallback reintroduces two vocabularies in one column and that is unfilterable by construction. The updated name is substituted at the extract, not in the reader, so the parser stays a faithful description of the file. An area may own several blocks (sheetBlocks), with the tripwire count being its total. ⚠️ The banner→area map is AUTHORED, never derived — not fastidiousness: fuzzy-matching these banners already caused a real mis-attribution (a near-miss between the two Aesthetics areas), the banners are genuinely inconsistent (Registrataion and Stall Signages, Miantenace, MSR 2026 -Lounge), and the map holds pairs no similarity measure could produce — MSR 2026 -ASHRAM CLINIC is the Medical area. ⚠️ Only ONE of the ten sub-headings is Accommodation's (Registrataion and Stall Signages, 25 items); the other nine are other teams' requests parked in the same run and are not loaded — a distinction nothing in the workbook carries, since position says they are all Accommodation's and position is wrong, which is the authored map's whole justification stated as data. A generator finally exists (gen:materials-source): the file it writes had said since 2026-08-15 that it came from "a throwaway one-off" with NO regeneration script, so a workbook change would not be reflected and nobody could tell. It refuses to write rather than guess — and that caught four invented area names on the first run (Ashram Clinic, Drinking Water, MSR Checkin, Stay Checkin; the real ones are Medical, Drinking water, MSR Check-in, Stay Check-in). Unloaded banners are listed, never silently skipped — an orphan we chose is a decision, an orphan we did not notice is a bug. materials.source retires in the same breath — the workbook has no source column, so the field is empty by construction (verified: materials_from null on all 2,757 rows) and ADR 0029 dec. 9's "wait for the back-office to say what it means" is moot; Materials serves five columns. Result: 2,543 → 2,757 rows loaded, 32 → 39 areas covered, Police Coordination 0 → 109, Signages 70 → 96, Temple 13 → 56. ⚠️ Accepted costs: 109 items in 12 listed banners not loaded — ⚠️ MSR IVR was rescued from that list by Monish reading it (11 items, an area sitting on zero), which is precisely what publishing the exclusions is for, leaving Lounge (30) as the largest single exclusion; Venue Cleanliness & Toilets 117 → 64, Travel & Transport 30 → 29, Issuing of passes 7 → 0; the "standardised" column has its OWN variants (Cap/cap, Priniting paper-A4/Printing paper A4/Printing Paper A4) so 1,360 is an over-count and wants the case pass the units got; and Eraser → Cap is a genuine sheet error, applied as-is — 408 renames share no word with the original and nearly all are legitimate (Alpin→Bell pin, Ruler→Scale - plastic), so the sheet stays authoritative and this is fixed upstream rather than guessed at. Rejected: sheet-preferred with a portal fallback; keeping the more-items rule; substituting inside the reader; teaching the parser to spot sub-headings by prefix, indentation or position; mapping the 22 unclaimed banners by name similarity (Signages + Guiding matched Guiding in trials when its items are Signages'). 🔴 Carried gap, captured separately: db:seed never deletes rows for an (edition, area, category) that has vanished from the fixture — proven on this very change, where Issuing of passes correctly has 0 rows in the fixture and still had 7 stale ones in dev, last written at an earlier seed and holding the old portal names. Prod is unaffected (no rows); UAT and dev keep orphans and show them in the previous-year panelAccepted · built
  • Requirements0033The date floor is the server's, and it is edition-relative. ✅ BUILT 2026-08-19. The entry-window rule was client-only since ADR 0028 — calendar and typing both checked it, paste walked straight past, and a past-year date saved clean. ⚠️ The decision hiding inside the fix: the server floor is 1 Jan of the year before the edition's, not the calendar year the client uses — validateRow runs on every submitted row including untouched ones, so a moving floor would make every row holding a last-year date unsavable on 1 January. Blocking correct work is worse than the bug.
  • Requirements0032Requirements stops enrolling departments. ✅ BUILT 2026-08-19 — table dropped, seams / routes / provider / registry / console toggle gone, every department listed. ⚠️ Its own dec. 3 did not survive the build: archiving reproduces the blind spot it declared deleted, so the handling stays (now derived by set difference, not by a flag). The module-side half of root ADR 0080, which carries the reasoning; this one is the inventory. Supersedes ADR 0007 — every argument it made still reads correctly, and the thing it argued for goes. What goes: requirements.department_registration (3 columns, 60 rows), registerDepartment/deregisterDepartment, listEnrolledDepartments, DepartmentNotRegisteredError, the requirementsEnrolment provider and with it the module's acceptance of platform authority, two HTTP routes, and the registerable-departments read. What stays: coordinator_scope, 4,296 requirement_rows, and foundation.department untouched — the data never depended on the registration, which is the evidence for removing it. Every department is offered: "Departments taking part this year" becomes "Departments", all 62 with their coordinators, and the unassigned count still leads — 58 of 62 rather than 56 of 60, so the work queue ADR 0023 built the screen around survives essentially unchanged. Assigning a coordinator is the only statement that a department takes part; the guard goes because there is nothing left to be inconsistent with. ⚠️ AND THIS IS THE CLEAREST WIN, easy to miss in a diff: ADR 0022's and ADR 0023's de-registered-department handling becomes dead code. Both spent real effort on "a scope row on a de-registered department" — the coordinator search shows whether each department a person holds "still takes part", and the panel reads scope rather than inverting the registration list precisely because a de-registered department is invisible to the latter. With no registration there is no such state: the reasoning was correct, and the hazard is deleted rather than mitigated. One narrow guard is KEPT and is a different rule: archived departments must still be filtered (foundation.department.archivedAt) — offering every department must not mean offering retired ones, and that belongs on the directory read rather than being inherited from the check it replaces. ⚠️ One less production blocker: the demo load's registration upsert disappears, and production — which skips that load — previously needed 60 boxes ticked by hand before anybody could open a screen. Why ADR 0007 was right when written: its rejected alternatives were rejected soundly and the first (eventOnlyFor as the participation list) is STILL wrong; what changed is measured evidence it could not have had. And it was right that deriving from coordinators loses "registered but nobody assigned" — except that state is simply coordinator_scope is empty, the same list two entries longer. Accepted costs: "register before assigning" is gone; a department that genuinely does not file becomes indistinguishable from one nobody has staffed (two today — both read as no coordinator, and only a person can tell them apart); the screen shows 62 rather than 60; and dropping the table drops registered_at, the only record of WHEN each was registered, leaving the trail's five department.registered entries as a partial history at best. Rejected: populate the table automatically from assignment; keep the read and drop the writes (a table nothing maintains decays into a stale list nobody trusts); move enrolment into the Requirements admin screen — tempting since the admin is already looking at that list, and it keeps every cost the concept has while fixing only where the button lives. ⚠️ Tests to DELETE rather than adapt — the enrolment endpoint and registry suites, and anything asserting DepartmentNotRegisteredError: a test adapted here rather than deleted is a sign the concept survived somewhere it should not haveAccepted · built
  • Requirements0034A freeze is one category, enforced on the one write path. Settles the freeze phase DESIGN mechanic 8 sketched at R2 and left open. Storage (freeze_state, keyed (edition_id, category_key)) and the freeze.toggle privilege have both existed since R2, read by nothing and checked nowhere. Eight decisions. One category across every department is the load-bearing one: per-department freeze was the strongest alternative (lock the 78 who submitted, keep chasing the 12) and was refused because it turns "is Materials frozen" into ninety answers, and a category 78/90 frozen is neither moving nor stable, so a planner cannot act either way. It also invents the module's first per-department state machine, since nothing records that a department is "done". Stragglers are a chasing problem, not a locking problem. Writes to requirement rows only, setup stays open, because otherwise frozen data could never be handed over when a POC leaves; a department added post-freeze gets a locked table, deliberately, since the alternative punches a hole through the guarantee. One write path already exists (POST /api/m/requirements/rows), and the check sits beside the closed-edition check that answers 409, not 403, for the same reason: entitled caller, shut door. ⚠️ After assertMayWrite, never before, or an unauthorized caller learns the category's fields. The screen reuses the pill it already has, grey plus lock, MSR 2027 · frozen, because that has meant "read this, do not change it" since the entry screen shipped. ⚠️ The trap: that pill only renders when there is a previous-year panel, and Cycles and Infrastructure carry shows_previous_year: false, so the two newest categories say nothing about being frozen unless it renders standalone. Controls are removed, not disabled. Toggles go on the append-only activity trail and freeze_state stays the current-state answer; no history table, because coordinator saves are already on the trail, so "what changed after Materials was frozen" is one read in time order and story 32 comes free. Unfreeze reopens the whole category and records a reason — the window is a known category-wide hole, guarded only by the trail; 🔴 and the trail guarded nothing readable. Corrected 2026-08-25. The reason was written into target_ref and rendered by NO screen: the console Activity page showed five columns and the payload was not one of them, so no entry said what it acted on — not even which category. That page is gated on an Admin Console tier a Requirements admin does not hold, so the person who typed the reason could not read it back. Both of this ADR's appeals to the trail — the guarded hole, and "story 32 comes free" — were false for as long as freeze existed. Fixed: the console trail gained a Details column for every action, and Requirements gained listFreezeHistory behind freeze.toggle, expanding in the Categories row. ⚠️ It reads the trail, not freeze_state — that row holds only the last toggle, so a re-freeze would overwrite the reason for the unfreeze before it. The Foundation's trail read took generic moduleKey/actions/target filters; the module passes the path ['categoryKey'] and the Foundation never learns what a category is. Fourth wrong-premise ADR in this module this week, all four found by BUILDING the sentence. the per-department exemption is refused now, not refuted, and the trail will measure how often windows open before that is decided. Nobody is notified: back-office tells the department out of band, written down as the intended flow rather than left implied. 🔴 Corrects two knowledge-doc errors — its permissions table says a Module Admin can edit any area's requirements (the opposite of module ADR 0003, and the code has no such privilege), and its post-freeze flow has the admin making the change, which is impossible. The real sequence is unfreeze, tell the coordinator offline, the coordinator edits, re-freeze. Declined: showing the empty-department count on the freeze confirmation. Consequence: the frozen flag must ride down with the rows read, a client↔screen contract change on a screen whose failure branches are near-uncovered. 🔴 Dec. 5 corrected 2026-08-24 while building, before anything shipped. It said toggles attribute to the reserved back-office department, "the route the admin's coordinator assignments already take" — false. assignCoordinator attributes to grant.actingForDepartmentId; back-office attribution is an Admin Console pattern the ADR imported from the Foundation without checking. Now the admin's own grant, matching the module's single pattern. Second wrong-premise ADR found in one day, both by building the sentence rather than re-reading it.Accepted · built 2026-08-24, unseen
  • Requirements0035A cluster coordinator owns a category across every department. Settles the cluster phase DESIGN mechanic 9 sketched, and amends it on three points. ⚠️ The design doc had the role backwards: it reads as leadership who see everything read-only, with category scoping an optional narrowing. The scoping IS the role — a cluster coordinator is the mirror image of a Department POC (one department across every category; here one category across every department). Building it the doc's way would have shipped a role with no storage for the only thing that defines it. New table cluster_scope, the module's first since R2. Measured before deciding: Materials is 2,951 rows across 42 departments in the 2026 load, ~70 per department, landing near 6,000 at full adoption — and 🔴 ONE category has no number field, so nothing on it can be totalled — Cycles. 🔴 This row said “two” and named Infrastructure, and that was WRONG TOO — Infrastructure has ELEVEN number fields (quantity, light_count, ac_tonnage, area_sqft …) after absorbing Electrical’s columns, 39 in all. Corrected 2026-08-24 — the THIRD publication of this count, and the SECOND correction of this very row, each earlier attempt having copied the number from prose instead of reading the registry object. ⚠️ This row first said "Cycles has nothing countable at all" and gave Furniture eleven number fields; both were WRONG. Cycles has one select and CAN be filtered; Furniture has one number, not eleven; Infrastructure has none and was never flagged. The counts came from a regex over the registry's source rather than from the registry, and Electrical reporting zero fields should have been the tell. Corrected 2026-08-24 — the decisions stand, the evidence under them did not, which is the same failure the standing correction added that morning describes. Every row loads at once, read-only, no paging — the argument is honesty, not speed: a total over a page is a wrong number that looks right. Nobody edits here and ADR 0003 is not reopened; the friction is the price of attribution being the product, and the escape hatch if it bites is flagging a row for the department, not edit rights. Two views: a flat table, and an accordion by department, collapsed with item counts — whose collapsed state IS the per-department summary and whose zero-count rows ARE the not-filled list, three asks on one screen. Two submission states only (Filled / Not-Filled) — ⚠️ accepted cost, quantified: 58 of 62 departments have no coordinator, so day one that list is 62 long with 58 nobody can contact. Three and four states were both recommended and declined. Assignment is person-first in the module's own admin setup — Coordinators becomes People, which settles the sidebar question ADR 0023 deliberately parked. The Admin Console cannot do it: its grant flow offers console tiers and module admins only, and teaching it what a category is repeats the mistake ADR 0079 was written about. Controls differ per category, built from what each declares; totals only over number fields, ⚠️ never a row count beside the word "total". POC name, email and phone open each department block — this is where PRD story 34's contact lookup finally lands. Export (story 23) deferred, not dropped, recorded because this module was caught once by a story neither built nor named as deferred.Accepted · built 2026-08-24, unseen
  • Requirements0036A person's roles are a page, not a panel. Amends module ADR 0023 dec. 4 for ONE section (Departments keeps its panel) and module ADR 0035 dec. 6, which put person-first assignment in the panel. 🔴 Reached by three failed attempts, each reported by a human looking at the screen, each fix causing the next: the footer said Save departments while the click saved categories too · the departments list had no heading and the two summary lines read as one contradiction · adding that heading broke the layout — the list was sized by flex-1 as a direct flex child, a <section> wrapper severed that chain, and 60 departments rendered at full height with the buttons floating over the rows. The replacement bound was max-h-64, a number invented from nothing. The pattern IS the finding: a box has a height and somebody has to pick it. A page has none. ADR 0023 is amended, not overruled — its reason was "the table stays visible… when the whole point is scanning for gaps", true of Departments and false of People. Prior art (recalled, not verified): AWS IAM, Okta, Workspace admin, Atlassian, GitHub all put a person's roles on that person's page; modals grant ONE thing at invite time. Azure dissents, but blades are a whole navigation paradigm. Role-first, one line per role saying what it grants — which answers the complaint actually made, that nothing said ticking a category grants the cluster coordinator role and with it every department's numbers. The Admins section folds in: a screen claiming to show what somebody holds cannot omit one. Cycle manager is hidden until its view exists. ⚠️ The two scoped roles get NO on/off switch — their list IS the switch, because "being a coordinator is exactly having at least one area" and a toggle beside it would be a second source of truth that drifts the first time somebody ticks the role and picks nothing. 🔴 Found while grilling: the panel's single Save already lied — two separate server writes in sequence, so a failure on the second leaves the first applied and the screen never says which half. One Save per role section, each dead until its own section is dirty. Your own admin row has no switch, the last-admin case does — one fact is permanent and knowable, the other is not, and treating them alike gets one wrong. 🔴 Dec. 6's reason was CORRECTED 2026-08-25 while building: it called last-admin a race, and that state cannot occur on this page — only a module admin holds the privilege to reach it, so if the person being viewed is the last admin they are you, which is the no-switch case. The decision stands on a different reason: the admins list is loaded once by the frame, so a client hiding the switch on its own count hides it on a stale number. Third wrong-premise ADR in this module in two days, all three found by BUILDING the sentence rather than re-reading it. 🔴 Also corrected: the panel's test surface was 34 tests across six files, not three — four were shape tests and died with the panel; thirty were behaviour and were ported to the page and the index, three whole files folding into the page's own. One was deleted as reversed rather than ported: "offers no Remove on the only admin" asserted exactly what dec. 6 overturns. Three things the decision did not anticipate and the build settled: the single-admin alarm had nowhere to live and moves to the index (a page about Ravi cannot state a fact about the module); Clear departments survives, because unticking sixty departments one at a time is not a removal path; and each Save announces what it saved, since three buttons going quiet and grey are indistinguishable from never having been pressed. ⚠️ Narrowing by department name is gone from People — the row shows counts, so there is nothing left to match, and that question belongs to Departments. The People list becomes an index: roles with COUNTS, not department names — listing "Volunteer Mobilisation & Coordination" per row is what pushed the old screen sideways. Three tests pinning the panel's shape go WITH the panel — one adapted rather than deleted would mean the panel survived somewhere it should not have.Accepted · built 2026-08-25, unseen
  • Requirements0038A quantity with no unit is not an answer. unit becomes required on Materials, which it never was — item and quantity were the only required fields, so Rope · 500 was a complete saveable answer and nothing said metres or rolls. The evidence it was never treated as meaningful: before the load normalised it the column held 40 distinct values against 23 options — 31-Jan-2025 ×16, bare numbers (1, 50, 165), Kg beside Kilogram. A field nobody had to fill collects whatever the cell to its left spilled into it. ⚠️ It fails the WHOLE submission, not the row — one blank unit stops a department saving an unrelated edit, because saveRows throws on the first violation and per-row saving is a rewrite of the save path, not a flag. Pinned by its own test. 🔴 And the load still BLANKS an unresolvable unit rather than defaulting it (ADR 0029 dec. 6 unchanged — a default invents a fact and feeds it to a dashboard), so legacy rows arrive that cannot be saved, met by a coordinator on a row they did not create. That is the same failure the loader fix removed, arriving from the other direction. Nothing on the client marks it required, matching item and quantity — an asterisk on one of three would read as "the other two are optional". Rejected: defaulting to Nos, which is 73% of real data and therefore exactly the dangerous kind of default; required only for new rows (the validator sees a row, not its age); reporting blanks on a dashboard (the person who can fix it never sees it). ⚠️ 17 existing tests broke, every one writing a Materials row as item+quantity — fixtures gained a unit, while blank-row fixtures and deliberately-invalid rows were left alone so neither lost its subject. ⬜ Open and inconsistent: Transport DROPS a row it cannot give a vehicle; Materials KEEPS a row it cannot give a unit. One of the two is wrong.Accepted · built 2026-08-25, unseen
  • Requirements0039The Dashboard is a department-by-category grid, and an empty box has two different meanings. Settles PRD story 28. Amends module ADR 0023 dec. 2 — a new Dashboard section leads Admin setup and Departments loses the landing redirect, under 0023’s own rule that frequency decides order. Measured against the CLOSED MSR 2026 first, and two numbers decided it: 196 of 488 department×category boxes filled at the end of a finished edition (8 departments filed nothing, 12 filed one, none filed all eight, Cycles 0 rows all year), and 4 of 61 departments hold a POC. So 60% of the grid is legitimately blank — a blank usually means no such need, not late — and on almost every empty row there is nobody to chase. That makes the department axis the whole screen: it carries whether anybody holds it, unassigned rows are de-emphasised, and what remains is 4 rows worth ringing. Two states only — Filled / Not filled — and no denominator anywhere. A derived target was available (last edition’s shape: 194 of 196 expected still to come instead of 2 of 488) and was declined to keep one definition of not-filled in the module; a 196/488 tile was drafted and cut because it reads as 40% done and 488 is not a target. Counts only, never rows — one grouped query, ~196 pairs, 2.5 ms, versus 4,138 rows if it showed items; a box links through to the cluster view. No new table, no schema change, no projection. Root ADR 0081 is NOT reopened — it bars completeness from the Foundation; a module reading its own tables was never in its scope, and the platform-wide dashboard stays unbuilt and still cannot say “40 of 60”. A frozen category’s empty boxes are not pending (nobody can write there), so freezing drops the outstanding count — that is scope leaving, not progress. tel: only under useIsMobile(), which measures viewport width not device; and whether a phone number exists at all is unknown — phone comes only from SSO’s optional claim, and dev’s 4 people (0 phones, 0 names) were all seeded, never signed in, so that measurement proves nothing. Every interesting thing here is an ABSENCE, so building the grid from the rows deletes zero-filing departments while every count it prints stays correct — tests pin the absences and each rule is mutation-checked. Dec. 12 also corrects the cluster view: its filter says Filed (which means submitted) and offers only All/Nothing filed, one direction of a two-state question — it becomes Filled with All/Filled/Not filled (its own ticket, decided here and shipped separately). Nothing is built.Accepted
  • Requirements0040Chasing is a list of people, not a panel per department. Amends ADR 0039 dec. 6 and dec. 9. Asked while looking at the built screen — “for calling them how is it helpful, they would need like a list right” — and it was not. ADR 0039 put each department’s contacts behind a click on that department’s row: right for assigning, wrong for chasing, because one coordinator holds many departments (ADR 0022) — so a department-shaped list makes an admin ring the same human once per department, after 57 clicks to find who is worth ringing. So: a Who to chase section grouped by PERSON, one row per human with every empty department they hold named beside them. A department nobody holds never appears — that is the other pile and its action is assign. Per-category stats widen dec. 9’s “exactly two counts”: Materials 42 depts · 2,794 entries vs Cycles nobody yet is the only place the screen says which category is behind. Four filters, stacking with the search — Everything · Nobody assigned · Assigned nothing filed · No <category>; the last deliberately includes departments nobody holds, because “who owes me Signages” is a question about the data, not about blame. The ban on ratios is REAFFIRMED, not loosened — 60% of the grid is legitimately blank, so any denominator is invented, and adding stats is exactly where a completion percentage gets back in. A frozen-category tile was proposed and declined. All of it is a pure module over the payload already loaded: no new endpoint, no new query, no server change. Two defects only looking caught: the chase row printed the email twice (the name falls back to the email, and nobody in the dev directory has a display name, so it was every row), and the Department column took 574px of an 887px box, pushing Cycles behind a scrollbar — the emptiest column and the most interesting thing the grid says. 🔴 Whether “calling” is possible at all is unknown — phone comes only from SSO’s optional claim, dev holds none, and the tel: branch has never rendered. Presentation iterated twice on 25 Aug from Monish reviewing it live (“all over the place”, then “simple things are not there”): the piles became four clickable summary cards that ARE the filter (All · Filled · Nobody assigned · Follow up, one derivation feeding both the numbers and the narrowing), per-category counts moved onto the grid’s own column headers (“2 filled”, entries in the tooltip), and the chase list shows on demand while the Follow up card is active — the default view is cards and grid alone, so the list can no longer shove the grid off screen as it grows.Accepted · dec. 1 superseded by 0041, dec. 4 by 0042 (same day)
  • Requirements0041The narrowed grid IS the follow-up list — contacts ride the rows. Supersedes ADR 0040 dec. 1 the same day, from Monish reviewing the built screen: “instead of having a list like this we can have the table filter it like that, right?” — and the screen proved him right, the Follow up card had already narrowed the grid to the same two departments the list restated one inch above. So: each held row carries its coordinators as mailto: links under the department’s name (name where one exists, email otherwise); an unheld row offers no contact — nobody to ring, its action is assign, in the row’s panel. Email only on the row, the phone stays in the panel. chaseList is deleted, not parked (root ADR 0080: unused machinery is worse than absent machinery). ⚠️ Per-person grouping went with it — a coordinator holding three empty departments appears three times — accepted knowingly: the state has no occupants today, and its real home is the email-campaigns phase. Four cuts, all reviewed live; this is the shape that survived the questions.Accepted
  • Requirements0042A filter nobody can name is not a filter — the per-category dropdown retires to the cluster view. Supersedes ADR 0040 dec. 4 the same day. The control wore three names in one afternoon (Missing, Nothing in, Not filled) and its one intended user asked what it meant after every rename, then asked whether it made sense anywhere. It did not: “who has not filled X” is the cluster view’s question, one click away through the column’s own number, where the Not-filled list comes with the POC contacts and the data itself — the dropdown was a worse copy of a screen that already exists. Removed with its filter axis, its tests, and the srOnlyLabel prop added for its sake. The Dashboard’s narrowings are the four cards and the search, nothing else — every control this screen lost died the same death: it restated something another element already said. Raises the cluster-view Filled-filter fix from wording change to the feature’s home.Accepted

Updated 2026-08-12 (ADRs 0022–0024 — coordinator removal, the admin screen’s first design pass, and departments leaving a module’s hands) — 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.