One chain of command. Everything else follows from it.
A single reporting tree. Everything else is computed from it.
Every unit — from a school Battalion up to State — reports to exactly one headquarters above it. You tell the system who reports to whom once, and it works out the whole chain for you. From that single fact, everything else follows: who can see what, whose numbers add into yours, and which events a cadet can sign up for.
Every unit has exactly one parent HQ. Who you can see, whose numbers roll up to you, which events a cadet may register for, whose property aggregates into your book — all of it is derived from that one tree. Nothing about “who rolls up to whom” is stored twice.
The chain is flexible, not fixed. Usually a Battalion sits under a Regiment, but a Battalion can report straight to a Brigade if that's how it's organized. Set a Battalion's parent and the system already knows its Regiment, Brigade, Division, and State — you never re-enter them.
The tree is configured, not hardcoded. A parent may be any higher echelon, so a Battalion under a Brigade is legal. Setting a BN’s parent implies its whole ancestor chain automatically — the REG/BDE/DIV/State are never re-entered.
Four promises the system keeps
The four hard invariants
Your roster is the truth
Roster is the write-source
Your people list is the one place names and strength come from. Reports read it — you never re-type your numbers.
Identity and strength originate in the BN roster. SAR reads it, never types it.
Showing up is what counts
Attendance makes activity
A cadet's activity is created only by attending a training event and being marked present.
An attended Year-of-Training event is the only thing that creates an activity.
Property changes by the book
Property moves only by form
You never edit an equipment total by hand. File one of four forms and the book updates itself — leaving a permanent record.
Four forms post to an immutable ledger. The book is a derived balance, never edited.
Totals always add up
Rollups derive from the tree
Every combined number at Brigade, Division, or State is just the real Battalion numbers added up — and you can trace it back down.
Nothing is aggregated by hand. Every rolled-up value traces to BN-level facts.
What this improves over today
What this supersedes in the live schema
Two things change from how the current tool works. Nothing already working is thrown away.
| TodayLive today | The problemProblem | The fixThis design |
|---|---|---|
| The chain of command is fixed with hard-wired labels | Can't handle a Battalion that reports straight to a Brigade; re-organizing means editing labels in many places | You set up the chain yourself, and moving a unit re-aligns everyone automatically |
units.brigade / battalion / regiment / division — denormalized int ladder |
Hardcoded 4-level ladder; can’t express “BN reports to BDE”; a reparent means editing four columns everywhere | Adjacency tree units.parent_id + closure table for O(1) scope & rollup. Legacy ints kept read-only. |
| Monthly strength is typed in by hand | Numbers can drift from the actual roster and have to be re-entered every month | Strength is counted automatically from your roster — always current, never typed |
sar_submissions grade×gender counts typed by hand |
Violates invariant 01 — strength must derive from the roster | Strength is a query over roster membership; sar_submissions becomes a frozen snapshot at period close. |
Everything else already close to right — cadets,
cadet_record_entries, events / attendance, user_profiles — is kept and extended.
The things the system remembers
The newtools data model
Everything the system needs falls into a few plain buckets. Here's each one in everyday terms.
Additive-first: new tables and columns, legacy columns retained read-only through
migration. All tables follow the repo’s data contracts — uuid keys, tracking columns,
RLS shipped with the table.
The chain of command
Each unit records the one headquarters it reports to. The system remembers the full chain above every unit, so it always knows who rolls up to whom.
People & accounts
Adults and staff sign in with their Microsoft / CACC account. Each cadet gets an official record. A cadet gets a login only when they actually need one — we don't force an account on every minor.
A cadet's history
The system remembers which Battalion a cadet was in and when, so strength history and transfers between units stay accurate over time.
The service record
Each cadet has a running file — appointments, awards, duty positions, orders, event attendance, fitness, marksmanship, and badges.
Events & attendance
Any level can host a training event. A cadet sees the events in their own chain and signs up. Being marked present is the single fact that becomes their activity.
The property book as a logbook
Every gain or loss of equipment is a permanent logbook entry made through a form. The “book” you see is just the running total of those entries — there is no total anyone can quietly edit.
Issued gear is tied to the specific cadet who received it, so you always know who has what.
A · Hierarchy — the single source of truth
units
parent_id → units(id) is the one reporting edge (NULL only for the State root). echelon + ordinal validate that a parent outranks its child, so BN→BDE is allowed. Legacy ints become a read-only projection.
unit_closure
(ancestor_id, descendant_id, depth), trigger-maintained. The physical answer to “who rolls up to whom.” Every access scope and every rollup reads it as one indexed lookup.
B · Identity & roster — BN-level source of truth
| Table | Role |
|---|---|
KEEP user_profiles | Adult/staff identity, 1:1 with Entra auth.users. |
EXT user_unit_links | Adds unit_id → units — the real access-scope anchor at any echelon. |
EXT cadets | The cadet record + strength source. Adds auth_user_id and current_unit_id. |
NEW cadet_unit_memberships | Temporal home-unit history — strength & transfer engine. One open membership per cadet. |
KEEP cadet_record_entries | Append-only service record — appointments, awards, duty, orders, attendance, fitness, rifle, badges. |
C · Events, registration, attendance
cacc_events.host_unit_id names the hosting unit. A BN cadet sees an event iff the host is an ancestor-or-self of their BN. event_attendance snapshots home_unit_id at record time so activity credit is stable across later transfers.
D · Property book — an auditable ledger
property_postings
Immutable, append-only. Every quantity change is one posting with actor, timestamp, justification, qty_delta, resulting balance. No UPDATE/DELETE — for anyone.
property_balances
SUM(qty_delta) per unit × item × serial. The book is this projection. No editable balance table.
The four forms each generate postings — Requisition (+ gain), Report of Survey (− loss, justified), Inventory (reconcile variance, justified), Issue (− to a roster cadet, links property↔cadet). Above BN there are no forms.
You see your unit and everyone below you — nothing else
One predicate: see your unit and everything below it
Access follows the chain of command automatically. A leader sees their own unit and every unit beneath them — never a unit beside them, never above them.
A user assigned at echelon X sees exactly their subtree — nothing sideways, nothing above. The whole visibility model is one closure lookup, so there is no per-table ACL to keep in sync.
-- can the current user act on rows scoped to this unit? cacc_can_see_unit(target_unit) := EXISTS( super_admin ) -- state/super: always OR EXISTS( SELECT 1 FROM user_unit_links l JOIN unit_closure c ON c.ancestor_id = l.unit_id WHERE l.user_id = auth.uid() AND c.descendant_id = target_unit ) -- target is in my subtree
Nothing above you
Above → invisible
A Battalion leader can't see into Brigade or State — only their own house.
The predicate only matches descendants; your ancestors are never ancestor_id for your links.
Nothing sideways
Sideways → invisible
Two Battalions under the same Regiment can't see each other. The Regiment sees both.
A sibling BN isn’t in your subtree. Two BNs under one REG can’t see each other; the REG sees both.
Move a unit, access follows
Reparent → re-scopes
Re-assign a unit to a new headquarters and everyone's access updates instantly — no cleanup.
Moving a unit rebuilds closure in one transaction, instantly flipping who can see it. No policy edits.
Seeing vs. changing
Read is subtree-wide; write encodes the invariants
- Higher headquarters can read a Battalion's roster, but only the Battalion can change it.
- Equipment logbook entries can never be edited or deleted by anyone — a correction is a new entry, so the history is always intact.
- Report numbers can't be hand-edited. Higher HQ can add a note to a reporting period, but never change the figures underneath.
- Roster writes only by a user linked to that BN. Ancestors read rosters, never edit them.
- Property postings have no UPDATE/DELETE policy — append-only for everyone, incl. super admin. Corrections are compensating postings.
- SAR figures have no client write path — written only by the period-close routine. Ancestors write
sar_annotations, never the numbers.
Signing in uses your Microsoft / CACC account. What you're allowed to do is set by which unit you're assigned to and your role.
Identity is Entra (authentication); authorization lives in user_unit_links.unit_id
+ primary_role + shop_admin_sections — the tree changes more often than tenant
group membership and RLS must read it transactionally.
What a cadet sees by default
Baseline (unprivileged) cadet scope
A cadet can sign in any time. With no delegated access or duty assignment that calls for more, they see exactly two things: their own cadet record, and the upcoming events of every echelon hosting them — their Battalion's Training Events page also lists higher-echelon events, for situational awareness. Cadets don't sign themselves up for higher-echelon events: their unit's S3 submits the unit and its roster for attendance to those events.
No user_unit_links scope + no qualifying cadet_duty_assignments ⇒ read-only self: own cadets record, plus events where host_unit_id is an ancestor-or-self of the cadet's BN (via unit_closure). Registration/accountability for ancestor-echelon events is a unit-level S3 action (submit unit + roster), not cadet self-RSVP.
Real numbers live at the Battalion; everything above is addition
BN facts are atomic; ancestors only sum
All the real facts — who's on the roster, who showed up, what equipment is held — live at the Battalion. Every number a Brigade, Division, or State sees is simply those Battalion facts added up along the chain of command.
Every atomic fact has exactly one home BN — cadets.current_unit_id,
event_attendance.home_unit_id, property_postings.unit_id. Summing disjoint BN
partitions up the tree needs no dedup, and any parent number drills back to the BN rows.
Because each cadet, each attendance, and each piece of equipment belongs to exactly one Battalion, adding them up never double-counts — and any total can be traced straight back to the Battalions it came from.
How fresh are the numbers?
Consistency model — three tiers
Your own Battalion numbers are always live and exact. The big combined dashboards at Division and State refresh regularly through the day, and are made exact the moment a month is closed out.
| Object | Mechanism | Freshness |
|---|---|---|
unit_closure | table + trigger | Strongly consistent — transactional with tree edits |
| BN strength / property balance | plain views, on-demand | Strongly consistent — BN screens see live numbers |
| DIV/State dashboards | materialized views (unit × period) | Eventually consistent — refreshed on cron + immediately on period close |
The contract: BN-level numbers are always live; ancestor rollups are at-most-N-hours stale between refreshes and exact right after a period close or manual recompute.
Every level runs the same tools — with two key differences
Parallel modules at every echelon — two structural rules
The roster lives only at Battalion
Roster & Records — BN only
Battalions are the only level with a roster — the one place people and strength are entered.
The one place identity and strength are created. Everything else reads from it.
Above Battalion = combined views
Above BN — rollups, not entry
Higher HQ don't enter data — they see combined strength and property totals. Only the calendar is run at every level.
Strength & Activities and Property Book are read-only aggregates. Only the calendar is native at every level.
| Tool | Battalion | Regiment / Brigade / Division / State |
|---|---|---|
| Setup (Super Admin) | Builds the chain of command first — assigns who each unit reports to, up to State, and assigns people to units.Builds the tree first — assign each unit’s parent up to State; assign users to units. | |
| Roster & Records | The people list → each cadet's file & issued gear; add cadet; transfer; removeList → cadet profile (record + issued property); add cadet; transfer; inactivate | — not available — |
| Strength & Activities | Live strength from the roster + activity from attendance; close out the monthLive strength from roster + activities from attendance; close period to freeze | Combined totals with per-unit breakdown; drill down to any Battalion; add notes — can't edit figuresRollup + per-child subtotals, drill to BN facts, annotate — never edit |
| Training calendar | Host events; sign cadets up → mark present → activityNative events; register → attend → activity | Host events at this level + see events higher in the chainNative events at this echelon + read of ancestor events |
| Property Book | The book (view only) + four forms + full historyBook (read-only) + four forms + full ledger | Combined totals; drill to the owning Battalion — no formsRollup book, drill to owning BN & postings — no forms |
How the tools connect
How the modules interlink
Setup builds the chain ──▸ decides what everyone can see Roster ──feeds──▸ strength Roster entry ──▸ the cadet's file Attendance ──feeds──▸ activity Property forms ──update──▸ the book Issued gear ──▸ tied to a cadet
Super Admin tree ──defines──▸ scope of every module below Roster ──is the source of──▸ SAR strength Roster row ──▸ Cadet profile Calendar attendance ──is the source of──▸ SAR activities Property forms ──post──▸ ledger ──project──▸ book Issue ──links──▸ Cadet
One action ripples through everything
Battalion module data-flow — one write, many reads
This is the heart of the design: the Battalion modules aren't separate tools — they feed each other. A cadet has to be on the roster before anything else can happen; from there, attending events, earning promotions and duty positions, passing certifications, and receiving equipment each land on that cadet's profile — on Form 13 (their career file) or Form 100 (their property). Follow the arrows.
The Battalion modules are a single connected graph, not independent CRUD screens. Each arrow is a real write path: a source module produces a fact that one or more downstream records read. Colors mark the kind of link.
unit_closure) into read-only REG/BDE/DIV/State totals; each rolled-up
value drills back to the originating BN fact.
Reading the map in one breath
The chain in words
- Roster & Records creates the cadet — identity, account, and profile. A cadet must be on the roster before any other module can touch them.
- The profile carries two forms: Form 13 is the cadet's career file (promotions, awards, and all admin/career data); Form 100 is their property record (everything issued to or held by them).
- Attending an event awards a ribbon onto Form 13 and posts an activity to the SAR.
- A duty assignment (S1) adds a leadership position to the cadet's duty history on Form 13 — and works the same way at every echelon.
- A PTA certification or a passed promotion test (S3) records onto Form 13.
- The property book changes only through four S4 forms — Requisition (ship items in), Report of Survey (remove unserviceable), Inventory (reconcile — and it flags when an inventory is due), and Issue Items (assign an item straight to a cadet). Issuing also drops the item onto that cadet's Form 100.
- Strength on the SAR is simply the roster count — nothing is entered twice.
- Delegate Access (S6) decides who on the roster can use which modules — at every echelon. Newsletter and Media Gallery (S5) publish unit news and photos, also at every echelon.
Rosterinsert →cadets+ membership + account; membership gates eligibility for every downstream module.- Form 13 = projection of
cadet_record_entries(appointment / award / badge / duty / order / promotion); Form 100 = projection ofproperty_postingswhereissued_to_cadet_idis set. event_attendance.present→ award entry (Form 13) + SAR activity for the home BN.cadet_duty_assignments(S1, per echelon) → duty entries (Form 13).pta_certifications+promotion_testing(S3) → certification / promotion entries (Form 13).property_postingsare written only by the four S4 forms —requisitions(+),reports_of_survey(−),inventories(variance; surfaces an "inventory due" flag),issues(−, linked tocadet_id→ Form 100). The book is their projection.delegate_access(S6) grantsmodule_accessto roster members;newsletters/media_gallery(S5) are unit-scoped, per echelon — no cadet-record writes.- A module's owning shop resolves from
module_shop_assignments(overrides the hardcodedstaffSectiondefault).
Which shop owns a module is now configurable
Module → shop mapping is data, not code
Today each module is hard-wired to a staff shop (S1–S8). In the new design an administrator can re-assign any module to a different shop without a code change — so if Duty Assignments should sit under a different shop, or PTA move out of S3, that's a setting, not a rebuild.
The hardcoded staffSection on each MODULES entry becomes a default. A new module_shop_assignments table (module_key → staff_section, optionally per echelon) overrides it; access checks and dashboard grouping read the resolved value. Editable from the Super Admin console.
Every module, by shop
Module catalog by staff section
The full planned set, grouped by the shop that owns each (owners are re-mappable). “Every echelon” means the module also exists at Regiment, Brigade, Division, and State — scoped by the chain of command.
Default shop shown; resolved via module_shop_assignments. “Every echelon” = native per-unit at all echelons (scoped by unit_closure); above BN, Property/SAR are read-only rollups.
| Shop | Module | What it does | Scope | Feeds |
|---|---|---|---|---|
| S1 | Roster & Records | create cadets + accounts; the unit's strength | BN only | everything |
| S1 | Duty Assignments | cadet billets / key leader positions over a career | every echelon | Form 13 |
| S1 | Leader Assignments | appoint subordinate commandants & advisors (adult cadre) | Regiment & above | unit leadership + scope |
| S1 | SAR | strength & activity report (derived, never typed) | every echelon | rolls up |
| S3 | Training Calendar | host events + chain-wide event view; S3 submits roster for higher-echelon attendance | every echelon | Form 13 + SAR |
| S3 | PTA Certification | certification, populated from roster & records | BN | Form 13 |
| S3 | Promotion Testing | C/SGT · C/2LT · C/MAJ testing | BN | Form 13 |
| S1 | Promotion Boards | convene / record an officer promotion board (any echelon) | every echelon | promotion packet |
| S1 | Advisor Endorsement Queue | BDE advisor endorses (signs) company- & field-grade packets | Brigade | packet → Form 13 |
| S1 | State Promotion Processing | cut field-grade orders once the packet is complete | State | Form 13 |
| S4 | Property Book | the item ledger (read-only view of postings) | every echelon (rollup above BN) | Form 100 |
| S4 | Requisition | request / ship items into the book | BN | Property Book |
| S4 | Report of Survey | remove unserviceable / lost (justified) | BN | Property Book |
| S4 | Inventory | reconcile counts; flags when an inventory is due | BN | Property Book |
| S4 | Issue Items | assign an item from the book straight to a cadet | BN | Property Book + Form 100 |
| S5 | Newsletter | publish unit news | every echelon | — |
| S5 | Media Gallery | unit photos & media | every echelon | — |
| S6 | Delegate Access | grant module access to roster members | every echelon | access control |
Higher echelons appoint their subordinates' leaders
Leader Assignments — delegated user_unit_links writes
Appointing the adults who lead units flows down the chain of command: each echelon's S1 appoints the commandant or advisor of the units beneath it. That's a separate S1 module — Leader Assignments — living at any echelon that has units beneath it (Regiment, Brigade, Division, State). It's the grown-up counterpart to Duty Assignments, which handles cadet billets. Any echelon above a unit can appoint its leader — not just the immediate parent. So because a Battalion frequently reports straight to a Brigade with no regiment in between, the Brigade advisor most often appoints the BN commandant directly.
Assigning a commandant/advisor to unit U is really "grant this adult scope + role over U" — a user_unit_links(user_id, unit_id, role) row. Leader Assignments delegates that write (today Super-Admin-only) to a higher echelon's S1, bounded by the tree: a user may appoint the leader of U iff U is a proper descendant of a unit they hold (never their own unit's leader — that's their parent's job — and never sideways/above) — so when a BN reports directly to a BDE (the common case, no regiment), the Brigade is the nearest ancestor and the BDE advisor appoints the BN commandant. A new leader_assignments table keeps the change-of-command history; the open row projects to the live user_unit_links grant. If the appointee has no account yet, they're provisioned/invited as an adult (Entra).
Not the same as Delegate Access: Leader Assignments sets which unit you lead (your scope and role); Delegate Access sets which tools you can open within a unit. One is command, the other is permissions.
Officer promotions are a packet that routes up the chain
Officer promotion packet workflow
An officer promotion is a packet with two required parts — a promotion board (convened by anybody) and a passed Promotion Test — which can happen in either order, but both must be done. Once both are in, the packet lands in the Brigade advisor's queue for endorsement (signature) — required for company- and field-grade. Company-grade promotions complete there. Field-grade promotions then forward to the State, which cuts the orders once the packet is complete (no State signature — but the system won't pass an incomplete packet).
| Rank tier | Cadet ranks | Required · either order | Endorsement | Orders cut by |
|---|---|---|---|---|
| Enlisted & NCO | through C/SGT | Promotion Test (BN) | — | Battalion |
| Company-grade officer | C/2LT · C/1LT · C/CPT | Board (any echelon) + Test | BDE advisor signs | Brigade (on endorsement) |
| Field-grade officer | C/MAJ · C/LTC · C/COL | Board (any echelon) + Test | BDE advisor signs | State (complete packet) |
promotion_boards (any echelon) + promotion_testing (BN) both feed one promotion_packets row; its state machine gates ready → bde_endorsed → (field grade) state_processed → complete. On complete, a cadet_record_entries(order) promotion posts to Form 13 and rank updates. The system enforces packet completeness before State can cut orders. ⚠ D26: board-convening authority + NCO ceiling.
Get one Battalion fully working, then add the higher-level views
One battalion end-to-end, then the rollup layer
We build the Battalion experience end to end first, prove it works, then add the combined views for Regiment, Brigade, Division, and State on top. Each step ships on its own.
Rollups are pure functions of BN facts, so they’re added last without reworking BN modules. Each phase ships behind the existing module-flag system.
Set up the chain of command
Foundation — the tree
Build the tool that lets an administrator lay out who reports to whom, and make everyone's access follow it.
units.parent_id, unit_closure + trigger; user_unit_links.unit_id; cut access rules to cacc_can_see_unit(). Super Admin hierarchy editor.
DONE WHEN · a Brigade leader sees exactly their units; moving a Battalion moves its access correctly.EXIT · a BDE-assigned user sees exactly that subtree; reparenting a BN moves its visibility correctly.
Battalion roster & accounts
BN Roster & Identity
Add and manage cadets; adding a cadet creates their record and, when needed, their account. Transfers keep their history.
cadets.auth_user_id/current_unit_id, cadet_unit_memberships; roster CRUD, transfer, Graph link-or-create provisioning.
DONE WHEN · adding a cadet creates their record; a transfer keeps their identity and history.EXIT · adding a cadet creates record + membership + linked account; transfer preserves identity.
Battalion property book & forms
BN Property Book + four forms
The four equipment forms and the book they update. Issued gear ties to a specific cadet.
items, append-only property_postings, property_balances view, four form tables; forms UI, book, ledger.
DONE WHEN · each form updates the book correctly; no total can be hand-edited.EXIT · each form posts correctly; book equals Σ(qty_delta); no path edits a balance.
Training calendar & attendance
Calendar + Attendance
Events at every level; cadets sign up for events in their chain; marking present creates the activity.
host_unit_id, home_unit_id snapshot; calendar, registration by eligibility, mark-present writes attendance fact + record entry.
DONE WHEN · a cadet signs up for their events; marking present creates one clean activity record.EXIT · a BN cadet registers for BN + ancestor events; present creates one stable attendance fact.
Battalion strength & activity report
BN SAR — derived
The monthly report, filled in automatically from the roster and attendance — no typing. Close the month to lock it.
Live strength & activity views; /sar BN screen; period close freezes sar_submissions; deprecate hand-typed entry.
DONE WHEN · the report matches the roster and the month's attendance exactly.EXIT · BN SAR matches roster + month’s attendance exactly; closing freezes it.
Higher-level combined views
Rollup layer — REG / BDE / DIV / State
Add the combined strength, activity, and property dashboards for Regiment through State, with drill-down and notes.
Materialized strength / activity / property rollups + refresh; ancestor read-only views with drill-down; sar_annotations.
DONE WHEN · a Brigade's totals equal the sum of its Battalions and drill down to them.EXIT · a BDE’s SAR equals the sum of its BNs and drills to BN facts; ancestors can’t edit any child figure.
Polish & scale
Hardening & scale
Performance tuning, finer permission settings, and keeping the system documentation up to date.
MV refresh tuning, per-role access-rule refinement, provisioning monitoring, and the required ARCHITECTURE.md update.
A few decisions we need from you before building
Decisions to confirm before build
These are policy calls, not technical ones — they shape how the system behaves. Here's what we'd suggest as the starting answer for each.
Full register in docs/DECISIONS.md. The load-bearing ones:
| # | The questionDecision | Our suggested answerAssumed default |
|---|---|---|
| 1 | Do all cadets get a login, or only when needed?Cadet account provisioning | Every cadet gets a record; a login is created only when a cadet needs one (e.g. to sign themselves up) — we don't force an account on every minor.Two tiers — cadets are records; an Entra login is link-or-created on demand. |
| 2 | When a cadet transfers, does their issued gear go with them?Issued property on transfer | No — the losing Battalion collects the gear first. Gear stays with the unit, not the person.Property does not follow the cadet — losing BN turns items in first. |
| 3 | Who closes out the monthly report, and how are corrections handled?SAR period close / freeze | Monthly; the numbers lock when the month is closed — we need you to confirm who closes it and how a fix is filed.Monthly; frozen snapshot at close — who closes & correction path need confirming. |
| 4 | How is equipment categorized?Item classification taxonomy | A catalog of items, each flagged as serial-numbered or bulk — confirm whether you use Army codes or a Cadet-Corps list.NSN/LIN-style items catalog with a serialized flag — confirm vs. local scheme. |
| 5 | Can the chain of command be re-organized after setup?Closure table & reparenting | Yes — an administrator can move a unit under a new headquarters and everything re-aligns; past monthly reports keep the chain they had at the time.Reparenting is a Super-Admin power that re-scopes live rollups; frozen snapshots keep their as-of chain. |