# CR-12 — Broker Management module

Requirements by the BA (Fable), 2026-09-03. Client instruction verbatim:
"make Broker Management accoring to my wireframe design, i want pixle perfect.
… Also remove multiple Companies option just use single companies detail for
broker and this all broker show on inquiries when select Inquiry Source * is
Broker. when select broker then Broker Contact Number * auto-fill if already
exist else user will enter or change. … Please don't create test cases and
policies."

Wireframes (design source of truth, vendored at `docs/wireframes-v12/`):
`broker-management.html`, `add-broker.html`, `edit-broker.html`,
`view-broker.html`, plus `index.html` for the sidebar entry.

Follow `docs/adding-a-module.md` (the repo's own recipe) wherever this spec is
silent. Exemplars: **Inquiries** (full-page CRUD with conditional fields,
code generation, show page) and **Bookings** (file uploads: sniffed mime,
private disk, download routes). Projects is the recipe's reference module.

## §0 Client-mandated overrides — do NOT "fix" back

1. **NO new test files.** Existing pinned tests (AppShellTest's sidebar
   literals + its line-447 "does not see Broker Management" assertion,
   RolePermissionSeedTest's permission count, TwinViewParityTest's module
   list if it enumerates, DemoSeedTest) may be MINIMALLY updated to keep the
   suite green — nothing else.
2. **NO policies.** Use bare spatie permission gates:
   `Gate::authorize('brokers.view')` etc. in the controller, FormRequest
   `authorize()`, `@can` in blades. Security stays non-negotiable.
3. **Multiple Companies REMOVED** (client). The wireframe's repeatable
   `.multi-entry-list` Companies section (add/edit-broker.html 127-168:
   two `.multi-entry-item`s, the `×` remove button, the "Add Another
   Company" button) is replaced by ONE non-repeating "Company" form section
   holding the same three fields once: Company Name*, RERA Reg No*,
   GST Number. The list page's "Companies" count column ("2 companies")
   is dropped entirely — the Company (name) column already identifies it.
4. **All commission / financial / rating content REMOVED** (client:
   "commissions and other things nor required. We are not handling financial
   for booking or inquries"). Specifics in §3.4/§3.1.

House conventions otherwise apply IN FULL: lang files (`lang/en/brokers.php`),
routes in `routes/modules/brokers.php`, per-module
`app/Http/Requests/Brokers/` namespace, string-backed enum with `label()`,
`declare(strict_types=1)` + final classes, HasAudit on the model (already
there), server-side yajra DataTable with column whitelist, `Js::from()` for JS
interpolation, em-dash `—` blanks, `config('nrems.date_format')` for dates,
no innerHTML.

## §1 Context (verified in recon)

- `brokers` table exists since CR-6, deliberately minimal: `id`,
  `name` (unique — index covers soft-deleted rows), `contact_number`,
  timestamps, softDeletes. Model `App\Models\Broker` has HasAudit +
  `inquiries()` HasMany. Its docblock says the module "grows columns here
  rather than a table of its own" — do that.
- 5 demo rows from `DemoBrokerSeeder` (looked up by name
  withoutGlobalScope; deliberately never resurrects trashed rows).
- Inquiries already carry `broker_id` (FK, nullOnDelete) +
  `broker_contact_number` (copied on purpose — the number actually used).
  The inquiry form's Broker select already auto-fills the contact number
  from `data-phone` and leaves it editable
  (`public/assets/js/nrems-inquiry-form.js` 260-269) — the client's
  auto-fill ask is ALREADY satisfied; preserve it, don't rebuild it.
- No routes/controller/views/DataTable/permissions exist for brokers.
  Sidebar entry is an inert Super-Admin preview
  (`layouts/partials/sidebar.blade.php:278-280`).

## §2 Schema

### 2.1 `add_broker_management_columns_to_brokers_table` migration

All new columns **nullable** (D10 — five demo rows predate the form; the
FORM enforces the wireframe's `*`s, the DB does not):

- `code` string, unique — BRK-YYYY-NNNN (§4.1); nullable at first for
  backfill, see 2.2.
- `broker_type` string (enum values `individual` / `company`)
- `pan` string
- `email` string
- `company_name` string
- `rera_no` string
- `gst_number` string
- `address_line1` string
- `city` string
- `state` string
- `pincode` string
- `bank_name`, `account_number`, `ifsc_code`, `branch`,
  `account_holder_name` strings
- `agreement_start_date` date, `agreement_end_date` date
- `status` string, NOT nullable, default `'active'` (enum
  `active` / `inactive` / `blacklisted`) + index
- File slots (3 fixed documents, columns not a child table — D9):
  `gst_certificate_path`, `gst_certificate_name`,
  `pan_card_path`, `pan_card_name`,
  `agreement_document_path`, `agreement_document_name` strings
- **DROP the unique index on `name`** (D10): `name` now means *Contact
  Person Name* and two people at different firms may share one. Update
  `DemoBrokerSeeder`'s docblock claim about the reserved name if it
  states it, and keep its lookup working (it matches by name; without the
  index duplicates are possible but the seeder's firstWhere-style lookup
  still behaves). Uniqueness moves to validation (§6): `pan` and `email`
  unique among non-deleted brokers.

Keep the `Broker` model's `@property` list in step (larastan level 6).

### 2.2 Data migration (same or second migration)

Backfill `code` for existing rows in `id` order using the §4.1 generator
(their `created_at` year), then leave the column nullable-with-unique-index
(MySQL unique ignores NULLs; simpler than a NOT NULL retrofit). Existing
rows keep `status='active'` via the column default so they stay in the
inquiry dropdown. Query builder only — no model events, no activity rows.
`down()` may be empty (document why).

## §3 Screens — pixel-perfect to the wireframe minus §0 removals

Sidebar: activate the Broker Management entry — move it out of the preview
block into a live `@can('brokers.view')` anchor, same position (3rd in
Administration, `fa-handshake`), `routeIs('brokers.*')` active state. The
Administration heading gate (flat `@if` at sidebar:267) gains
`|| $user->can('brokers.view')` (keep it flat, per its docblock).

### 3.1 List — `brokers.index` (broker-management.html)

- h1 "Broker Management", breadcrumb Home / Broker Management.
- Action bar: h2 "All Brokers" + sub-text "Manage broker network, referrals
  and commission tracking" (wireframe wording kept verbatim — it is copy,
  not a feature); right: Add Broker button (`@can('brokers.create')`).
- **Stat cards, grid-4, all real counts, no fake trend deltas** (D6 —
  follow how earlier list pages treated wireframe deltas):
  1. Total Brokers (non-deleted count)
  2. Active (status=active count)
  3. Total Referrals (count of inquiries with a broker_id)
  4. **Active Bookings** (confirmed bookings whose inquiry has a
     broker_id) — REPLACES the wireframe's "Commission Paid" card (§0.4);
     reuse its card slot/icon style (yellow) with `fa-file-signature` or
     similar neutral icon, label "Active Bookings".
- Filter bar: search input (placeholder
  "Search brokers by name, company, RERA..."), Status select
  (All Status / Active / Inactive / Blacklisted), City select
  (All Cities + DISTINCT non-null broker cities, dynamic — D7, not the
  wireframe's hardcoded five).
- DataTable (yajra, server-side, whitelist) columns — wireframe's 11 minus
  "Companies" (§0.3) = **10**: Broker ID (code, fw-500), Name (fw-600),
  Company, RERA No, Phone, Email, Total Referrals (fw-500, subquery
  count), Active Bookings (fw-500, count of confirmed bookings via the
  broker's inquiries), Status (badge: success/Active,
  secondary/Inactive, danger/Blacklisted, with `.dot`), Actions.
- Actions per row: View (`fa-eye`, @can brokers.view), Edit (`fa-pen`,
  @can brokers.edit), **Delete** (`fa-trash`, @can brokers.delete — D3;
  the wireframe shows only View/Edit but the client asked for delete;
  DELETE form + confirm() like the projects/inquiries pattern, message
  "Delete broker {name}?…"). Em-dash `—` for null cells.
- Search covers name, company_name, rera_no, code, email, phone
  (server-side, whitelisted).

### 3.2 Add / Edit — `brokers.create` / `brokers.edit` (add/edit-broker.html)

Twin full-page forms (recipe §9 twin rule — shared partials so create/edit
cannot drift). One `.card`, sections in wireframe order, labels VERBATIM
including `*`:

**Broker Information** (`fa-user`):
- Row 1 (form-row-3): Broker Type (select: Individual / Company, no *),
  Contact Person Name * (text, maps to existing `name` column,
  placeholder "Enter contact person name"), PAN * (text, "XXXXX0000X").
- Row 2 (form-row-3): Phone * (tel, "+91 XXXXX XXXXX", maps to existing
  `contact_number`), Email * (email, "broker@example.com"), empty spacer.
- Row 3 (form-row-2): GST Certificate Upload and PAN Card Upload —
  `.upload-zone-sm` widgets, no *, "Drop … or click to upload (PDF, JPG
  up to 5MB)"; on edit, an uploaded file shows `fa-file-pdf` +
  "{original name} · {size} · Replace" per the wireframe.

**Company** (`fa-building`) — §0.3 single group, form-row-3:
Company Name * ("Enter company or firm name"), RERA Reg No *
("AG/RERA/RAJ/YYYY/XXXXXX"), GST Number (no *, "24XXXXX0000X1Z5").

**Address** (`fa-map-marker-alt`): Address Line 1 * (span-2 in
form-row-2, "Street address, building name"), City * ("e.g. Rajkot"),
State * (select: Gujarat / Maharashtra / Rajasthan / Madhya Pradesh —
Gujarat is the wireframe's `selected` default on ADD), Pincode *
("360001").

**Bank Details** (`fa-university`), two form-row-3 rows: Bank Name *
("e.g. State Bank of India"), Account Number *, IFSC Code *
("e.g. SBIN0000123"), Branch (no *), Account Holder Name (no *,
"Name as per bank records"), spacer.

**Agreement** (`fa-file-contract`): Agreement Start Date (date, no *),
Agreement End Date (date, no *), spacer; Agreement Document — full-size
`.upload-zone`, "Drop agreement document here or click to upload",
"PDF, DOC, DOCX up to 10MB"; edit shows uploaded state.

**Status** (D1 — NOT on the wireframe, client-decided): a Status *
select (Active / Inactive / Blacklisted, default Active) added to the
form. Placement (BA): the Broker Information section's Row 2 spacer slot,
so no new row is invented. Wireframe has no status input anywhere; this
is the sanctioned addition.

Footer: Cancel (outline, → index) + "Save Broker" / "Update Broker"
(primary, `fa-check`).

Edit h1 "Edit Broker", breadcrumb "… / Edit {code}". Add h1 "Add Broker".

### 3.3 Uploads (Bookings exemplar)

- Private disk (same disk/dir strategy as booking customer docs:
  `storage/app/private/...`, e.g. `brokers/{id}/…` hashed names).
- GST certificate + PAN card: max 5 MB, PDF/JPG (jpg/jpeg); agreement:
  max 10 MB, PDF/DOC/DOCX. Reuse CR-8's sniffed-mime handling incl. the
  .doc OLE asymmetry (SNIFFED_TYPE_EXTENSIONS) — do not re-derive it.
- Store path + original name per slot; replacing uploads deletes the old
  file AFTER the DB write commits; deleting never breaks the row.
- Download routes per slot (`brokers.download` with a slot parameter
  whitelisted to the three slot names, or three named routes — keep it
  whitelisted either way), gated `@can('brokers.view')`, streamed as
  attachment with the ORIGINAL name; 404 for an empty slot.
- The upload-zone click opens the file input (hidden input + JS like
  CR-8's upload screen); chosen-file name reflected client-side,
  textContent only.

### 3.4 View — `brokers.show` (view-broker.html, minus §0.4)

- Title/h1 "Broker - {company_name ?? name}", breadcrumb "… / {code}".
- Top summary card: initials tile (first letters of name, 2 chars,
  uppercase), h3 name, secondary company_name, status badge, Edit button
  (`@can('brokers.edit')`) — plus grid-4 detail row: Broker ID (code),
  RERA No, Phone, Email.
- **grid-2**: Card A "Broker Details" (`fa-user`): Broker Type, PAN,
  GST Number, City ("{city}, {state} - {pincode}"), Address,
  Agreement Period ("{start} - {end}" in nrems date format, — when both
  null), Bank ("{bank_name} - {branch}"). NO Internal Rating row (§0.4).
  Card B — the wireframe's Commission Structure card is REMOVED; in its
  slot render a **"Documents"** card (D9, `fa-folder-open`): the three
  slots (GST Certificate / PAN Card / Agreement Document) as
  label + original file name + download link, em-dash when empty. Same
  card idiom as the rest of the page.
- **Referral Performance** card: header + `badge-primary`
  "{n} Total Referrals" (real count of the broker's inquiries). Table of
  the broker's BOOKINGS (bookings whose inquiry belongs to this broker),
  columns (wireframe's 7 minus the two commission ones and Commission
  Status): Booking ID (code), Customer, Project - Unit, Date
  (booking date, nrems format), **Status** (booking status badge —
  replaces Commission Status so the column count stays balanced).
  Laravel paginator, 10/page, own pageName (`referrals_page`),
  withQueryString, fragment-anchored; empty state sentence when none.
- **Activity Log** card (`fa-clock-rotate-left`): the broker's REAL
  spatie activity rows (causer name + description + event), newest
  first, `.timeline` markup per the wireframe, time in nrems date+time
  format; cap at the latest 20 (BA) — no pagination.
- No commission cards, no rating, no Value/Commission columns anywhere.

## §4 Behavior

### 4.1 Broker code

`BRK-YYYY-NNNN`, 4-digit per-year sequence starting 0001 (wireframe shows
BRK-2026-0042; inquiries' 5-digit re-pad was inquiry-specific — follow the
broker wireframe). Generate exactly the way InquiryService generates
inquiry codes (same locking approach against duplicate codes under
concurrency; reuse/mirror that service pattern, do not invent a new one).
Include soft-deleted rows when finding the year's max. Server-owned:
NOT in `$fillable`, written via forceFill by the service.

### 4.2 Create / Update

`BrokerService` (per-module Services namespace): create assigns code +
status + handles uploads in one transaction (file writes outside/after
commit per CR-8's pattern); update never touches `code`. HasAudit covers
fillable changes automatically; no manual activity rows needed (all these
columns are fillable EXCEPT code — log the create with its code via the
normal spatie create event; that is sufficient).

### 4.3 Delete (D3)

Soft delete via confirm() DELETE form. **No in-use guard**: inquiries
outlive their broker by CR-6 design. After delete the broker vanishes
from the list (SoftDeletes global scope) and from the inquiry dropdown.
Inquiry HISTORY must keep showing the name: change
`InquiryController::show()`'s eager load to
`'broker' => fn ($q) => $q->withTrashed()` (select id,name) — plus the
same on any other read that renders `$inquiry->broker?->name`
(check `show.blade.php:73` match arm and the inquiries DataTable if it
joins brokers). The copied `broker_contact_number` already survives.

### 4.4 Inquiry dropdown + validation (D1)

- `InquiryController` form-data provider: brokers = status `active`,
  ordered by name — UNION the currently-selected broker on EDIT (the old
  inquiry's broker may be inactive/blacklisted/trashed; it must remain
  selected and render, flagged with a suffix like " (Inactive)" — BA —
  so the user understands; keep `data-phone` intact).
- Inquiry validation (`StoreInquiryRequest` and the update path): the
  existing `Rule::exists('brokers','id')->whereNull('deleted_at')` gains
  an active-status condition, EXCEPT the unchanged-broker allowance on
  edit (an inquiry already pointing at a now-inactive broker may be
  saved without changing broker). Implement precisely: create → must be
  active + not deleted; edit → (active + not deleted) OR
  `broker_id === $inquiry->broker_id` (trashed allowed only via the
  unchanged arm). Keep the auto-fill JS untouched.

### 4.5 Status (D1)

Plain fillable string column behind `BrokerStatus` string enum
(`active`/`inactive`/`blacklisted`, `label()` "Active"/"Inactive"/
"Blacklisted", `badgeClass()` success/secondary/danger, `options()`).
No default match arm. Status edits are ordinary form updates (HasAudit
logs them). Blacklisted behaves exactly like Inactive everywhere except
its badge (no extra semantics this CR).

## §5 Validation (`app/Http/Requests/Brokers/` Store + Update)

Wireframe `*` = required. Everything `sometimes`-free and explicit:

- broker_type: nullable, in enum values (Individual/Company)
- name (Contact Person Name): required, string, max:255
- pan: required, string, max:20, unique among non-deleted brokers
  (ignore self on update)
- contact_number (Phone): required + the SAME phone rules inquiries use
  (reuse the shared pattern/constant if one exists — recon shows
  `PHONE_RULES` in StoreInquiryRequest; extract/share rather than fork
  if cleanly possible, else mirror byte-identically with a comment)
- email: required, email, max:255, unique among non-deleted brokers
  (ignore self)
- company_name: required, max:255; rera_no: required, max:100;
  gst_number: nullable, max:50
- address_line1: required, max:255; city: required, max:100;
  state: required, in the four listed states; pincode: required,
   6 digits (regex)
- bank_name: required, max:255; account_number: required, max:50,
  digits-tolerant string; ifsc_code: required, max:20;
  branch/account_holder_name: nullable, max:255
- agreement_start_date / end_date: nullable, date; end after_or_equal
  start when both present
- status: required, in enum values
- Files: gst_certificate / pan_card: nullable file, max 5120 KB,
  mimes pdf,jpg,jpeg (sniffed, CR-8 pattern); agreement_document:
  nullable file, max 10240 KB, mimes pdf,doc,docx (sniffed, incl. the
  OLE asymmetry)
- jsvalidation client exclusions for whatever proengsoft would render
  wrongly (follow StoreInquiryRequest's `clientRuleExclusions()`
  reasoning — uploads and uniques at minimum).
- Lang: `lang/en/brokers.php` with fields/placeholders/sections/
  validation keys, wireframe wording verbatim.

## §6 Routes & permissions

- `routes/modules/brokers.php`:
  `Route::resource('brokers', BrokerController::class)` full seven
  MINUS nothing (index/create/store/show/edit/update/destroy) + the
  download route(s). Names `brokers.*`. Registered like the other
  module files.
- Permissions `brokers.view/create/edit/delete` appended to
  `RolePermissionSeeder::PERMISSIONS` (24 → **28**), Admin gets all
  (Admin => PERMISSIONS covers it automatically), Sales/Operations get
  none (D4: further grants happen through Role Management at runtime).
  Update RolePermissionSeedTest's pinned count minimally.
- Controller: `Gate::authorize` per action; DataTable ajax gated the
  CR-7 way (`ajax() && wantsJson()`).

## §7 Decisions

- **D1 (user)** Status select on Add/Edit, default Active; inquiry
  dropdown lists Active brokers only (+ the edited inquiry's own).
- **D2 (user)** All commission/financial/rating content removed;
  real-data replacements per §3.
- **D3 (user)** Delete = soft delete with confirm popup; no in-use
  guard; inquiry history reads broker withTrashed.
- **D4 (user)** Access purely permission-driven: 4 brokers.* permissions,
  Admin seeded, rest via Role Management.
- **D5 (client)** Single company detail; Companies repeater + count
  column removed.
- **D6 (BA)** 4th stat card = Active Bookings (real) replacing
  Commission Paid; no fake trend deltas.
- **D7 (BA)** City filter options = distinct broker cities, dynamic.
- **D8 (BA)** Code format BRK-YYYY-NNNN (4 digits, per wireframe);
  backfilled for the 5 demo rows.
- **D9 (BA)** Three fixed document slots as broker columns (not a child
  table); view page's second grid-2 card = Documents with downloads.
- **D10 (BA)** New columns nullable in DB; the form enforces required.
  Unique `name` index dropped (name = contact person now); pan + email
  unique via validation among non-deleted rows.
- **D11 (BA)** Inactive/blacklisted broker on an existing inquiry stays
  selectable-as-unchanged and is labeled with a status suffix in the
  dropdown.

## §8 Definition of Done

Run everything with `C:\wamp64\bin\php\php8.3.28\php.exe` (PATH php is
8.2.29). FOREGROUND runs only:

1. `php artisan migrate:fresh --seed` green; seeder idempotent (run
   RolePermissionSeeder twice → still 28 permissions).
2. Full `php artisan test` green: expect the pinned-test updates only
   (AppShellTest sidebar assertions incl. line ~447,
   RolePermissionSeedTest count, DemoSeedTest if touched) — NO new test
   files (§0.1). Suite baseline: 1097 tests / 4 pre-existing skips.
3. `composer analyse` (larastan level 6) = 0 errors.
4. `vendor/bin/pint --test --dirty` clean (never touch the corrupt
   `tests/Feature/Projects/ProjectCrudTest.php`).
5. Commit NOTHING; run no git write commands.
6. Report: files created/modified, any deviation from this spec flagged
   explicitly with reasoning, and the real gate output.

---

# CR-12.1 — Broker Management fix round (client instructions, 2026-09-07)

Client-mandated changes to the shipped CR-12 module. Same overrides as
CR-12 §0 (no new tests, no policies). Verbatim asks, restated as
decisions:

- **A1 — Address & Bank Details sections: no required fields.** Remove
  `required` from the rules AND the form asterisks for: `address_line1`,
  `city`, `state`, `pincode`, `bank_name`, `account_number`, `ifsc_code`
  (branch/account_holder_name already optional). All become `nullable`,
  KEEPING their format rules for when a value IS given (state `Rule::in`,
  pincode 6-digit regex, max lengths). The state select keeps its
  wireframe shape (Gujarat default, no empty option) — it will simply
  always post a value; that is acceptable and literal to the instruction.
- **A2 — PAN optional.** `pan`: `required` → `nullable`; remove the
  asterisk on both twins. KEEP `max:20` and the soft-delete-scoped
  unique rule (a duplicate PAN among live brokers is still wrong when
  one is supplied). Keep `pan` in `clientRuleExclusions()` (the unique
  rule still must not render as a remote check).
- **A3 — Email stays required + valid-format, and must validate LIVE on
  the client.** Server already has `required|email|max|unique`; keep it.
  Defect: `clientRuleExclusions()` excludes the whole `email` FIELD (to
  suppress the remote/unique render), so the client currently applies NO
  rules to it. Fix: after jsvalidation initialises, add the two safe
  rules back to that one input via jQuery Validation's
  `$('[name=email]').rules('add', { required: true, email: true })`
  (guarded — only if the form/validator exists; DOMContentLoaded; put it
  in nrems-broker-form.js or an inline push, implementer's choice, but
  no innerHTML and messages from the lang layer, not hardcoded English —
  reuse jsvalidation's own message source or `__()` printed via Js::from).
  Both twins. Do NOT remove `email` from the exclusion list (that would
  re-render the forbidden remote rule; ProjectJsValidationTest asserts
  none renders).
- **A4 — Address section layout bug.** City/State/Pincode touch the
  Address Line 1 box. ROOT CAUSE (verified): nrems-wireframe.css:2308
  `.form-row > .form-group:last-child { margin-bottom: 0 }` — Address
  Line 1 is the ONLY child of its `.form-row-2`, so it is `:last-child`
  and loses its 20px bottom margin; in Broker Information the 3-cell
  rows keep the gap through sibling margins. Fix scoped to this screen
  (do NOT touch the global rule): e.g. a `brk-`-prefixed rule with
  enough specificity to beat (0,3,0) — `.form-row > .form-group.brk-col-
  span-2 { margin-bottom: 20px }` in the app-additions tail, with a
  comment naming the mechanism. Result: the vertical rhythm matches the
  Broker Information section exactly (20px). Applies to create AND edit
  (both use `brk-col-span-2`). Check the last-row case: the address row
  is not the section's last row, so rule 2309 does not fight this.
- **A5 — Listing: remove the stat-card strip entirely.** Delete the
  `.grid-4` strip from brokers/index.blade.php (and the `$stats`
  default), remove `stats()` from BrokerController and its `'stats'`
  payload key (keep `cities`), drop now-unused imports, and remove the
  `brokers.stats.*` lang keys. The page becomes: action bar → the one
  card (filter bar + table). Update the view/class docblocks that
  describe the strip.
- **A6 — Listing: remove the Broker ID, Total Referrals and Active
  Bookings columns.** In BrokersDataTable: drop the three columns from
  `getColumns()`; drop `code` from the whitelist, the projection and its
  `editColumn`; delete `AGGREGATE_ORDER_SQL`, the `orderColumn` loop,
  the `withCount()`/`withCasts()` block (BrokerController::show keeps
  its own counts — untouched). Remove `columns.code`, `columns.referrals`,
  `columns.active_bookings` lang keys. **Default order must remain
  newest-first**: the current `->orderBy(0,'desc')` sorted on `code`;
  with it gone, pass `'order' => []` in parameters and give the base
  query an explicit `->orderByDesc('brokers.id')` (id order = insertion
  order = the code order the wireframe showed). Header sorting on the
  remaining whitelisted columns must still work; verify a draw with a
  hand-rolled `order[0][column]` for a removed index cannot 500.
- **A7 — Twins + parity.** Every form change lands identically in
  create.blade.php and edit.blade.php (TwinViewParityTest compares name
  sets AND required-attribute sets). jsvalidation picks up the new
  nullable rules automatically — verify no `required` leaks client-side
  for the loosened fields.
- **A8 — Docblock hygiene.** Every docblock whose claims this round
  falsifies (StoreBrokerRequest "The required set", BrokersDataTable
  class docblock ten-columns/aggregates paragraphs, index.blade.php
  header comment, controller stats docblock) must be rewritten to tell
  the truth. No comment may describe removed code.

Definition of Done: §8 unchanged, except the permission seeder baseline
is now **32** (CR-13 landed) and the suite baseline is **1102 tests /
4 pre-existing skips** — pinned-test updates only if one actually
asserts the removed cards/columns (check AppShellTest and any broker
smoke assertions; flag every such edit explicitly).
