Multi-tenant admin application built on MintyPHP. Manages tenants, users, departments, roles, permissions, address books, mail, CSV imports, scheduled jobs, and Microsoft Entra ID SSO.
- **Full workflow trigger is risk-based** and defined in `.agents/checks/enforcement-policy.json` (`full_workflow_required_when`). Matching changes MUST follow `.agents/workflow.md` (Analyst → Planner → Executor → Reviewers → Finalizer). Read `.agents/README.md` and the relevant role prompt in `.agents/prompts/` before starting.
- **Quick fixes** may skip full workflow only if they match `.agents/checks/enforcement-policy.json` (`quick_fix_allowlist`) and still pass required gates.
- **Factory** (`*Factory.php`): Only place inside `core/Service/` that may call `new` on services/gateways/repositories. Registers via DI container.
- **Policy** (`*Policy.php` in `core/Service/Access/`): Authorization decisions per resource type. Returns `AuthorizationDecision`. No HTTP, no business logic.
- **Cross-module coupling:** Modules may depend on other modules but MUST declare `requires: [<module-id>]` in their manifest. Undeclared cross-module imports are forbidden. Prefer loose coupling via events and providers over direct service injection
- **Module DB changes:** Via `module:migrate`, not `db/updates/` (which is for core schema only)
- **Module permissions:** Registered in manifest, synced via `module:permissions-sync` — never hardcoded
- **Manifest contract:** Must conform to `.agents/contracts/module-manifest.schema.json`
- **Tab panel width**: `data-tab-panel` defaults to `max-width: 70ch` (optimal for form readability). Add `data-tab-panel-wide` for panels that need full width (e.g. split layouts, side-by-side editors, preview panels, tables). Use 70ch for standard form fields (text inputs, selects, textareas); use wide for multi-column layouts or content that benefits from horizontal space.
- **Buttons**: Always use core classes (`secondary outline small`, `danger outline small`) — custom CSS only for shape/position. Icon-only buttons must have `data-tooltip` + `aria-label`.
- **Components inside forms**: Reset core `margin-bottom` on buttons/inputs/selects/labels at component scope. Checkboxes in flex need explicit `1.25em` sizing + `flex-shrink: 0`.
- **Sticky elements**: Always use `top: calc(var(--app-topbar-height) + ...)` — never `top: 0`. Multi-column grids must have a responsive single-column `@media` fallback.
- **File uploads** use `app-file-upload.phtml` partial (deliberate exception to plain-HTML inputs — the markup is too complex for inline use). See `templates/partials/app-file-upload.phtml` for the `$fileUpload` array contract. Never write file-upload markup inline; always use the partial.
- **List exports**: every Grid.js list that offers a download MUST go through the core primitive — no inline `fputcsv`, no hand-rolled headers, no bespoke dropdown. (GR-UI-EXPORT)
- **Server:** endpoint calls `exportRequireGetRequest()` + `exportCapLimit()` + `exportSendCsv()` from `core/Support/helpers/export.php`, builds columns as `ExportColumn[]` (`core/Service/Export/CsvExportService.php`), and reuses the SAME `filter-schema.php` via `gridParseFiltersFromSchemaFile()` as the data endpoint. Extract a shared service or presenter if row-formatting is non-trivial (see `DomainListService` / `SystemAuditRowPresenter`).
- **View:** `require templatePath('partials/app-list-export-dropdown.phtml');` inside `$listTitleActionsHtml`. Pass the URL via `'exportUrl' => endpointUrl('<route-path>')` in the page config — **always `endpointUrl()`, never plain `lurl()`**. The helper resolves module routes to their target path so query strings survive.
- **Client:** `initListExport({ gridConfig, exportUrl })` from `/js/core/app-list-export.js`. No hand-rolled click listener, no inline URL construction.
- **Detail drawer**: Row-level detail views that slide in from the right use the core primitive — no per-module drawer implementation.
- **Server:** Action `<route>/<name>-fragment($id).php` + view `<route>/<name>-fragment(none).phtml`. The `(none)` template renders without layout/topbar/breadcrumbs. Reuse the same partial that the full-page view uses (extract a `modules/<id>/templates/<name>-profile.phtml` if needed). Must enforce auth + scope exactly like the full-page view.
- **Route:** Declare `['path' => '<route>/<name>-fragment', 'target' => '<route>/<name>-fragment']` in the module manifest.
- **Client:** `initDetailDrawer({ gridConfig, fetchUrl: (uuid) => new URL(`<route>/<name>-fragment/${uuid}`, appBase).toString(), fullUrl, hashPrefix, rowProvider?, onContentLoaded? })` from `/js/components/app-detail-drawer.js`. The drawer handles focus-trap, body-scroll-lock, session-expiry reload, and auto-initializes tabs/tooltips/fslightbox in the fragment via `initFragmentContent()`. Consumers do NOT need to call `initTabs`, `refreshFsLightbox`, etc. manually. For module-specific runtime (e.g. async communication feeds) use `onContentLoaded(contentEl, uuid)`.
- **`fetchUrl` signature:** write it as a single-expression arrow (not a block body) so the architecture test can statically locate the fragment path. Pattern: ``(uuid) => new URL(`path/${uuid}...`, appBase).toString()``.
- **Architecture test** `DetailDrawerFragmentContractTest` enforces that every `initDetailDrawer({ fetchUrl })` has matching `*-fragment($id).php` + `*-fragment(none).phtml`.
- **Filename rule:** URL parameters go only into the `.php` action filename (`*-fragment($id).php`). The `(none).phtml` view filename must NOT contain `($id)` — MintyPHP's router parses paren groups and breaks silently on nested ones.
- **Token-select field** (combobox + flat removable list): use `tokenSelectForm()` from `core/Support/helpers/ui.php` for any multi-select where the selected set is potentially large and a chip-header or full dropdown becomes unreadable. The primitive renders an inline typeahead input with popover suggestions and a flat alphabetical list of selected items with per-row remove. No drawer, no grouping — keep callers flat.
- **Server:** `tokenSelectForm($id, $name, $label, $placeholder, $items, $selected, $disabled, $labelKeys, $formId, $labelOverrides)`. Hidden `<select multiple name="<name>[]">` is the form submission contract — the consuming action reads it verbatim via `$request->body('<name>', [])`, no parsing required.
- **Data contract:** each item is `['id' => int]` plus ONE of the keys listed in `$labelKeys` (default: `['description', 'label', 'name']`). Optional `'key' => string` is a hidden search hint that participates in fuzzy matching but is NOT displayed to the user.
- **Label overrides:** pass `$labelOverrides` (10th arg) with any of `emptyState`, `noMatches`, `removeTooltip`, `clearAll`, `countSuffix`, `errorMessage` to replace the generic defaults with domain-specific text. Values are already-translated strings (call `t(...)` at the call-site).
- **Readonly:** pass `$disabled = true` — combobox, clear-all and remove buttons are suppressed; the list renders as pure presentation.
- **Client:** `initTokenSelect` from `/js/components/app-token-select.js` is auto-registered as `token-select` in `app-init.js`. The component reads the hidden `<select>` as source of truth, syncs it on every add/remove, and dispatches a native `change` event that callers can listen to for dependent UI.
- **Remove button:** uses the shared `.app-icon-button` primitive + `<i class="bi bi-x-lg">` — never hand-roll a close button.
- **Coexistence:** `multiSelectForm()` (vendor MultiSelect) remains available for small closed lists where the chip-header is acceptable. Pick `tokenSelectForm()` when the selected set can grow beyond ~10 items or typeahead is the expected interaction.
- **Tests:** helper contract lives in `tests/Support/Helpers/TokenSelectFormHelperTest.php`; component lifecycle + entrypoint registration are enforced by `tests/Architecture/FrontendRuntime*ContractTest.php`.
- **Dashboard tile** (icon + count + label link): use `appTile()` from `core/Support/helpers/ui.php` for dashboard / stat-grid tiles that link to a filtered list view. Renders the shared `templates/partials/app-tile.phtml` with `.app-tile` CSS. Options: `href`, `label`, `count`, `icon` (bi-*), `iconBg`, `iconColor`, `class`, `tooltip`, `tooltipPos`. Group tiles inside `<div class="app-tiles">` for the grid layout. Modules may call `appTile()` directly — no wrapper class needed.
- Hand-rolled list exports (inline `fputcsv`, own header setup, bespoke click-to-download JS) — always use `core/Service/Export/*` + `exportSendCsv()` + `app-list-export-dropdown.phtml` + `initListExport()` (GR-UI-EXPORT)
- Building an export/data endpoint URL with `lurl()` when it will carry query params — use `endpointUrl()` so module routes where `path ≠ target` still resolve (GR-UI-EXPORT)