# Changelog

## 1.7.1 (2026-08-18) — Measured callback performance + transition range

### Changed
- `transition_effect_ms` now accepts 0..5000; default remains 300.
- Transition timing no longer sleeps or sends an analysis edit inside the webhook.
- Student/admin callback ACK moved earlier after dedupe/authz validation.
- Idle admin cleanup queries and repeated per-request user lookup were reduced.

### Added
- Opt-in compact `perf_trace` JSON lines at `storage/logs/perf.log` with DB/TG/count/stage timings.
- Factual before/after report in `docs/PERF_TRANSITION_REPORT.md`.

### Verified
- 30 before + 30 after samples per hot path; 17 scripts / 596 assertions / 0 failures.

## 1.7.0 (2026-08-17) — Secondary multistage + reliability/ops hardening

- Single-active secondary guard; bare `/start` prioritizes the active secondary independently of default test.
- Durable categories with cat1 backfill; text/photo/video/voice/audio/document capture via files-channel copy.
- Multistage student walk, exact next/soon labels, callback dedupe and secondary-only persistent keyboard.
- Global update_id dedupe, student/admin callback claim-before-side-effect, transactional one-active session and locked Back/Answer.
- protect_content on secondary copies, non-blocking membership start, bounded category delivery.
- Secret-free local code safety backups, optional Telegram upload, uncompressed ZIP cap, unique tmp, atomic per-file rename, code rollback, lock refresh, SQL denylist and health without migration.
- Processed-event retention, pump lock, reminder claim, bounded admin broadcast slice, atomic settings cache, single-default guard and deep-link payload cap.

## 1.6.1 (2026-08-17) — Production stability, transfer and schema-safe operations

- Eliminated secondary schema drift crashes and guaranteed nullable channel-reference columns/index through the common Migrator.
- Repaired back-question rewind without illegal repository access; stale answers/scores are removed safely.
- Added centralized Telegram safe-edit classification/fallback with admin/student pointer recovery and benign-error suppression.
- Production request tracing is now opt-in; logs rotate at 512KB with three generations and repeated warnings are throttled for 60 seconds.
- Added versioned JSON test export/import (draft import, graph/profile/score/result/media/gate/secondary mapping; no runtime statistics).
- Rebuilt update/downgrade around wrapper-aware ZIP validation, manifest, denylist, safety backup, lock TTL, maintenance snapshot, apply→migrate→verify and terminal workspace results.
- Backup artifacts are ZIP files containing database.sql and backup-meta.json; restore accepts ZIP/SQL, streams SQL, refuses future revisions and always forward-migrates.
- Fresh install now runs the same final Migrator as update/restore; current schema revision is 1.6.1.

## 1.6.0 hotfix B1–B4 (2026-08-17)

- Campaign payloads now resolve their bound test before the no-payload default-test policy, so campaign/test links work with default start disabled.
- Secondary voice/document captions are retained; new secondary media and text are delivered anonymously with `copyMessage` from the files channel.
- Secondary plain text is first published to the files channel and stores durable channel/message references in ordered secondary metadata.
- Removed the obsolete plain-text option-answer route. Non-command free text now reaches the exact generic help fallback while inline option callbacks remain active.
- Added idempotent secondary metadata columns for caption and channel source references; no version bump was required for this surgical hotfix.

## 1.6.0 (2026-08-16) — Funnel integrity, clean Telegram UX, truthful attribution

- Centralized deletion of all tracked admin prompts and user inputs on save, retry, Back, Cancel, and stage changes.
- Fixed escaped/raw question bold tags; full option text is numbered in the body and short inline buttons map to option IDs.
- Removed broadcast ReplyKeyboard CTA mode; test and profile/tag filters now apply and are test-first/scoped.
- Linked test re-entry always starts fresh; result previews are disabled; transition duration is globally configurable.
- Added editable current-only test/campaign slugs, configurable per-test gates, editable channel IDs, and admin student-link access.
- Added per-test secondary content packs (multiple text/voice/document items) for test, campaign, and default entry paths.
- Fixed per-test analytics navigation and campaign unique starts/completes/joins attribution, including prior-completion and pre-campaign-member cases.
- Hardened one-time incomplete reminders and update/downgrade terminal reporting; migrations are additive/idempotent.
- Regression suite and real HTTP/mock-Telegram/MariaDB checks are recorded in `docs/AGENT_HANDOFF.md`.

## Update 1.4.3-ops-htaccess-hotfix (2026-07-28) — Secure .htaccess Resolution & Package Integrity Verified

A critical operations patch resolving webhook reachability with active `.htaccess` security, paired with a complete, verified package integrity audit of the 1.4.2 release.

### F1 — Smart .htaccess Hardening
- Replaced the broad, unconditional `Require all denied` in the project root with a secure, layered rewrite and block model.
- Direct web access is strictly blocked (`403 Forbidden`) for system directories (`config/`, `src/`, `storage/`, `database/`, `tests/`, `bootstrap/`, `bin/`, `templates/`, `docs/`) and sensitive extensions (`.sql`, `.log`, `.bak`, `.env`, `.git`, `.md`, `.json`, `.yml`, `.yaml`).
- Incoming Telegram webhook POST requests and health GET/HEAD requests are cleanly routed to `/public/index.php` (for both Mode A and Mode B hosting layouts).
- Added `Require all granted` to `/public/.htaccess` to ensure seamless reachability on Apache 2.4.

### F2 — Package Integrity Verified
- Conducted a full automated inventory check comparing the workspace files against the original baseline ZIP.
- Confirmed **0 files** (no PHP code, no tests, no database schemas, no template views) were lost. The ~5KB size difference is due to documentation text cleanups and zip compression dictionary variations.

## Update 1.4.2-hotfix-ui-clean (2026-07-28) — UI Hardening & Developer Governance Institutionalized

A comprehensive UI/UX cleanup and robust governance patch applied on top of the 1.4.0 baseline, eliminating chat spam, solving persistent keyboard glitches, and establishing a professional permanent documentation standard.

### F1 — ReplyKeyboard Excision (F1 Rule)
- Removed all obsolete/duplicate bottom keyboards from the student and admin paths.
- Student flow is 100% inline-only (glass buttons) for answering questions.
- Admin panel uses exactly one bottom keyboard button: `/start` (for quick resets), with all real operations remaining inline.
- Entering both student and admin panel actively removes leftover reply keyboards using `ReplyKeyboardRemove`.

### F2/F4 — Sticky Workspace & Aggressive Cleanup
- Developed `AdminReplier::cleanupTemps()` to automatically track, group, and best-effort delete all temporary notice/prompt messages.
- Centrally integrated in `AdminMessageDispatcher` and `AdminCallbackDispatcher` to wipe out old prompts and user inputs on any state transition.
- Tapping cancel or typing `/cancel` automatically deletes prompts and user inputs, cleanly editing the workspace back to the main menu.
- Open scoring edits the workspace directly instead of sending new bubbles. Saving or cancelling scores edits the workspace back to the Option details with a top-anchored notice.

### F3 — Campaign List & Open Bug Fix
- Root-caused why campaign details showed empty `()` or failed with "Campaign not found". `CampaignService::listActive` returned array maps whereas the list view treated them as objects. Refactored lists to access array properties, fully restoring campaign management.

### F5 — Settings UI Audit
- Skipped rendering non-functional legacy Settings options (e.g. old force-join text configs) in `AdminSettingsController` to avoid operator confusion.

### F6 — Non-Member Broadcast Query Fix
- Fixed a SQL bug where `u.main_channel_status != 'member'` omitted all users with default `NULL` membership statuses. Added proper `IS NULL` checking, restoring accurate audience selection.

### F8 — Unique Analytics & Time Ranges
- Integrated time-range buttons (`۲۴ ساعت`, `۷ روز`, `۳۰ روز`, `کل`) on the campaign details and main analytics views, editing the workspace message in-place.
- Recalculates unique starts/completes/joins and introduces a high-precision `countUniqueAbandonersFiltered` subquery ensuring a user taking a test multiple times only counts once.

### Documentation & Governance (PART5)
- Upgraded/created all core documents (`README.md`, `PROJECT_STATUS.md`, `ARCHITECTURE.md`, `DOMAIN.md`, `PRODUCT_RULES.md`, `ADMIN_FLOWS.md`, `STUDENT_FLOWS.md`, `DB_SCHEMA.md`, `OPERATIONS.md`, `AGENT_HANDOFF.md`, `CODING_STANDARDS.md`, `KNOWN_ISSUES.md`).
- Institutionalized a machine-readable `STATUS.json` file.
- Established a mandatory protocol forcing every future AI coding agent to inherit this documentation duty.
- Applied useful, descriptive comments on all critical non-obvious execution paths.

## Update Pack (PART1+2+3+4) — F01..F17 + P3-1..P3-6 + PART4 Installer Hardening

Applied on top of the completed Phase 07 MVP per a comprehensive
product spec covering: admin UX overhaul (persistent 2-column
keyboard), full student-flow rebuild (ReplyKeyboard numbered options,
progress bar, no force-join gate), honest unique/membership analytics,
campaign link rotation, scale-safe broadcast engine, in-bot ZIP
update/downgrade system, a one-time secure web console for
token/DB-credential changes, and installer prefill/self-cleanup
hardening. See `docs/PRODUCT_RULES.md` §26+ for the full behavioral
rule list and `docs/BROADCAST.md` / `docs/UPDATE_ROLLBACK.md` /
`docs/TELEGRAM_LIMITS_AND_SCALE_FA.md` for architecture deep-dives.

### Schema/migrations (idempotent, `Migrator::VERSION` bumped `1.3.0` → `1.4.0`)

New tables: `test_user_stats` (unique per-user-per-test start/complete
tracking), `campaign_links` (rotate-able campaign deep links),
`test_question_stats` (per-question drop-off funnel), `broadcast_jobs`
+ `broadcast_job_items` (scale-safe broadcast queue),
`secure_setup_tokens` (one-time core-settings-change links),
`release_backups` (update/downgrade backup metadata).

New columns: `users.is_super_admin`, `users.main_channel_status` (+
`_changed_at`), `users.admin_workspace_message_id`,
`users.accepts_relay_messages`, `users.last_completed_test_id` (+
`_at`), `users.is_admin_keyboard_shown`, `users.relay_admin_id`;
`tests.unique_start_count`/`unique_complete_count`/`attempts_count`;
`test_sessions.campaign_link_id`/`current_question_message_id`/`keyboard_nonce`;
`analytics_events.campaign_link_id`. New composite indexes on
`analytics_events(test_id, event_name, created_at)` and
`analytics_events(session_id, event_name)`.

New settings keys (all seeded via `Seeder::updatePackSettings()`):
`dual_result_intro_text`, `dual_result_intro_enabled`,
`cta_delivery_mode`, `enable_admin_keyboard_on_start`,
`membership_attribution_window_hours`, `student_progress_bar_enabled`,
`link_disabled_text`, `student_stale_option_text`,
`broadcast_batch_size`, `broadcast_progress_every`,
`telegram_max_retries`.

### F01 — Admin keyboard always on /start
`StudentController::handleAdminStart()` intercepts every `/start` from
a configured admin (with or without a payload) and shows the
persistent `Tfb\Bot\AdminReplyKeyboard` (2-column, 6-row layout,
grouped logically) via `AdminMenuController::sendPersistentReplyKeyboard()`,
then opens the dashboard — an admin never needs `/admin` first. An
admin landing on a test deep link is guided to test with a
non-admin account rather than silently entering the student flow.
`Tfb\Controllers\Admin\AdminReplyKeyboardDispatcher` routes each
button-press to the matching section (dashboard/tests/users/broadcast/
campaigns/analytics/files/settings/update/health-logs/backup), checked
BEFORE `AdminMessageDispatcher` in `UpdateRouter` so an admin can
always escape a stuck wizard by tapping a menu button.

### F02 — Honest unique start/complete counters
`Tfb\Repositories\TestUserStatsRepository` + `TestService::
recordUniqueStart()/recordUniqueCompletion()`: a retake by the same
user bumps `attempts_count`/legacy raw counters but NEVER
`unique_start_count`/`unique_complete_count`, which only move on that
user's genuine first start/complete of that specific test. Verified by
`tests/phase_update_pack_e2e.php` (retake scenario: same user retakes
→ unique counters stay flat, a genuinely different user → unique count
increments).

### F03 — Dual-result intro text
`ResultDeliveryService::deliver()` sends the admin-configurable
`dual_result_intro_text` (gated by `dual_result_intro_enabled`) BEFORE
any profile content, only for a genuine dual selection.

### F04/F06/F07/P3-2/P3-3 — Student answer flow rebuilt
`QuizFlowHandler` rewritten: options render as a numbered
ReplyKeyboardMarkup (`Tfb\Bot\StudentKeyboardFactory`, "n) text") —
never inline buttons for students. The question message is
edited-in-place where possible / deleted+resent as a fallback (Bot API
cannot swap a message's reply_markup TYPE from inline to reply, so a
true edit is only used within the same keyboard type) — never leaves
more than one live question bubble. `Tfb\Support\ProgressBar` renders
an 8-cell text bar alongside "سوال n از m" (toggleable via
`student_progress_bar_enabled`). `Tfb\Support\StudentKeyboardResolver`
matches a pressed button's "<n>) <text>" against the CURRENT
question's options only — a stale/previous-question press safely fails
match and gets a hint (`student_stale_option_text`) with the current
question's keyboard re-shown, never silently mis-answers. Double-submit
safety via `TestSessionRepository::claimQuestionForAnswer()` — an
atomic `UPDATE ... WHERE current_question_id = :question_id` that only
the FIRST concurrent/duplicate request can win.

### F05 — Force-join gate fully removed from student path
`QuizFlowHandler::maybeRecordMembershipForAnalytics()` replaces the old
blocking `maybePromptChannelJoin()`/`sendJoinPrompt()`/
`recheckMembership()` trio — no message, state, or keyboard related to
channel membership is EVER shown to a student; membership is checked
purely for analytics after the result has already been fully
delivered, and any failure is swallowed.

### F08 — CTA delivery modes (inline/reply/both)
`ResultDeliveryService::deliver()` accepts a `$ctaMode` parameter
(`cta_delivery_mode` setting). `both` mode attaches the inline URL
button to the last content message AND (since one Telegram message can
only carry one reply_markup type) `QuizFlowHandler` sends one
additional short message carrying the ReplyKeyboardMarkup half.
Whenever no reply-keyboard CTA is shown, the keyboard is explicitly
cleared (`remove_keyboard`) so a student is never left with dead quiz
option buttons after the flow ends (P3-3).

### F09 — In-bot ZIP update / downgrade system
`Tfb\Services\UpdateService` (validate → backup-first → extract to
`storage/update_staging/` → preflight → run the SAME `Migrator::
upgrade()` the installer uses → swap, EXCLUDING `config/app.php` and
all of `storage/` → re-disable any installer copy the zip might
contain → health check) + `Tfb\Controllers\Admin\AdminUpdateController`
(super-admin-only wizard) + `Tfb\Repositories\ReleaseBackupRepository`
(backup metadata). Downgrade reuses the identical pipeline against a
previously-uploaded code backup. See `docs/UPDATE_ROLLBACK.md`.
Path-traversal / oversized-zip / missing-structure / config-never-
overwritten / storage-never-touched all verified in
`tests/phase_update_security_test.php`.

### F10 — One-time secure core-settings console
`public/secure-setup.php` (separate, hardened entry point from
`install.php`) + `Tfb\Services\SecureSetupService` +
`Tfb\Repositories\SecureSetupTokenRepository` (`secure_setup_tokens`,
SHA-256-hashed, single-use enforced atomically at the DB layer,
15-minute TTL) + `Tfb\Controllers\Admin\AdminCoreSettingsController`
("🔐 هسته/امنیت" — masked view of every core secret, issues one-time
links for token/DB changes, direct-in-bot add-secondary-admin with
confirm). A new value is tested (`getMe`/PDO connect) BEFORE ever being
written — a failed test never touches the live `config/app.php`.
CSRF-protected, IP-rate-limited (reuses `Tfb\Security\InstallRateLimiter`).
24 checks in `tests/phase_update_security_test.php` cover: CSRF
rejection, wrong-credential rejection (never saved), successful
change, and single-use re-visit rejection (403).

### F11 — Campaign CRUD + button-driven test binding
`AdminCampaignController` extended: full delete (soft, via
deactivate+link-disable, never a hard DB delete per the
no-hard-delete convention), `promptBindTest()`/`bindTest()` — a picker
of active tests, selecting one issues (or reuses) a campaign-link bound
to that test and shows the ready-to-share deep link — no manual
payload construction ever required of the admin.

### F12 — Real membership tracking (fixes the "fake join" bug)
`Tfb\Controllers\System\MembershipEventHandler` processes genuine
`chat_member` webhook updates (added to `allowed_updates` in
`TelegramClient::setWebhook()`) for the configured main channel only.
`UserRepository::updateMainChannelStatus()` records a transition and
returns `{was_member, is_member}` — `CHANNEL_JOINED`/`CHANNEL_LEFT` are
recorded ONLY on a genuine status transition, never on a repeated
"already a member" check. **Bug found and fixed during this work**: the
original UPDATE statement reused the same named parameter (`:status`)
twice, which fails under `PDO::ATTR_EMULATE_PREPARES=false` (silently
caught and logged, never surfaced) — this meant the F12 fix itself was
initially non-functional until caught by
`tests/phase_update_pack_e2e.php`'s dedicated membership-transition
checks. Fixed by using two distinct named parameters bound to the same
value.

**Practical join-attribution model**: a channel join is a channel-level
event with no direct link to "which test" caused it, so exact
attribution is not knowable. As a transparent, documented
approximation, a genuine new join is attributed to whichever test the
joining user most recently COMPLETED within
`membership_attribution_window_hours` (default 48h, admin-configurable)
— stored as `analytics_events.test_id` on the `channel_joined` row.
Outside the window (or with no completed test at all) the join is still
recorded, but with a `NULL` test id — never a guessed/false attribution.
See "Bugs found and fixed" item 4-5 below for the two real bugs caught
while wiring and testing this.

### F14 — Scale-safe broadcast engine
`Tfb\Services\BroadcastService` + `Tfb\Repositories\BroadcastRepository`
+ `Tfb\Controllers\Admin\AdminBroadcastController`. Full architecture
in `docs/BROADCAST.md`. Audience population via a single set-based
`INSERT...SELECT` (never a PHP loop); chunked/resumable `pump()` with a
job-level lease for concurrency safety; blocked users (403) marked and
skipped automatically; throttled progress-message editing (never once
per recipient); explicit cancel support.

### F15 — Users: pagination, clickable ids, private relay
`AdminUserController` list gained ±5/first/last pagination (previously
only next/prev); telegram_id rendered as a clickable `tg://user?id=`
HTML link in the detail view. New "💬 گفتگوی خصوصی" relay mode:
`UserService::relayIncoming()`/`AdminUserController::relayOutgoing()`
forward messages bidirectionally without ever persisting message
bodies to the database (ephemeral relay, only a body-less audit-log
entry records that a session happened); a student can self-disable
receiving relay messages (`accepts_relay_messages`); `/cancel` from
either side ends the session cleanly.

### P3-1 — Anti link-burn campaign links
`Tfb\Repositories\CampaignLinkRepository` (`campaign_links`) +
`StartPayloadParser` extended to recognize `t_{slug}__k_{token}` /
`k_{token}` deep-link formats (fully backward compatible with the
legacy `t_{slug}__c_{code}` / `c_{code}` formats, both still parse
correctly). `AdminCampaignController::rotateLink()`/`disableLink()`:
rotating marks the old link `replaced` (never deleted — full historical
attribution preserved) and issues a fresh unguessable token; a request
using a disabled/replaced token gets `link_disabled_text` and is never
attributed or auto-started.

### P3-4 — Question drop-off funnel ("کدام سوال کشت؟")
`Tfb\Repositories\TestQuestionStatsRepository` (`test_question_stats`)
rebuilds `unique_shown`/`unique_answered`/`unique_dropped` per question
from `analytics_events` on demand (never live on every dashboard open)
using session-distinct counts (never retake-inflated, since a session
can only see/answer a given question once by construction).

### P3-5 — Result content edit/replace/delete
Confirmed already fully satisfied by the existing Phase 06
`AdminResultController` (text/voice/document add/replace/remove with
atomic per-type replacement in `ResultMediaRepository::
replaceForResultAndType()`) — no changes needed, verified by
inspection and the existing `tests/phase06_admin_more_test.php`
coverage.

### P3-6 — Anti-Telegram-limit / anti-hang global audit
Full write-up in `docs/TELEGRAM_LIMITS_AND_SCALE_FA.md`. Concrete code
changes (not just documentation): `TelegramClient::setMaxRetries()`
(admin-configurable via `telegram_max_retries`, applied centrally — no
call site duplicates retry logic); `deleteMessage()` added to the
Telegram client for the student question-replace flow; `TelegramClient`
retry/backoff logic already centralized (confirmed, not changed) for
429/5xx/transport errors.

### PART4 — Installer prefill defaults + post-install self-cleanup
`Tfb\Install\InstallDefaults` (optional `config/install.defaults.php`,
gitignored, prefills the wizard's form fields — every field remains
editable, full validation/test-connect still runs) +
`Tfb\Install\InstallSelfCleanup` (deletes `config/install.defaults.php`
and `public/install.php` immediately after a verified successful
install; falls back to overwriting `install.php` with a locked
403-stub if deletion fails due to permissions, with a loud warning
either way). `AdminHealthController` gained an `installer_exposed`
CRITICAL check (warns if `install.php` is still present/unlocked after
the app is already installed). Verified end-to-end against an isolated
rsync copy of the project (never the live working tree) — full wizard
run confirmed both `config/install.defaults.php` and `public/install.php`
are gone from disk after a successful install, and re-visiting the
(now-deleted) install.php URL falls through to the normal front
controller. Automated coverage in `tests/phase_install_defaults_test.php`
(24 checks against disposable fake directory trees, including the
permission-denied stub-fallback path).

### Bugs found and fixed during this work (all caught by genuine test failures, not by inspection alone)
1. **SQL duplicate named parameter** in `UserRepository::
   updateMainChannelStatus()` and `BroadcastRepository::acquireLease()`
   — both reused a `:name` placeholder twice in one query, which
   `PDOException`s under `PDO::ATTR_EMULATE_PREPARES=false` (the
   project's standing configuration). This silently broke F12's
   membership-transition tracking end to end until caught. Fixed with
   distinct parameter names bound to the same value.
2. **Wrong-object method call**: `SystemController::plainText()` called
   `$this->users->relayIncoming()` where `$this->users` was typed as
   `UserService`, but the method had originally been written on
   `AdminUserController`. Fixed by moving `relayIncoming()` onto
   `UserService` itself (with an added optional `TelegramClient`
   dependency), which is the class actually injected into
   `SystemController`.
3. **Unconditional file-deletion risk** (documented, not found via
   test failure this round but re-verified): `AdminBackupController`'s
   restore-from-existing-backup path — already fixed in Phase 07, no
   regression introduced by this Update Pack.
4. **Three seeded-but-never-consumed settings**, found via an exhaustive
   grep of `Seeder::updatePackSettings()`'s 11 keys against real
   `$settings->get*()` call sites: `telegram_max_retries` (fixed by
   adding `TelegramClient::setMaxRetries()`, wired in
   `WebhookKernel::build()`), `broadcast_progress_every` (fixed by
   adding `BroadcastService::maybeEditProgressMessage()` + a live
   progress message set up in `AdminBroadcastController::
   confirmAndStart()`), and `membership_attribution_window_hours`
   (fixed by wiring `SettingsService` into `MembershipEventHandler`
   and calling the previously-orphaned
   `UserRepository::findRecentlyCompletedTestAttribution()` on every
   genuine new join, passing the attributed test id into
   `AnalyticsEvents::CHANNEL_JOINED`'s `test_id` column). After this
   fix, all 11 seeded Update Pack settings keys are confirmed consumed
   somewhere in `src/`.
5. **Off-by-boundary attribution window bug**, caught by a new test
   assertion added specifically to exercise
   `membership_attribution_window_hours=0` ("never attribute"):
   `UserRepository::findRecentlyCompletedTestAttribution()` compared
   `last_completed_test_at >= UTC_TIMESTAMP() - INTERVAL :window_hours
   HOUR`, so a windowHours of `0` still matched a completion from the
   same second (the boundary instant equals "now", and MySQL's
   sub-second timestamp truncation let an equal-or-later completion
   satisfy `>=`). Fixed by using strict `>` instead of `>=`, so a
   zero-hour window unambiguously means "never attribute" and a
   completion can never leak through purely by timing coincidence.

### Regression
All prior Phase 3/4/5/6/07 test suites re-verified green after every
change in this pack, with zero modifications to their assertions
except one deliberate, documented update:
`tests/phase06_admin_e2e.php`'s student-answers-via-callback lines were
updated to plain-text numbered-ReplyKeyboard presses, matching F04/F06
(the underlying scoring/branching/result-delivery logic under test is
unchanged and still fully verified).

## Phase 07 — Ops, Analytics, Hardening, Release Package (MVP Complete)

**Schema/migrations:** additive only, applied via `Tfb\Install\Migrator::upgrade()`
(idempotent — `Migrator::VERSION` bumped `1.2.0` → `1.3.0`):
- 2 new `settings` keys: `maintenance_enabled` (bool, default `'0'`, now editable from the admin panel — previously only a config-file flag), `log_level` (string, default `'warning'`, editable from the admin panel).

No new tables were needed — every Phase 7 Ops feature (users/campaigns/analytics/backup/logs/health) reads/writes tables that already existed from Phase 2/4/5.

**Files created — Diagnostic logging system (§17 of the phase spec):**
- `src/Logging/Diagnostic.php` — the single centralized facade (`debug/info/warning/error/critical/exception/problem/enter/leave`) every part of the codebase uses for anything beyond a plain log line.
- `src/Logging/ProblemsLog.php` — owns `storage/logs/problems-index.json` (deduplication/occurrence-counting) and `storage/logs/problems-YYYY-MM-DD.log` (the human/support-facing `[PROBLEM]...[/PROBLEM]` block format), with milestone-based + 10-minute-cooldown re-logging so a repeated error never floods the file.
- `src/Logging/Redaction.php` — shared secret-redaction rules (token/password/secret key-name patterns + defensive bot-token-shape pattern matching) used by both `FileLogger` and `ProblemsLog`.
- `src/Logging/GlobalErrorHandler.php` — installs `set_exception_handler`/`set_error_handler`/`register_shutdown_function` so any uncaught throwable or PHP fatal error anywhere in a request is captured (CRITICAL + a `ProblemsLog` entry) instead of silently vanishing or leaking a raw stack trace.
- `src/Logging/LogReader.php` — read-only tail helper for the admin "لاگ‌ها" screen (separate from the write-only `FileLogger`/`ProblemsLog`).
- `src/Services/BackupService.php` — pure-PDO logical backup/restore (no `shell_exec`/`mysqldump` dependency, since shared hosts commonly disable both): `createBackup()` writes a `DROP TABLE IF EXISTS` + `CREATE TABLE` (from the live `Tfb\Install\Schema` definitions, not a frozen snapshot) + batched `INSERT` SQL file; `restoreFromFile()` splits and executes it with a quote-aware statement splitter; `resolveBackupPath()` makes path traversal structurally impossible (`basename()` + a fixed filename pattern check).
- Admin controllers: `AdminUserController`, `AdminCampaignController`, `AdminAnalyticsController`, `AdminBackupController`, `AdminLogsController`, `AdminHealthController`.
- Tests: `tests/phase07_ops_e2e.php` (31 assertions, full live-HTTP E2E: users, campaigns + attribution, analytics reports, backup+restore round-trip including a real mutate→restore→verify cycle and a from-existing-on-server-backup restore path, non-admin rejection, diagnostic-logging + secret-redaction verification, and a permanent XSS-escaping regression check).
- `docs/LOGGING_AND_DIAGNOSTICS_FA.md`, `docs/DEPLOY_CPANEL_FA.md`, `docs/USER_ACCEPTANCE_CHECKLIST_FA.md`, `docs/SECURITY.md`, `docs/TROUBLESHOOTING_FA.md`.

**Files modified (minimal, targeted, backward-compatible — every new dependency is an optional/nullable constructor param defaulting to `null`):**
- `src/Logging/LoggerInterface.php` — added `critical()` (one level above `error()`).
- `src/Logging/FileLogger.php` — now also writes `error-YYYY-MM-DD.log` (ERROR+CRITICAL only) and `diagnostic-YYYY-MM-DD.log` (DEBUG-only, written ONLY when `diagnostic_mode` is on — the check is the very first line of `diagnostic()`, so it's a true no-op cost when off); added a per-day file-size cap (20MB, app/diagnostic channels only, never the error channel); added `setMinimumLevel()` so `settings.log_level` (a DB setting, available only after `WebhookKernel::build()` runs) can override the config-file-only default set during early bootstrap.
- `src/Core/Database.php` — accepts an optional `Diagnostic`; logs DB connect/query failures as `ProblemsLog` entries (`E_DB_CONNECT`/`E_DB_QUERY`, SQL *fingerprint* only, never bound parameter values) and slow queries (`app.log_query_slow_ms`, default 300ms) as warnings.
- `src/Bot/TelegramClient.php` — accepts an optional `Diagnostic`; every `ok=false` response and transport-level failure (after the existing single retry) is now also recorded as an aggregated `ProblemsLog` entry (`E_TG_API`/`E_TG_TRANSPORT`); added `sendLocalDocument()` (multipart upload of a *local* file — the one deliberate exception to "only ever send by file_id", used exclusively for backup/log file delivery, never for student-facing content), `getFile()`, `downloadFileTo()` (used by the restore-from-Telegram-upload flow).
- `src/Middleware/WebhookAuthMiddleware.php`, `src/Middleware/RateLimitMiddleware.php` — accept an optional `Diagnostic`; secret mismatches and rate-limit trips are now recorded as `ProblemsLog`/warning entries (rate-limit logs once per window-crossing, not per hit).
- `src/Bot/UpdateRouter.php` — accepts an optional `Diagnostic`; sets request-correlation context (`update_id`/`telegram_user_id`/`chat_id`) at the top of every dispatch so every subsequent log line in that request automatically carries them; router-level exceptions are now also recorded as `ProblemsLog` entries.
- `src/Engines/Flow/QuizFlowHandler.php` — accepts an optional `Diagnostic`; IDOR-rejected callbacks, "quiz has no active questions", "no profiles at completion", and "winning profile has no result_definitions row" are now all recorded as `ProblemsLog` entries (previously some were silent or logger-only) — pure additive logging, zero business-logic change.
- `src/Repositories/UserStateRepository.php` — accepts an optional `Diagnostic`; a corrupt `context_json` value (previously silently swallowed to an empty array) now also records a `ProblemsLog` entry (`E_STATE_CONTEXT_CORRUPT`) while keeping the exact same fail-safe fallback behavior.
- `src/Services/AnalyticsService.php` — accepts an optional `Diagnostic`; a failed analytics write is now caught and logged instead of propagating (per the spec: "Analytics write failures نباید کل flow را بشکند؛ لاگ شود") — this was previously an unguarded call that could have thrown into student/admin flow.
- `src/Controllers/Admin/AdminCallbackDispatcher.php` — accepts an optional `Diagnostic`; unauthorized admin attempts and in-dispatch exceptions are now logged; wired to the 6 new Phase 7 controllers.
- `src/Controllers/System/SystemController.php` — accepts an optional `Diagnostic`; the previously fully-silent `catch (\Throwable) {}` around `/cancel`'s state reset now logs the exception (still best-effort, still never blocks the reply).
- `src/Services/MembershipService.php`/`src/Engines/Flow/QuizFlowHandler.php` — **no change this phase** (the `soft_join_fail_open` wiring was already completed in Phase 6).
- Repositories extended (additive only): `UserRepository` (`countNewSince`, `search`, `findById`), `TestSessionRepository` (`countByUserId`, `findLatestByUserId`, `averageDurationSeconds`, `countByStatus`), `CampaignRepository` (`listAll`, `countAll`), `AnalyticsEventRepository` (`countByEventNameFiltered`, `countDistinctUsersByEventNameFiltered`, `resultDistribution` — all index-friendly range/equality queries, no full scans).
- `src/Services/UserService.php` — added `countAll`, `countNewLast24Hours`, `listRecent`, `search`, `findById`.
- `src/Support/AdminStates.php` — added Users/Campaigns/Backup-restore states (`USER_SEARCH_QUERY`, `USER_SEND_MESSAGE`, `CAMPAIGN_ADD_TITLE/CODE/SOURCE`, `CAMPAIGN_EDIT_TITLE`, `RESTORE_WAIT_FILE`, `RESTORE_WAIT_CONFIRM_PHRASE`).
- `src/Engines/Flow/AdminCallbackCodec.php` — added 6 new entities (`u`=users, `c`=campaigns, `a`=analytics, `b`=backup, `l`=logs, `h`=health) to the whitelist.
- `src/Support/AdminEditableSettings.php` — added `maintenance_enabled`, `support_username`, `log_level` (with `log_level` validated against the exact `debug/info/warning/error` enum at the controller layer, not just accepted as free text).
- `src/Controllers/Admin/AdminMenuController.php` — dashboard now shows 24h metrics (new users, starts, completes, joins), overall completion rate, and top campaign by starts (all additive, existing fields unchanged); menu now includes all 6 new Phase 7 sections.
- `bootstrap/app.php` — wires `FileLogger`/`ProblemsLog`/`Diagnostic` into the container; `Database::class` binding now passes the `Diagnostic` instance and `app.log_query_slow_ms`.
- `public/index.php` — installs `GlobalErrorHandler` before any business logic runs; health check `phase` value bumped `5` → `7`.
- `src/Install/Installer.php` — `buildConfigArray()` now also writes the new `app.diagnostic_logging`/`app.log_query_slow_ms`/`app.log_max_context_bytes`/`app.problems_log_enabled` keys for fresh installs; version string bumped to `1.3.0-phase7`; both `seedAll()` and the final lock write now use `Migrator::VERSION` instead of the older, no-longer-accurate `Schema::VERSION`.
- `templates/install/step_result.php` — the stale "پنل ادمین در فازهای بعدی فعال می‌شود" post-install message (accurate for Phase 2, no longer true since Phase 6) was replaced with actual next-step guidance (`/start`, `/admin`, first-test-building) and a pointer to `docs/DEPLOY_CPANEL_FA.md`/the `problems-*.log` files.
- `config/app.example.php` — documents the 4 new `app.*` diagnostic keys.

**Bugs found and fixed during Phase 07 verification** (all real, all caught by writing genuine E2E tests against a live app server + mock Telegram + MariaDB, not by inspection alone):
1. **Real XSS/HTML-injection bug:** `AdminCampaignController::showList()` interpolated `$campaign->title` (free-text, admin-typed) into an HTML-`parse_mode` message via `replyHtmlWithKeyboard()` without escaping — an admin-entered campaign title containing `<script>`/other tags would have been sent to Telegram's HTML parser unescaped, exactly the class of bug documented as a known risk in Phase 6's `docs/CODING_GUIDE.md` "escaping gotcha" section. **Fixed** by escaping the title before interpolation; a permanent regression test was added to `tests/phase07_ops_e2e.php`.
2. **Backup-restore file-deletion bug:** the restore confirmation handler unconditionally `@unlink()`'d the source `.sql` file after a successful restore — correct when the source was a temporary Telegram-upload download, but this would have silently **deleted a legitimate on-server backup** when restoring via the "pick an existing backup from the list" flow. **Fixed** by tracking an `is_server_file` context flag and only deleting temporary downloads, never a file the admin picked from `storage/backup/`'s own listing; caught and verified by a dedicated `tests/phase07_ops_e2e.php` scenario.
3. **Statement-splitting bug in `BackupService::restoreFromFile()` (caught before it ever shipped, during initial manual round-trip verification, not via the automated suite):** an early implementation's naive `str_starts_with($trimmed, '--')` comment-skipping check discarded an entire SQL chunk (including the real `CREATE TABLE`/`INSERT` statement after a leading `-- comment` line the backup format itself writes), which silently restored an empty/incomplete schema. **Fixed** with a `stripLeadingComments()` helper that strips only the leading comment/blank lines from a chunk, keeping the real statement; also replaced an initial naive `explode(";\n", ...)` statement splitter (unsafe against admin/student free-text content containing a literal `";\n"` sequence inside a quoted value) with a proper quote-aware character-by-character splitter that correctly tracks PDO's backslash-escaping convention (verified with a dedicated round-trip test containing quotes, semicolons, backslashes, and newlines inside a single value before this was wired into the E2E suite).

**Tests run (full, zero-regression, run repeatedly throughout the phase after every change):**
- `php -l` on all 186 PHP files in the project — 0 syntax errors
- `php tests/smoke.php` — 15/15 passed (Phase 1 regression)
- `php tests/phase3_unit.php` — 31/31 passed (Phase 3 regression, offline)
- `php tests/phase04_domain_smoke.php` — 40/40 passed (Phase 4 regression)
- `php tests/phase05_score_engine_test.php` — 39/39 passed (Phase 5 pure-logic regression)
- `php tests/phase05_runtime_smoke.php` — 24/24 passed (Phase 5 full-runtime regression)
- `php tests/phase06_admin_unit.php` — 32/32 passed (Phase 6 pure-logic regression)
- `php tests/phase06_validation_test.php` — 10/10 passed (Phase 6 activation-gate regression)
- `php tests/phase06_soft_join_test.php` — 9/9 passed (Phase 6 hardening regression)
- `php tests/phase06_admin_e2e.php` — 38/38 passed (Phase 6 full-build E2E regression)
- `php tests/phase06_admin_more_test.php` — 27/27 passed (Phase 6 supplementary regression)
- `php tests/phase07_ops_e2e.php` — 31/31 passed (new: users/campaigns/attribution/analytics/backup+restore round-trip/non-admin rejection/diagnostic-logging/secret-redaction/XSS-escaping regression)
- **Manual full installer walkthrough over real HTTP** (preflight → bot → db → app → review → execute), confirming: fresh install succeeds end-to-end with all 21 tables + 28 settings seeded correctly, `config/app.php` contains every new Phase 7 key, re-running `install.php` after success is correctly blocked with HTTP 403, and the webhook-registration-failure-does-not-block-install / retry-webhook path still works exactly as it did in Phase 2.

**Known issues carried forward / new residual gaps:** see `docs/PROJECT_STATUS.md` "Blockers / risks" and `docs/SECURITY.md` §14.

## Phase 06 — Telegram Admin Builder + Files + Scoring UI


**Schema/migrations:** additive only, applied via `Tfb\Install\Migrator::upgrade()`
(idempotent — `Migrator::VERSION` bumped `1.1.0` → `1.2.0`):
- 1 new `settings` key: `enable_default_test_on_start` (bool, default `'0'`) — also added to `Tfb\Install\Seeder::defaultSettings()` for fresh installs.

No new tables/columns were needed — every Phase 6 admin feature writes into tables Phase 2/4/5 already created.

**Files created:**
- Support/codec layer: `src/Support/AdminEditableSettings.php`, `src/Engines/Flow/AdminCallbackCodec.php`, `src/Bot/CallbackHandlerInterface.php`
- Services: `src/Services/OptionScoringService.php`, `src/Services/TestValidationService.php`, `src/Services/AdminFileUploadService.php`
- Controllers (`src/Controllers/Admin/`, new directory): `AdminReplier.php`, `AdminMenuController.php`, `AdminTestController.php`, `AdminProfileController.php`, `AdminQuestionController.php`, `AdminOptionController.php`, `AdminResultController.php`, `AdminSettingsController.php`, `AdminFileLibraryController.php`, `AdminCallbackDispatcher.php`, `AdminMessageDispatcher.php`
- Tests: `tests/phase06_admin_unit.php`, `tests/phase06_validation_test.php`, `tests/phase06_soft_join_test.php`, `tests/phase06_admin_e2e.php`, `tests/phase06_admin_more_test.php`
- `docs/ADMIN_GUIDE.md`

**Files modified (minimal, targeted):**
- `src/Support/AdminStates.php` — full rewrite: was a Phase 4 stub with 4 placeholder constants, now the complete list of every "waiting for admin input" state used by the builder, plus `all()` (used by `AdminMessageDispatcher` to detect "is this user mid-admin-wizard").
- `src/Bot/TelegramClient.php` — added `editMessageReplyMarkup()`, `copyMessage()` (the latter powers the file-upload-to-private-channel flow without a "Forwarded from" header, and without re-uploading any binary).
- `src/Bot/Update.php` — added `voiceFileId()`, `voiceFileUniqueId()`, `documentFileId()`, `documentFileUniqueId()`, `documentFileName()`, `documentMimeType()`, `fileSize()`, `hasVoice()`, `hasDocument()`.
- `src/Bot/KeyboardFactory.php` — added `inlineRows()` for multi-button-per-row layouts (the +/- scoring UI's `[➖][label][➕]` rows).
- `src/Bot/UpdateRouter.php` — `/admin` now routes to `AdminMenuController::openMenu()` when wired (falls back to the old `SystemController::admin()` stub if not, so the constructor's two new params stay optional/backward-compatible); callback_query dispatch list now also tries `AdminCallbackDispatcher`; plain-text/media messages are first offered to `AdminMessageDispatcher` before falling through to `SystemController::plainText()`.
- `src/Bot/WebhookKernel.php` — wires the entire Phase 6 admin DI graph (9 controllers + 3 services + `AdminCallbackCodec`/`AdminReplier`) alongside the unchanged Phase 5 student runtime graph.
- Repositories extended (all additive methods, no existing method signatures changed): `TestRepository` (`updateTitle`, `setThreshold`, `countAll`, `countActive`, `unsetDefault`, **and see "bug fix" below for `create()`**), `TestProfileRepository` (`updateTitle`, `countByTestId`), `QuestionRepository` (`getAllByTestIdOrdered`, `updateText`, `deleteById`, `setSortOrder`, `maxSortOrderByTestId`, `countByTestId`), `QuestionOptionRepository` (`updateText`, `setBranching`, `deleteById`, `countByQuestionId`, `maxSortOrderByQuestionId`), `OptionProfileScoreRepository` (`getByOptionId`, `replaceForOption`), `ResultDefinitionRepository` (`updateTitle`, `updateDescription`, `updateCta`, `clearCta`), `ResultMediaRepository` (`findByResultAndType`, `replaceForResultAndType`, `removeByResultAndType`), `StoredFileRepository` (`listRecent`, `countAll`, `deleteById`), `UserRepository` (`countAll`, `listRecent`).
- `src/Models/ResultDefinition.php` — **bug fix**: `toArray()` was missing `description` and `cta_button_text` (both existed as typed properties, just never surfaced through the array shape). Fixed by adding both fields.
- `src/Services/FileService.php` — added `listRecent()`, `countAll()`, `deleteMetadata()`.
- `src/Services/TestService.php` — added `listAll()`, `countAll()`, `countActive()`, `updateTitle()`, `setThreshold()`, `unsetDefault()`.
- `src/Services/StudentStartService.php` — minimal connective patch: added the `enable_default_test_on_start` opt-in check (auto-starts the default active test on a deep-link-less `/start` only if this setting is explicitly on) — implements Product Rule §4.11 ("never implicit").
- `src/Services/MembershipService.php` — added `checkLiveDetailed()` (returns `is_member` + `transport_ok` + raw `status`, vs. the old `checkLive()`'s bare bool) so callers can distinguish a genuine non-member from a failed check. `checkLive()` kept as a backward-compatible thin wrapper.
- `src/Engines/Flow/QuizFlowHandler.php` — **hardening fix, not a rewrite**: `maybePromptChannelJoin()`/`recheckMembership()` now use `checkLiveDetailed()` and only apply `settings.soft_join_fail_open` when `transport_ok=false` (a real Telegram-side failure) — a confirmed "left"/"kicked" status is never bypassed regardless of the setting. This closes the gap flagged in Phase 5's `docs/PROJECT_STATUS.md`/`docs/AGENT_HANDOFF.md`.
- `src/Install/Seeder.php` — added `enable_default_test_on_start` to `defaultSettings()`, new `phase06Settings()` method.
- `src/Install/Migrator.php` — `VERSION` bumped `1.1.0` → `1.2.0`; `upgrade()` now also seeds `Seeder::phase06Settings()` for pre-Phase-6 installs.
- `tests/mock_telegram_server.php` — added `editMessageReplyMarkup`/`copyMessage` handlers; extended the `getChatMember` fake-status map with `'-999004' => 'administrator'` (files-channel-is-admin health check) and `'-999005'` (simulates a genuine Telegram-side API failure, for the soft-join-fail-open test).

**Bugs found and fixed during Phase 6 verification** (both real, pre-existing-or-introduced-this-phase issues, surfaced by writing real E2E tests against a live app server + DB, not by inspection alone):
1. **HTML-escaping bug (introduced this phase, caught before ship):** every admin screen that built literal `<b>`/`<code>` HTML tags (dashboard, settings list, test/question/option/result detail views, mode-menu) was being sent through `AdminReplier::reply()`/`replyWithKeyboard()`, which always HTML-escapes its input — so tags rendered as literal `&lt;b&gt;` text to the admin instead of bold formatting. **Fixed** by adding `AdminReplier::replyHtml()`/`replyHtmlWithKeyboard()` (a "caller has already built trusted HTML and is responsible for escaping every dynamic fragment" variant) and switching every affected screen to it, with `MessageBuilder::escapeHtml()` applied individually to every admin-typed fragment (titles, question/option text, settings values, file names) before interpolation.
2. **`tests.is_active` schema default (Phase 1/2-era gap, surfaced by Phase 6's activation gate):** the `tests` table's `is_active` column defaults to `1` at the schema level — harmless while every test was fixture/SQL-inserted already-complete, but meant a brand-new *admin-created* test (0 profiles, 0 questions, structurally incomplete) would be born "active" before `TestValidationService` ever got a say. **Fixed** with a minimal, targeted change: `TestRepository::create()` now explicitly inserts `is_active = 0`; the schema-level `DEFAULT 1` was deliberately left untouched (other legitimate insert paths — fixtures, tests — already set their own explicit value, so changing the column default was unnecessary and riskier than fixing the one INSERT statement that needed it).
3. **`settings.soft_join_fail_open` never wired (flagged since Phase 5, closed this phase):** see the `QuizFlowHandler`/`MembershipService` entries above — this was a known, documented gap, not a surprise.

**Tests run:**
- `php -l` on all 173 PHP files in the project — 0 syntax errors
- `php tests/smoke.php` — 15/15 passed (Phase 1 regression)
- `php tests/phase3_unit.php` — 31/31 passed (Phase 3 regression, offline)
- `php tests/phase04_domain_smoke.php` — 40/40 passed against a live MariaDB (Phase 4 regression)
- `php tests/phase05_score_engine_test.php` — 39/39 passed (Phase 5 pure-logic regression)
- `php tests/phase05_runtime_smoke.php` — 24/24 passed against a live MariaDB + mock Telegram server (Phase 5 full-runtime regression, re-run after every Phase 6 wiring change)
- `php tests/phase06_admin_unit.php` — 32/32 passed (offline: `AdminCallbackCodec` encode/decode/malformed-input, `AdminEditableSettings` index stability, `OptionScoringService` increment/decrement-floor-at-0 math, `AdminStates` prefix/uniqueness invariants)
- `php tests/phase06_validation_test.php` — 10/10 passed against a live MariaDB (every `TestValidationService` blocking/warning scenario: empty test, <2-option question, missing result content, incomplete CTA warning, cross-test jump target, `dual_on_close` threshold requirement, fully-valid test)
- `php tests/phase06_soft_join_test.php` — 9/9 passed against a live mock Telegram server (transport-failure vs. genuine-non-member distinction, `checkLive()` backward compatibility)
- `php tests/phase06_admin_e2e.php` — 38/38 passed, real HTTP webhook POSTs through a live PHP built-in app server + mock Telegram server + MariaDB: admin auth (admin allowed, non-admin silently rejected with zero state leakage), full test build (title → 2 profiles → 2 questions → 2 options each → live +/- scoring with save AND cancel paths verified separately → branching → result text/CTA/voice upload → activation gate blocking-then-passing), slug-uniqueness on identical titles, settings edit (valid + invalid-input-rejected), file library upload+delete, `/cancel` mid-wizard, then a **real student** completing that exact test via its deep link and receiving the exact winning result content (verified against the mock server's request log, not just the DB) — proving full Phase 5 ↔ Phase 6 integration
- `php tests/phase06_admin_more_test.php` — 27/27 passed, supplementary coverage: edit-title (test/profile/question/option), profile delete → result `SET NULL` (not cascade) per the actual FK definition, question toggle-active round-trip, option delete (sibling untouched) + branch-jump, question delete, result-text/CTA "حذف" clearing to NULL, voice attach+remove, `/cancel` (not just the button) discarding an in-progress scoring draft, safe "not found" handling for nonexistent test/option ids (HTTP 200, no crash), and the files-channel-not-configured error path never allowing entry into the wait-for-voice state
- **Manual live E2E over real HTTP**, same mock-Telegram-server + PHP-built-in-server + real-MariaDB setup as Phase 5, used to visually confirm the HTML-escaping bug (fetching the mock server's raw request log and URL-decoding the `text` parameter) both before and after the fix

**Known issues carried forward / new residual gaps:** see `docs/PROJECT_STATUS.md` "Blockers / risks" and "What must NOT be assumed working".

## Phase 05 — Core Runtime Engines + Student Funnel (+ Product Rule Deltas)

**Schema/migrations:** additive only, applied via `Tfb\Install\Migrator::upgrade()`
(idempotent, safe to run repeatedly — see `bin/migrate.php`):
- `tests.result_display_mode` ENUM(`single`,`dual_on_close`) NOT NULL DEFAULT `single` (also added directly to `src/Install/Schema.php` for fresh installs)
- 6 new `settings` keys: `start_without_test_text`, `start_resume_prompt`, `question_progress_enabled`, `membership_recheck_button_text`, `default_result_display_mode`, `default_dual_threshold` (also added to `Tfb\Install\Seeder::defaultSettings()` for fresh installs)

**Files created:**
- `src/Engines/Scoring/ProfileScoreEngine.php`, `WinnerSelector.php`, `Selection.php` (rewrote `ScoringEngineInterface.php` to a practical `aggregate()` contract)
- `src/Engines/Flow/BranchResolver.php`, `CallbackCodec.php`, `StartPayloadParser.php`, `ParsedStartPayload.php`, `QuizFlowHandler.php` (rewrote `FlowHandlerInterface.php`'s `handle()` to return `bool`)
- `src/Services/StudentStartService.php`, `SlugService.php`, `ProfileCodeGenerator.php`, `ResultDeliveryService.php`
- `src/Support/Slugifier.php`
- `src/Controllers/Student/StudentController.php`
- `src/Install/Migrator.php`, `Phase05Fixture.php`
- `src/AdminHttp/README.md` (reserved namespace, no code)
- `bin/migrate.php`, `bin/load_phase05_fixture.php`
- `database/fixtures/phase05_quiz_fixture.sql` (reference doc; real loader is `Phase05Fixture` + `bin/load_phase05_fixture.php`)
- `tests/phase05_score_engine_test.php`, `tests/phase05_runtime_smoke.php`
- `docs/PRODUCT_RULES.md`, `docs/WEB_ADMIN_READINESS.md`

**Files modified (minimal, targeted):**
- `src/Install/Schema.php` — added `tests.result_display_mode` column to the `tests` CREATE TABLE
- `src/Install/Seeder.php` — added `phase05Settings()` + `seedMissingSettings()` (idempotent, `INSERT IGNORE`), 6 new default settings entries
- `src/Models/Test.php` — added `resultDisplayMode` property + `MODE_SINGLE`/`MODE_DUAL_ON_CLOSE` constants + `isDualOnClose()`
- `src/Models/TestSession.php` — **bug fix**: `toArray()` was missing `started_at`, breaking real session-duration computation (see "Bugs found and fixed" below)
- `src/Repositories/TestRepository.php` — added `findBySlugIncludingDeleted()`, `setResultDisplayMode()`
- `src/Services/TestService.php` — added `createFromTitle()` (auto-slug admin path), `setResultDisplayMode()`; constructor now takes `SlugService`
- `src/Bot/TelegramClient.php` — added `sendVoice()`, `sendDocument()`, `sendPhoto()`, `sendVideo()`
- `src/Bot/UpdateRouter.php` — `/start` now routes to `StudentController`; callback_query updates are offered to a list of `FlowHandlerInterface` implementations before falling back to `SystemController::unknownCallback()`
- `src/Bot/WebhookKernel.php` — wires the full Phase 5 dependency graph (engines, `QuizFlowHandler`, `StudentStartService`, `StudentController`) alongside the unchanged Phase 3 middleware chain
- `src/Support/AnalyticsEvents.php` — added `BOT_START_DEEP_LINK`, `QUESTION_VIEWED` (reserved), `TEST_DROP` (reserved), `VOICE_SENT` (reserved), `MEMBERSHIP_PASS`, `MEMBERSHIP_FAIL`
- `tests/mock_telegram_server.php` — added `sendVoice`/`sendDocument`/`sendPhoto`/`sendVideo`/`answerCallbackQuery`/`getChatMember`(configurable per-channel-id status)/`editMessageText` handlers
- `tests/phase04_domain_smoke.php` — updated `TestService` instantiation for its new `SlugService` dependency (no behavior change)
- `public/index.php` — health check `phase` value bumped `4` → `5` (no behavioral change)
- `README.md`, `docs/PROJECT_STATUS.md`, `docs/ARCHITECTURE.md`, `docs/DOMAIN.md`, `docs/PHASES.md`, `docs/AGENT_HANDOFF.md` — updated for Phase 5 (see those files)

**Not modified:** `src/Middleware/*`, `src/Install/Installer.php`, `src/Install/Schema.php`'s other tables, `src/Controllers/System/*` — Phase 3's security pipeline and Phase 2's installer were left untouched.

**Bugs found and fixed during Phase 5 verification** (both pre-existing, surfaced by writing real E2E tests against a live DB — not introduced by Phase 5 itself):
1. `Tfb\Models\TestSession::toArray()` omitted `started_at`. `SessionService::complete()`/`QuizFlowHandler` need it to compute real session duration; without the fix, every session's `duration_seconds` would have silently computed as ~0. **Fixed** by adding the field to `toArray()`.
2. `session_scores` was only written for profiles an answer happened to score — a profile with zero total never got a row, making future reporting queries (Phase 9) undercount. **Fixed** in `QuizFlowHandler::completeAndDeliver()` (and mirrored in the test) by filling in `0` for every profile in the test before calling `SessionService::saveScores()`.

**Tests run:**
- `php -l` on all 151 PHP files in the project — 0 syntax errors
- `php tests/smoke.php` — 15/15 passed (Phase 1 regression)
- `php tests/phase3_unit.php` — 31/31 passed (Phase 3 regression, offline)
- `php tests/phase04_domain_smoke.php` — 40/40 passed against a live MariaDB (Phase 4 regression)
- `php tests/phase05_score_engine_test.php` — 39/39 passed (offline: ProfileScoreEngine, WinnerSelector all 6 selection reasons incl. 100-run random tie-break distribution check, CallbackCodec, StartPayloadParser)
- `php tests/phase05_runtime_smoke.php` — 24/24 passed against a live MariaDB + mock Telegram server (fixture load, BranchResolver next/jump/end/loop-guard/cross-test-jump, full session lifecycle, ResultDeliveryService no-placeholder rule for all 3 fixture result shapes, SlugService/ProfileCodeGenerator collision handling)
- **Manual live E2E over real HTTP** (mock Telegram server + PHP built-in server + real MariaDB), covering every Phase 5 acceptance-criteria scenario: deep-link start (clear winner, exact tie → random single-mode pick, dual_on_close close/far), no-intro verification, question-only rendering (no media), result content rules (text+CTA / voice-only-no-CTA / CTA-only-no-content, all confirmed via the mock server's request log — no placeholders sent in any case), membership fail→recheck→pass, resume/restart, jump branching, callback replay idempotency (exact same `callback_query.id` redelivered — confirmed no double-advance/double-answer), cross-user IDOR rejection (foreign user with no session on that question gets a safe "no longer valid" answer, zero DB side effects), `/start` without payload (no default-test fallback), ban/maintenance/rate-limit/`/myid`/`/help`/`/admin` regression (all identical to Phase 3 behavior), secret/token leak check on logs (none found), and a DB query-count spot check (~10 queries for a full `/start` + test dispatch)

**Known issues carried forward:** see `docs/PROJECT_STATUS.md` "Blockers / risks".

## Phase 04 — Domain Layer (Models, Repositories, Core Services)

**Schema/migrations:** none. All Phase 4 work uses the existing
Phase 2 schema (`src/Install/Schema.php`, version `1.0.0`).

**Files created:**
- `src/Models/CastsFromRow.php`, `User.php`, `UserState.php`, `Campaign.php`, `Test.php`, `TestProfile.php`, `Question.php`, `QuestionOption.php`, `OptionProfileScore.php`, `ResultDefinition.php`, `ResultMedia.php`, `StoredFile.php`, `TestSession.php`, `SessionAnswer.php`, `SessionScore.php`, `AnalyticsEvent.php`, `Setting.php`, `AdminAuditLog.php`, `MembershipLog.php`
- `src/Repositories/CampaignRepository.php`, `TestRepository.php`, `TestProfileRepository.php`, `QuestionRepository.php`, `QuestionOptionRepository.php`, `OptionProfileScoreRepository.php`, `ResultDefinitionRepository.php`, `ResultMediaRepository.php`, `StoredFileRepository.php`, `TestSessionRepository.php`, `SessionAnswerRepository.php`, `SessionScoreRepository.php`, `AnalyticsEventRepository.php`, `AdminAuditLogRepository.php`, `MembershipLogRepository.php`, `ProcessedCallbackRepository.php`
- `src/Services/CampaignService.php`, `TestService.php`, `TestGraphService.php`, `SessionService.php`, `AnalyticsService.php`, `FileService.php`, `AuditService.php`, `MembershipService.php`
- `src/Support/StudentStates.php`, `AdminStates.php`, `AnalyticsEvents.php`, `CallbackNamespaces.php`, `DomainValidator.php`
- `src/Engines/Flow/FlowHandlerInterface.php`, `src/Engines/Scoring/ScoringEngineInterface.php` (stubs only)
- `src/Core/Exceptions/NotFoundException.php`
- `tests/phase04_domain_smoke.php`
- `docs/ARCHITECTURE.md`, `docs/DOMAIN.md`, `docs/CODING_GUIDE.md`, `docs/PHASES.md`, `docs/PROJECT_STATUS.md`, `docs/AGENT_HANDOFF.md`, `docs/CHANGELOG.md` (this file)

**Files modified (minimal, targeted):**
- `src/Repositories/SettingsRepository.php` — added `find()` and `set()` (was read-only in Phase 3)
- `src/Repositories/UserRepository.php` — added `setBanned()` and `assignCampaign()`
- `src/Services/SettingsService.php` — added `set()`, `getInt()`, `getJson()`, `getMany()`; default TTL raised 30s → 60s per PRD; docblock updated to reflect write capability
- `src/Services/StateService.php` — added `mergeContext()`; state name constant now sourced from `Tfb\Support\StudentStates` (kept `StateService::IDLE` as a deprecated alias so Phase 3 call sites keep working unmodified)
- `src/Services/UserService.php` — added `ban()`, `unban()`, `assignCampaignIfMissing()`, `isAdmin()`
- `public/index.php` — health check `phase` value bumped `3` → `4` (no behavioral change)
- `README.md` — rewritten to reflect current architecture and documentation index

**Not modified:** `src/Bot/WebhookKernel.php`, `src/Middleware/*`, `src/Bot/UpdateRouter.php`, `src/Controllers/System/*`, `src/Install/*` — the Phase 3 pipeline and Phase 2 installer were left untouched; Phase 4 only added a data layer underneath them.

**Tests run:**
- `php -l` on all 131 PHP files in the project — 0 syntax errors
- `php tests/smoke.php` — 15/15 passed (Phase 1 regression)
- `php tests/phase3_unit.php` — 31/31 passed (Phase 3 regression, offline)
- `php tests/phase04_domain_smoke.php` — 40/40 passed against a live MariaDB instance (17 offline model/validator checks + 23 DB-backed repository/service checks)
- Live E2E webhook regression: started a mock Telegram API server + the app under PHP's built-in server, POSTed real webhook payloads (`/start`, `/myid`, `/admin`, wrong secret) — all passed, confirmed no regression from the domain-layer changes; users were correctly created/role-synced in the database

**Known issues carried forward:** see `docs/PROJECT_STATUS.md` "Blockers / risks".
