# Deferred Work

## Deferred from: code review of 1-1-project-scaffold-design-system (2026-05-20)

- **W1** `src/Views/layout.php:36` — Unescaped `$content` output is intentional PHP view pattern but is a latent XSS sink; enforce escaping in all dynamic views when they are first added.
- **W2 (RESOLVED 2026-05-21)** `config/app.php:13` — Session cookie `secure` flag is now env-gated: defaults to `true` when `APP_ENV=production`, `false` otherwise. `SESSION_COOKIE_SECURE=true` in `.env` overrides for non-production TLS deploys (e.g. staging). Verified across dev/prod/staging-override matrix and the dev login flow still works.
- **W3 (STALE 2026-05-21)** Story 1.2 shipped `AuthMiddleware::requireAuth()` and `AuthMiddleware::requireRole()`, now applied to ~20 routes in `public/index.php`. Closing as superseded.
- **W4 (RESOLVED 2026-05-21)** Added a comment above `Database::$instance` documenting that the per-process singleton is safe under PHP-FPM and the CLI (fresh interpreter per request) but unsafe under Swoole/ReactPHP/FrankenPHP worker mode, with the migration hint (per-request injection).
- **W5 (RESOLVED 2026-05-21)** Added `exit;` after the `$router->dispatch(...)` call in `public/index.php`. Closes the latent risk that future code appended to `index.php` would silently execute after a 404 or matched route. Kept the `return;`s inside `Router::dispatch` so the router stays a normal method (testable). 105/105 still green.
- **W6 (STALE 2026-05-21)** Story 1.2 shipped `CsrfMiddleware::validate()`, called from 10+ POST handlers across controllers (`Auth`, `Admin`, `Backup`, `Prediction`, `CategorizationBatch`, `ImportJob`). Closing as superseded.
- **W7 (RESOLVED 2026-05-21)** `Database::reset()` now throws `RuntimeException` when `$_ENV['APP_ENV'] === 'production'`. Test path (`APP_ENV=development`) unaffected — all DatabaseTest cases still pass against the live PostgreSQL.
- **W8 (RESOLVED 2026-05-21)** Moved `tests/Unit/DatabaseTest.php` → `tests/Integration/DatabaseTest.php`, updated namespace `Tests\Unit` → `Tests\Integration`, and added an `Integration` testsuite stanza to `phpunit.xml`. Default `phpunit` runs both suites (101 Unit + 4 Integration = 105 total). `phpunit --testsuite Integration` runs the DB-bound tests in isolation when needed.
- **W9 (RESOLVED 2026-05-21)** `/` now invokes `$prediction->index()` directly (no redirect, no extra hop). Removed the "Dashboard" item from the sidebar in `src/Views/layout.php` so Predictions is the first nav entry. Deleted the placeholder `src/Views/dashboard.php` (a stub that displayed only hardcoded "Not configured / No data yet" cards — the real stats live at `/admin/health`). 105/105 tests still green.

## Deferred from: code review of 1-2-user-authentication (2026-05-20)

- **W10** `src/Controllers/AuthController.php` — No rate limiting or lockout on login endpoint; out of Story 1.2 scope; add when a security hardening story is planned.
- **W11** `src/Services/AuthService.php` — `password_verify` bcrypt silently truncates passwords > 72 bytes (PHP/bcrypt limitation); low risk for admin-only app; document and revisit if self-registration is ever added.
- **W12** `src/Middleware/AuthMiddleware.php` — `getCurrentUser()` reads from `$_SESSION` only; deleted or disabled users remain authenticated for the full session lifetime with no re-check against DB; acceptable now, revisit if user management is added.
- **W13** `users.updated_at` — No DB-level trigger to auto-update `updated_at` on row changes; seeder handles it manually; add a PostgreSQL trigger or application-level update when a user management story arrives.
- **W14 (RESOLVED 2026-05-21)** Added `declare(strict_types=1)` to all 7 files named in the original note (`AuthController`, `AuthMiddleware`, `CsrfMiddleware`, `AuthService`, `Views/login.php`, `Views/layout.php`, `bootstrap.php`). Tests still 105/105, live pages still 200. **Broader gap remains**: every other file in `src/` (~34 files) also lacks the declaration. Tracked as W69 below.
- **W69 (RESOLVED 2026-05-21)** Sweep complete. Added `declare(strict_types=1)` to all 34 remaining files in `src/` across three batches with `phpunit` between each — 14 in Router+Utils+Services, 6 in Controllers, 14 in Views. All 105 tests stayed green at every checkpoint; `php -l` clean on every view. Every file under `src/` (41 total) now declares strict types. Live HTTP smoke-test was skipped per scope decision; risk is low because views consistently `(string)`-cast DB-sourced values before `htmlspecialchars` (verified via grep), and `PDO::ATTR_EMULATE_PREPARES=false` is already set so coercion surfaces would have shown up in unit tests anyway.
- **W15** `src/Controllers/AuthController.php` — `session_destroy()` called without prior `session_write_close()`; safe under PHP-FPM file sessions (covered by W4); revisit if session handler is changed to Redis/Memcached without locking.

## Deferred from: code review of 2-1-admin-configuration-dashboard (2026-05-20)

- **W16** `system_settings.akeneo_client_secret`/`akeneo_password` — stored as plaintext in PostgreSQL; per architecture.md decision (no DB-at-rest encryption for self-hosted trusted env). Revisit if deployment context changes.
- **W17** `src/Controllers/AdminController.php` + `src/Services/AkeneoService.php` — no rate limiting on `/api/admin/test-akeneo-connection`; could brute-force lock the Akeneo account. Address in a future security-hardening story (consistent with login no-rate-limit deferred at W10).
- **W18 (RESOLVED 2026-05-21)** Added `AuthMiddleware::requireJsonAuth()` (any-auth, `{success, error: {code, message}}` shape) and `AuthMiddleware::requireJsonRole(string $role)` (admin-style, `{success, message}` shape). Replaced all 7 inline auth blocks in `public/index.php` — each route now reads as one helper call plus the controller call. Net: roughly 60 duplicated lines collapsed to 14, response shapes preserved exactly, JSON Content-Type set inside the helper so error responses are guaranteed to carry the right header (closes a minor inconsistency where 4 of the inline blocks were echoing JSON without setting it). 105/105 tests still green. (Note: like the existing `requireAuth/requireRole`, the new helpers exit on failure and so aren't unit-tested — same pattern.)
- **W19 (RESOLVED 2026-05-21)** Added `CsrfMiddleware::rotate()` (unsets `$_SESSION['csrf_token']` so the next `getToken()` mints fresh); called from `AdminController::saveSettings()` after successful commit. Replay attack with the pre-save token now returns 403. Test coverage: 2 new tests in `CsrfMiddlewareTest` (rotate mints new + invalidates old against validate).
- **W20** `db/migrations/20260521000001_create_system_settings_table.php` — no `created_at` column; AC4 schema satisfied without it, but full audit trail would benefit. Add via follow-up migration if needed.
- **W21** `src/Services/SystemSettingsService.php` — `set()` accepts any key (no whitelist); controller is sole gate today. Constrain in service if additional callers appear.
- **W22** `public/assets/js/admin.js` — no `AbortController` and no server-side test-in-progress lock; impatient admins can hold PHP-FPM workers. Address if concurrency becomes a real problem.
- **W23** `src/Services/AkeneoService.php` — `CURLOPT_TIMEOUT=10` may be tight on slow networks; raise to 20-30s if real-world feedback warrants.

## Deferred from: code review of 2-2-error-logging-troubleshooting (2026-05-20)

- **W24** `src/Utils/Logger.php` — Redaction is key-only; values containing secrets (URLs with `?token=...`, Bearer headers embedded in strings, JWTs in `message` field) pass through unredacted. AC6 is key-based by design; revisit if value-leak incidents occur. Mitigation: callers should pass sensitive data under sensitive-named keys.
- **W25** `src/Services/LogReader.php` — `file()` loads the entire log into memory; mitigated in this story via a 50MB filesize cap with a guidance entry, but full streaming/reverse-read (`SplFileObject` byte-seek from end) is deferred until logs routinely exceed the cap.
- **W26** `src/Utils/Logger.php` — `setLogPaths()` is a public static mutator with no path validation. Exists for test affordance; only reachable from already-compromised app code. If we ever expose Logger to plugins/extensions, lock this down.
- **W27 (RESOLVED 2026-05-21)** Added `scripts/logrotate-ai-cats.conf` — a deployable logrotate snippet for `logs/app.log` and `logs/error.log`. Rotates daily-or-50MB-whichever-first (matches LogReader's 50MB read cap from W25), keeps 14 compressed generations, uses `create` (no `copytruncate` needed — Logger reopens via `file_put_contents` on every write). Install header comments document the symlink and dry-run steps. Validated parses cleanly with `logrotate -d`. **Caveat:** per-batch logs (`logs/categorize_*.log`) are not yet rotated — tracked as W70.
- **W70 (RESOLVED 2026-05-21)** Added `scripts/cleanup_batch_logs.sh`, a small bash cleanup script (cron-driven) that deletes `logs/categorize_*.log` files older than 30 days. Retention is overridable via positional arg or `AI_CATS_BATCH_LOG_DAYS` env var; `--dry-run` flag lists what would be removed without touching files. Header docs cover install steps and a daily cron stanza pointing at `/var/log/ai_cats_batch_log_cleanup.log`. Verified end-to-end: planted a synthetic file with mtime 50 days ago, confirmed dry-run identified exactly that file, real run deleted it, and an unrelated 2-day-old file was correctly retained. Chose age-based deletion over a `logrotate` stanza per the original W70 note (single-file rotation produces meaningless `categorize_42.log.1` artifacts).
- **W28 (RESOLVED 2026-05-21)** Codebase-wide sweep across 12 view files added explicit `'UTF-8'` charset to every `htmlspecialchars()` call (~98 substitutions via one `sed -i 's/ENT_QUOTES)/ENT_QUOTES, '\''UTF-8'\''/g'`). Also fixed the one 1-arg call in `src/Views/layout.php:12` (`htmlspecialchars($pageTitle ?? 'AI Cats')` → `htmlspecialchars($pageTitle ?? 'AI Cats', ENT_QUOTES, 'UTF-8')`). Verified zero remaining bare-flag calls anywhere under `src/` or `public/`. Behavior now identical between PHP < 8.2 (which defaults to ISO-8859-1) and PHP 8.2+. 105/105 tests still green.
- **W29** `tests/Unit/LogReaderTest.php` — Uses `sys_get_temp_dir() . '/ai_cats_test_logreader.log'` with a fixed filename; flaky if PHPUnit is ever run in parallel or against a symlinked `/tmp`. Switch to `tempnam()` when parallelism is needed.

## Deferred from: Story 2.4 — Automated Backup Scheduling (2026-05-20)

- **W30** `src/Services/BackupService.php` — Backup runs synchronously in the HTTP request. Acceptable for small databases; if backup duration approaches `max_execution_time`, raise the PHP limit or move to async (job queue + status polling) in a future story.
- **W31** `src/Services/BackupService.php` — No cap on backup file size; a very large database could exhaust disk space on the destination. Add a pre-flight `disk_free_space()` check and a configurable size warning when a backup storage management story arrives.
- **W32** `scripts/backup.php` — Cron setup is manual (documented in header comment only). Consider shipping a `scripts/install-cron.sh` helper or a Makefile target to automate cron registration when ops tooling is prioritised.
- **W33** `src/Controllers/BackupController.php` — `runBackup()` has no concurrency guard; two simultaneous "Run Now" clicks could launch parallel `pg_dump` processes. Acceptable for an admin-only tool; add an in-progress `system_settings` flag if concurrent backups become a real problem (consistent with W22).
- **W34 (RESOLVED 2026-05-21)** Failed backup rows in `backups.php` now render the `error_message` inline beneath the "Failed" badge in the Status cell (small red text, word-break for long messages). Dropped the `title="…"` hover-only fallback on the `<tr>` since the message is now visible. The Status column wraps cleanly without widening the table.

## Deferred from: Stories 3.1–3.3 — Supplier Enrichment Data Import (2026-05-20)

- **W35** `src/Router.php` — `{param}` placeholders compile to `(\d+)` — numeric IDs only. If a future route needs a non-numeric segment (e.g. a slug), extend the pattern syntax.
- **W36** `src/Controllers/ImportJobController.php` — `process_import.php` must be triggered manually or via cron; there is no automatic dispatch after upload. Consider a queue/worker approach when volume warrants it.
- **W37** `public/uploads/csv/` — Uploaded CSV files are stored indefinitely. Add a cleanup job or retention policy (e.g. delete files for completed jobs older than 30 days) when storage management becomes a concern.
- **W38** `src/Services/ImportProcessor.php` — `processed_rows` is updated every 100 rows. For very large files the progress indicator can lag. Tune the flush frequency or use a transaction per row if finer granularity is needed.
- **W39 (STALE 2026-05-21)** False alarm on closer reading. (a) `window.location.reload()` reloads the current URL *with* its query string intact — `err_page` is preserved by the browser, not dropped. (b) The polling JS that calls `reload()` is wrapped in `<?php if (!$isTerminal): ?>` in `show.php`, so it only runs while the job is in flight — by the time the user is paginating errors (`err_page=N`), the JS isn't loaded and there's nothing to reload. No code change.
- **W40** `src/Views/import-jobs/index.php` — Index page badge polling is emitted as inline JS. If the admin keeps many tabs open, each tab independently polls for the same jobs. Acceptable at low volume; consider SSE or a shared worker at scale.

## Deferred from: Stories 4.1–4.3 — Akeneo Integration & Workflow Connectivity (2026-05-20)

- **W41** `scripts/sync_akeneo.php` — Akeneo pagination relies on empty `items` array or `count < limit` as the last-page signal. If Akeneo returns a paginated envelope with `_links.next` or a `current_page`/`pages_count` field, switch to that for correctness.
- **W42** `src/Services/AkeneoService.php::submitToCollaborativeWorkflow()` — The response parsing reads `decoded['code'] ?? decoded['proposal_code']` with a fallback to `{identifier}-draft`. The actual Akeneo draft API response shape should be validated against the installed Akeneo version when the feature is first exercised in production.
- **W43** `scripts/sync_akeneo.php` — `shell_exec` background dispatch is Linux-only (`> /dev/null 2>&1 &`). If the app is ever deployed on Windows or in a containerized environment where background process spawning is restricted, replace with a queue worker or a process manager.
- **W44** `src/Controllers/AdminController.php::triggerSync()` — No concurrency guard: two admin users clicking "Sync" simultaneously will spawn two parallel sync processes and create two `akeneo_sync_log` records. Add an in-progress flag check (e.g. query `akeneo_sync_log WHERE status = 'running'`) before spawning, consistent with W22 and W33.
- **W45** `scripts/poll_akeneo_workflow.php` — No pagination on the submitted-records query; if thousands of workflow records are `submitted`, the poll script processes them all in one pass, potentially timing out. Add `LIMIT`/offset pagination when workflow volume warrants it.
- **W46** `src/Services/AkeneoService.php` — `getProposalStatus()` maps Akeneo status values via a hardcoded string set (`approved`, `accepted`, `in_progress_approved`, `rejected`, `refused`). Validate the full set of Akeneo Collaborative Workflow status values for the installed Akeneo version and update the map if needed.

## Deferred from: Stories 5.1–5.5 — AI Categorization Engine (2026-05-20)

- **W47** `src/Views/categorization-batches/show.php` — "Retry Failed Products Only" button (from Story 5.5 AC) is not implemented. Failed product IDs are not individually tracked — only `error_products` count is stored. Requires adding a `categorization_batch_errors` table (analogous to `import_job_errors`) and a `POST /categorization-batches/{id}/retry-failed` route in a future story.
- **W48** `python/services/evidence_builder.py` — Part-number pattern rules and manufacturer-category keyword map are hardcoded with illustrative starting weights. These should be tuned once real categorization results are available; consider moving them to DB-backed configuration in a future story.
- **W49** `python/categorize.py` — `_update_counters()` does a separate commit after every product; under high concurrency this creates many small transactions. Batch the counter update every N products (matching the 100-row pattern from `ImportProcessor`) when throughput becomes a concern.
- **W50 (STALE 2026-05-21)** W68 wired `applyThresholdRouting()` in via `CategorizationBatchController::ensureRoutingApplied()`, called lazily from `show()` and `statusApi()` on terminal-status detection (idempotent via service-level `WHERE status='predicted'` guard). Closing as superseded.
- **W51** `src/Services/CategorizationService::spawnCategorizer()` — Background process dispatch uses `shell_exec` (Linux-only), consistent with W43. Same caveat applies: replace with a queue worker if deploying to restricted environments.
- **W52** `python/services/confidence_scorer.py` — Minimum confidence floor of 10.0 is hardcoded. If a future story needs a configurable floor, read it from `system_settings`.

## Deferred from: code review of 2-3-health-monitoring-dashboard (2026-05-20)

- **W53** `public/index.php:57-60` — `GET /admin` redirect has no auth check; unauthenticated users receive a 302 to `/admin/health` and are then redirected to `/login?redirect=/admin/health` instead of `/login?redirect=/admin`. Functional outcome is identical but the originating URL is lost. Add `AuthMiddleware::requireRole('admin')` (or equivalent) to the `/admin` catch-all if redirect-URL fidelity becomes a requirement.
- **W54** `src/Services/HealthService.php:88` — `getRecentImportJobs(int $limit = 10)` accepts any `int` with no upper bound; currently only called internally with the default. Add a `max(1, min($limit, 100))` guard if the method is ever exposed to external input.
- **W55** `src/Services/HealthService.php:112` — `getLastAkeneoSync()` returns the most recent `akeneo_sync_log` row with no staleness check. If `sync_akeneo.php` crashes without updating status from `running`, the sync card displays "Sync in progress…" and the poll cadence stays at 10 s indefinitely. Add a staleness guard (e.g. `started_at < NOW() - INTERVAL '2 hours'` ⇒ treat as failed) when operational reliability of the sync card warrants it.
- **W56 (RESOLVED 2026-05-21)** Removed the dead `$row === false` early-return blocks from `getImportJobStats()` and `getCategorizationStats()`. `COUNT(*)` aggregates always return exactly one row, so the guards were unreachable; deleting them eliminates the misleading suggestion that a zero-row response is a real case. HealthServiceTest still passes.
- **W57** `public/assets/js/admin-health.js:207-213` — Timer cadence switch from POLL_SLOW to POLL_SYNC (or back) is not atomic with concurrent in-flight `fetchAndUpdate()` responses. If two poll responses arrive out of order, `syncRunning` can be flipped by a stale response, leaving the timer at the wrong cadence. Acceptable at current single-user admin usage; fix if concurrent admin sessions become common.

## Deferred from: code review of 6-1-predictions-review-queue (2026-05-20)

- **W58** `src/Services/PredictionService.php:296-334` — `submitToWorkflow()` has no transaction wrapping its INSERT (`akeneo_workflow`) + two UPDATEs (`predictions`, `products`). Partial failure leaves an orphaned workflow row without a matching status update. `AuditLogService::write()` is also outside the implicit transaction scope. Wrap the per-prediction DB writes in a transaction with rollback on failure. (6.3 scope — address alongside 6.3 review.)
- **W59** `src/Services/PredictionService.php:359-364` — `getAllNeedsReviewIds()` has no upper bound. "Select all queue" can return thousands of IDs, trigger N sequential Akeneo API calls, and exhaust PHP `max_execution_time` mid-loop leaving a partial submission. Add a configurable page-size cap (e.g. 500) with a follow-up batch mechanism. (6.3 scope.)
- **W60 (RESOLVED 2026-05-21)** `getPredictions()` now clamps `$pageSize` to `max(1, min($pageSize, 200))` and `$page` to `max(1, $page)` at method entry. Callers passing `pageSize=100000` are silently capped to 200, closing the full-table-JOIN DoS vector. No call sites needed to change.
- **W61** `src/Services/PredictionService.php:193` — `attachTopEvidence()` relies on `ORDER BY prediction_id, weight DESC` to produce per-group ordering in PHP. This order is advisory and not guaranteed across all query plans. Replace with a window function (`ROW_NUMBER() OVER (PARTITION BY prediction_id ORDER BY weight DESC)`) when DB version supports it, or accept the risk for small page sizes.
- **W62 (RESOLVED 2026-05-21)** Removed the `searchInput.value = ''` wipe at the top of `loadPredictions()`. After the new table HTML is injected, if the user's search field still has a value, we dispatch a synthetic `input` event so the existing client-side filter (lines 296-297) re-applies to the freshly rendered rows. Net effect: typed search now survives tab and confidence switches with no extra UI.
- **W63** `src/Views/predictions/index.php` — Story 6.1 and 6.3 IIFEs each attach their own `updateActionBar` function and `clearBtn` click listener via `document.addEventListener`. Both fire on every checkbox interaction. Logic is currently compatible (both set the same DOM state), but divergence risk grows as either handler is modified. Consolidate into a single listener in a future cleanup pass.
- **W64** `src/Views/predictions/index.php:603` — `evidenceCache` in the 6.2 IIFE is keyed by prediction ID and never invalidated when `loadPredictions()` replaces `container.innerHTML`. Stale evidence panel HTML from a previous tab is served on re-click without re-fetching. Add a `clearCache()` or full `evidenceCache = {}` reset at the start of `loadPredictions()`. (6.2 scope.)

## Deferred from: end-to-end smoke test (2026-05-21)

- **W65 (RESOLVED 2026-05-21)** `getImportJobStats()` now counts both `completed` and `completed_with_errors` in the `completed` bucket — a job that processed some rows successfully is still a finished job from the dashboard's perspective.
- **W66 (RESOLVED 2026-05-21)** `getCategorizationStats()` query rewritten to LEFT JOIN predictions onto batches in the 7-day window. `total` and `failed` remain batch-scoped (matching the card subtitle "Categorization Batches"); `auto_approved` and `sent_to_workflow` are now prediction-scoped, with `sent_to_workflow` covering `submitted`/`awaiting_akeneo`/`approved`.
- **W67 (RESOLVED 2026-05-21)** `getRecentImportJobs()` now aliases `original_filename AS filename` and `total_rows AS row_count` so the existing view template renders correctly.
- **W68 (RESOLVED 2026-05-21)** `applyThresholdRouting()` was orphaned — defined but called from nowhere, so predictions stayed in `predicted` status forever and the review queue was always empty. Wired in: `CategorizationBatchController::ensureRoutingApplied()` now invokes routing lazily from both `show()` and `statusApi()` on terminal-status detection. Idempotent (service-level `WHERE status='predicted'` guard). Inline fixes to `python/categorize.py:49` and `src/Services/CategorizationService.php:159` (both queried non-existent `setting_key`/`setting_value` columns) were also made as part of this fix, otherwise the threshold read silently fell back to 90.0 in PHP and aborted the Python transaction.

## Deferred from: Stories 6.4–6.5 — Inline Category Adjustment & Empty State (2026-05-21)

- **W72** `src/Services/PredictionService::adjustCategory()` — no transaction wrapping the UPDATE + `AuditLogService::write()`. Partial failure (UPDATE succeeds, audit log fails) leaves `is_adjusted=true` without an audit record. Low risk for single-row updates but worth wrapping if audit completeness is required.
- **W73** `src/Views/predictions/index.php` — the `evidenceCache` (Story 6.2 IIFE) is not cleared on `doAdjust()` success. The evidence panel still shows original AI evidence (correct per AC), but if the panel was open during adjust, clicking the row again will serve the cached HTML which doesn't reflect the adjusted indicator in the panel header. Acceptable since the AC explicitly states evidence stays unchanged; revisit if a panel-level "Adjusted" note is ever desired.
- **W74** `src/Views/predictions/index.php` — the 6.4 IIFE accesses `row.cells[4]` and `row.cells[8]` by hard-coded index to reach the category and action cells. These are stable for the current 9-column table layout; if columns are added or reordered, update both the PHP template and this IIFE.
- **W75** `src/Controllers/PredictionController::adjustCategory()` — accepts `category_label` from the POST body. The label is validated indirectly (code must exist in `categories` table), but label is taken at face value from the client. If the client sends a mismatched label, the DB stores it. Add a server-side label lookup (`SELECT label FROM categories WHERE akeneo_code = :code`) to guarantee label/code consistency.
- **W76** `scripts/sync_akeneo.php` — category sync failure is non-fatal (logged as warning, sync log still records `completed`). If categories never sync (e.g. Akeneo category API requires additional permissions), the search autocomplete will always return empty results with no user-visible indication. Add a `system_settings` entry tracking last category sync time so the admin dashboard can surface staleness.

## Deferred from: Apache deployment (2026-05-21)

- **W71 (RESOLVED 2026-05-21)** Apache vhost deployed and reachable at `http://10.0.0.55:8888/`. Files involved: `scripts/apache-vhost-ai-cats.conf` (vhost — `Listen 8888`, docroot `/var/www/html/ai_cats/public/`, `AllowOverride All`), `public/.htaccess` (front-controller rewrite: real files passthrough, everything else → `index.php`). Port 8888 chosen because 8080–8083 are occupied by the *arr/sabnzbd stack on this host. Two follow-on issues surfaced during deployment and were fixed: (1) inbound TCP was blocked — opened with `sudo ufw allow 8888/tcp`; (2) Postgres peer auth rejected the `sayre` role when PHP ran as `www-data` — fixed without touching `pg_hba.conf` by setting a password on the `sayre` PG role (via socket+peer self-auth) and switching `.env` to `DB_HOST=127.0.0.1` + `DB_PASSWORD=<generated>`. Ubuntu's default `pg_hba.conf` already allows `host all all 127.0.0.1/32 scram-sha-256`, so no host-level config edits were needed. CLI scripts (`scripts/backup.php`, `scripts/sync_akeneo.php`), phinx, and PHPUnit all share the same `.env` and continue to work via TCP+password.
