One platform for the whole event.
Mahashivratri is run by 90+ departments and thousands of volunteers. This is their shared operations platform — one sign-in, one list of people, and one common base that every department's app is built on, so no team has to rebuild logins and access from scratch.
This page is a plain-language map of how the MSR platform is built — what it is, how the pieces fit, and the decisions behind it. The Overview and Foundation sections need no technical background; Architecture and Schema go a level deeper for engineers. A handful of words have a precise meaning here — Person, Module, Foundation, role — and each is explained the first time it appears. A number like 0004 links to a one-page record of a decision. Nothing here is the final software; it's the shared picture the whole team can point at while we build.
The Foundation is the shared groundwork under every part of the system: signing in, the single list of people, who's allowed into what, and a record of every important action. A Module is one department's app that sits on top of it — Vehicle Passes, Stalls, Requirements. The one idea to hold onto: the Foundation only records that you have a role somewhere; each Module decides what that role can actually do.
In one picture
Before any detail, the whole system in one glance — the parts you work in on top, and the shared base they all stand on underneath.
Who owns what — the whole idea
The one split to get right — because everything else in the system follows from it.
Foundation stores
- Login — one sign-in (Isha SSO)
- People directory — one record per human
- Participations — who holds which role where
- Activity trail — the record of what happened
Module owns
- Its screens & workflow
- Permissions — what each role can do
- Scope — which slice of data a role sees
- All its own data
All access is one tiny row — the participation
Every bit of access in the platform comes down to one small record — a participation. Here's everything it holds.
A 20-second example
Jaya is Admin in Requirements; Ravi is a read-only Coordinator. Both click "Freeze budget." The module reads each one's role_key — Jaya's says admin → allowed; Ravi's says cluster_coordinator → blocked. Same button, different outcome — because of one word on the row. Drop role_key and both rows look identical: the module can't tell them apart, so it must let everyone freeze the budget or no one. And "show me every Admin across all 90 departments" stays one quick lookup on that same column.
Signing in ≠ getting in
Worth stressing, because it's the heart of how access works: proving who you are and being allowed in are two separate steps — one without the other gets you nowhere.
Role, permission, scope
Three words that sound alike but mean different things — and mixing them up is the usual source of confusion. Here's each in plain terms, and who owns it.
Two Approvers, same permissions — one sees South Gate, the other North Gate. That difference is scope. (Like Google Drive: permission = view vs edit; scope = which folder.)
Several roles add up — union
What happens when one person wears more than one hat in the same module.
How a module talks to the Foundation — just 3 connections
A module doesn't wire into the Foundation a dozen different ways. There are exactly three connections between them — no more — and here's what each one carries.
The data behind it all — five things
Strip the platform to its bones and there's remarkably little to track. Here's how the core pieces fit together.
Two records for one person → merge
People sometimes end up with two accounts for the same person. Here's how the Foundation quietly resolves that.
redirects on every lookup
Built to run every year — and beyond one ashram
Two things the platform handles from day one: the event coming round every year, and running at more than one ashram. Both are kept cleanly apart without splitting the system in two.
Every year is kept
Mahashivratri comes round every year. Each year's work — every pass, request and record — is kept as its own edition, sitting right next to last year's, so you can always look back at what happened last time instead of it being wiped to start over.
More than one ashram, one system
It's built for the Isha Yoga Center today, but the same system is ready to run the event at other ashrams later. Each ashram's data is walled off from the others — while the people who oversee everything can still see across all of them in one place.
- The current year is open; past years are frozen. When a year ends it's closed — nobody can edit it, whatever their role. "Read-only" comes from the year being closed, not from the person.
- Who can see a past year is set by each person's grant scope: a grant reaching that year (its own year, or an ashram / whole-system grant that covers it) can view it; someone with no grant reaching it doesn't see it at all.
- When each is decided: the freeze happens when an admin closes the year; who-can-see is set at onboarding (the grant's scope); the module renders a closed year view-only on every screen. (ADR 0013.)
Who runs the system — and how much they can touch
Not everyone who runs the platform needs the same powers. The people who manage the system work in an admin area, and access to it comes in levels. Someone on the technical team can do everything; a back-office person handles the day-to-day but can't reach the sensitive parts — managing people, changing settings, or turning parts of the system on and off. A level is just a checklist of which areas it's allowed to open, like the one below.
Built with
The main tools the platform is built with. One line is deliberately still open — where it's hosted — which the Architecture section picks up.
How the software itself is organised. In plain terms: it's built as one app — not dozens of separate services — but cleanly divided inside into the shared Foundation plus one walled-off section per Module, all running on one database. (The industry name for this shape is a modular monolith.) Where the app will be hosted isn't decided yet.
The rest of this section goes a level deeper — the shape of the code and the shape of the database — and is written for the engineers reviewing it. The plain version is the paragraph above.
Two views of one system
Two settled views of a single decision — plus one question that's still open.
How the code is organised
Folders in one shared codebase — the Foundation, and one folder per department's part.
How the data is stored
One database, with each department's part kept in its own section.
The choice — a modular monolith
The shape we chose: one app holding the Foundation and every module together, with hard walls between them — a modular monolith.
- Hard walls between the parts. Each module lives in its own folder and its own section of the database. It can't quietly read another module's data or borrow its code — the separation is a real wall, not just a naming habit.
- They only talk through set doorways. A module reaches the shared Foundation through the three connections shown earlier, and reaches another module only through a small, agreed "public menu" — never by reaching into its internals.
- Each feature kept in one place. Everything for one feature — its screen, its rules, its data — sits together in one folder, instead of being scattered across the codebase. Easier to find, easier to change.
- Ready to split off later, if ever needed. Those clean walls mean a module could be pulled out into its own separate service down the line if it ever truly has to — but we start simple and only add that complexity under real pressure, never the other way round.
What it looks like in the repo
msr/ ├─ CLAUDE.md root rules — govern the whole repo ├─ apps/ │ ├─ web/ React SPA — one build │ │ └─ src/ │ │ ├─ foundation/ login · shared member-mgmt component │ │ └─ modules/ │ │ ├─ vehicle_passes/ │ │ ├─ stalls/ │ │ └─ requirements/ │ └─ api/ Fastify API — one deployable │ └─ src/ │ ├─ foundation/ identity · participation · activity trail │ └─ modules/ │ ├─ vehicle_passes/ │ │ ├─ CLAUDE.md module-only rules (added only when needed) │ │ └─ passes/ ← one vertical slice (a feature folder) │ │ ├─ route.ts │ │ ├─ handler.ts │ │ ├─ schema.ts (validation) │ │ └─ data.ts │ ├─ stalls/ │ └─ requirements/ ├─ packages/ │ └─ shared/ published DTOs / types — never internals ├─ docs/ │ ├─ DESIGN.md Foundation design (STABLE) │ ├─ adr/ cross-cutting decisions · 0001–0012 │ └─ modules/ │ └─ requirements/ │ ├─ DESIGN.md module design (Living) │ └─ adr/ module decisions (own numbering) └─ .dependency-cruiser.cjs CI boundary rule — build fails if a module reaches in
docs/, each module its own docs/modules/<m>/; and CLAUDE.md carries the rules, root-level plus a per-module one only where a module needs its own.Data view — one database, a shelf per module
The same choice seen from the data side — one database, but each module keeps its tables in its own section.
Inside the one database — a schema per module
msr_dev ← one PostgreSQL database │ ├─ schema foundation ← the shared join spine │ ├─ person person_id (PK) · sso_id · email · phone │ ├─ participation person_id → module · role_key · dept · enabled │ ├─ department │ ├─ module │ └─ activity_log who · dept · module · what · when │ ├─ schema vehicle_passes │ ├─ pass FK person_id → foundation.person │ └─ vehicle │ ├─ schema stalls │ └─ stall FK person_id → foundation.person │ └─ schema requirements ├─ requirement FK person_id → foundation.person └─ requirement_item
How it stands against the rest — the code shape
Every sensible way to structure the code, side by side with why we landed where we did. The others aren't wrong everywhere — they just don't fit an app this size with a shared people-thread at its centre.
Why it fits — this app specifically
Beyond the general case, the specific reasons this shape suits MSR — the product, the small team, and the once-a-year burst.
- Our product literally is "Foundation + Modules." The architecture names the design you already have — code layout mirrors the mental model 1:1.
- Transparency needs one database. "Who did what across all departments?" is a single lookup in one database — not a stitching-together of data from separate systems.
- Small team, tight timeline. One app, one build, one database. Every hour goes to features, not plumbing.
- A burst once a year, not constant scale. Turn capacity up for MSR, down after — no rebuild, whatever the host.
- "Nothing meaningful is ever destroyed." One database means one backup, one restore, and the ability to rewind to any point in time.
- Grows without regret. A new module is just a new folder, its own section of the database, and the same three connections to the Foundation; the automatic boundary check guarantees it can't break the others.
The exact tables and columns the database starts with. A database's shape is the one choice that's genuinely hard to change once it's built, so it was drawn on paper first; it is now built, test-driven, and applied — a single migration on a foundation schema, 37 invariant tests green, seeded on a fresh DB. This is the most detailed, engineer-level part of the doc; the plain idea behind it is in the Foundation and Architecture sections above. Shape changes from here go through a new ADR, not an edit.
- Real Postgres enums for
edition.statusandscope_level(nottext+CHECK) — stricter typing, settles ADR 0013's "howscope_refis typed". - Role tables keyed by
role_key/privilege_keyalone — the schema-lab'smodule_keycomposite dropped, since the context is the schema (truer to ADR 0018's per-context layout). scope_refis a polymorphicuuid, no FK — it points at an edition or an ashram, guarded by ascope_guardCHECK (level ↔ ref must agree), not a foreign key.- Append-only enforced at the database — a trigger blocks UPDATE/DELETE on
activity_log, not just app convention (defense-in-depth, ADR 0004). - Two locks post-date the schema-lab:
person.sso_idis nullable + unique (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.
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.
UQ is a pre-SSO leftover, open for S4 (ADR 0041)/userinfo may not return onemsr) — the "MSR area" marker; generic, never hardcoded. "exists-for", not "participates-in".vehicle_passesiyc, ssbiyc. Data must never cross ashrams.msr; "MSR" never hardcodedMSR·IYC·2027 (open) + last year MSR·IYC·2026 (closed, holds imported history). Every module's data hangs off an edition.scope_guard CHECK keeps level ↔ ref in agreement; null only when globalparticipation.granted, pass.issuedperson_id is the shared thread every module hangs off.role_key is, and why it lives in the Foundation:
- What it is: the one word that says what someone is here — "admin", "approver", "read-only". The Foundation stores only the word, never what it means (the module decides that).
- Why the Foundation needs it (the real reason): "who is what, where" is a whole-event fact — not any one module's business. Only the common place can answer what leadership actually asks: your Workspace (all your roles in one screen), "who's an admin across all 90 departments?", "cut all of Jaya's access — she left", and the same for the AI. A module knows only its own corner — it can never answer across all of them.
- And it has to be fast. Because it sits in one indexed place, those answers are a single sub-millisecond lookup (measured), onboarding is one write, and offboarding is one switch — not a 90-module hunt.
- Yes — a module could enforce on its own. It could keep its own role and still allow/block an editor vs a viewer; that part works without the Foundation. We keep the word here for the whole-event jobs above — and since it already lives here, the module just reads the same word and needs no table of its own. Enforcement comes free; it isn't the reason.
scope_level (edition · ashram · global) says how far a grant reaches — never a blank "everything".How a module hangs off it — the first real one, plus one mock
The left-hand table is real and built — the heart of the Requirements module's own section of the database (its full set is below, under Requirements). The right-hand one is made up, to show the same pattern would hold for any other module. Notice each links back to the shared person / department lists and carries its edition, but keeps everything else to itself.
person, department, edition. Everything else — scope, status, workflow — is the module's own; the Foundation never stores or interprets it. Requirements is built (its five tables, plus its own three access tables, landed together in one step); vehicle_passes.pass is still an illustration.Roles as data — the role-builder (ADR 0018)
role_key stays opaque; these tables just give it something to resolve against. Validated in the schema-lab (resolution sub-millisecond; a role change stays O(role), touching zero participations). Built and applied in Slice 3.Each role-owning context — every module, and the Foundation for its own roles — keeps four small tables, and can() becomes a join instead of a hardcoded check. They live in the context's own section, never a shared registry.
budget.write). Roles are composed from these; capabilities are never invented in the UI.participation.role_key — resolved by joinadmin_console for the platform's own operator roles (platform_admin, back_office_operator, auditor). Same four tables everywhere — the Admin Console is just another module-context (ADR 0012/0020). Measured in the schema-lab at 50k grants: resolving privileges 0.49 ms; a role change re-empowers hundreds of grants in 0.85 ms with zero participation writes. Full numbers in sandbox/schema-lab + ADR 0018.- One copy per module, in that module's own section —
requirements.role,stalls.role, … Never a single global set, and never in the core Foundation. - The Foundation's own operator roles (
platform_admin,back_office_operator,auditor) sit in the admin_console module's schema — the admin console is itself a module (ADR 0012/0020). - The core Foundation gains no new tables — it still keeps only
role_keyon the participation (the assignment). - Why per-module, not global: one module's privileges (
budget.freeze) mean nothing to another; a single shared table would couple all modules and let one bad change break the rest. Separate copies keep each module's blast radius to itself. - "But cross-module oversight?" Already covered by the global participation table (+ an admin-tier marker) — you get "who's admin anywhere" without making the definitions global.
Two rules the joins obey
With the tables drawn, two rules govern how they may link to one another — one for how a module reaches the Foundation, one for how modules reach each other.
Modules → Foundation
A module may link into foundation.person and foundation.department — the one connection every module is allowed. It reads its own participations to build a screen, and adds one line to the activity trail on every meaningful change. It never touches another module's tables.
Module ↔ Module
Never a direct link into another module's tables. A module reads another's data only through its published "public menu" — a small, stable, agreed set of things it offers — or through announcements it broadcasts. One-directional, no loops, and checked automatically.
materials.line_item──▶requirements.requirement
one module reaching straight into another's tables — not allowed; the automatic check stops the build.
materials──asks──▶requirements through its public menu
through the front door — the small, stable public menu. One-directional; if the reverse is ever needed, the other module sends an announcement instead.
email/phone uniqueness and blanks are handled), how the merge redirect is enforced, the role registry (labels stored here vs read live from each module), whether a module row copies the department it's acting for or works it out, and how the ashram + edition scope sits on the participation and on module rows (denormalised vs derived). Refinements, not a blank page.What actually happens when someone logs in, opens a module, and does something — which tables are read and written, and how fast. Measured on a mock of the real schema at above-real scale (1M activity rows · 200k guests · 500k requirements · 50k role grants resolved live). Foundation↔Module calls are in-process (one app), not network hops. Full sequence diagrams, table-updates and reproduction live in docs/performance.md (+ sandbox/schema-lab/) — this is the high-altitude summary.
| Call · most frequent first | Direction | Tables | R/W | DB time | End-to-end* |
|---|---|---|---|---|---|
| Read participations — "who + roles here" | Module → Foundation | participation | R | 0.18 ms / 0 cached | ~2–4 ms |
Resolve privileges — can() (enforcement) | Module (internal) | role_privilege | R | 0.49 ms | ~2–4 ms |
| Change a role's privileges | Module (internal) | role_privilege | W | 0.85 ms · 0 grants touched | ~3–5 ms |
| Oversight/AI — "who can X anywhere" | API → Foundation | participation + marker | R | 13–15 ms | ~15–20 ms |
| Log activity | Module → Foundation | activity_log | W | ~1–2 ms | ~4–6 ms |
| Module data read | Browser → API → Module | module tables | R | 6–33 ms | ~8–35 ms |
| Workspace load | Browser → API → Foundation | participation | R | 0.18 ms | ~3–5 ms |
| Role label (display) | Module → its role table | role | R | ~0.1 ms | ~1 ms |
| Write / change participation | Module → Foundation | participation + activity_log | W | ~3–5 ms | ~8–12 ms |
| Register module + roles | Module → Foundation | module | W | deploy only | — |
person_id (the person-360 query spanning all three schemas is 5 ms). The single biggest speed lever is keeping the API co-located with the database — bigger than schema or ORM. Full detail: docs/performance.md.Stress test — data-driven roles + load (schema-lab, at scale)
- Enforcing a permission is essentially free. Resolving a role → its privileges (the new data-driven
can()) is a 0.49 ms index-only join; a single yes/no check is 0.045 ms. Moving roles from code → data costs nothing at the database. - Changing what a role can do stays O(role), not O(members). Editing a role's privileges wrote one row in 0.85 ms and instantly re-empowered 683 people — with zero participation writes.
- Cross-module oversight / AI ("who can freeze anywhere", "who's admin anywhere") runs in 13–15 ms over 50k grants — fine for oversight; indexable if it ever goes hot.
- Concurrency: a small pool of ~20–30 connections sustained ~6,600 queries/sec with zero failures — enough for 10,000+ users (each query is 1–11 ms, so a small pool cycles for thousands). The only wall hit was raw connections (Postgres caps at 100) → solved by a connection pooler (PgBouncer), not a schema change.
- Scaling is infrastructure, never the schema: a pooler + a few app copies behind a load balancer + keeping the API co-located with the DB. The data model doesn't change from 1,000 → 10,000+ users.
sandbox/schema-lab/README.md · decisions: ADR 0018 (roles as data), ADR 0011 (hosting / pooler).The first real department app built on the Foundation. Its job: collect what every area needs for the event — materials, transport, volunteers, furniture, signages, infrastructure, sevadhars, cycles — into one trusted list the budget can actually be built from. Today that lives in scattered emails and spreadsheets, so nobody can see the whole picture or tell who has and hasn't submitted. Design in full: docs/modules/requirements/DESIGN.md · what & why: docs/modules/requirements/PRD.md.
One module, 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.
The entry screen — last year beside this year
The one screen that decides whether this tool gets used. Coordinators plan by looking at what they actually used last year — so last year sits right next to the table they're filling in, not behind a tab. Left is last year, locked; right is this year, editable; the divider between them drags, so a coordinator reviewing a long list can widen the left, and one who just needs a glance can shrink it. (ADR 0001.)
Copy last year forward — the values, not the dates
Starting from a blank page is why coordinators under- and over-plan. So they can tick last year's rows and copy them over as a starting point. But we deliberately do not carry the dates across.
What comes across
The descriptive things that genuinely repeat year to year — the item, how many, where it comes from. Each field is individually marked as "carry this forward" or not, so operational leftovers (what was already issued, return dates, remarks) never travel.
What doesn't — and why
Dates. We considered shifting them automatically to match this year's festival date and rejected it: not every date tracks the festival, so a blanket shift would quietly fill the sheet with dates that look right and aren't. A wrong-but-plausible date is worse than an empty one. The coordinator sets them — with last year visible alongside and drag-to-fill making it quick.
Nothing about the eight categories is hardcoded
The old app baked each category and its columns into the code, so every rename or new column meant a developer change in several places plus a release. But these will keep changing — several categories' fields are still under review right now. So the categories and their fields are a list the system reads, not code. (ADR 0002.)
area_registration came with the admin setup screen, once building it showed the year's area list had nowhere to live.) The tell that it worked: the admin's inability to edit an area's numbers is stored as data — the admin role simply holds no "write" permission — rather than relying on a check a developer could forget.Only the area's own coordinator edits its numbers — not even the admin
This data feeds the budget, so its value rests entirely on it being what each department itself declared. So there is no admin edit screen at all — a deliberate departure from how the old process worked.
Area Coordinator can
- Enter and edit their own area's rows — and only theirs
- Work through several areas separately, if they hold several
- Read their own past years
Module Admin cannot
- Edit any area's numbers — at all
- Their job is set-up (register areas, assign coordinators) and freeze
- A genuine emergency correction goes through break-glass — logged and attributed, never a quiet edit
Last year's data, and why each year looks like itself
Last year's numbers have to be there on day one, or the copy-forward flow has nothing to stand on. They're brought over from the old app once, by a script, and then live here as ordinary rows stamped with their year — so after that load, nothing depends on the old app any more. (ADR 0005.)
Each year shows the categories it actually had. Categories come and go, so we show a category for a given year only if it held data that year — plus, for the year you're working in, everything currently active. A category introduced this year doesn't clutter last year's view; a retired one still appears in the years it was really used. No per-year snapshot of the structure is stored anywhere — this reads straight off the data we already have.
Freeze — where the whole thing is heading
The point of collecting all this is a number the budget can rely on. Once a category is complete enough, the Module Admin freezes it: from then on that category stops moving — for everyone, through every path, enforced on the server rather than by hiding buttons. Categories freeze one at a time, so materials can be locked while volunteers is still being filled in. Undoing it is a deliberate unfreeze, and re-freezing is required to lock it again — so every post-freeze change is a conscious, traceable act.
What ships first
The first deliverable proves the whole entry loop end-to-end on one category — because once the engine is driven by the category list (mechanic 4), the other seven are entries in that list, not seven more screens to build.
In the first deliverable
- The entry screen + the category-driven engine
- Materials proven end-to-end, then the rest as list entries
- Coordinators scoped to their own areas
- The admin set-up that makes the above possible
- Last year's data loaded in
Later phases, same module
- Freeze
- The Cluster view + who's-submitted tracking
- Email campaigns to coordinators
- A richer admin oversight dashboard
- Cycle Management
The significant decisions behind the platform, each written up as a short one-page record — an ADR ("Architecture Decision Record"). This is the index; the full reasoning for each one lives alongside the code. The list only grows: when a decision changes we don't rewrite the old record, we add a new one that replaces it — so this history always matches what we actually built.
- 0001Shared Foundation, bespoke modules — no universal workflow engineAccepted
- 0002The Foundation is a participation registry, not an authorization engineAccepted
- 0003Modules are run by one department, used by manySuperseded by 0014
- 0004Schema designed for downstream analytics and AIAccepted
- 0005Module roles are static (defined in the module) — no runtime role-builderSuperseded by 0018
- 0016No role registry in the Foundation — role definitions/labels live in module code, resolved live in-process (refines 0005, 0014)Accepted
- 0006Multiple roles per module; permissions are the unionAccepted
- 0017The participation carries the role (role_key) — the grant is the role, not bare membership (why role_key exists)Accepted
- 0018Roles/privileges/scope authored as data — a runtime role-builder, module-owned; Foundation is its own role context (supersedes 0005; validated by the schema-lab)Accepted
- 0007Modular monolith, one DB (schema-per-module), AWS hostingSuperseded by 0011
- 0008Modules communicate through published contracts, never by reaching inAccepted
- 0009Security is enforced on the server, every request — never in the UIAccepted
- 0010Documentation governance & doc-sync workflow (ADRs append-only)Accepted
- 0011Modular monolith + one DB (schema-per-module) settled; hosting deferredAccepted
- 0012Foundation admin is a capability-based console module — tiered operators, seeded roles, runtime builder deferredAccepted
- 0013Multi-tenancy (ashram) & yearly editions — one shared Foundation, hierarchical scope on the participation; multi-event kept open, unbuiltAccepted
- 0014Module ownership is emergent from the appointed admin participation, not a stored owning-department; modules may be jointly runAccepted
- 0015Areas are Departments; a nullable
eventOnlyFormarker distinguishes permanent departments from event-only areas (generic across events)Accepted - 0019Department is flat — no stored hierarchy; grouping is naming-convention only, rollups via participations & scoping via member_scope (additive escape hatch if a real hierarchy ever surfaces)Accepted
- 0020Operator-tier role tables live in the Admin Console module's
admin_consoleschema, not thefoundationcore (reaffirms ADR 0012, clarifies ADR 0018; corrects the Slice 3 placement)Accepted - 0021App shell + module mounting: a thin Workspace launcher over participation rows; each module declares its routes in code (a frontend manifest —
key·name·icon·routes·group?) mounted under/m/{key}via React Router; separate modules + display group (Vehicle Pass / Name Tags = separate; "Access Control" = grouping); server registry unchanged (ADR 0016). Amended by 0025 — the launcher now expands a module into its role-unlocked views; the other nine points standAccepted · amended by 0025 - 0022App-shell chrome (additive to 0021): a persistent centered operating-context switcher pill (
edition · ashram, always visible; switching visual-only/deferred per ADR 0013, source of truthapp-config.ts) + a ⌘K/Ctrl-K command palette for jump-to-module; Workspace is labelled "Your Dashboard" in the UI (concept unchanged)Accepted - 0025The Workspace expands a module into its role-unlocked views (amends 0021's one-tile-per-module). The module stays the unit; under it sit the views the person's roles unlock (Requirements: My Requirements · Cluster · Admin · Cycle, ~1:1 with roles), shown directly — no menu page. A single-view module collapses to one tile; it expands into a labelled block only for someone with several. Modules declare their views in the manifest (
name·route·unlockedBy) so the Foundation renders them generically (ADR 0016/0008 intact); client-side fromroleKeys, no backend/contract/stored-data change; server still authorizes each view (ADR 0009). Landing is the Workspace; "Dashboard" reserved for the story-28 reporting screenAccepted - 0023Versioning & rollback: version the whole monorepo by git SHA (one matched frontend+backend+contract per commit), immutable SHA-tagged artifacts, roll back both apps as a unit; DB is never rolled back with the code — migrations stay forward-only but backward-compatible via expand/contract. Artifact/deploy mechanics ride on the deferred hosting ADR 0011Accepted
- 0026Appointing a module admin: the client sends a module and an email, nothing else. The admin
roleKeyis derived from the module's ownMODULE_ADMIN_ROLESdeclaration (ADR 0016) and an explicitly-named one is validated against it — closing write-any-role-into-any-module; the acting-for department defaults to the reserved Back-office the operator's console grant already carries (mechanic 7, no dropdown); scope defaults to the edition appointed for (ADR 0013), level+ref as a pair. A module admin may appoint a peer admin — stated and tested, not an accidental pass-through: a single-admin module can't survive that admin being unreachable. Both grant paths are idempotent on(person, module, role, dept), fixing a re-appoint500. Back-office-only UI; a403locks the surface (ADR 0009). Extends ADR 0009 §4 and 0012Accepted - 0029A module admin can see the module's admin set — supersedes ADR 0028 decision 8 ("the screen lists no admins yet"), which said so only because no read existed. ADR 0026 dec. 6 and ADR 0028 both exist because a module with exactly one admin is stranded the moment that admin is unreachable; an admin cannot act on that risk without seeing it, so the mitigation shipped without the instrument that says whether you need it. The list renders beside the appoint card, so the reason to appoint is visible where appointing is possible. Three settlements: the read is a Foundation seam (
listModuleAdmins, same context shape asonboardMember) not a Requirements one, because every module hosting the member seam has the same failure mode and two implementations of an authorization-bearing read is how they diverge; it is authorized exactly as the paired write is — the caller holds one of the target module's admin roles — not by a Requirements privilege join, since "who holdsmodule_admin" is a Foundation grant fact and a newadmins.readprivilege would only restate an existing authority; and it shows active grants only, for the active edition — a disabled grant is not a backup, and who was removed is the activity trail's record, told better there. Not the generic members screen ADR 0028 rejected: that rejection was about a screen covering every role, and a seam answering one question does not commit us to itAccepted - 0028A module admin appoints peers from the module's own screen — supersedes ADR 0026 decision 8 ("appointing UI is back-office-only"), which had left ADR 0026 decision 6's peer-delegation capability with no surface a module admin could actually reach. Requirements' Admin setup gains an Appoint another admin card (email only; edition-scoped, department defaulted to the appointer's own). The back-office Module Admins screen stays and is not a duplicate: it bootstraps a module with no admin (
modules.manage, no participation), where this extends an existing admin set (authorized by holding an admin role there) — different authority, precondition and seam. Second half:onboardMembernow validates the grantedroleKeyagainst the target module's declared role set (403otherwise), closing the twin of the hole ADR 0026 dec. 2 closed on the other grant path —role_keyis opaque text with no FK, so an unrecognised key persisted a grant that authorized nothing and appeared nowhere. The whitelist is read from each module's own in-codeROLES(module-roles.ts, composition root → module), never restated, so it cannot drift. Admin roles stay grantable — bounding which module's roles, not how privilegedAccepted - 0027A view-tile owns its icon and badges only the roles that unlock it (extends ADR 0025):
ModuleViewgains an optionalicon— falling back view → module → initials monogram — because two views of one module both drew the module's clipboard and stopped telling the tiles apart; and a view-tile badgesroleKeys ∩ unlockedBy, so "Admin setup" is no longer labelled Area Coordinator. The badge is the only place the UI says why a surface is open to you, so it must not say something untrue. Chrome corrections in the same pass: the group header renders only when there is more than one group (one group drew the literal fallback "GENERAL" above the module's own label), and the counts pill counts modules only — the group count is dropped rather than relabelled, because an Area IS a Department (ADR 0015) and a manifestgroupis display-only (ADR 0021). Frontend-only: no schema, contract or endpointAccepted · dec. 3 superseded by 0052 - 0030Search-and-confirm before minting a Person — restores
DESIGN.mdmechanic 1 ("find first, then add"), which the code has never implemented: every add-or-reuse-by-email path did an unconditionalupsertsince slice 7, so a well-formed but wrong address silently minted a phantom Person holding a real grant. Supersedes nothing — the decision was already written; the code was out of step. Running the gate before designing found the thing that shaped it:Personhas no name and no phone, so an email-only search cannot prevent the harm (the admin searches the address they believe, finds nothing, confirms anyway) — matching on a name is what lets the search disagree with the admin. So:persongains a nullabledisplay_name(admin-supplied, SSO-authoritative at first login; never backfilled from an email local-part, which would fabricate the field the search must trust); the read is one Foundation seamsearchDirectory(per ADR 0029 dec. 2), authorized as the write it precedes — each of the five callers runs its own existing gate and then calls the seam, which does not re-run it (the first draft had the seam re-run the caller's gate; a Foundation seam re-running Requirements' privilege join would import from a module — an ADR 0008 cycle, caught at implementation planning before any code was written, andlint:boundariescovers onlyapps/webso CI would not have), because a newdirectory.searchprivilege would be a synonym for that exact set (ADR 0016 / 0029 dec. 3); matching is a case-insensitive substring of email or name (the wrong-address case is a middle difference —sadhgurvssadhguru— which a prefix match misses), min 3 chars, capped at 25, unpaginated on purpose — paging turns a lookup into a browse. The create path states its intent:personId(reuse, picked from the directory) xornewPerson(asserting a new human); a bare email is a400, because the confirm cannot live in the UI (ADR 0009) — the idiomEmptySubmissionErroralready set.merged_intoresolved on reuse and merged-away rows hidden from search (mechanic 1); a sign-in-disabled Person does appear, marked, since their record is the one that must be reused rather than duplicated. Five API sites + three forms change together via one sharedPersonPicker; an exact email match is still picked, never auto-reused. Breaking API change. Closes prevent only — detect / confirm / merge stay unbuiltAccepted - 0024The target edition is MSR 2027: seeded editions corrected to
2027open +2026closed (the legacy*_currtables run to Feb 2026, so_curris MSR 2026). Supersedes only the year labels in ADR 0013 — its rule, "current open + last year closed", is unchanged. The seed keepsupdate: {}and so cannot self-repair a mis-yeared edition: opening/closing stays an audited admin action, never a redeploy side-effectAccepted - 0031A module's single-view Workspace tile is labelled by its view, not the module — supersedes ADR 0025 decision 3's tile-labelling clause only (the collapse-to-one-tile behaviour itself is unchanged). A person holding one role saw a tile named after the whole module (e.g. "Requirements") that opened one specific screen ("Admin setup") — label and destination disagreed. Now the tile takes the view's own name/icon, matching the multi-view case ADR 0025 already got right. A module declaring no
views(Admin Console) is unaffected. Frontend-only, no schema/contract/server changeAccepted - 0032Entering an edition is grant-scoped; the Workspace owns the switch, the header only says where you are. Amends ADR 0013 §6 (a closed edition is no longer "fully visible" to whoever can reach it — entering one needs a grant; its read-only half stands and is now shown on screen, not merely refused on save) and ADR 0022 dec. 1 (the pill shows, it no longer switches). The pill has listed reachable editions since 0022 and switching has never worked — a radio group with a
valueand noonValueChange; its follow-up ticket is marked complete with the switching half of its brief undone. Nine decisions. Two layers is the load-bearing one: the Foundation gates which editions you may enter, a module still decides what past data it shows inside your context — so Requirements' read-only last-year panel stays area-scoped reference and copy-forward survives (the single-layer alternative would have deleted module ADRs 0001/0004/0005 for every ordinary coordinator, and its "fix" was re-granting every coordinator on the closed edition — the same access spelled out row by row). Active edition is server-held, validated against grant scope on the guard shipped at roadmap row 21; a module may still name aneditionIdas a read TARGET, authorized by the same seam — two verbs, enter needs a grant, reference is the module's own scoped read. One list grouped by location: an Edition is event × ashram × year, so a separate Location control could only ever express states that do not exist — andDisplayEditiondropsashramKey, so a second ashram would draw two indistinguishable "MSR 2027" rows. Archive access is an ordinary grant scoped to the closed edition, the role deciding the view (cluster_coordinator= every area read-only) — no new privilege, no "archive viewer" concept. Header shows, Workspace switches — a deliberate deviation from GCP/AWS/Slack, all of which keep the switcher in the header, taken so a mid-entry switch is structurally impossible rather than warned about; the accepted cost is a trip to the Workspace and a label that looks clickable. Sticky for live years only, so rollover self-corrects and nobody wakes up parked in a closed year. Prior art for the archive half is accounting software, not the cloud consoles: Xero / QuickBooks / NetSuite "close the books" — a locked period is read-only for everyone regardless of role, and prior-period access is a separate explicit permission. Two of its nine decisions have since been amended — see 0033. Dec. 7 was corrected a second time the same day: no separate Workspace switcher exists — the header pill IS the control, live only on the Workspace path and only when more than one open edition is reachable (built as a distinct card first, dropped on sight for repeating the header with nothing to click).Accepted · part built - 0033An archive is a URL, not an operating context. Amends ADR 0032 decisions 2 and 9. 0032 dec. 9 said a live edition a person chose is restored "next session" and a closed one is not — but there is no session:
getCurrentPersonresolves a bearer token straight to aperson_id(a JWT under SSO, still no server state), andgetAppContextruns on every page load, not once at sign-in. So "not restored" and "pressed F5" were the same event, and a coordinator reviewing last year would be thrown back to the open year on their next navigation — deleting the archive access 0032's own decisions 4 and 5 exist to provide. Found the same way 0032's earlier self-contradiction was: reading the ADR against the code before implementing it. The fix keeps the intent and drops the mechanism — the active edition is a live year and only a live year (POST /api/me/active-editionrefuses a closed one with 409, deliberately distinct from the 403 for reach, because someone holding a real grant on last year has not done anything unauthorized and must not be sent chasing access they already hold), and a closed edition is reached by naming it in the request, authorized per request by the same reach guard, so nothing is persisted and nothing has to expire. Leaving an archive is closing the tab. The rejected alternative was a timestamped preference that decays — a clock invented to simulate a session we deliberately do not have, with a constant that is too short for careful reading and too long for coming back the next morning. Rollover still self-corrects, now for free. Named cost: this is 0032's second correction in one day — both caught before code, which is the process working, but worth stating plainly thatgrillingsettles decisions and does not verify them against the system. Decision 1 is built; the URL half is web work, not startedAccepted · part built - 0034UAT hosting: API on AWS EC2 behind an ALB (targets the box on
:80directly, no reverse proxy), self-managed Postgres 16 on the same instance, web on Netlify; a plain Node process (no Docker), deployed over SSM Session Manager (no SSH keys). UAT-only — partially answers ADR 0011; production hosting and its data-governance question stay open. Trade-offs named as UAT-acceptable: no managed backups/PITR, single box/AZ (app+DB share fate), manual deploysAccepted - 0035Automated UAT deploy: a push to
msr-devships the backend via a GitHub Actions workflow that runs one AWS SSM command on the box (no SSH; role assumed by OIDC, no stored keys), executingapps/api/deploy/deploy-uat.sh—reset --hard→npm ci→db:generate→db:deploy→db:seed→ restart → health-check gate. Migrations use a DDL-capable URL pulled from SSM Parameter Store; the app keeps its least-privilegerw_role. Delivers ADR 0034's automation follow-up. UAT-only; gating the deploy on CI-green and the prod guardrails of ADR 0023/0011 are named follow-upsAccepted - 0036The live doc is a static multi-file site, not one self-contained file. Supersedes the "one self-contained HTML file" property of the live-doc convention (ADR 0010's sync rule is unchanged). Split into
msr-live-doc.html(structure) +styles.css(theme — mirrorsapps/web/src/index.css) +app.js(sidebar/scroll/theme toggle), same folder, no build; the tiny no-flash theme script stays inline. Filename kept so the ~23 references (CLAUDE.md, ~14 ADRs, two skills, the doc-guard, both preview servers) stay valid — hosting names the entry document, the repo renames nothing. Trade: no longer one portable file, but it's served, not emailedAccepted - 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 putrole_idin 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 flatrole_idalso cannot express ADR 0006's union of several roles held per module. No blacklist:signInDisabledalready 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). ThegetCurrentPersonseam 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_idis 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".Persongains nullablephone; 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: capturingsso_idat 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+ OAuthstatelive 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'sid_tokenand 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 —
emailjoinsdisplay_nameandphoneas SSO-authoritative, refreshed on every sign-in. 0038 named only the latter two, contradictingDESIGN.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 existingpersonrow was typed by an admin and carries nosso_id, whileemailisUNIQUE— so at S2 a real sign-in would find nosso_idmatch, 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. WhetheremailstaysUNIQUEis deliberately left open for S2 — moved to S4 by ADR 0041, and settled by ADR 0050: it staysUNIQUEAccepted · 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 theemail @uniquequestion 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. RetiringdevLoginat 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 withreplaceState: fragments are never sent to a server, so it stays out of our access logs, the ALB's, and anyReferer— 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/userinfomissingsuboremailfails 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/loginanswers 503. Cost:person.emailstaysNOT NULL UNIQUEthrough 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 itsid_tokentrade-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 theid_tokenin a second one scopedPath=/api/auth/logout. Why now:localStorageis 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.tomlrewrites/api/*withstatus = 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 viaend_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 noid_token. CSRF is closed by three independent locks:SameSite=Laxwithholds the cookie on every cross-site write, a cross-site HTML form cannot sendapplication/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 (signInDisabledis 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 —
devLoginno longer stays "exactly as it is" until S4; S4's scope is untouched and this removes nothing.POST /api/dev-logintakes an email and mints a real session token with no password, and it had no environment gate of any kind — verified: noNODE_ENVcheck anywhere inapps/api. On a public UAT host now holding real PII, and carrying a seeded globalplatform_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 unlessMSR_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
401on a write discards unsaved data — ordinary during all-day Requirements entry, not an edge case. Three things were measured, not assumed: Isha's/authorizereturnsX-Frame-Options: SAMEORIGINso a hidden iframe is refused (the hosts are same-site but framing is judged on origin),prompt=noneis ignored (it redirects to their own login page instead of returningerror=login_required), and — the one that decided it — Isha does issue a refresh token, on the ordinary authorization-code grant, despiteoffline_accessbeing absent fromscopes_supported. One log line on one real sign-in settled what three documents could not; theirclaims_supportedis literally["TODO"]. So renewal becomes a server-to-server POST withgrant_type=refresh_token— no browser, so nothingX-Frame-Optionscan block. Held in a third cookie,__Secure-msr_refresh, path-scoped to/api/auth/refreshalone. Proactive, not reactive: renewing after a401means 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 inlocalStorage, 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 byhttpOnly/Secure/__Secure-/path-scoping/rotation, not eliminated. Two unknowns that must be measured at implementation: the refresh token's lifetime (norefresh_expires_inis 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:
maxAgesat on aSHAREDconstant 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_accessis absent fromscopes_supported), or the cookie hit its ceiling while the session did not. That answers409and clears nothing;401is 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 nomaxAge(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_grantfor 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_inandid_token exp − iatboth 3600s) and the refresh token's own lifetime is unmeasurable (norefresh_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, and401and409now 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 existing401-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 viasignInDisabled, 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 on401but not409(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
401navigate 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 tosessionStorage(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 a401(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=1enabled and uncommented (.env.example:18 — "Shipped ENABLED and uncommented on purpose";.env.uat.example:32), and the runbook instructs re-adding it after everygit reset --harddeploy. A production.envwill 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.devLoginsurvives 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 becomesnpm run dev:signinplus a Vite dev-proxy cookie rule, which cannot ship —vite.config.tsis executed byvite devand 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 throughtest/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 alocalhostredirect 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--createguard 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 containinglocalhost(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'snewPersonbranch, reachable from five seams and three screens throughPersonPicker's "Add as a new person". Measured 7 Aug: that path is a trap, not a convenience.resolveOrCreatePersonupserts onssoIdalone (correctly, per 0038 dec. 2); a pre-created row has none, so its owner's first real sign-in takes the create branch, hitsperson.email @unique, and fails — andsso-handshake.tsdoes 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 withaddPersonToDirectoryandPOST /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.emailKEEPS@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 needsso_ids from Isha and its own ADR. Built 7 Aug, with one trap worth carrying forward: under thePrismaPgdriver adapter aP2002does not populatemeta.target— the shape every example shows — the columns sit atmeta.driverAdapterError.cause.constraint.fields. Matching onlymeta.targetcompiles, 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_adminisroot@msr.local, seeded with nosso_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 onssoId), no route can mint a session for it, and everyone who signs in through Isha lands with no participation. So nobody holdsusers.manageand nobody can grant anybody anything — and there is no first-user-becomes-admin mechanism anywhere inapps/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 runsnpm 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. Removingroot@msr.localalso 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); givingroot@msr.localansso_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, butactivity_log.actor_person_idis NOT NULL with an FK and the ADR also claimed "no schema change". Settled — the entry names the granted person and the action keyplatform_admin.bootstrappedcarries 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 nogroupand 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 agroupso 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-maskedcolor-mix(in oklch, var(--primary) 15%, var(--background))layer incolorblend — 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-canvasforce-light scope is deleted and aThemeTogglesits in the header — dark darkens the stone so the white tilaka and crescent become the brightest points. New--font-displaytoken (Fraunces,standard/standard-italicimports soopszstays variable at 144);--font-headinguntouched. The page never scrolls —h-svh+ every vertical measuresvh-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 anAlertabove the button in the server's words, with no advice added: only a message reaches the client, never a code, so the screen cannot tellSsoEmailAlreadyHeldError(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
Cardarriving directly after the most considered screen in the app. Nothing broken; inconsistent — which on a first impression is its own defect. Now: theCardgoes, 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-displayreaches "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-headingstill untouched. Grain extracted to a sharedgrain-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-displaykeeps 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, andbackdrop-blurover 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 dropsbrightness()for a gamma curve (SVGfeComponentTransfer,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-usedeletes 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: crescentr−b=+1vs sky−20at the same luminance, which is also whygrayscale-100flattens 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
--primaryfor 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:--backgroundin light (0.9),--foregroundin dark (0.985). Using--foregroundfor both produced a black pill in light, where that token isoklch(0.24 …).--font-displayand 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 readsAPP_NAME, never a literal —app-config.tsis 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 readingDate.now()once per render (reported from a screenshot: the clock had rolled over and the footer had not) — ⚠️ the tick is not local midnight,ceildrops 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;visibilitychangecovers tabs whose timers were deferred while asleep. A dev-only/dev/access-wallpreview route is added, gated onimport.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 outsideRequireAuth, 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 inoklchrotates hue the SHORT way, and a chroma-0 token has hue 0 —--foregroundin dark isoklch(0.985 0 0), so mixing toward primary's 277° travelled 0°→341°→277°, through magenta, and produced a pink button; these mixes are inoklab. (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 insideAppShellwith 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 nomainpadding), and the grain —absolute inset-0, so bounded by a boxmaininsets withpx-6/max-w— drew a visible card rectangle, re-creating the exact thing ADR 0054 exists to remove. Grain and glow are nowfixed(viewport-anchored, no edge) while the figure staysabsolutesotop-0still means the content box; the figure is capped at50svhbecause 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 ofmin(130svh,110vw)binds FLIPS WITH THE VIEWPORT'S ASPECT: on desktop130svhwins, but on a 375×812 phone110vwis 412px against130svh's 1055, so the figure collapsed into the top third while the door, positioned insvh, 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, sobackdrop-blurhad 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 — figuremin(130svh,260vw)and stacktop-[70svh], both restored atsm:— 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 wasmin-w-88, a hard 352px MINIMUM, andmin-widthbeats bothwidthandmax-widthin CSS while the page isoverflow-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. Noww-full max-w-88— 352px at 1100 (unchanged), 272px at 320, inside the padding, neither axis scrolling. ⚠️ The wrapper needsw-fulltoo 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=100is 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 insrc/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 rewriteslink[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/loginand 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 andmin(130svh,260vw)can render near that, so no headroom — the win had to come from the codec); a build-time conversion plugin; atransformIndexHtmlplugin 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.htmldoes 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 untilimg.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.mdreaches a session, and the rule itself (read it first) is unchanged.CLAUDE.mdhas carried "before anything else, readHANDOFF.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.mdis 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. ASessionStarthook (tools/hooks/handoff-inject.mjs, onstartup|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 statedfeat/signin-visual-redesign, tree clean, against a livechore/repo-cleanupwith 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 isHANDOFF.mdonly —WORKFLOW.md,DESIGN.md,CONTEXT.mdandknowledge/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.mdis ignorable partly because everything in it is marked important. Follows the patterndoc-sync-reminder.mjsstates outright: the machine guarantees the trigger, not the judgement — the hook guarantees the handoff is present, whether it is true still depends on/handoffbeing 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 %sunquoted makes git read%sas a pathspec and throw — and because the git block was onetry, 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 fixedslice(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 insettings.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 ciclean), butSessionStarthad 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; leavesdeploy-uat.ymluntouched. Prompted by a GitHub Actions billing report naming two causes — thatci.ymlruns twice per commit becausepushandpull_requestare 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; anddeploy-uat.yml's last 30 runs were all onmsr-devand allskipped(itspaths-ignore: docs/**working), consuming zero billable minutes — which is why it does not appear in the usage metrics at all. Narrowingpushis 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-loginalone 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-dev26,feat/sso-login17,requirements14,feat/signin-visual-redesign11) at ~7 billable minutes each. So a newscopejob decides two things and the expensive jobs need both: did anything outsidedocs/,knowledge/,.claude/or*.mdmove (code), and is this a ref worth paying full price for — a PR,msr-devormain(deep).checksis 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 nobeforecommit, 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 reachingmsr-devor a PR runs everything before the work integrates. ⚠️ THE SECRET SCAN IS NOT SCOPED, and that is the load-bearing line: gating the wholesecurityjob 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.envexample 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 thenpm ciit needs. The concurrency key now states the push/PR split instead of arriving at it by accident —github.head_refis set only onpull_request, so the old key already produced two groups (CI-refs/heads/xvsCI-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-tests3,security3 = 7/run now; a deep run becomes 8 and a cheap run 3–4 (the gitleaks-onlysecurityjob is unmeasured — that range is the whole uncertainty), so ~504 min becomes ~296–352. ⚠️ A deep run now costs one minute MORE than before, becausescopeis a whole extra billed job making a twenty-second decision and repeating a checkoutchecksalready does — the saving is bought on cheap runs only. Folding the classification intochecksis the obvious follow-up (a billed minute off every run, and a failingcheckswould stopintegration-testsstarting at all); left out deliberately so the gating lands as one reviewable change. Docs-only commits are 20 of the last 60 onmsr-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 thepushtrigger tomsr-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.ymlfires on push tomsr-devand ADR 0035 leaves "deploy waits for CI-green" an open follow-up. If that follow-up ever lands, re-open this trade; gating the wholesecurityjob; a whole-workflowpaths-ignoreonci.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 agit diffand onegrep); addingtools/to the allowlist. ⚠️ Verified by replaying real commits, not by reasoning about the regex —e35ff39andb7ee16a→ docs-only;270a636→ code, ontools/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 thatchecksand 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 — andscopespent a full billed minute making a twenty-second decision, while repeating the full-history checkoutchecksperforms 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 ofchecks— beforesetup-node, since two jobs wait on its output — withcodeanddeepexposed as job outputs; the logic is unchanged. ⚠️securitydeclaresneeds: checkswithif: !cancelled(), and the second half is not decoration: a bareneeds: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 readchecks's output — empty on a failed run, so they skip, which is correct.integration-testskeeps a plainneeds: checksand 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 forchecksinstead 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: keepingscopewith a shallower checkout (the job itself was the cost, not its checkout); putting the classifier insecurityinstead (a gitleaks failure would then block the test suite for no reason);if: always()onsecurity(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, anmsr-devpush shows all three, and a run wherechecksFAILS still showssecurityhaving 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. ⚠️--primarywas 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--primarywash, 8% on the sign-in hero and 6% on the access wall, where every other surface paints a bare--backgroundand 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_operatorholdsdirectory.read+identity.merge+activity.readand 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 onlyactivity.readwould 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 returnsviaBreakGlass: 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 tocomponents/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
unlockedBysits 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_operatorcan use one screen,auditornone. 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.participationholds arole_keyit never interprets, and each module carries its ownprivilege/role/role_privilegeset (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 —AppContextResponseis NOT extended, per ADR 0008. ⚠️ The clinching fact is in the schema:admin_console.rolealready 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'sviewslist 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 viaunlockedBy(the cheap answer, seriously considered); extendingAppContextResponse(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) andaccount.disable(disabling is enforced withusers.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 onconsole.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 todirectory.read; changing anything still needs the write permission it always did and the escalation guard is untouched. ⚠️ Theauditortier gainsdirectory.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.breakglassstays 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 — anauditorgrant 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 anaccess.readbeside it (over-modelling — nothing wants one read without the other); keepingsettings.managereserved (a reserved permission is the phantom entry); givingauditorevery read including merge (merge is a write); dropping theauditortier altogether (would hand auditorsidentity.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.mdwas 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"; andstage-advisor.mjsexists 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 firstapps/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--skipacknowledgement 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: adocs/file is ignored; anapps/write with a matching branch allows and reminds; a second write in the same session is silent; malformed stdin and a missingfile_pathboth 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.mjswas 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 withUserPromptSubmitfiring every turn, where a wall of text is the very failurestage-advisorwarns 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, nolistDirectory, noGET /api/admin/people. ThePOST /api/admin/peoplethat once existed was a create path, deleted by ADR 0050 because an admin-created Person carries nosso_idand 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.mdcalls it the People directory and the capability isdirectory.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, behinddirectory.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 andperson-access.tsalready 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_coordinatorthat 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.grantlives only onplatform_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 holdingconsole.grantcan 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 ownremovableWith— 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 entrygrant.revokedattributed 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.mdor 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 1role="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 ondraft.length, notrows.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 oneErrorchannel — 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 comparingerr.messageto 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 }—kindis what code branches on,messageis 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 aquietflag. 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—forbiddenandmissingwere failure kinds wearing a different coat, andidlewas 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 forkind: '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;unauthenticatedrenders 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; andunauthenticatedstays in the union although nothing renders for it, because collapsing it intofailedwould 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. ⚠️sonneris treated as suspect until it compiles — shadcn'ssidebardid not work as delivered here — and itsnext-themesimport is stripped, since this project's.darkclass already flips the tokens. All six steps built and green (14 Aug) — CI green includingintegration-testson PR #19, the first CI run of the API suite on this branch (583 tests / 66 files) — the typed failure: both clients hold zero rawthrow 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
--sidebarfamily 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-hoveris added because--mutedcannot 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:--primaryis 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.tsxcould 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 wasbrightness()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-hoversplit, 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,--primary277.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--primarynow 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 wereshadow-noneover 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 (at102% 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:--bordernow 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. Twot3a.smallinstances 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 sayslocalhost: the connection now crosses a network, which turns TLS, apg_hbarule 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_uqneedsNULLS 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 atmigrate 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 itALL ON DATABASE, which includesCREATE, so a compromised app process could have rewritten its own schema beneath the layer ADR 0009 guards. NowCONNECTonly, 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 runsnpm cievery deploy, and the11.10.0.0/16addressing — 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
Departmentcould be created by nobody through the UI, and 56 of 59 rows carryevent_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.readthe read —users.managemeans 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 clientashramId: the operating context is already on screen in the header pill, so a form field would be a second answer that can disagree.eventOnlyForis 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 nullableretired_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: keepingusers.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 andlint:boundarieswould be right to fail. ModuleManifest gains an optionalenrolmentdeclaration; the console loops overMODULESand 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 aviewsentry 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 holdingareas.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 readingadmin_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 provesdepartments.manageand 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 demandsareas.managefrom 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:boundariesscansapps/web/srcONLY, 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 realmodule_adminrole 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.manageleaves 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 fromauditorby 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_admin3 holders (Monish, Morya, Sowmya),back_office_operator0,auditor0. 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 holdsplatform_adminthere.) 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_operatoris now IDENTICAL toauditor, both holdingdirectory.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 referenceauditor(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 needsmodules.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 anotherplatform_adminor 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 momentidentity.mergereturns there, anyone holding that tier for read-only oversight silently gains a write — exactly what ADR 0066 named when it refused to collapseauditorinto 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_adminwhen 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.tscarried the line “this filter hides NOTHING today”, and Roles being gated onconsole.grantends 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 therole_privilegetables that actually enforce it. Dev still grantsidentity.mergeto 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 reportedidentity.mergeas 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 onconsole.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 gotback_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 exactlyactivity.read+directory.read, back-office holds both plusdepartments.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.tsproves the console’sdepartments.manageis 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.tssays 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 indocs/(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
deleteManyis for coordinator scopes — so removing a privilege fromROLESran, 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_keyhas 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.roleandrequirements.requirements_rolelive in different schemas per ADR 0020, and a shared helper would be the cross-module reach ADR 0008 forbids); holder counts are scoped bymodule_keyfor 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:Roleis 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 withDEVtrue 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 inapps/webcatches a render-time throw: measured zero occurrences oferrorElement,ErrorBoundary,componentDidCatchoruseRouteErrorin 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 readingrow.grantswhile 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 sharedFailedcomponent 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 tokind: '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 insideAppShell(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 unauthenticatedPOSTneeds 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, notFailed:ApiFailureis defined as what a failed request is, so reusing it means manufacturing a false kind and hollowing out the rule that component enforces — andFailedrenders 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 iswindow.location.reload(): a same-pathnavigatecan 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.
SheetFooteralready carriesmt-autoso 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 sharedPanelSection/PanelRowcomponents 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 withflex-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:RemoveGrantrenders<span className='ml-auto'>managed in {module}</span>— thatml-autoputs 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-betweenrather thanflex-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:HOLDSis uppercase, letter-spaced, with aborder-primarybar — 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, anddepartment-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-reverselays 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-detailalready 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 ciclean. HALF-SEEN: a platform admin watched both controls appear; the back-office half, which is the reason the unit exists, is unlooked-at, andremoveDepartmenthas 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:seedruns 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 isON 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 writtenarea_registrationsince 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) anddepartments.remove(delete a department nobody has used) to both tiers,departments.archiveto 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 deliberateback_office_operator/auditorduplication 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 grantauditor, 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 readingcoordinator_scopeandrequirement_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 RESTRICTraises SQLSTATE23001, not23503, so the first refusal came back as a 500 instead of a 409; and the error arrives as aDriverAdapterErrorinside a transaction where it is aP2003outside one. ⚠ The first refusal test passed while proving nothing — it pinned the coordinator’s participation to the same department it scoped, soparticipation_..._fkeyrefused 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 returnedundefinedand 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.
ListFacetSelecttook alabeland put it onaria-labelonly — 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-labelsatisfies the test, the linter and a screen reader while the screen says nothing — andgetByLabelTextpassed before and after the fix, so the suite was green on a screen nobody could use. The test that turns red isgetByText: the name is on the SCREEN, not merely on the element. Now named by association (htmlFor/id, notaria-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 (
ListFilterInputhas 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.tsxandbadge.tsxboth 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 saidgit checkout msr-devwith its own warning attached that “mainis the better long-term answer”. Asked mid-deploy and settled: production runsmain, UAT runsmsr-dev, and the box moved onto it the same day. A release now reaches production one way: PR intomsr-dev, then PR intomain, 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 reportingsystemctl active,/api/health200 and/api/auth/login200. All three were the checks the runbook had; all three passed; the release had not landed. Cause:db:deploy && db:seedin 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_scopewas 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.systemctlreports a process;/api/healthreports 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 handedpg_dumpthe Prisma URL, andschema=/uselibpqcompat=are Prisma’s parameters which libpq rejects outright. It failed twice on this deploy. The rule is now written down:$MIGfornpm run db:*,$PGURLforpg_dumpandpsql. ⚠ 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_managerandcycles.fulfilleave 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 therows.read.allthe 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 tomainin 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 holdsplatform_admin, which carriesmodules.manage, so the module sat in their Give-access menu and any of them could grantcycle_managerto themselves (onboarding.tsonly stops you removing yourself, not granting). So:cycleManagementModuleis 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 oncycles.fulfil(ADR 0009 owns the refusal). The schema, the migrations, the API and every grant are untouched; restoring it is adding the import back. 🔴mainonly —msr-devdeliberately 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-devhas no competing change, so the removal survives) but the routine catch-up in the other direction: mergemainINTOmsr-devand 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
quantityisrequired, 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 droppedNumberbecause it andNoswere 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 becausematerials.unitbecomes 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 whiteandgada cloth offwhitestay 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 givesA4back 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.totalColumnsderives from the registry, so the screen has always been right; only the sentences were not. - 0088A capability nobody enforces leaves the tiers —
identity.mergecomes 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:seedruns 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.manage7 ·directory.read5 ·modules.manage4 ·console.grant4 ·users.manage2 ·activity.read1 ·breakglass1 ·identity.merge0. 🔴 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”, dropssettings.manageandaccount.disableon exactly that basis (“phantom permissions are worse than missing ones”), then prints a table two sections later grantingidentity.mergeto two tiers. Same defect, same document, and the exemption was never argued because nobody noticed it was one —CONTEXT.mdinherited 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 whyback_office_operatorhad never been driven — “its set differs fromauditoronly byidentity.merge, which has no surface” — a sentence that made the tier look untestable and was already false, since it also holdsdepartments.managewith 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.manageandaccount.disablewere 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.breakglassstays, 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 arole_privilegeleaves 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_operatornow differs fromauditorby exactlydepartments.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
listOperatorsnever 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 whiletscfailed, 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 matchplatform_adminbut 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 returnsPersonMatch, 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. ⚠️grantsByPersonis 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 theDirectoryRowexclusion 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’sphone_numberclaim, 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 —DirectoryRowis documented as deliberately thinner thanPersonAccessbecause “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 overfoundation.participationreturns 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 readingRequirements · Module Adminfor 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 andSign-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,08825575293and8825575293are 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 ONEwhereobject: 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 hadautocomplete="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 totextand reads like a username field — so both the shared filter input and the picker are nowtype="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 fromtextboxtosearchbox, 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 insrc/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’stotaland states its truncation, and the console’s directory search is re-gated ondirectory.read—DIRECTORY_SEARCH_CAPABILITIESis deleted, and anauditorcan 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 shapeDirectoryPagealready 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_LIMITandTRAIL_DEFAULT_LIMITare both 50 (max 200), and the trail response is{ entries }with nototal, 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_CAPABILITIESis['users.manage', 'console.grant', 'modules.manage']— three writes — while the directory LIST on the same screen sits behinddirectory.read. So anauditorbrowses 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 tolistOperatorsand 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 ondirectory.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 — notmoduleKey, not the action code, because nobody typesperson.signin_disabledand 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: anauditorcan type in the People box and get results — no existing test exercises a role holdingdirectory.readwithout 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 innetlify.tomland 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.orghanded back issueruat-login.sadhguru.organd clientmsr-apps-alxdag3z0bcppp. 200s all the way, nothing looking broken; the exact failureab52accexisted to prevent. It had already bitten us twice and been misread both times — a Person row created by testing landed in UAT (which is whygrant:platform-adminrefused 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 frommain, and the.invalidplaceholder 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 fromAPI_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.tomlis evaluated first, so leaving the SPA fallback there would have made/*swallow every API call and answer it withindex.html. ⚠️ The transferable lesson: a verification note in a file is not a gate.ab52accwrote the exactcurlto 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 returnsmsr-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 —
SsoHandshakeErrorkeeps the detail on.causeand 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’skind(refused|failed) and the screen derives what it offers;failedgets no new control because the sign-in button is the retry. Measured, with approval: Isha acceptsprompt=login,select_account,noneandprompt=totally_bogus_valueidentically — and does not validateclient_ideither — so the parameter is safe to send and almost certainly not read. Hence nopromptis 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 forrefused— never automatic, which would infinitely bounce ifpromptis 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 newsso_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 noid_tokenthe 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 ish-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 atmin(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)\bmade a guard unfalsifiable:/\brefused\b/ican never match a real leak, becausetextContentconcatenates 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: anid_tokentypedstring | undefinedwhere the source returnsstring | nullcompiled 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 atopacity: 0with 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 anid_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 (anid_tokenlives 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: nopromptwas 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. Nowmin(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.53svhis only a guard past 2.41:1. ⚠️ Four attempts were rejected on sight and are recorded so they are not re-derived —57svhput 8% of the ink through the translucent stone (exactly the0and inner2, 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; and53svhas the primary anchor held the year against the STATUE, which on a differently-shaped screen ran the outer2and7off 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),F12shrinks 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. 🔴
authorizeModuleAdminfiltered 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;removePeerAdminmerely 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 (
listMyDepartmentsresolves fromcoordinator_scopeand 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 insiderequirements/. 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.departmentstays 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 andeventOnlyForis 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.
participationanswers 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.activityanswers 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: readactivity+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.savedsix 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 fromactivityalone, because a department that did nothing has no rows there — it needsdepartmentLEFT JOINactivity. Both Foundation tables, so nothing is crossed, but a query written overactivityalone 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;targetRefis JSON so richer detail is queryable but unindexed (the first thing that would get slow); ⚠️recordActivitybecomes 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 fromparticipationalone, 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.mdglossary 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, andeventOnlyForstill 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_keyis opaque text with NO foreign key (ADR 0016/0017) because the Foundation deliberately does not interpret it — sorole_privilegecascades on update but participation does not, and omitting that oneUPDATEwould 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 publishesDepartments. ⚠️legacy/keeps the OLD SYSTEM's words, and that is not an exception:materials_requirements_curr.area_idis 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 fordepartment_idand find nothing. Quoting a foreign system accurately is not inconsistency —legacy/is where the translation happens, and the compiler confirmed that boundary unprompted when renamingareaNamebrokeextract-demo-dump.tsagainst 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 thearea_cluster/area_sqftregistry 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:listAreascould not becomelistDepartments— the console already ownsfetchDepartmentsand 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 islistEnrolledDepartments, andAreaSummarybecomesEnrolledDepartment. 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 rundb:deployANDdb:seed, since a database migrated without the code (or the reverse) authorizes nothing. Rejected: relabel-only, keepingareain 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); renaminglegacy/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/legacywith 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)becamenot.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 whyDESIGN.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
AlertDialogprimitive installed at all —components/ui/shipsdialog,sheetandalert, 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 throughcommit()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 insrc/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.
Each Module keeps its own ADR series, independent of the Foundation numbering above. First module: Requirements — its full design is in docs/modules/requirements/DESIGN.md, the what/why in that folder's PRD.md.
- Requirements0001Area Coordinator entry screen: resizable side-by-side year comparison (previous year read-only, left; current year editable, right) over a draggable divider — not a fixed 50/50 split or a full-table expand toggle; current-year grid on an adopted Excel-like library, not hand-coded; copy-from-last-year is the default on-ramp; file upload/download round-trip deferredAccepted
- Requirements0002Categories & their fields are a config-driven registry with stable keys — remove = retire (status flag), rename = relabel, add = append; never hard-delete anything with history behind it. Config-as-code (dev-owned, not a runtime form-builder); JSONB values so field churn needs no migration. Settles "one category vs all 8": build the engine once, the rest are configAccepted
- Requirements0003The Area Coordinator is the sole editor of area data; the Module Admin cannot edit it (oversight/setup/freeze only) — a deliberate deviation from legacy §10, to protect attribution. Emergency fixes are logged break-glass, never a routine admin edit surface; MVP builds no admin-edit path. Coordinator scoping reuses existing Foundation primitives (ADR 0015/0017/0009)Accepted
- Requirements0004Copy-forward copies the values, not the dates — the coordinator sets dates for the current year, aided by last year shown alongside. No auto date-shift (a blanket shift injects plausible-but-wrong dates; not every date tracks the festival) and therefore no Foundation
event_datechange. A targeted per-category shift stays a future option only if real usage demands it. The drag-to-fill mechanism is superseded by ADR 0010 — a Fill-down button now reaches the same outcomeAccepted - Requirements0005Last year's reference data is loaded once from the legacy app's prev/current tables (2024 + 2025) into the new DB and kept native — stamped by edition, in the config-driven shape — not a live legacy integration. Read-only comes free from the closed edition (no archive table). Load 2025 for the MVP, 2024 if cheapAccepted
- Requirements0006Entry grid library is RevoGrid, not react-datasheet-grid or Glide Data Grid — settled by a live browser spike on React 19 (the app's actual version), not docs alone. The other two each have a live-reproduced React 19 problem (a crash, or an install-time failure); RevoGrid has neither, is the best-maintained by a wide margin, and (at the time) was the only candidate with live-wired touch support for the drag-to-fill gesture — that last reason is moot as of ADR 0010 (drag-to-fill was replaced by a button); the library choice itself stands on the other reasonsAccepted
- Requirements0007Which areas take part in a given year is recorded, not inferred — a small per-year list the admin edits. The tempting shortcut was to read it off the "exists only for MSR" marker, but that marker describes what a department is, not whether it takes part: Traffic is a year-round department and an MSR area. The other shortcut — treating "has a coordinator" as "takes part" — would make it impossible to register the areas before naming who runs them, which is the actual order the work happens inAccepted
- Requirements0008A past year is shown with today's columns, not its own historical set — blank where a field didn't exist yet; a retired field's old values stay in the data (and in analytics) but aren't shown in the entry grid. The grid is reference for entering this year, not an archive viewer: seeing last year in this year's shape is the point, and it keeps the two side-by-side panels reading straight across. True per-year fidelity would need a stored yearly snapshot the module deliberately avoids — it belongs to the later oversight surface if ever neededAccepted
- Requirements0009The entry screen's controls are one sticky row of menus, and the year is stated rather than offered — reverses the layout half of the 2026-07-29 roadmap position ("the switchers stayed visible") while keeping its requirement: the screen still says where you are, and now keeps saying it while you scroll. Five stacked blocks (title, "Your areas" pills, "Viewing" pills, a wrapping row of eight category buttons, a sticky breadcrumb) pushed the grid most of the way down a 720px viewport, and the category row already wrapped at eight with more coming by PR (ADR 0002). Now: area + category are menus whose trigger shows the current value (plain text when a coordinator holds one area — a menu with one option is a click that cannot change anything), the page title goes because the Workspace tile is itself named "My Requirements" (root ADR 0031), and the breadcrumb goes because the row it duplicated is now sticky itself. The in-module year switcher is retired: last year is not hidden by it — it is the permanent read-only left panel (ADR 0001 §5) — and entering a past edition is a grant-scoped Foundation concern, not one module's toolbar. The row must sit outside shadcn's
Card, which isoverflow-hiddenand so silently makes any sticky child inert. The accepted cost: you can no longer see the other areas/categories without opening a menu — revisit this first if a coordinator reports losing their place. A left rail was the honest alternative and was rejected for spending ~200px of width, the axis the 10–12-column categories need most (ADR 0001 §4). Frontend-onlyAccepted - Requirements0010The grid gets real cell editors for date/select/multiselect, and a Fill-down button replaces drag-to-fill. R8 declared column types RevoGrid was never actually configured to honour — confirmed live on the running grid (
columnTypes/editorsboth empty) — so every cell, whatever its declared type, has always fallen back to plain text. Multiselect could never be saved through the UI at all (proven against the live API: a string is rejected, a real array is accepted) — the roadmap's R10 claim that this was "verified in the running app" did not hold for coordinator entry, only for legacy-loaded data; corrected in place. Dates had zero format validation anywhere — proven by saving a nonsense string, accepted clean. Fix: native<input type="date">(a real calendar picker, always ISO format, no new dependency), the shadcnSelect, a checkbox list for multiselect's three options, and the same validation shape phone numbers already have, extended to email and dates. Decisions 1, 2 and 4 (editors, validation, theapplyOnClosegap) stand as built. Decision 3 (the Fill-down button) is superseded by ADR 0012 — see belowAccepted · built - Requirements0011Undo/redo in the entry grid is one shared, labeled, linear history stack, settled by
grillingafter Ctrl+Z was found to do nothing — there was no undo implementation at all, not a bug in one. Covers every mutating action uniformly (cell edits, paste, delete rows, copy-forward, add-row — fill-down folded into paste at ADR 0012), one undo step per action (a 40-row paste undoes in one step), unbounded depth for the session, and clears on save or on switching area/category/year — so undo can never put the screen and the database quietly out of step. Triggered by bothCtrl+Z/Ctrl+Yand toolbar buttons (disabled empty or mid-save); redo is standard linear (a new edit after undo drops the redo branch); every undo/redo names what it reverted via the existing save-style notice. Implemented as a new pure module,grid-history.ts, sibling togrid-model.ts, tested with no RevoGrid involved. A follow-up bug found live the same session — the Save button's "unsaved" label stuck after an undo restored the exact last-saved state, becausedirtywas a manually-toggled flag — is fixed by deriving it from a real comparison (sameRows) insteadAccepted · built - Requirements0012The Fill-down button is replaced by RevoGrid's native copy-paste fill — supersedes ADR 0010 decision 3 only (its real-cell-editor and validation decisions stand). Monish asked whether copy-paste would be more natural than a button. Reading RevoGrid's compiled source (not just its docs) found
getRangeFillClipboardData: copy one cell, select a range, paste, every cell fills with the copied value — gated behind the OBJECT formuseClipboard={{ rangeFill: true }}, not the baretruedefault the grid was quietly running on. Confirmed live in the running preview before deciding, and again after removal. One prop added;fillDown(), the Fill-down button, and its handler/state all removed — less of the module's own code, and the ordinary Excel gesture coordinators already know instead of one to discover. Mobile is not worse here (unlike drag-to-fill's real touch problem): OS copy/paste fires the same native clipboard eventsAccepted · built - Requirements0013Rows are marked for delete with Ctrl/Cmd+click, and grid selection state is never written into RevoGrid's
source. Closes a roadmap entry corrected three times in one day as each mechanism failed live — range-only → plain-click-selects → drag → Shift+click → Ctrl/Cmd+click (drag never fired a range event through a real pointer: ~150 focus events, zero range; Shift+click's range event isn't reachable from the public event surface). The last round shipped two live-reported defects with different causes that looked like one. (A) Typed values vanished on click-away — real data loss, suite green throughout. The highlight rode on the row data (rowClassnames a property on each row), sosourcebecame a new array on every click →dataSourceChanged→setData→ the open editor re-rendered, and RevoGrid's defaultTextEditoris controlled, so it re-applied the stored value to the live<input>;applyOnClosethen read that reset input and saved the OLD value back. Every step is ordinary library behaviour — the defect was entirely in drivingsourcefrom view state. (B) "I don't know if I have selected it or not." Not the selection code:--revo-grid-focused-bgwas themedprimary 18%and RevoGrid spends it on the whole row under the cursor — the same colour the selection used, so the two were pixel-identical. Now focus and selection differ in kind, not strength: neutral wash vs brand wash + a 3px left bar. Highlight moved to a generated stylesheet keyed on thedata-rgRowindex RevoGrid already stamps (survives row virtualisation for free; every value a token, no colour in the component). A plain click now only clears — a destructive action needs a deliberate gesture, and undo (ADR 0011) still covers a mistake. Tests pinsourceas the same array across selection gestures, not merely deep-equalAccepted · built - Requirements0014Electrical is a 9th category, own tab, own real grid columns (like Signages' 18) — and the same ~20-field content is separately spread (
.map()-derived, not hand-duplicated) intoINFRASTRUCTURE.fieldstoo, tagged with a new field-levelentryMode: 'panel'display mode there. Closes the open question the prior session's handoff carried: not an either/or —electricalsits sibling toinfrastructureinCATEGORY_REGISTRY(zero migration per ADR 0002), and a shed row can independently capture the same detail when itsservices_neededincludes it — two doors, two separate rows, nothing linking them at the data level. Revised live, same session: the first build put BOTH homes behind the panel, reasoning "~20 fields is too many columns" independent of category — reconsidered once tried: that cost (a row growing dozens of blank columns) only exists on Infrastructure's shed rows, not on Electrical's own, where every row genuinely is about this detail; Signages' 18-column precedent settled it back to a plain table there.panel-mode fields (Infrastructure's copy, plus new IT/Telecom 2-field sets) render behind one appended summary cell instead of real columns, grouped by section in a reusable side panel, opened by a plain click/keydown rather than a RevoGrid editor (the read-only previous-year pane never enters RevoGrid's edit mode at all, so an editor-based trigger could only ever work on one side). Same panel component serves both panes via areadonlyprop, mirroringtoGridColumns's existing convention, with a category-agnostic "Service details" title. The Sheet UI primitive is hand-rolled fromdialog.tsx's own structure rather than installed via the shadcn CLI, which wanted to overwritebutton.tsxon a version mismatch. Verified live: re-seededmsr_dev, Electrical's own tab renders as a plain grid and saves a real row; Infrastructure's "Service details" doorway does too; theservices_neededmultiselect's own pick step could not be driven through this session's automation — the same class of pre-existing RevoGrid-automation gap ADR 0006's drag-fill follow-up already documented, not a defect hereAccepted · built · 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_neededand the ~24ELECTRICAL_PANEL_FIELDS/IT_FIELDS/TELECOM_FIELDSspread 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 arecordActivityentry with the actor'spersonId, 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_neededis an open, ~55-option multiselect seeded from a new MSR 2027 intake document's examples,Otherpaired with a free-text companion field (theshed_type_otherpattern); 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;ServiceDetailSheetstays in the codebase unwired for future reuse, and the standalone Electrical category is untouched. The multiselect cell editor is rebuilt on the shadcnCommandcomponent 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 ingrid-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'svalidateRowhas always had anumberbranch, 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 inonAfteredit, the one place a typed cell and a pasted range both land, on the samenumericCellValueboth call. (2) Decimals are legitimate —quantityismin: 1, which admits2.5, and it sits beside aunitfield 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 ismin: 0ormin: 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 of120is 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 aquantityturned from150into'150'. Accepted:'1,200'is refused rather than repaired to1200— 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 admitse/+/-, 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:5accepted,arefused with value and cursor intact,5.2committedAccepted · 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,
listMyYearsstill 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 2027appeared three times — the app-shell chip, this label, and the grid panel headerMSR 2027 · this year(withMSR 2026 · read-onlyover 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.viewedYeargoes 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 fromcoordinator_scopewhile 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 andprisma generateblocked 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 (
CardTitlerenders adiv), area selection was colour-only (noaria-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.tsxwas 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 fromcoordinator_scopedirectly — not by inverting the areas list, which starts fromarea_registrationand 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 whencluster_coordinatorandcycle_managership — 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 behindmodule_admin, and the box has no PostgreSQL and cannot runprisma 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_registrationand 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: aDepartmentis 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-3at 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.rgCellbackgrounds so the focus wash still reads through. Built as a generated rule per row index, never a field on the row:rowClassneeds 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 purechangedRowIndexes(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.displayNameandPersonMatchalready 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
sourceIN 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 loggedbeforeQty: 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 inStrictModepassed). 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.setCellDatacarries an internalmutateflag with no public prop to disable it), andbeforeedit/beforecellsavedo 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; onlydraftis ever handed to the grid, always as a clone, so a stored snapshot cannot be rewritten in place and redo stays honest; andsetHistorymoves outside thesetDraftupdater, 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 assourcewould also have worked, but it puts a freshsourcearray 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 thatsourceis 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:
selectedRowsdrove 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 realrangeinto 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 aneeded_bysince 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'svalidateRowdoes 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 firebeforecellfocushid a double-handled Ctrl+click;userEvent.click(el,{shiftKey})silently drops the modifier), which belongs inapps/web/CLAUDE.mdas 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.skillswas 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 plusDriving, 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), andsignages.locationlooks like a place and is not (its 71 values are placement instructions — "double sided with b3 left b2 straight" — whilezoneandsub_zonealready carry the structured place data). The census also found an unreported defect:materials.unitholds other columns' data —"MSR Store"×21,"31-Jan-2025"×20, bare numbers ×34. Four free-text fields convert — plusvolunteers.days_before_event, asked for separately and the one exception to the reasoning, since it was already anumberand so never unfilterable: 265 rows use exactly eleven lead times, and ⚠️ aselectstores 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 stored28and aGROUP BYwould 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.itemstays 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. NoOtherescape 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. ⚠️Numberwas dropped from the supplied unit list — it andNosare the same thing,Nosis 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/Sackadded, ~95 blanked and every one reported. Vehicle: 185 of 194 confident,407 Mini lorry→newMini 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 haveinheritedValuesmigrated alongsidevalues, or the gold changed-row wash (ADR 0025) fires on every row nobody touched.signages.board_categoryretires (1 row in 601, and that value is two place names).materials.sourcestays 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; anItem categorybeside free-text item; deriving the vehicle list from our own data; keeping bothNosandNumber; defaulting the ~95 wrong-column units toNos; moving that text intoremarksfirst; 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_compensationfor 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 saidYes— not oneNo, 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 aYesis recorded against nothing. The validation bug that surfaced this was a trap, and that is the transferable part. The field carried novalidationat all, so a row saved withNoor with nothing — butrequired: truedoes not fix it, becauserequiredonly asks that a value EXIST and"No"is a value. Andpattern: '^Yes$'is silently ignored on aselect: the validator's select branchcontinues beforepatternruns, 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 —cycleshas 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_dateis deliberately left alone: same shape, 120/128Yes, and it asks agreement to a due date the product never states (last year's column was literally namedreturn_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 newmustEqualrule (~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.mdstill 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 norequiredrule, 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.tsnot to substitute the workbook'sUPDATED 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 Coordinationhad 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 Team12 → zero;Accommodation→ zero. The cause cannot be fixed by a cleverer parser:isAreaBannercannot 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 whyGENTS 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 CLINICis theMedicalarea. ⚠️ 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 areMedical,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.sourceretires in the same breath — the workbook has no source column, so the field is empty by construction (verified:materials_fromnull 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 IVRwas rescued from that list by Monish reading it (11 items, an area sitting on zero), which is precisely what publishing the exclusions is for, leavingLounge(30) as the largest single exclusion;Venue Cleanliness & Toilets117 → 64,Travel & Transport30 → 29,Issuing of passes7 → 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; andEraser→Capis 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 + Guidingmatched Guiding in trials when its items are Signages'). 🔴 Carried gap, captured separately:db:seednever deletes rows for an (edition, area, category) that has vanished from the fixture — proven on this very change, whereIssuing of passescorrectly 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 —
validateRowruns 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, therequirementsEnrolmentprovider and with it the module's acceptance of platform authority, two HTTP routes, and the registerable-departments read. What stays:coordinator_scope, 4,296requirement_rows, andfoundation.departmentuntouched — 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 (eventOnlyForas 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 dropsregistered_at, the only record of WHEN each was registered, leaving the trail's fivedepartment.registeredentries 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 assertingDepartmentNotRegisteredError: 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 thefreeze.toggleprivilege 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. ⚠️ AfterassertMayWrite, 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 carryshows_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 andfreeze_statestays 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 intotarget_refand 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 gainedlistFreezeHistorybehindfreeze.toggle, expanding in the Categories row. ⚠️ It reads the trail, notfreeze_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 genericmoduleKey/actions/targetfilters; 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.assignCoordinatorattributes togrant.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-1as 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 wasmax-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 departmentssurvives, 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.
unitbecomes required on Materials, which it never was —itemandquantitywere 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),KgbesideKilogram. 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, becausesaveRowsthrows 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, matchingitemandquantity— an asterisk on one of three would read as "the other two are optional". Rejected: defaulting toNos, 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
Dashboardsection 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 underuseIsMobile(), which measures viewport width not device; and whether a phone number exists at all is unknown —phonecomes 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 saysFiled(which means submitted) and offers onlyAll/Nothing filed, one direction of a two-state question — it becomesFilledwithAll/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 chasesection 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 —phonecomes only from SSO’s optional claim, dev holds none, and thetel: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.chaseListis 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
srOnlyLabelprop 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.