# Changelog

All notable changes to Aumni AMS. Newest first.

---

## [1.0.0] — 2026-08-07

**Version 1.0.0 — Production Release.** Every module in the original
architecture was built across Phases 0–10 and approved. RC1 was a complete
application-wide QA, security, performance, code-quality and UI-consistency
review with no new features — findings and fixes are detailed below. RC1 has
been reviewed and approved. See `docs/RELEASE_NOTES_v1.0.0.md` for the
release-facing summary.

### Fixed

- **Unbounded QR bulk-label selection.** `AssetQrController::bulkPreview()`
  and `bulkPdf()` accepted any number of `asset_ids[]`, unlike the directly
  analogous Reports PDF export, which has carried a row cap since Phase 9.
  Label generation is heavier per row than a report row (it renders a QR
  image per asset before laying every one out in one dompdf document), so an
  unbounded selection was a genuine, if not yet exploited, resource-exhaustion
  risk. Added `MAX_BULK_ASSETS = 500` (~21 sheets on the Standard template);
  both routes now redirect to the picker with an explanatory message on an
  empty *or* over-cap selection, instead of only on empty.
- **`<x-filter.date-range>` hardcoded `id="date_from"`/`id="date_to"`**,
  unlike every sibling `filter/*` component, which derives its id from a
  `name` prop specifically to avoid collisions. Not an active bug (every
  current caller renders the component once per page), but a latent one:
  a future page rendering it twice would have silently broken label
  association and let only the first field's value take effect. The
  component's fixed `date_from`/`date_to` query-string keys are untouched
  (multiple report pages depend on that contract) — only the `id`/`for`
  pairing is now namespaced via a new, optional `id` prop, defaulting to the
  previous behaviour.
- **Warranty index's five KPI cards** (Active / Expiring 30 / 60 / 90 days /
  Expired) sat in a 4-per-row grid, orphaning the fifth card alone on its own
  row on desktop and tablet — the one layout inconsistency found across a
  69-screenshot pass (23 pages × desktop/tablet/mobile). Every other KPI
  row in the app divides evenly. Fixed by reusing the Dashboard's own
  `col-xl-2dot4` utility (added in an earlier phase specifically for a
  5-across KPI row) instead of inventing a new one.

### Hardened

- **`config('filesystems.disks.local')` no longer sets `'serve' => true`.**
  Investigated as a potential critical vulnerability: this Laravel 11+ flag
  auto-registers a `GET storage/{path}` route for the disk backing
  `storage/app/private` — exactly where warranty documents and import files
  live, otherwise reachable only through the app's own authenticated,
  ownership-checked routes. Verified against a real uploaded file that the
  route was **not** actually exploitable — with no `visibility => public` on
  this disk, Laravel's native `ServeFile` handler requires a cryptographically
  signed URL that the application never generates anywhere, so an
  unauthenticated request correctly received a 403, not the file. Removed
  the flag anyway as defense-in-depth: the app has no legitimate use for the
  route, and leaving it registered meant a future, unrelated config change
  (e.g. copying `'visibility' => 'public'` from the `public` disk block)
  could silently turn it into a real, unauthenticated file-serving
  route with no code review able to catch it from the route list alone,
  since the route was already there either way.

### Verified, not changed

A full pass was made across every checklist item in `docs/ARCHITECTURE.md`'s
production-readiness scope, confirming each of the following was already
correct and needed no change: CSRF protection (no exceptions registered),
session cookie flags and `.env.example`'s production guidance, login rate
limiting, mass assignment guards, N+1 queries and eager-loading across every
paginated listing and dashboard widget, database indexing against every
filter/scope, and the `AppSettings`/dashboard caching layers. Every model
trait (`Auditable`, `TracksBlameable`) and the `app/Support/helpers.php`
file were re-read in full and found clean — no dead code, no duplication.

### Added

- **`docs/USER_GUIDE.md`** and **`docs/ADMIN_GUIDE.md`** — the first
  end-user-facing documentation this project has had; every workflow,
  field, validation message and status rule described in them was verified
  directly against the current code, not assumed from memory of building it.
- **`docs/RELEASE_NOTES_v1.0.0.md`** — the release-facing summary for
  Version 1.0.0.

### Tests

491 tests / 1,642 assertions, all passing (2 added this pass, covering the
QR bulk-cap fix). Laravel Pint and Larastan (level 5) both clean.

---

## [0.13.0-phase10] — 2026-08-07

**Phase 10 — Settings, Audit Logs, production hardening.** The final
development phase: the last two modules in the original architecture
(Settings, Audit Logs) built, a full production-readiness review across
security, database indexing, dead code, caching and deployment, and the
first-ever `docs/DEPLOYMENT.md` / `docs/BACKUP.md` runbooks. Architecture,
database schema, design system and every previous module stayed frozen
except where this review found a genuine bug worth fixing.

### Added

- **Application Settings** (`/admin/settings`, master-only) — one page, five
  tabs (Company, System Preferences, Assets & Imports, Notifications,
  Security), one `PUT` covering every field at once so switching tabs can
  never feel like it risks losing an edit made on another. Company name,
  logo (upload/replace/remove, stored on the public disk), address, contact
  email/phone, application name, date/time formats, currency, pagination
  size, asset tag format, the warranty digest's alert windows and
  recipients, and security settings (password floor, audit retention) —
  every one of these already existed as a seeded `Setting` row with no UI
  until now.
- **Two settings that are new, not just newly editable**: `timezone`
  (applied via `date_default_timezone_set()` once per request, in
  `AppServiceProvider::configureTimezone()` — governs every `now()` computed
  from that point on) and `mail_from_name`/`mail_from_address` (overrides
  `config('mail.from')` for the warranty digest's From header only — SMTP
  connection details stay in `.env`, per the existing split between business
  configuration and infrastructure secrets).
- **`import_max_rows`** — `ImportService`'s row cap (`MAX_ROWS = 2000`) is
  now a ceiling read from settings at runtime, not a hardcoded constant; the
  constant remains as the seeded default.
- **Audit Logs** (`/audit-logs`, open to every signed-in user — architecture
  never restricted this to masters) — a dashboard row (total/today/this
  week/active users), search, and User/Module/Action/date-range filters,
  reusing the exact `<x-filter.date-range>` component Reports introduced in
  Phase 9. A detail page per entry renders the full before/after diff via
  `AuditLog::changes()`, already written in Phase 0 with no consumer until
  now. Export honours the current filters, matching every other export in
  the app.
- **`ams:prune-audit-logs`**, scheduled weekly — deletes routine entries
  older than the `audit_retention_months` setting (seeded since Phase 0,
  also with no consumer until now); custody events (assigned/returned/
  renewed) are exempt, matching the exemption Reports and the dashboard
  already give them.
- **`docs/DEPLOYMENT.md`** and **`docs/BACKUP.md`** — referenced by
  `docs/ARCHITECTURE.md` §20–21 since the very first draft, written for the
  first time: pre-flight host requirements, the release checklist, `.env`
  values that need real thought rather than a placeholder, storage
  permissions, the single cron job, a production readiness checklist, and a
  backup/restore runbook including the details a restore drill specifically
  needs to catch (settings and the logo file must be restored together, or
  the sidebar renders a broken image).

### Fixed — found during the production hardening review

- **`config('app.timezone')` was hardcoded to `'UTC'`**, regardless of
  `.env`'s `APP_TIMEZONE` — the config file never actually read the
  environment variable. Every timestamp the application had ever computed
  was silently in UTC, never the intended zone. Undetectable locally or
  under PHPUnit, since neither exercises wall-clock display against a real
  clock in a way that would reveal a wrong timezone. Fixed by wiring
  `env('APP_TIMEZONE', 'UTC')`, and given a second, runtime-editable layer
  via the new `timezone` setting.
- **The CSP's `style-src 'self'` was silently stripping inline styles**
  already in use across roughly a dozen views (free-text notes fields, the
  dashboard chart panel's dynamic height, table column widths, the QR label
  sheet's embedded stylesheet) — invisible in every environment except a
  real browser enforcing the policy in production, which local development
  and the test suite never do. Weighed a nonce/nonce-hash fix against
  broader impact and settled on adding `'unsafe-inline'` to `style-src`
  only — CSS injection is a materially lower-severity risk than script
  injection, and `script-src 'self'` stays exactly as strict as before,
  with zero inline `<script>` blocks anywhere in the application to weaken
  it for.
- **`warranty-renewal-history`'s PDF export 500'd** on any report grouped
  by `GROUP BY` with no `HAVING` clause (the four Assets-by-* inventory
  reports) — actually a Phase 9 bug, caught only now while re-verifying
  every export path: `Builder::count()` rewrites the `SELECT` to
  `COUNT(*)` but leaves an existing `GROUP BY`/`ORDER BY`-on-alias in
  place, which MySQL rejects. Fixed by wrapping the grouped query as a
  derived table for the count.

### Changed

- `SettingSeeder`'s `company_name`/`company_logo` regrouped from
  `general`/`branding` into a new `company` group, matching the Settings
  screen's actual tab structure — pure presentation metadata, re-applied on
  every seed run by design, no behavioural change.
- README, ARCHITECTURE and DESIGN-SYSTEM all reviewed and brought current —
  ARCHITECTURE's revision history had accumulated components (Reports'
  sort-link and date-range filter, Phase 8's dashboard chart components)
  that were never documented in DESIGN-SYSTEM.md; the Status line had read
  "No code written yet" since v1.0.

---

## [0.12.0-phase9] — 2026-08-06

**Phase 9 — Reports.** A professional reporting module for IT
administrators, managers and auditors: 22 reports across five categories
(Inventory, Employees, Assignments, Warranty, Audit), every one filterable
by search/date-range/status, sortable, paginated, and exportable to Excel
and PDF — all through one reusable report viewer rather than 22 bespoke
screens. Roadmap amended (v1.5): Reports' scope grows from the original 6
to 22, matching how Phase 7 (warranty documents) and Phase 8 (dashboard
scope) each grew beyond architecture's original schematic when the phase
brief called for more.

### Added

- **`ReportService`** — the single source for every report: `catalog()`
  returns lightweight metadata (title/description/category/icon) for the
  landing page without paying for any report's lookup-option queries;
  `definition($key)` builds exactly one report's full shape — columns,
  filters, query, row mapper, sortable whitelist — on demand;
  `run($key, $filters)` executes that report's query and applies the one
  shared sort/whitelist step every report goes through, so a report can
  never expose a "sortable" column that silently does nothing.
- **`ReportDefinition` / `ReportColumn` / `ReportFilter`** (`App\Support\Reports`)
  — the declarative shape one report is described in. Each report's `row`
  closure is the single place a database record becomes a table row, called
  identically by the HTML viewer, the Excel export and the PDF export — a
  spreadsheet column can never quietly drift from what the on-screen table
  shows for the same report.
- **One `ReportController`, one `ReportExport`, three views** for all
  twenty-two reports — `reports/index.blade.php` (category cards with
  descriptions, not a flat link list), `reports/show.blade.php` (the
  generic viewer), `reports/pdf.blade.php` (a plain `<table>` layout,
  learning Phase 7's lesson that dompdf's CSS Grid and float support
  silently misplace content).
- **Every query reuses an existing model scope** — `Asset::filter()`,
  `AssetWarranty::primary()/active()/expired()/expiringWithin()`,
  `AssetAssignment::overdue()/returned()`, `AuditLog::betweenDates()`,
  `Employee::search()/status()/inDepartment()`, `ImportBatch::byStatus()` —
  rather than re-deriving filtering rules the modules that own those tables
  already established and already test.
- **Warranty reports read the featured coverage only**, except one — Active/
  Expiring/Expired Warranties all query `AssetWarranty::primary()`, carrying
  forward Phase 7's "dashboard/report widgets use the active warranty only"
  rule. Warranty Renewal History is the deliberate exception: it queries
  every row, primary or not, because the whole point of that report is
  showing the lineage renewal preserves.
- **New reusable components**: `<x-table.sort-link>` (a column header that
  toggles `sort`/`direction` in the URL while preserving every other filter,
  the first sortable-header control in the app) and `<x-filter.date-range>`
  (a "from / to" date pair, always `date_from`/`date_to` regardless of which
  column a given report actually filters by).
- **PDF export row cap** (2,000 rows, matching Import's existing row-cap
  precedent) — a dompdf render of more rows than that is slow and
  memory-heavy for shared hosting; Excel has no such limit because
  `FromQuery` streams rather than building a DOM. Exceeding it redirects
  back with a message pointing at Excel instead of silently truncating data.
- **Chart.js is not used here** — Phase 9's brief asked for tables, not
  charts; the grouped reports (Assets by Category/Department/Location/
  Status) are aggregate tables, not the capped, chart-shaped summaries
  Phase 8's dashboard widgets already show, so nothing here duplicates that
  logic.

### Fixed

- **`Builder::count()` on a plain `GROUP BY` (no `HAVING`) produces invalid
  SQL** — Eloquent rewrites the `SELECT` to `COUNT(*)` but leaves an
  existing `GROUP BY`/`ORDER BY`-on-alias in place, which MySQL rejects.
  Hit by the PDF export's row-cap check on all four grouped inventory
  reports. Fixed by wrapping the grouped query as a derived table
  (`DB::query()->fromSub($query, 'grouped')->count()`) for the one case
  that needs it, keeping the cheap single-`COUNT(*)`-query path for every
  row-level report.
- **`warranty-renewal-history`'s default sort referenced `asset_tag`**,
  which lives on the joined `assets` table, not `asset_warranties` —
  500'd immediately. Sorted by `asset_id` instead (still groups every
  asset's coverage periods together, without needing a join just for one
  header's sort order).

---

## [0.11.0-phase8] — 2026-08-06

**Phase 8 — Dashboard.** The landing screen after sign-in becomes the
operational home page: with Assets, Assignment/Return, Warranty and
Employees all live, `DashboardService` now assembles ten executive KPIs, six
charts and six operational worklists from real data, on top of the
account-administration summary Phase 1 already showed masters. Roadmap
amended (v1.4): Dashboard and Reports swap places — see `docs/ARCHITECTURE.md`
§18.

### Added

- **`DashboardService`** — the single source for everything the dashboard
  shows: KPI counts (total/assigned/available/under-repair/lost/retired
  assets, warranty expiring/expired, total/active employees), six chart
  datasets (assets by category/department/location, asset status
  distribution, warranty status, monthly procurement), and six operational
  widgets (recently assigned/returned assets, assets awaiting return,
  upcoming warranty expiry, recent activity, employees holding the most
  assets). The whole snapshot is one `Cache::remember()` entry with a fixed
  five-minute TTL — a deliberate simplification from architecture §16's
  write-triggered invalidation, which would have meant touching
  `AssetService`, `AssignmentService` and `WarrantyService`, outside this
  phase's "previous modules' business logic stays frozen" boundary.
- **Warranty aggregates read the featured coverage only** — the KPI cards,
  the warranty status chart and the upcoming-expiry widget all query
  `AssetWarranty::primary()`, never every historical row, carrying Phase 7's
  "dashboard widgets use the active warranty only" rule into the one place
  it applies here.
- **Chart.js**, bundled locally via Vite (not a CDN script, for the same
  reason the fonts are self-hosted) — the one place in the app where
  something is drawn client-side rather than server-rendered, fed by a
  `type="application/json"` data island per chart so no inline-script CSP
  exception is needed. Each chart panel falls back to a plain `<table>`
  inside a `<noscript>` for the no-JavaScript case.
- **`<x-dashboard.chart-panel>`** and **`<x-dashboard.activity-list>`** —
  the two reusable components the whole page is built from: one Chart.js
  panel with an empty state for an all-zero dataset, and one compact
  href/icon/title/subtitle/meta/badge record list reused by all six
  operational widgets.
- **A fifth KPI column** (`.col-xl-2dot4`, 20% width above the `xl`
  breakpoint) — the one non-standard Bootstrap grid step added, so ten KPI
  tiles read as two clean rows of five on a wide monitor instead of an
  uneven 4-per-row wrap.

### Changed

- **Roadmap v1.4** — Dashboard and Reports swap places (was 8 Reports, 9
  Dashboard; now 8 Dashboard, 9 Reports). Every module the dashboard
  summarises already exists and holds real data by Phase 7, and the
  dashboard is the screen every user lives in daily — more urgent to ship
  than the periodically-consulted reporting screens. No schema or
  module-capability change.
- **`resources/views/dashboard/index.blade.php`** rebuilt around the new
  KPI/chart/widget layout; the Phase 1 "Rollout" checklist panel is retired
  (every module it tracked is now live) and the "Your account" detail panel
  is dropped in favour of the existing Profile page it duplicated. The
  master-only account-administration summary is kept, relocated below the
  new content.

### Fixed

- **Overdue-days badge showed a raw float** (`14.693161947431d overdue`)
  during manual verification — `Carbon::diffInDays()` returns a float in
  the Carbon version this app pins, and the dashboard view's inline
  overdue-day calculation was the one place that difference wasn't cast to
  `int` before display (`AssetWarranty::daysRemaining()`, written in Phase
  7, already did this correctly). Fixed, and the regression test for the
  "Assets Awaiting Return" widget now asserts the exact whole-day string
  rather than just the word "overdue", so a fractional value fails loudly.

---

## [0.10.0-phase7] — 2026-08-06

**Phase 7 — Warranty Management + QR Labels.** A complete warranty lifecycle
— manufacturer/extended/AMC/insurance coverage, renewal that preserves full
history, documents, a dashboard, advanced filters, an urgency-grouped alerts
view, bulk export and a daily digest email — plus the QR capabilities
deferred from Phase 4: per-asset PNG download and single-label print, and
bulk, multi-asset A4 label sheets with a genuine print preview.

### Added

- **`asset_warranties` table** — `type`, `provider`, `reference_number`,
  `start_date`/`end_date`, `cost`, `notes`, a document (path/original
  name/mime), `is_primary`, soft-deletable. The one-primary-warranty
  guarantee is a database constraint, not an application promise — the same
  virtual-generated-column-plus-partial-unique-index trick Phase 5 used for
  the one-active-assignment invariant, so a double-primary is physically
  impossible even under a race.
- **`WarrantyService`** — `create()` (an asset's first warranty is always
  featured automatically; every other one only if asked), `update()`
  (deliberately never touches `is_primary`), `renew()` (closes the current
  primary exactly as it was and opens a new one of the same type as the new
  primary, in one transaction), `delete()`, `expiringWithin()`/`expired()`,
  and `digestData()` for the email command.
- **Renewal preserves history by construction** — renewing never edits or
  removes the record it replaces, only clears its `is_primary` flag; the old
  coverage period stays exactly as it was, forever, in the same "Warranty
  History" table as everything else.
- **`WarrantyStatus`** — the one enum in this app that isn't backed by a
  column: Active/Expiring Soon/Expired is computed fresh from `end_date`
  every time, because a warranty moves between them just by time passing,
  with no write ever happening.
- **Documents** — a PDF/JPG/PNG upload per warranty (10 MB cap, same private
  `local` disk convention Phase 6 established for import files), replaceable
  and removable from the edit form, downloadable from the warranty history
  table.
- **Warranty dashboard** (`/warranty`) — five stat cards (Active, Expiring in
  30/60/90 days, Expired), each linking to the register pre-filtered;
  search, type and status filters; bulk Excel export honouring the current
  filter, matching the Assets/Import export convention.
- **Expiring Soon** (`/warranty/expiring`) — a read-only, three-bucket
  urgency view (≤30 days, 31–90 days, expired) in the spirit of OptiSet's
  own Warranty Alerts screen, with a Renew shortcut on every row that's
  still the featured coverage.
- **Daily digest email** (`ams:send-warranty-digest`, scheduled 08:00) — the
  `warranty_alert_days` / `warranty_alert_recipients` / `enable_warranty_emails`
  settings seeded since Phase 0 finally have a consumer. Fires once per
  configured window (default 30/15/7 days out) per warranty, not once a day
  for the whole window, so a recipient sees each warranty mentioned a
  handful of times total, not dozens.
- **QR: PNG download and single-label print** — `QrCodeService::pngFor()`
  (GD-backed, already a required extension, no new deploy dependency);
  `assets/{asset}/qr/print` for one large, clearly printable label.
- **QR: bulk label sheets** — a picker (`/assets/qr/bulk`, filters +
  checkbox selection, reusing the Assets index's own bulk-select pattern) →
  a print preview rendered from the exact same markup the PDF uses → a
  downloadable A4 PDF (`barryvdh/laravel-dompdf`). Two templates (Standard,
  24/sheet; Large, 10/sheet); each label carries the QR, asset tag and name.
- **`AuditEvent::Renewed`** — one audit entry per renewal, attached to the
  asset (not the new warranty row) so it appears in the same Recent Activity
  feed as every other domain event.
- **`WarrantyManagementTest`** (21 tests) and **`QrLabelTest`** (10 tests).

### Fixed

- **CSS Grid and floated divs both silently dropped content in the PDF.**
  A first version of the label-sheet layout used CSS Grid; dompdf rendered
  every label into a single column instead of the configured N-per-row, with
  no error. A floated-divs rewrite fixed the column count but then dropped
  every row after the first outright, again silently — both are exactly the
  parts of dompdf's CSS support that lag furthest behind a real browser's.
  Neither was caught by the test suite, which checks HTTP status, headers
  and the underlying HTML text (all present and correct in every version;
  Blade's `@foreach` output was never the problem) — only rasterising the
  actual generated PDF and looking at it caught this. Replaced with a plain
  `<table>`, which is what dompdf is actually built to render reliably.
- **`WarrantyService::renew()` attached its audit entry to the wrong
  model** — the new warranty row instead of the asset, matching every other
  domain event (Assigned, Returned, Imported). Found by looking at the
  rendered Recent Activity feed during the design review, where a fresh
  renewal was visibly missing; the existing test only checked the log's
  description text, not which record it was attached to; strengthened
  alongside the fix.

---

## [0.9.0-phase6] — 2026-08-06

**Phase 6 — Import.** Bulk-onboarding for the asset register, moved ahead of
Warranty/QR/Reports/Dashboard in the roadmap on the reasoning that a system
holding no inventory has nothing to report on or issue QR codes for — see the
roadmap reorder note in `docs/ARCHITECTURE.md` §18. Excel (.xlsx) and CSV,
template download, a genuine preview step before anything is written,
row-by-row validation reusing Asset's own business rules, duplicate detection
both within a file and against the database, a downloadable error report that
doubles as the re-import vehicle, and full import history.

### Added

- **`import_batches` table** — one row per upload, from the moment it's
  stored (status `pending`) through to commit (`completed` /
  `completed_with_errors` / `failed`, with row counts and an optional error
  report path). `module` is a single column rather than per-module tables;
  only `assets` has an importer built, `employees` is reserved.
- **`ImportService`** — `store()` (upload + header/row-count validation
  only), `preview()` (parses and validates every row, writes nothing),
  `commit()` (writes only the rows that passed, inside one transaction).
  Upload and preview independently re-read the stored file rather than
  passing parsed rows between requests — there is no queue and nothing to
  usefully cache 2,000 rows of parsed data in between two requests, so
  re-parsing (a few hundred milliseconds) is simpler and fully stateless.
- **Preview before import** — the one thing OptiSet's own import flow
  doesn't have. Every row is shown with a Ready/Error badge and, for an
  invalid row, the exact reason, sorted so problems surface first. Nothing
  is written to the database until "Import N Assets" is pressed on this
  screen — the upload step alone never creates an asset.
- **Row validation reuses `StoreAssetRequest`'s own rules** — serial-number
  uniqueness (including against soft-deleted rows, same "may be in the
  deleted list" messaging), the `assignableOnCreate()` status set, condition
  values, purchase-date bounds — so an imported asset can never be less valid
  than a manually created one. Category / Location / Department are matched
  by name (a subcategory as either its own name or "Parent / Child",
  matching `AssetCategory::qualifiedName()`); Asset Tags are never part of
  the template — every imported row gets one from `AssetTagGenerator`, the
  same as a manually created asset.
- **Duplicate detection** — a serial number repeated within the same file is
  flagged from its second occurrence on; a serial number that already exists
  in the register (including soft-deleted) fails the row before any write is
  attempted.
- **One row cap, one transaction** — 2,000 rows per file (there is no queue;
  every import runs synchronously inside the request that triggers it). An
  ordinary per-row validation failure is never a reason to roll anything
  back — that row is simply skipped and reported, which is what makes "42 of
  50 imported" possible. A genuine failure partway through the commit
  transaction (a database error, not a rejected row) rolls back to nothing
  imported at all, and the batch is marked `failed` with the reason.
- **Error report doubles as the re-import vehicle** — the downloadable
  report is the template's own columns, populated with the original values
  of every failed row, plus one appended "Import Errors" column. Fix the
  flagged cells in that same file and upload it again; the extra column is
  simply ignored on the way back in, since column matching only requires the
  template's own columns to be present, not an exact set.
- **Import history** (`/imports`) — every batch, filterable by status, each
  linking to a detail page with counts, timestamps, the uploader, and
  (when relevant) the error summary and a "Re-import" shortcut.
- **One audit entry per import attempt, whatever the outcome** — success,
  partial success, or an outright failure (wrong template, every row
  rejected, a rolled-back exception) are all recorded via a single `imported`
  event with a human-readable description, rather than one `created` entry
  per imported row — `Auditable`'s own docblock anticipated exactly this
  since Phase 0.
- **`AssetService::create()` gained a `$silent` parameter**, used only by
  `ImportService`, to suppress the per-row `created` audit entry in favour
  of that one summary event.
- **`<x-form.file>`** — the first file-upload field this app has needed;
  matches every other `<x-form.*>` component's label/help/error contract.
- **`submit-progress.js`** — a `data-ams-progress="…"` attribute that
  disables a form's submit button and swaps it for a spinner on submit, for
  the two actions here (upload, commit) that can take a genuine few seconds
  on a large file. There is no queue and nothing to report real progress
  from, so this is an honest "please wait", not a fabricated percentage.
- **`ImportManagementTest`** (18 tests) — upload, header-mismatch rejection,
  full success, partial success with an error report, within-file and
  against-database duplicate detection, the row cap, idempotent re-confirm
  of an already-finished batch, name-to-ID resolution for Category/Location/
  Department, and that no per-row `created` audit noise is produced.

### Changed

- **Roadmap reordered** (see `docs/ARCHITECTURE.md` §18, revision 1.3):
  Import moved from Phase 11 to Phase 6; Warranty and QR Labels merged into
  one Phase 7; Reports and Dashboard shifted to Phases 8–9; Settings, the
  Audit Logs UI and Hardening merged into one closing Phase 10.

### Fixed

- **`import_batches.status` widened from `varchar(20)` to `varchar(30)`.**
  The architecture doc's own spec was too narrow for its own enum value —
  `completed_with_errors` is 21 characters — which silently truncated the
  write and threw a SQL error on every batch that finished with a mix of
  successes and failures. Caught during this phase's own manual
  verification, not by the type system, since the column was wide enough for
  every *other* status value to pass tests that didn't specifically exercise
  the mixed-outcome path.
- **Within-file duplicate detection was a no-op.** The row-validation loop
  used `Collection::map(fn (...) => ...)` with an arrow function, which
  captures outer variables by value at closure-creation time — so the
  `$seenSerials` accumulator passed by reference into `validateRow()` never
  carried state from one row to the next; two rows sharing a serial number
  both validated as fine. Fixed by replacing the `map()` call with a plain
  `foreach` loop, where the by-reference parameter behaves as intended.
- **CSV uploads were rejected outright.** `mimes:xlsx,csv` validates the
  extension it *guesses* from the file's sniffed content type, and a plain
  CSV with nothing distinctive in it routinely sniffs as `text/plain` —
  which mapped to the extension `txt`, not `csv`, failing validation for a
  completely genuine `.csv` file. Switched to `extensions:xlsx,csv` (checks
  the name the file actually has) alongside a deliberately generous
  `mimetypes:` allowlist as the real content-type guard.

---

## [0.8.0-phase5] — 2026-08-06

**Phase 5 — Assignment & Return.** The complete asset lifecycle: issue an
asset to an employee, track custody, and close it out with a return that
routes the asset back to stock, repair or damaged automatically. The two
denormalised pointer columns `assets` was deliberately left without in
Phase 4 — `current_assignment_id` / `current_employee_id` — are populated for
the first time this phase, by exactly one writer.

### Added

- **`asset_assignments` table** — one row per custody period (issue *and*,
  when it happens, return), not separate `assignments` and `returns` tables.
  A return is the closing event of an assignment, not an independent fact;
  one row per custody period makes "who has this now" a single indexed
  predicate and makes an orphaned return impossible. No `deleted_at` — this
  is immutable history. The one-active-assignment invariant is enforced
  twice: an application-level row lock in `AssignmentService`, and a
  database-level partial unique index (`uniq_active_assignment`, via a
  virtual generated `active_flag` column) that makes a double-assignment
  physically impossible even under a race or a bad manual query.
- **`current_assignment_id` / `current_employee_id` on `assets`** — the
  denormalised "who has this" cache the asset list needs to avoid a
  correlated subquery per row. Maintained exclusively inside
  `AssignmentService`, inside the same transaction that opens or closes a
  custody period; deliberately left out of `Asset::$fillable` so nothing
  else can ever write them.
- **`AssignmentService`** — `assign()` and `receiveReturn()`, sharing one
  class rather than two because they are both halves of the same invariant
  on the same table (the row-locking and pointer-maintenance rules would
  otherwise live in two files that must never disagree). `assign()` locks
  the asset row, checks `AssetStatus::canBeAssigned()` and
  `EmployeeStatus::canReceiveAssets()`, then writes the assignment and the
  asset's pointers together. `receiveReturn()` locks the assignment, closes
  it, and routes the asset to `in_stock`, `under_repair` or `damaged` via
  `AssetCondition::statusOnReturn()` — the mapping that already existed on
  the enum since Phase 4, unused until now.
- **One audit entry per action, against the asset.** `AssetAssignment` uses
  `Auditable` per the architecture, but its own `created`/`updated` entries
  are suppressed (`disableAuditing()`) in favour of a single, hand-written
  `assigned` / `returned` entry `AssignmentService` writes directly —
  *"Assigned AUM-LAP-0042 to Priya Sharma"* — attached to the asset, so it
  lands in the same Recent Activity feed and history page Phase 4 already
  built, exactly as that phase's own docblocks anticipated.
- **`AssignmentPolicy`** — `viewAny`/`view`/`create` open to every signed-in
  user; no `update` or `delete` at all, matching that no route exists for
  either; `return()` gated on the assignment still being active. Registered
  explicitly in `AppServiceProvider` since the class name (`AssignmentPolicy`,
  matching the module) doesn't follow Laravel's `AssetAssignmentPolicy`
  auto-discovery convention.
- **Assign Asset** (`/assignments`) — dedicated create/show/index pages, no
  modal, matching every other module. The asset or employee (or both) can
  arrive preselected via `?asset_id=`/`?employee_id=` from the Asset or
  Employee show pages, in which case that side of the form is shown locked
  rather than as a second picker.
- **Returns** (`/returns`) — a genuinely separate module at the UI layer
  (its own worklist, its own sidebar entry) even though it writes to the
  same table, because operationally it is a different task: "someone is at
  my desk handing back a laptop." The worklist lists only outstanding
  (`returned_at IS NULL`) assignments, oldest expected-return-date first, so
  overdue items surface at the top.
- **Delete guards activated.** `AssetPolicy::delete()` now also refuses
  while the asset is currently assigned; `EmployeePolicy::delete()` now also
  refuses while the employee holds any unreturned asset
  (`assignments()->whereNull('returned_at')->exists()`) — both anticipated
  by name in their Phase 3/4 docblocks, now real.
- **Asset detail page** — a Current Holder block in Current Status (employee,
  since-date, expected-back), an "Assign" or "Record Return" action in the
  page header depending on availability, and a full Assignment History table
  alongside the existing Recent Activity feed.
- **Employee detail page** — "Assets Held" (everything currently issued,
  with an inline Return action) and "Assignment History" (everything ever
  issued), replacing the "not available yet" placeholder from Phase 3, whose
  own docblock named this exact feature.
- **`AssignmentManagementTest`** (22 tests) — assign, prevent double-assign,
  prevent assigning a non-`in_stock` asset, prevent assigning to an
  exited/inactive employee, return routes status/condition correctly for
  good/poor/unusable, prevent returning an already-returned assignment, both
  delete guards, and that the audit trail records `assigned`/`returned`
  against the asset with a human-readable description.

### Changed

- **`Auditable` trait** — `restoring`/`restored` listeners now register via
  the base `Model::registerModelEvent()` rather than the `SoftDeletes`
  trait's `restoring()`/`restored()` sugar methods, and unconditionally
  (the `class_uses_recursive(SoftDeletes::class)` guard was removed).
  `AssetAssignment` is the first model to use `Auditable` without
  `SoftDeletes`; the SoftDeletes-specific static methods it was calling
  simply don't exist on a model that doesn't use that trait, which
  `Model::registerModelEvent()` — available on every Eloquent model — sidesteps
  entirely. A no-op for every existing model.
- **`Asset` / `Employee` models** — new relations: `Asset::currentAssignment()`,
  `Asset::currentEmployee()`, `Asset::assignments()`;
  `Employee::assignments()`, `Employee::activeAssignments()`,
  `Employee::currentAssets()` (via `hasManyThrough`).

### Fixed

- Three UI polish issues found during the design review: `assigned_at` /
  `returned_at` were rendering with a fabricated `, 00:00` (both are
  captured as dates only, never a time of day, so they now format as dates);
  the "Condition on Return" select had no placeholder, meaning an
  inattentive submit silently recorded "New" instead of requiring an
  explicit choice; and the "Since {date}" caption on an employee's Assets
  Held row was ellipsis-truncating mid-date on a 390px viewport — it now
  wraps instead.

---

## [0.7.0-phase4] — 2026-08-06

**Phase 4 — Assets.** The core of the application: CRUD, generated tags,
status lifecycle, category-driven specifications, QR codes, search and
advanced filters, export, bulk actions, and a detail page built to be
significantly richer than the OptiSet reference it was benchmarked against.

### Added

- **`assets` table** — tag, UUID, name, category, manufacturer/model/vendor,
  serial number, location, department, status, condition, purchase details,
  free-form JSON specifications, notes, `created_by`/`updated_by`.
  `current_assignment_id` / `current_employee_id` are **not** included —
  they arrive in Phase 5 together with `asset_assignments`, since
  `AssignmentService` is their only intended writer; adding them now would be
  columns nothing could populate.
- **`department_id` on assets** — which team owns the asset on the books,
  independent of who currently holds it. Not in the architecture's original
  table listing; added this phase at explicit request, and validated by an
  existing comment already in `DepartmentController::destroy()` anticipating
  exactly this column.
- **`AssetTagGenerator`** — race-free, gapless tag numbering via a locked
  counter row (`asset_tag_sequences`), never `MAX()+1`. Format, scope
  (per-category or global) and padding all read from the `assets` settings
  group seeded since Phase 0.
- **`AssetService`** — `create()`, `update()`, `changeStatus()`, `delete()`.
  The only two things a bare `Asset::create()` / `$asset->update()` cannot do
  safely alone: generate a collision-free tag, and refuse an illegal status
  transition.
- **Status is not a field on the edit form.** It moves only through
  `PATCH /assets/{asset}/status`, validated against
  `AssetStatus::allowedTransitions()` — the transition matrix already defined
  in the enum, now actually enforced end-to-end for the first time.
- **`QrCodeService`** — per-asset SVG via `endroid/qr-code`, encoding the
  asset's UUID (never its guessable tag) into a short `/a/{uuid}` scan URL.
  One endpoint serves both inline display and download. The bulk, multi-asset
  A4 label sheet stays Phase 7 — a materially separate feature (PDF layout,
  page breaks, a selection screen), not "QR code generation" in the singular.
- **Export** — `AssetsExport` (Laravel Excel) honours the same
  `Asset::filter()` scope the listing itself uses, or a specific row
  selection from the bulk action bar when one is made. Pulled forward from
  the architecture's later Reports/Export modules, scoped to assets only.
- **Bulk actions** — row checkboxes plus an always-visible action bar
  (Export Selected, Delete Selected). The checkboxes and buttons are outside
  the table's own per-row forms via `form="…"`, since HTML forbids nesting a
  `<form>` inside a `<form>`; `confirm.js` was extended to read
  `data-ams-confirm` from the *submitting button* as well as the form, which
  is what a shared multi-destination form needs.
- **Detail page** — Overview, Technical Information (manufacturer/model plus
  a free-form specifications key/value list), Purchase Information, Current
  Status (with the governed change-status control), Warranty Summary (an
  honest placeholder — Phase 6 owns `asset_warranties`, not this table),
  QR Code, Record metadata, and a real Recent Activity feed drawn from the
  same `audit_logs` every model already writes to. "Asset history
  foundation" is exactly this: the foundation already existed via the
  `Auditable` trait, so this phase surfaces it rather than building a new
  timeline system.
- **`assets.history`** — the full paginated trail behind "View Full History".
  Assignment and return events land in this same feed once Phase 5 starts
  writing them; this page needs no change when that happens.
- **Specifications editor** — repeatable `spec_key[]` / `spec_value[]` rows,
  not a hidden JSON field kept in sync by JavaScript: the form renders three
  working rows with JavaScript disabled, and the script only adds the
  convenience of inserting more or removing one.
- **28 feature tests** covering access, search/filters, tag generation
  (including per-category sequence isolation), validation, status
  transitions (legal and illegal), delete/restore, bulk delete (including
  the empty-selection fallback), QR + scan, export (filtered and selected),
  history, and the three new master-data delete guards.

### Changed

- **`AssetCategoryController` / `LocationController` / `DepartmentController`
  destroy() guards activated.** All three carried a comment since Phase 0–3
  saying assets would need to be checked "once that relation exists" — it
  now does, and deleting any of the three with assets attached is refused
  with a friendly, count-based message instead of a raw RESTRICT
  foreign-key error.
- **`confirm.js`** — the destructive-action confirmation dialog now checks
  the submitting button's `data-ams-confirm*` attributes before the form's
  own, and resubmits via the original button (not a plain `form.submit()`)
  so a button's `formaction`/`formmethod` override — Export Selected's GET
  override on a POST-default form — survives the confirm-then-resubmit round
  trip.

### Fixed

- `AssetCategory::qualifiedName()` reads `$category->parent`; the category
  picker on the asset form and the category filter both eager-loaded
  `category` without `category.parent`, which lazy-loading-prevention in
  development turned into a hard error rather than a silent N+1.
- `Asset::ageInMonths()` declared `?int` but `Carbon::diffInMonths()` returns
  `int|float`; an asset with a purchase date threw a `TypeError` on the
  detail page.
- `AssetService::create()` failed Larastan's model-property check when built
  around `Asset::create($data)` with `$data` behind a generic
  `array<string, mixed>` service-method boundary — Laravel's own
  FormRequest-aware type inference for `create()` doesn't survive being
  routed through a second method. Rebuilt as `new Asset()` +
  `fill()` + individually-assigned typed properties + `save()`, mirroring
  the pattern `AssetService::update()` already used successfully.

---

## [0.6.0-phase3] — 2026-08-06

**Phase 3 — Employees.** Module 4 from the architecture: CRUD, reporting
hierarchy, status lifecycle, search/filter, and a detail page.

### Added

- **`employees` table** — employee code, name, contact details, department,
  location, designation, team, reporting line, status, employment dates,
  notes, `created_by`/`updated_by`. The two columns deferred since Phase 0 are
  now live: `departments.head_employee_id` and `users.employee_id` (schema
  only — no screen reads or writes the latter yet).
- **`team`** — a plain optional text column, the same judgement already
  applied to manufacturer, vendor and designation: a label with no attributes
  of its own, captured with `<x-form.datalist>` rather than a lookup table.
  Searchable and filterable from the index.
- **Reporting hierarchy** — `reporting_to_id` is a real self-referencing
  foreign key, not free-text "Manager Name" fields, so `directReports()` and
  `departmentsHeaded()` are navigable. `EmployeeRules::validateReportingLine()`
  refuses a self-reference and walks the chain to refuse an indirect loop
  (A → B → C → A), which a foreign key alone cannot prevent.
- **`EmployeePolicy`** — viewAny/view/create/update open to every signed-in
  user; delete master-only. Unlike the three Masters modules, this is a real
  policy rather than middleware alone, because it carries a record-level
  condition middleware cannot express: Phase 5 will extend `delete()` to also
  refuse an employee who still holds assets.
- **Department & Location delete guards activated.** Both controllers carried
  a literal comment since Phase 0/2 — *"From Phase 3 this also refuses when
  employees are attached"* — which is now real: deleting either is refused
  with a friendly count-based message when employees are attached, the same
  pattern already used for a category with subcategories.
- **Head of Department** — departments can now name an employee as head,
  shown on the department listing and linked to their profile.
- **Detail page** richer than the OptiSet reference it was benchmarked
  against: profile, employment, reporting line (manager + direct reports +
  departments headed), record metadata (added/updated by and when), and an
  "Assets Held" panel that explains it is not available yet rather than
  silently showing nothing — the section is wired for Phase 5, not omitted.
- **29 feature tests** covering access, listing/search/filters, create,
  update, the reporting-loop validator (direct and indirect), delete/restore,
  and the two master-data guards.

### Deliberately deferred to Phase 5 (Assignment)

`employees.assets` and `employees.clearance` are not implemented — both need
`asset_assignments`, which does not exist yet. The "Assets Held" panel on the
detail page is the placeholder both will replace.

### Fixed

- `phpstan.neon` carried an ignore rule for `TracksBlameable` being unused,
  with a comment saying to remove it once a consumer existed. `Employee` is
  now that consumer; the rule is gone.
- `FoundationTest` asserted `employees.index` did not exist yet, written back
  in Phase 0. Updated to assert the route now renders in the sidebar, matching
  the assertions already in place for `dashboard` and `users.index`.

---

## [0.5.0-ui] — 2026-08-06

**UI refinement.** A pass over hierarchy, density and usability — not a
redesign. No backend change.

### Changed — density and layout

- **Rail 260px → 216px**, **topbar 60px → 48px**, **content 1440px → 1680px**.
  Chrome is navigation; every pixel it takes is a pixel a table does not get.
- **Forms no longer run to the full content width.** A "Full Name" field
  stretched to 900px stops looking like a form. Listings stay wide; form and
  detail screens opt into `ams-container--narrow` (1140px).
- **The filter toolbar moved inside the table card**, separated by a hairline
  rather than a gap — a search bar floating in its own card above a table is
  the clearest single tell of an admin template. Toolbar controls drop to 32px:
  they frame the data rather than compete with it.
- **The topbar carries the current location.** It is sticky and the page header
  is not, so on a scrolled listing it is the only thing still naming the screen.
  It also stops the bar being 48px of nothing on every page.
- Page header tightened to one baseline; breadcrumbs moved inside the header
  block so the title is not orphaned from its trail.

### Changed — tables

- **Column sizing.** The first column absorbs the slack, every other column
  shrinks to its content, with `.ams-col-fill` / `.ams-col-fit` to move the
  slack elsewhere. Widening the canvas previously scattered five columns across
  it with lakes of nothing between them.
- Softer separators, tighter header strip, 12px secondary cell text, and cell
  padding aligned to the card's own edge so the table, toolbar and footer share
  one content line.
- Row actions are 28px icon buttons that rest at a low-contrast **colour**
  rather than at reduced opacity — opacity muddied the glyph and faded the
  focus ring with it.

### Changed — components

- **Badges** gained a matching border and a 6px radius; soft fills alone were
  all the same lightness, so a status column read as one texture. Live/not-live
  statuses now take a leading `dot` instead of an icon.
- **Stat cards** put the label first and the number second, in a compact card —
  icon-then-number down a tall card gave the least informative element top
  billing and left the value floating. Hover is a fill and border step, not a
  lift.
- **Empty states** use a drawn concentric mark and offer **Clear filters** when
  a filter is what emptied the list.
- **Checkbox rows** tint neutral rather than teal when checked. Two teal blocks
  per form were drowning out the primary button.
- **Panel `tone="warning"`** no longer draws a coloured border; an amber card
  beside a red one made both read as the same weight. The border is reserved
  for the irreversible action.
- **Validation messages** carry an icon, so an error is identifiable without
  relying on red alone.
- Fieldset legends became uppercase micro-labels — structure, not content.

### Removed

- Every gradient: the sidebar mark, the avatar, and the radial wash behind the
  sign-in card. Decoration at 26px is decoration nobody can resolve, and it is
  the first thing that reads as "template".

### Fixed

- `.form-select.is-invalid` lost its chevron — Bootstrap stacks the validation
  icon on the indicator, and dropping the background took both.
- The fill column collapsed to zero width on a phone, leaving every row an
  avatar next to nothing. It now has a `min-width` floor and the table scrolls
  inside its card instead.
- The Locations address column was squeezed to its minimum and wrapped to five
  lines, dragging row height with it.

---

## [0.4.0-ui] — 2026-08-06

**UI redesign.** A design system replaces the thin Bootstrap theme. No backend
change: no route, controller, model, migration or business rule was touched.

### Added

- **`docs/DESIGN-SYSTEM.md`** — tokens, components, and the rules every future
  module follows.
- **A token layer** (`resources/scss/_tokens.scss`) — cool neutral ramp, single
  teal accent (`#0d9488`), 14px Inter scale, three radii, three elevations, one
  40px control height. Every Bootstrap variable override lives here.
- **Self-hosted Inter** via `@fontsource-variable/inter`, bundled and
  fingerprinted by Vite. Never fetched from a CDN — the production host sits
  behind a corporate network where that request may not resolve.
- **New components** — `<x-panel>`, `<x-badge>`, `<x-detail-list>`,
  `<x-detail-row>`, `<x-form.fieldset>`, `<x-table.actions>`,
  `<x-table.count>`, `<x-filter.search>`, `<x-filter.select>`,
  `<x-error-page>`.
- **`resources/js/modules/filters.js`** — filter selects auto-submit. Pure
  progressive enhancement; the Apply button remains the no-JS path.

### Changed

- **Shell** — fixed dark rail (260px, `#111827`) carrying brand and location;
  light sticky topbar carrying only search and identity; gray `#f9fafb` canvas
  with a 1440px content column. Below `lg` the rail is still Bootstrap's
  offcanvas drawer, with no custom JavaScript.
- **Filters** — the collapsed filter panel became an always-visible inline
  toolbar with a leading-icon search field. The old panel hid the fact that a
  listing was filtered, which is the one thing a filtered listing must not hide.
- **Tables** — hairline rows, uppercase 11px header strip, `.ams-cell-*`
  classes so every module weights its columns identically. Row action buttons
  became ghost icons that reveal on hover or focus, replacing the joined
  `.btn-group` that repeated on every row.
- **Badges** — soft pills across all eight Bootstrap theme names. The
  `App\Enums\*::color()` contract is unchanged; the names are restyled, never
  renamed.
- **Forms** — `<hr>` + `<h2>` section breaks became `<x-form.fieldset>`;
  standalone booleans became bordered decision rows; forms close with
  `.ams-form-actions`.
- **Detail panels** — two-column `<table>`s became `<x-detail-list>`, which
  reflows on a narrow screen instead of scrolling sideways.
- **Error pages** — six copies of one card became `<x-error-page>`.
- **Dashboard** — time-aware greeting, redesigned stat tiles, rollout checklist.

### Fixed

- Filled accent buttons rendered **black** text. Bootstrap picks button text
  with `color-contrast()`, which against a mid-tone teal falls back to black;
  the four text colours are now stated outright.
- The page header's action group could push a `nowrap` button off the right edge
  on a narrow screen. It no longer shrinks, and below `sm` the header stacks.
- The search field held its width instead of shrinking, pushing the filter
  select beside it off the edge.

### Tests

- `DashboardTest` and `DepartmentTest` asserted on presentation copy that the
  redesign changed. Both now assert the underlying fact — that the user is
  greeted by name, and that the deleted-list link is master-only — rather than
  an exact string. No behaviour changed; 233 tests pass.

---

## [0.3.0-phase2] — 2026-08-06

**Phase 2 — Master Data.** Departments, Locations and Asset Categories.

### Added

- **Departments** — name, optional code, active flag. Name and code unique.
- **Locations** — name, optional code, full address (every address field
  optional, so "Remote — WFH" is a legitimate row), active flag.
- **Asset Categories** — two-level hierarchy, mandatory unique code that becomes
  the middle segment of every asset tag, optional default warranty months and
  depreciation years.
- **Search, status filters and pagination** on all three listings. Categories
  additionally filter by level (top-level / subcategory).
- **Soft deletes with restore.** A "Deleted (n)" view lists removed records and
  restores them. Deleting and restoring are master-only; creating and editing
  are open to every signed-in user.
- **Audit logging** for create, update, delete and restore on all three models,
  via the `Auditable` trait.

### Structural rules enforced

- Names and codes are unique across **all** rows, soft-deleted included — the
  unique index covers them, so validating only live rows would pass and then
  fail at insert. The message points at the deleted list instead.
- Codes are upper-cased on save. A category code becomes part of a printed asset
  tag, so its case cannot depend on how somebody typed it.
- Category hierarchy is capped at two levels, a category cannot be its own
  parent, and one with subcategories cannot be moved under another.
- Sibling names must be unique — including at root level, which the database
  index cannot enforce because MySQL treats NULL parents as distinct.
- A category with subcategories cannot be deleted; the foreign key is RESTRICT,
  so without the guard the user would meet a raw database error.
- A subcategory cannot be restored before its parent, or it returns orphaned.

### Migrations

Three new tables: `departments`, `locations`, `asset_categories`.

`departments.head_employee_id` is **deferred to Phase 3**, together with its
foreign key, because the `employees` table does not exist yet — the same
deferral Phase 0 applied to `users.employee_id`.

### Notes

- The sidebar picked up all three modules automatically on route registration.
  No navigation code was written or changed this phase.
- No new packages, no service classes, no policies: none of these modules spans
  more than one model or has a record-level rule that middleware cannot express.

### Tests

233 tests / 872 assertions (64 added this phase).

---

## [0.2.0-phase1] — 2026-08-06

**Phase 1 — Authentication & User Management.**

### Added

- **Sign in / sign out.** Session-based, hand-written (no starter kit). Failure
  returns one message for every cause — unknown address, wrong password,
  disabled account — so the form cannot be used to enumerate accounts.
- **Two-layer sign-in throttling.** Per email+IP in `LoginRequest` (the primary
  limit, with a readable "try again in N seconds"), plus a looser per-IP limiter
  registered as `throttle:login` to catch one host spraying many addresses.
- **Forced password change.** An account created or reset by a master always
  carries `must_change_password`; every other screen bounces to the change form
  until it is cleared. Rendered without the sidebar — there is nowhere else to go.
- **Change password.** Current password required for a voluntary change, skipped
  when forced. The new password must differ from the old one, and the change
  destroys every other session for that account.
- **Profile.** Name and email only. Cannot reach `is_master`, `is_active`,
  `must_change_password` or `password`.
- **User Management** (master only): list with search + status/role filters,
  create, edit, enable/disable, reset password, soft delete.
- **Generated passwords.** Leaving the password blank produces a 14-character
  password displayed exactly once, on the next screen only.
- **`UserPolicy`** — you may not disable, reset or delete your own account, and
  the last active master cannot be demoted, disabled or deleted.
- **Dashboard** — the landing target after sign-in. Shows the signed-in account
  and, for masters, the user-account summary. Asset KPIs arrive in Phase 9.
- **Audit coverage** for every action in this phase: `login`, `login_failed`,
  `logout`, `password_changed`, `password_reset`, `user_disabled`,
  `user_enabled`, plus create/update/delete via the `Auditable` trait.

### Changed

- `AuditLogger` now decides "is this a web request" from whether a route was
  resolved, rather than from `runningInConsole()`. The old check was true under
  PHPUnit as well as artisan, which made every audit entry written during a
  feature test claim to have come from the console.
- `Controller` now uses `AuthorizesRequests`, so controllers can call
  `$this->authorize()` for actions with no Form Request.
- `phpunit.xml` uses the `database` session driver instead of `array`. The array
  driver has no session table, which made the "disabling an account ends its
  sessions" guarantee untestable.
- `resources/views/foundation.blade.php` and its route removed, as planned. `/`
  now redirects to the dashboard, which sends guests to sign in.

### Migrations

**None.** The `users` table created in Phase 0 already carries `is_master`,
`is_active`, `must_change_password`, `last_login_at`, `last_login_ip`,
`password_changed_at` and `deleted_at`.

### Tests

169 tests / 667 assertions (97 added this phase).

---

## [0.1.0-phase0] — 2026-08-06

**Phase 0 — Foundation.** Establishes the project. No user-facing module yet.

### Project

- Laravel 12 on PHP 8.3 (`composer.json` pins the platform so dependencies
  resolve against the production runtime).
- Laravel Pint (PSR-12 + project rules, `declare(strict_types=1)` enforced),
  Larastan level 5, PHPUnit.
- `composer check` runs formatting, static analysis and tests together.
- Vite + Bootstrap 5.3 + Bootstrap Icons, compiled locally to `public/build`,
  which is committed so the server needs no Node.js.

### Configuration

- `config/ams.php` — version, auth paths, security policy, audit rules,
  pagination, and the sidebar navigation map for every planned module.
- Password broker and `password_reset_tokens` removed from `config/auth.php`:
  there is no email-based password reset.
- `QUEUE_CONNECTION=sync` and no queue tables — nothing in the application
  defers work.

### Database

- `users` — with `is_master`, `is_active`, `must_change_password`, login
  tracking and soft deletes. No `email_verified_at`.
- `sessions` — database driver, carrying `user_id`.
- `settings` — key/value business configuration with type, group and label.
- `audit_logs` — append-only, polymorphic, indexed for per-record timelines,
  per-user history, event filtering and retention pruning.

### Application

- **Enums** with label, colour and icon: `AssetStatus` (including the legal
  transition matrix), `AssetCondition`, `AssignmentStatus`, `EmployeeStatus`,
  `WarrantyType`, `AuditEvent`, `ImportStatus`.
- **`Auditable` trait** — records create/update/delete/restore with a
  changed-attribute diff, a pre-rendered description, and secrets masked rather
  than stored.
- **`TracksBlameable` trait** — `created_by` / `updated_by` stamping. First
  consumers arrive in Phases 3–4.
- **`AuditLogger`** — the single write path into the audit table.
- **`AppSettings`** + `setting()` / `settings()` helpers — cached, type-cast
  access to the settings table, degrading to defaults before migration.
- **Middleware** — `active` (terminates a disabled account's live session),
  `master`, `password.change`, and application-wide security headers with a
  strict CSP in production.
- **`ams:create-master-user`** — creates the first master account, optionally
  with a generated one-time password that must be changed at first sign-in.

### Interface

- Authenticated shell (topbar + responsive off-canvas sidebar) and guest shell.
- Self-assembling sidebar driven by `config('ams.navigation')`.
- Component library: `page-header`, `stat-card`, `status-badge`, `confirm-modal`,
  `filter-bar`, `form/{input,select,datalist,date,textarea,checkbox}`,
  `table/{wrapper,empty-state}`.
- Branded error pages for 403, 404, 419, 429, 500, 503.

### Tests

72 tests / 375 assertions covering the settings accessor, audit logging
behaviour, every middleware guarantee, the master-user command, and the enum
transition rules.

### Notes

- `GET /` currently renders a foundation verification page. Phase 1 replaces it
  with the sign-in screen and deletes `resources/views/foundation.blade.php`.
- `phpstan.neon` carries one scoped `trait.unused` exception for
  `TracksBlameable`, to be removed in Phase 4.
