diff --git a/AGENTS.md b/AGENTS.md
index 22745e0..bd0dc89 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,5 +1,12 @@
# AGENTS.md — Polaris Task Force
+> **Sub-AGENTS.md files** (read these for domain-specific context):
+> - `src/components/frontend/AGENTS.md` — Frontend component patterns, server/client split, Item primitive
+> - `src/collections/AGENTS.md` — Payload collection map, RBAC, hooks, relationship graph
+> - `src/lib/AGENTS.md` — Shared business logic, domain services, dependency graph
+> - `src/utils/AGENTS.md` — Access control layers, event log, utilities
+> - `src/bot/AGENTS.md` — Discord bot architecture, commands, services
+
## What this is
Next.js 16 + Payload CMS 3.88.0 app for an Arma 3 unit. PostgreSQL database via `@payloadcms/db-postgres` + Drizzle. Tailwind CSS v4 (no config file — CSS-based). shadcn/ui (new-york style, `lucide` icons). Dark-themed frontend.
@@ -32,7 +39,7 @@ bun run generate:importmap # regenerates payload admin importMap
npx tsc --noEmit 2>&1 | grep -E "error TS" | grep -v "\.next/"
```
-The typecheck should now pass clean (the two pre-existing errors in `hasLogisticsQualification.ts` and `payload-generated-schema.ts` were resolved by the Payload 3.88.0 upgrade). Any error is yours.
+Known pre-existing errors (not yours, don't widen scope to fix them): `src/collections/users/Users.ts:57`, `src/tools/seed/backfillProfiles.ts:68`, `src/tools/seed/seedProfiles.ts:19` — all the same Payload profiles-create overload mismatch. Any **other** error introduced by your changes is yours.
## Test details
@@ -47,6 +54,27 @@ The typecheck should now pass clean (the two pre-existing errors in `hasLogistic
- If the user says **no**, do **not** start a dev server and do **not** attempt to verify via E2E or Playwright — the user will run the app and test themselves, then report back.
- Stale dev processes: killing the process is not always enough; remove `.next/dev/devserver.lock` before restarting.
+## Agent debugging SOP (read before fixing any bug)
+
+These rules were extracted from a real multi-failure debugging session — the full story, including every wrong turn, is in `docs/case-studies/login-return-url.md`. Follow them literally; each one exists because skipping it produced a wrong fix.
+
+1. **Trace the real render path before writing any fix.** State in your reply: which layout wraps the failing route, what each conditional renders, and which component actually owns the navigation or mutation you're changing. If a layout conditionally replaces `{children}` (the `(frontend)` guest gate renders `` instead of the page), then code inside those children — including `redirect()` calls — is **dead code for that branch** and never runs.
+2. **Treat every framework API as a hypothesis.** Before calling a header, hook, or helper, confirm it exists and returns what you expect — against the running app or current docs. An observed default value (e.g. `?next=%2F` when you expected `%2Fflappy`) means the data source was **empty** and your fallback leaked through — not that the data got mangled.
+3. **Two failed fixes = your mental model is wrong.** Do not add a third fallback layer on top of a failing approach. Stop editing, re-read the flow, find the wrong assumption.
+4. **Ask "which component actually knows this fact?"** The current URL is known client-side (`usePathname()`), not in server layouts. Attach data where it is known, at the point the navigation happens — not where it is merely convenient to compute.
+5. **Match producer and consumer.** If you emit a query param (`next`), confirm the consumer reads that exact name (`returnTo`). Mismatches fail silently.
+6. **Verify end-to-end before reporting done.** curl the failing route as a guest, follow the redirect, hit the API, check the authed route (a copy-pasteable matrix is in the case study). "Should work" is not verification.
+7. **Restate before acting.** For non-trivial changes, output: assumptions → plan → verification command. Then implement.
+
+## Next.js 16 hard rules
+
+Break these and you get runtime errors or silently dead code:
+
+- **`searchParams` and `params` props are Promises** in pages/layouts. `await` them before any property access. Error if violated: ``Route used `searchParams.x`. `searchParams` is a Promise and must be unwrapped with `await` or `React.use()```.
+- **Server components (layouts/pages) cannot mutate cookies.** `cookies()` from `next/headers` is read-only there; calling `.set()` throws `Cookies can only be modified in a Server Action or Route Handler`. Writing cookies is only legal in Server Actions and Route Handlers.
+- **`headers.get("x-invoke-path")` does not reliably contain the current pathname** in layouts. Never build redirect logic on it. Reliable sources of the current path: `usePathname()` (client components) and the request object (middleware / Route Handlers).
+- **`redirect()` narrows poorly across control flow.** After an `if (!user) redirect(...)` early exit, TypeScript may still see `user` as nullable when the narrowing crosses a closure boundary — keep an explicit truthy branch around later `user` usage.
+- **Sanitize user-controllable redirect targets** (open-redirect guard): accept only values starting with `/` and reject `//` (protocol-relative URLs). Working example: `safeReturnTo` in `src/app/login/page.tsx`.
## Generated files — never edit manually
@@ -228,8 +256,9 @@ A Discord bot living in `src/bot/`, run as a standalone long-running process via
Username-based login (no email login). Users log in via Payload admin with `username` only.
-- `src/app/(frontend)/layout.tsx` is the gate: guests render `LandingPage` (no app shell); authed users get sidebar + `GameTickRealtime`.
-- `/login` (`src/app/login/page.tsx` + `src/components/frontend/auth/LoginForm.tsx`) POSTs `{ username, password }` to `/api/users/login`, then `router.push("/")` + `router.refresh()`. The page redirects already-authed users to `/`.
+- `src/app/(frontend)/layout.tsx` is the gate: guests render `LandingPage` (no app shell); authed users get sidebar + `GameTickRealtime`. Because the gate replaces `{children}` for guests, page-level `if (!user) redirect(...)` blocks under `(frontend)` are **unreachable dead code for guests** — they only serve as type-guards for the authed render path.
+- `/login` (`src/app/login/page.tsx` + `src/components/frontend/auth/LoginForm.tsx`) POSTs `{ username, password }` to `/api/users/login`, then `router.push(returnTo ?? "/")` + `router.refresh()`.
+- **Return-URL flow**: the `LandingPage` login CTA is `LoginLink` (`src/components/frontend/auth/LoginLink.tsx` — a client component using `usePathname()`), which links to `/login?returnTo=`. The login page awaits `searchParams`, sanitizes `returnTo` via `safeReturnTo` (must start with `/`, must not start with `//` — open-redirect guard), and passes it to `LoginForm`. Already-authed users hitting `/login?returnTo=X` are redirected straight to `X`. The path is attached client-side because server components cannot reliably know the current path (see "Next.js 16 hard rules"). Full design story: `docs/case-studies/login-return-url.md`.
- Logout lives in `NavUser` (sidebar) → `/api/users/logout`.
- Tip: a corrupted `payload-token` cookie causes an infinite login loop (`Unexpected end of JSON input` on `/api/users/me`) — clear cookies/use incognito. Dev user `dev` / `Test123`.
@@ -264,6 +293,7 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
## Gotchas
+- Next.js 16 framework traps (each one caused a real failed fix — see "Next.js 16 hard rules" above): `searchParams`/`params` are Promises and must be awaited; `cookies().set()` throws outside Server Actions/Route Handlers; `x-invoke-path` does not carry the real pathname in layouts.
- `.env.example` shows MongoDB URI but the app uses PostgreSQL — trust `DATABASE_URI` format in `.env.test` as the real reference.
- `bun run build` passes `--max-old-space-size=8000` — the build is memory-intensive.
- The `devturbo` script uses Turbopack; `dev` and `devsafe` use webpack. These are different bundlers with different behavior.
@@ -271,3 +301,17 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
- Payload admin layout and importMap are auto-generated — do not edit by hand.
- `.npmrc` sets `legacy-peer-deps=true` for dependency resolution compatibility.
- `bun run db push` fails with `must be owner of table spatial_ref_sys` (a PostGIS table owned by the DB superuser) — drizzle-kit push does a full-schema diff and trips on it. Workaround: apply the needed `ALTER TABLE` directly (via node + `pg` reading `DATABASE_URI` from `.env`) or start the dev server, whose Payload `push: true` path may skip the offending table.
+
+## Server Actions convention
+
+Every `actions.ts` file follows the same pattern (10+ files use it):
+1. `"use server"` directive at top
+2. `import config from "@payload-config"` + `const payload = await getPayload({ config })`
+3. Local `authenticate()` helper — dynamic import of `next/headers`, calls `payload.auth()` and `headers()`
+4. Return type `ActionResult`: `{ success: boolean; error?: string; data?: T }`
+5. Permission check: `const { user } = await authenticate()` then `await hasPermission(payload, user, "domain:action")`
+6. Mutation via `payload.create` / `payload.update` / `payload.delete`
+7. Event emission: `await emitGameEvent(payload, { type: EventTypes.xxx, message, ... })`
+8. Error handling: `catch (e) { return { success: false, error: e instanceof Error ? e.message : "Unknown error" } }`
+
+The `authenticate()` function is **duplicated in every file** — not extracted to a shared helper. If you add a new server action, copy the pattern from an existing one (e.g., `src/app/(frontend)/logistics/banking/actions.ts`). Do NOT attempt to extract it to a shared helper unless the entire codebase migrates at once.
diff --git a/docs/case-studies/login-return-url.md b/docs/case-studies/login-return-url.md
new file mode 100644
index 0000000..b6820da
--- /dev/null
+++ b/docs/case-studies/login-return-url.md
@@ -0,0 +1,238 @@
+# Case study: preserving the return URL through login
+
+How a "remember where I was going" feature was implemented (and mis-implemented three times
+before that) in this repo. Written for agents — especially smaller local models — as a pattern
+to copy and a set of anti-patterns to recognize early. Every wrong turn below was really taken;
+the point of writing it down is that each failure had a *signal* that said "stop, your model is
+wrong" long before anyone listened to it.
+
+The distilled rules live in `AGENTS.md` → "Agent debugging SOP" and "Next.js 16 hard rules".
+This document is the evidence behind them.
+
+## The task
+
+Unauthenticated users who navigate to a protected page (e.g. `/flappy`) should, after logging
+in, land back on that page instead of the dashboard.
+
+## The symptom (user report #1)
+
+> I just logged out, went to `/flappy`, found the landing page and was redirected to `/login`
+> (no url params!), logged in, and was brought to the dashboard instead of back to Flappy.
+
+## The failure arc
+
+### Attempt 1 — redirect from the page: never runs
+
+**What was done:** the login page and `LoginForm` were taught to read a `returnTo` query
+param, and the protected pages (`flappy`, `helpdesk`) got guest blocks like:
+
+```ts
+if (!user) {
+ const pathname = headers.get("x-invoke-path") || "/flappy";
+ redirect(`/login?next=${encodeURIComponent(pathname)}`);
+}
+```
+
+**Why it seemed reasonable:** pages own their auth checks; `redirect()` is the canonical
+Next.js way to bounce unauthenticated users.
+
+**What actually happened:** no query param appeared at all. The user landed on `/login` bare
+and went to `/` after login.
+
+**Why it failed (two independent reasons):**
+
+1. **The page never rendered.** `src/app/(frontend)/layout.tsx` is the real gate, and for
+ guests it *replaces* `{children}`:
+
+ ```tsx
+ {user ? (
+ … {children} …
+ ) : (
+ // ← guests never reach {children}
+ )}
+ ```
+
+ Every `if (!user) redirect(...)` inside a page under `(frontend)` is **dead code for
+ guests**. The `redirect()` never executes because the page component never renders.
+2. **Producer/consumer mismatch.** The pages emitted `?next=…`; the login page read
+ `searchParams.returnTo`. Even if the redirect had fired, the param would have been ignored.
+ Query-param mismatches fail silently — there is no error, just nothing.
+
+**The signal that was missed:** the user said "I found the landing page" — the landing page
+rendering *at all* proves the page body (and its redirect) never ran. That was the clue.
+
+### Attempt 2 — move the redirect into the layout: `?next=%2F`
+
+**What was done:** the same `x-invoke-path` redirect logic was moved into the layout, firing
+for every guest.
+
+**What actually happened (user report #2):**
+
+> I did see `?next=` appear, but it was actually `?next=%2F` despite expecting `?next=/flappy`.
+
+**Why it failed:** `headers.get("x-invoke-path")` does **not** reliably contain the current
+pathname in Next.js 16 layouts — here it returned `/` (or nothing, hitting the `|| "/"`
+fallback). Read the symptom the right way: `%2F` is not a mangled `/flappy`; it is the
+**default value leaking through**. When you observe a default instead of your data, the data
+source was empty. Mangling was never on the table.
+
+**Bonus failure:** this also silently changed product behavior — guests lost the landing page
+entirely and got an instant redirect instead. The task never asked for that.
+
+### Attempt 3 — store the path in a cookie: runtime error
+
+**What was done:** since the layout couldn't put the path in the URL, it tried to stash it in
+a cookie: `cookies().set("loginRedirect", pathname, …)` in the layout body, then
+`redirect("/login")`.
+
+**What actually happened (user report #3):**
+
+```
+Error: Cookies can only be modified in a Server Action or Route Handler.
+ at RootLayout (src/app/(frontend)/layout.tsx:68:16)
+```
+
+**Why it failed:** in Next.js App Router, server components (layouts/pages) **cannot mutate
+cookies**. `cookies()` from `next/headers` is read-only there. Writes are only legal in Server
+Actions and Route Handlers. The framework error message is precise and correct — read it
+literally instead of routing around it.
+
+### Attempt 4 — referer fallback: complexity accretion
+
+**What was done:** with `x-invoke-path` proven useless, a `referer`-header fallback was added
+on top of the cookie approach (and then a second, duplicated copy of the same fallback block).
+
+**Why it failed:** `referer` is empty on direct navigation (typing a URL), and when present it
+points at the *previous* page, not the requested one. It cannot answer this question by
+construction. This attempt also demonstrates the worst failure pattern of the whole session:
+**when a fix fails, adding a fallback on top of it preserves the wrong assumption and adds
+code.** By now there were three mechanisms layered (header → cookie → referer), none of which
+could work, and duplicated code on top.
+
+**This is the point where the correct move was:** stop editing. Two-plus failed fixes means
+the mental model is wrong, not the implementation.
+
+## The turn: trace the render path
+
+Re-reading the flow instead of patching it produced the key facts:
+
+1. Guests never reach page code — the layout's `LandingPage` branch is the whole guest
+ experience. So the path can only be captured **where the guest actually is**: inside the
+ landing page.
+2. The landing page's CTA was a plain `` — a **static** link with no
+ knowledge of where the user is standing. *That* is the navigation point the whole feature
+ hangs on, and it is the thing that should carry the path.
+3. Server components cannot reliably know the current path (`x-invoke-path` unreliable, no
+ request URL in layouts). The current path **is** reliably known by `usePathname()` in
+ client components — exactly at the point where the user clicks "log in".
+
+Rule of thumb that falls out: **attach data where the fact is known, at the point the
+navigation happens — not where it is convenient to compute.**
+
+## The fix (4 small changes)
+
+1. **`src/components/frontend/auth/LoginLink.tsx`** (new, ~8 lines) — a client component that
+ knows the current path and builds the link:
+
+ ```tsx
+ "use client";
+
+ import Link from "next/link";
+ import { usePathname } from "next/navigation";
+ import type { ComponentProps } from "react";
+
+ export function LoginLink(props: Omit, "href">) {
+ const pathname = usePathname();
+ return ;
+ }
+ ```
+
+2. **`src/components/frontend/LandingPage.tsx`** — the CTA swaps `` for
+ ``. A guest at `/flappy` now gets `href="/login?returnTo=%2Fflappy"`.
+3. **`src/app/login/page.tsx`** — awaits `searchParams` (Next 16: it's a Promise), sanitizes
+ the target against open redirects, sends already-authed users straight to their target:
+
+ ```tsx
+ function safeReturnTo(value: string | undefined): string | undefined {
+ if (!value?.startsWith("/") || value.startsWith("//")) return undefined;
+ return value;
+ }
+
+ export default async function LoginPage({ searchParams }: {
+ searchParams: Promise<{ returnTo?: string }>;
+ }) {
+ const { returnTo } = await searchParams;
+ const target = safeReturnTo(returnTo);
+ // …
+ if (user) redirect(target ?? "/");
+ return ;
+ }
+ ```
+
+4. **`src/components/frontend/auth/LoginForm.tsx`** — after a successful login POST,
+ `router.push(returnTo ?? "/")`. (Also: dropped `returnTo` from the POST body — the API
+ never read it; the redirect is purely client-side.)
+
+And **reverted** the layout to its original guest flow, plus removed the dead page-level
+redirect blocks' reliance on `x-invoke-path`. Net result: less code than the failing versions.
+
+## The verification matrix
+
+Run against a live dev server with the dev user (`dev` / `Test123`). Copy-pasteable:
+
+```bash
+# 1. Guest hits the protected route — landing page renders, CTA carries the path
+curl -s http://localhost:3000/flappy | grep -o 'href="/login?returnTo=[^"]*"'
+# expect: href="/login?returnTo=%2Fflappy"
+
+# 2. Login page passes returnTo through to the form
+curl -s "http://localhost:3000/login?returnTo=%2Fflappy" | grep -o '%2Fflappy'
+# expect: %2Fflappy (present in the RSC payload)
+
+# 3. Login API sets the auth cookie
+curl -s -X POST http://localhost:3000/api/users/login \
+ -H 'content-type: application/json' \
+ -d '{"username":"dev","password":"Test123"}' -c /tmp/cookies.txt -o /dev/null -w "%{http_code}\n"
+# expect: 200
+
+# 4. Authed user reaches the protected page
+curl -s -b /tmp/cookies.txt http://localhost:3000/flappy -o /dev/null -w "%{http_code}\n"
+# expect: 200
+
+# 5. Authed user hitting /login?returnTo=... bounces straight to the target
+curl -s -b /tmp/cookies.txt -o /dev/null \
+ -w "%{http_code} -> %{redirect_url}\n" "http://localhost:3000/login?returnTo=%2Fflappy"
+# expect: 307 -> http://localhost:3000/flappy
+```
+
+All five passed against the running dev server before the work was reported done. Note the
+dev-server subtlety encountered along the way: a spawned `bun run dev` detected an
+already-running server, exited, and the curls actually hit the *existing* hot-reloaded server —
+which is fine (that's the surface the user sees), but know which process you're testing
+against. Check the dev log for "Another next dev server is already running" and its PID.
+
+## Signals you are on the wrong path (recognize these early)
+
+- **A default value shows up instead of your data** (`?next=%2F`). The data source is empty.
+ Find out why it's empty — don't post-process the value.
+- **The component you're editing never renders** for the scenario you're fixing (guests and
+ `{children}` replacement). Verify by asking what the user *saw* — if they saw the landing
+ page, page code didn't run.
+- **A framework error message names a restriction** ("Cookies can only be modified in a Server
+ Action or Route Handler"). It is stating a rule, not a bug. Restructure to comply; don't
+ fight it.
+- **You're adding a second or third fallback.** Each fallback is an admission the previous
+ model was wrong — while keeping it. Stop and re-derive instead.
+- **The fix changes behavior the task never asked about** (landing page disappears). Scope
+ creep during a bug fix is a sign the approach is wrong, not a bonus.
+
+## The rules (mirror of the AGENTS.md SOP)
+
+1. Trace the real render path before writing any fix.
+2. Treat every framework API as a hypothesis; verify it.
+3. Two failed fixes = wrong mental model. Stop, re-read, find the wrong assumption.
+4. Attach data where the fact is known (`usePathname()` client-side), at the point of
+ navigation.
+5. Match producer and consumer (`next` vs `returnTo` fails silently).
+6. Verify end-to-end (curl matrix above) before reporting done.
+7. Restate before acting: assumptions → plan → verification command.
diff --git a/src/bot/AGENTS.md b/src/bot/AGENTS.md
new file mode 100644
index 0000000..d91ff2a
--- /dev/null
+++ b/src/bot/AGENTS.md
@@ -0,0 +1,69 @@
+# AGENTS.md — Discord Bot
+
+> **Parent**: `../../AGENTS.md` — env vars (`DISCORD_TOKEN`, `DISCORD_GUILD_ID`), deployment, Payload config.
+
+## Overview
+
+Standalone long-running process (`bun run bot`). Imports `@payload-config` directly, shares PostgreSQL with web app. Under active development.
+
+## Structure
+
+```
+bot/
+ index.ts # Entry point
+ config.ts # Env validation (fail-fast on missing required vars)
+
+ commands/
+ index.ts # Command registry (global vs guild scope)
+ ping.ts # /ping (global, DM-usable)
+ signup.ts # /signup (DM-only, creates Payload user with temp password)
+ link.ts # /link (global, links discordId to existing user)
+ announce.ts # /announce (guild-only, staff only)
+
+ events/
+ interactionCreate.ts # Routes ptf-att: RSVP button interactions
+
+ services/
+ index.ts # Service registry
+ missionEmbeds.ts # Attendance embed lifecycle + reconcile loop (poll tick)
+ notificationBridge.ts # Poll user-notifications → Discord DMs
+ signup.ts # Signup service logic
+
+ lib/
+ roles.ts # isStaff check
+ resolve.ts # discordId ↔ Payload user lookups
+```
+
+## Command registration scope
+
+- **Global** (DM-usable): `signup`, `link`, `ping`
+- **Guild-only**: `announce` (guild-scoped commands never appear in DMs)
+
+## Feature flow: signup/link
+
+`/signup` is DM-only. Creates Payload user with `username` = `discordUsername` = caller's Discord username. Ephemeral reply carries temp password (guaranteed delivery path); `interaction.user.send()` is best-effort persistent copy. `/link` works in servers and DMs — matches `discordUsername` → sets `discordId`.
+
+## Feature flow: attendance
+
+Bot posts RSVP embeds (Yes/Tentative/No) for future, Ready/Scheduled, visibility:"unit" missions into ops channel. Stores `discordMessageId` + `discordAttendanceHash` on mission. Reconciles hash changes every poll tick (web ↔ Discord two-way sync). Web UI: `MissionAttendance` component.
+
+## Feature flow: notifications
+
+`notificationBridge` polls `user-notifications` (cursor = last seen id; cap 5 DMs/tick). DMs opted-in users (`preferences.discord.enabled`, not in `mutedTypes`).
+
+## Where to look
+
+| Task | Path |
+|------|------|
+| Add new slash command | `bot/commands/.ts` + register in `bot/commands/index.ts` |
+| Add button interaction | `bot/events/interactionCreate.ts` |
+| Modify embed lifecycle | `bot/services/missionEmbeds.ts` |
+| Change DM bridging | `bot/services/notificationBridge.ts` |
+| User lookup patterns | `bot/lib/resolve.ts` |
+
+## Anti-patterns
+
+- **NEVER** run bot and web app with separate database connections without connection pooling — they share PostgreSQL
+- **NEVER** register guild-only commands if the command needs to work in DMs (signup, link, ping must be global)
+- **NEVER** assume DM delivery succeeded — `/signup` uses ephemeral reply as primary delivery path
+- **NEVER** modify `discordId` directly in Payload admin — use the `/link` command or the resolve helper
diff --git a/src/collections/AGENTS.md b/src/collections/AGENTS.md
new file mode 100644
index 0000000..a212c59
--- /dev/null
+++ b/src/collections/AGENTS.md
@@ -0,0 +1,81 @@
+# AGENTS.md — Payload Collections
+
+> **Parent**: `../../AGENTS.md` — commands, RBAC basics, Payload CMS config.
+
+## Overview
+
+36 collections across 12 domain groups. Access control defined in `src/permissions/index.ts` (616 lines, 100+ permissions across 25 groups). RBAC check: `hasPermission(payload, user, "collection:action")`.
+
+## Structure
+
+```
+collections/
+ Media.ts # Generic media upload
+ Shims.ts # Global: CSS/JS shims (admin-only)
+
+ users/ 9 files # Users (auth), Ranks, Profiles, Awards,
+ # Qualifications, Assignments, Experience,
+ # Roles (dynamic RBAC), UserNotifications
+ intelligence/ 5 files # Missions, MissionAttendances, Campaigns,
+ # Factions, Technologies
+ logistics/ 5 files # Structures, Resources, Assets, Vehicles, Shipments
+ banking/ 3 files # BankAccounts, BankTransactions, LedgerEntries
+ market/ 2 files # MarketListings, MarketNegotiations
+ locker/ 2 files # LockerStorages, Loadouts
+ game/ 6 files # GameRules (global), GameStructures, GameVehicles,
+ # GameNpcs, GameHardResources, GameEventLogs
+ server/ 2 files # MissionFiles, ModLists
+ world/ 2 files # Maps, NarrativeEvents (React Flow editor)
+ tickets/ 1 file # Tickets (Lexical rich text)
+```
+
+## Key relationships
+
+```
+Users ──┬── Profiles ──┬── Qualifications
+ │ ├── Awards
+ │ ├── Assignments ── Ranks
+ │ └── Experience
+ ├── UserNotifications
+ └── BankAccounts ── BankTransactions ── LedgerEntries
+
+GameStructures ── Structures (template) ── Resources/Assets/Vehicles
+GameVehicles ── Vehicles (template)
+GameNpcs ── MarketListings ── MarketNegotiations
+MissionAttendances ── Missions ── Campaigns
+```
+
+## Access control pattern
+
+Collections define `access` at field + document level using helpers from `src/utils/access-control/`:
+- `isRole(role)` — single role check
+- `hasRoles(roles[])` — any-of role check
+- `hasPermission(payload, user, "collection:action")` — full RBAC (cached 30s)
+- `hasLogisticsQualification()` / `hasIntelligenceQualification()` — queries Profiles
+
+Admin group access: `developer` only for destructive operations, `admin` for read/write.
+
+## Hooks with side effects
+
+- `Structures` `beforeChange`: emits `structure:resize`
+- `GameStructures` `afterChange`: emits storage edit events
+- `Users` `afterChange`: maintains profile sync
+- `BankTransactions` `beforeValidate`: auto-generates `transactionNumber`
+- `MarketNegotiations`: patience meter enforcement
+
+## Where to look
+
+| Task | Path |
+|------|------|
+| Add a new collection | Create `.ts` here, register in `src/payload.config.ts` |
+| Add RBAC permission | `src/permissions/index.ts` (add to group + add check) |
+| Modify collection access | `/access.ts` or inline in collection file |
+| Add field hook | Inline in collection definition (beforeChange/afterChange) |
+| Relationship graph | See key relationships above; Payload manages FK constraints |
+
+## Anti-patterns
+
+- **NEVER** edit `src/payload-types.ts` or `src/payload-generated-schema.ts` — run `bun run generate:types` instead
+- **NEVER** add `access` functions that call `payload.auth()` without handling the null user case
+- **NEVER** use `payload.create` in `afterChange` hooks without checking for infinite loops
+- **NEVER** mix `overrideAccess: true` without a permission check — always gate behind RBAC first
diff --git a/src/components/frontend/AGENTS.md b/src/components/frontend/AGENTS.md
new file mode 100644
index 0000000..1e82d4c
--- /dev/null
+++ b/src/components/frontend/AGENTS.md
@@ -0,0 +1,65 @@
+# AGENTS.md — Frontend Components
+
+> **Parent**: `../../AGENTS.md` — commands, auth flow, Next.js 16 rules, deployment.
+
+## Overview
+
+100+ client components organized by domain. The `(frontend)` layout renders guests `LandingPage` (no app shell); authed users get `AppSidebar` + `SiteHeader` + `CommandPalette`.
+
+## Structure
+
+```
+components/frontend/
+ auth/ 2 files LoginForm, LoginLink (returnTo via usePathname)
+ account/ 4 files Profile, password, preferences, Discord link
+ banking/ 9 files Account cards, ledger, deposit/withdraw/transfer dialogs
+ blocks/ 6 files AppSidebar, NavCore, NavUser, XPDisplay
+ dashboard/ 6 files ProfileSummary, QuickStats, MissionBriefing, RecentEvents
+ flappy/ 4 files Canvas game, leaderboard, sounds
+ helpdesk/ 6 files Ticket list/detail, create dialog, timeline
+ intelligence/ 13 files Mission cards/attendance/comms, campaign/faction cards
+ locker/ 12 files Grid, equipment editor, loadouts, wardrobe
+ logistics/ 8 files Shipment cards/actions, toast notifications
+ market/ 10 files Listing cards, negotiation flow, NPC chat, patience meter
+ notifications/ 2 files Bell (polling), inbox page
+ realtime/ 1 file GameTickRealtime (SSE -> router.refresh)
+ roster/ 1 file Org chart view
+ storage/ 14 files Structure grid, storage dialogs, event ledger
+```
+
+## Conventions
+
+### Server → Client data flow
+Pages are server components that fetch via `getPayload()`, then pass data as props to client components. Client components receive typed props and never call Payload directly.
+
+### Item compound component
+`storage/` uses a compound `` component for grid cells with slots: ``, ``, ``. Attachments render as stacked badges on the grid cell; popover shows full detail on hover.
+
+### SSE consumers
+- `GameTickRealtime` mounts in `(frontend)/layout.tsx` for all authed users.
+- `ShipmentToasts` mounts only for logistics-qualified users.
+- Both use `useGameTick()` hook (custom `ptf:game-tick` event dispatch).
+
+### Nested dialogs
+Market negotiation uses `MakeOfferDialog` nested inside `ListingCard` dialog. Loadout editor uses `EquipmentEditorDialog` nested inside locker grid. When a parent dialog unmounts (e.g., sold listing), the child stays visible for 3.5s minimum via `useNow()` hook for readability before refresh.
+
+### Route anomalies
+`logistics/vehicles/VehiclesList.tsx` is a client component placed directly in the route directory (not under `components/frontend/`). This is the only route with an inline component file.
+
+## Where to look
+
+| Task | Path |
+|------|------|
+| Add a new page | `src/app/(frontend)//page.tsx` + create client component here |
+| Add nav entry | `src/components/frontend/blocks/NavCore.tsx` |
+| Modify auth gate | `src/app/(frontend)/layout.tsx` (conditional renders `LandingPage` or shell) |
+| Add SSE consumer | `src/hooks/useGameTick.ts` + mount in layout |
+| NPC dialogue/chatter | `src/lib/market/npcDialogue.ts` (pure, client-safe import) |
+| Keyboard shortcuts | `src/components/command-palette/` |
+
+## Anti-patterns
+
+- **NEVER** call `getPayload()` in client components — fetch in server page, pass as props
+- **NEVER** add `useRouter().refresh()` in SSE consumers without a guard — use the `useGameTick()` hook
+- **NEVER** use shadcn defaults for dark theme — the admin panel's theme handles styling
+- **NEVER** place server-side logic (hooks, Payload calls) in `"use client"` files
diff --git a/src/lib/AGENTS.md b/src/lib/AGENTS.md
new file mode 100644
index 0000000..cf50ef3
--- /dev/null
+++ b/src/lib/AGENTS.md
@@ -0,0 +1,85 @@
+# AGENTS.md — Shared Business Logic
+
+> **Parent**: `../../AGENTS.md` — Payload config, server actions pattern, env vars.
+
+## Overview
+
+23 files across 8 domain subdirectories. Contains pure business logic and Payload-dependent service modules. Server actions in route directories delegate here; these modules hold the actual domain rules.
+
+## Structure
+
+```
+lib/
+ utils.ts # cn() class merger (Tailwind)
+ storageRules.ts # Storage validation (prohibited → whitelist → per-item cap)
+ shipping.ts # Fuel cost, transit time, vehicle effective speed
+ distance.ts # Haversine distance calculation
+ logistics.ts # Shared logistics helpers
+ versionInfo.ts # Build version display
+
+ attendance/ # Mission attendance service (single write path)
+ banking/ # Transaction engine, account creation, formatting
+ locker/ # Grid logic, placement validation, loadouts
+ market/ # Buy/sell, NPC negotiation, dialogue, vendor resolution
+ notifications/ # User notification helper + muteable types
+ realtime/ # In-process SSE bus (single-instance only)
+ tickets/ # Ticket vocabulary, Lexical helpers, staff resolution
+```
+
+## Dependency graph
+
+```
+Server actions ──┬── storageRules.ts
+ ├── lib/banking/index.ts ──┬── format.ts
+ │ └── ui.ts
+ ├── lib/market/index.ts ──┬── negotiations.ts
+ │ ├── npcs.ts
+ │ ├── npcDialogue.ts (pure, client-safe)
+ │ └── chatBubbles.ts (pure, client-safe)
+ ├── lib/locker/index.ts ── search.ts
+ ├── lib/attendance/index.ts
+ ├── lib/notifications/index.ts
+ └── lib/tickets/
+
+game-tick script ── shipping.ts, distance.ts, storageRules.ts
+market-tick script ── lib/market/index.ts (autoPrice, autoQuantity, npc vendor logic)
+```
+
+## Pure vs Payload-dependent
+
+- **Pure modules** (no Payload import, safe for client + server): `npcDialogue.ts`, `chatBubbles.ts`, `ticketMeta.ts`, `distance.ts`, `storageRules.ts` (logic only)
+- **Payload-dependent** (import `@payload-config`, server-only): everything else
+
+## Key modules
+
+### `storageRules.ts`
+Enforcement order: **prohibited → whitelist → per-item cap**. Functions: `checkStorageDeposit`, `isResourceProhibited`, `isResourceWhitelisted`, `getResourceStorageCap`, `storageViolationMessage`. Used in structure actions, shipment creation, and arrival processing.
+
+### `banking/index.ts`
+`applyTransaction()` — single source of truth for balance math. Validates accounts, creates transaction + ledger entries, updates balances. `ensurePersonalAccount()` — dedup find-or-create. `getMainCurrencyId()` — resolves Game Rules main currency.
+
+### `market/index.ts`
+`buyListing()` — validates, debits buyer, credits locker, marks sold. Supports partial buys via `quantity` parameter. `creditLockerQuantity()` — merges stackables or fills empty grid spots; throws if no space.
+
+### `market/negotiations.ts`
+NPC vendor pricing engine: stance starts at asking price, only moves down. Offers ≥ stance accepted; within 2% with 85% chance; well-below draw concession (30% of gap). Patience meter increments per round, closes at cap.
+
+### `realtime/bus.ts`
+Module-level subscriber Set. SSE endpoint `GET /api/realtime` subscribes; game tick POST `/api/game-tick/notify` broadcasts. **Single-instance only** — will not work across multiple server processes.
+
+## Where to look
+
+| Task | Path |
+|------|------|
+| Add storage rule logic | `storageRules.ts` (pure functions) |
+| Modify transaction flow | `lib/banking/index.ts` — `applyTransaction()` |
+| Change NPC pricing | `lib/market/negotiations.ts` — NPC_ACCEPT constants |
+| Add notification type | `lib/notifications/notificationTypes.ts` |
+| Add pure client+server logic | Verify no Payload import; place in appropriate domain dir |
+
+## Anti-patterns
+
+- **NEVER** import `@payload-config` in files under `market/npcDialogue.ts` or `market/chatBubbles.ts` — they must stay client-safe
+- **NEVER** duplicate business logic in server actions — always delegate to `lib/{domain}/`
+- **NEVER** use module-level state in `realtime/bus.ts` for cross-instance scenarios — use external pub/sub (Redis) instead
+- **NEVER** mutate `storageRules.ts` enforcement order without updating all 3 call sites (structure actions, shipment creation, arrival processing)
diff --git a/src/utils/AGENTS.md b/src/utils/AGENTS.md
new file mode 100644
index 0000000..2c4099b
--- /dev/null
+++ b/src/utils/AGENTS.md
@@ -0,0 +1,57 @@
+# AGENTS.md — Utilities & Access Control
+
+> **Parent**: `../../AGENTS.md` — RBAC overview, permission groups, event system.
+
+## Overview
+
+10 files across 3 subdirectories. Core cross-cutting concerns: three-layer RBAC, fire-and-forget event logging, XP resolution.
+
+## Structure
+
+```
+utils/
+ access-control/ 6 files Permission checking, role gates, qualification queries
+ event-log/ 3 files Event emitter, type constants, formatting
+ xp/ 1 file Level resolver
+```
+
+## Access control layers
+
+Three independent access mechanisms, each used in different contexts:
+
+### 1. `hasPermission(payload, user, "domain:action")`
+Full RBAC check against `src/permissions/index.ts` (100+ permissions). Superuser bypass. Cached 30s via `loadUserPermissions`. Used in server actions and collection access functions.
+
+### 2. `isRole(role)` / `hasRoles(roles[])`
+Lightweight role checks. Used for quick conditional rendering (e.g., `isRole("admin")` for admin-only UI). No Payload call — reads from the user object directly.
+
+### 3. `hasLogisticsQualification()` / `hasIntelligenceQualification()`
+Queries `Profiles.progression.qualifications` for specific qualification strings (case-insensitive). Admin/developer always pass. Used to gate logistics-only and intelligence-only UI sections.
+
+## Event log system
+
+### Emitter: `emitGameEvent(payload, { type, message, ... })`
+Fire-and-forget create on `game-event-logs`. Always sets `system: true`. Silent error catch (no throw). Always called AFTER successful mutation in server actions.
+
+### Event types: `EventTypes` constants (95 types across 12 categories)
+Defined in `eventTypes.ts`. Use these constants for TypeScript narrowing on the `type` field. Categories: `mission:*`, `finance:*`, `market:*`, `structure:*`, `logistics:*`, `bank:*`, `notification:*`, `locker:*`, `xp:*`, `ticket:*`, `attendance:*`, `system:*`.
+
+### Display: `formatType(type)` — human-readable label for event types.
+
+## Where to look
+
+| Task | Path |
+|------|------|
+| Add new RBAC permission | `src/permissions/index.ts` + use in `hasPermission` calls |
+| Add qualification gate | `access-control/hasLogisticsQualification.ts` (or create similar) |
+| Add event type constant | `event-log/eventTypes.ts` (add to `EventTypes` object) |
+| Emit event from action | `import { emitGameEvent } from "@/utils/event-log/emit"` |
+| Check qualification in layout | Use `hasLogisticsQualification(payload, user)` |
+| Modify XP calculation | `xp/resolveLevel.ts` |
+
+## Anti-patterns
+
+- **NEVER** throw in `emitGameEvent` — it's fire-and-forget by design
+- **NEVER** use `isRole` for permission-sensitive operations — use `hasPermission` instead
+- **NEVER** hardcode qualification strings — use the constants in the qualification collection
+- **NEVER** add event types without adding to `EventTypes` constants (breaks TypeScript narrowing)