# Track C Status тАФ Learning Runtime (Learner-First)

Pre-flight (2026-09-27): GATE 1 artifacts verified on origin/main тАФ T-A-01тАжT-A-06 all
merged (last merge 4ad28ae `[merge][T-A-05]`). Note: `track_a.md` still reads "Gate 1
Not yet passed" тАФ that text predates the orchestrator's T-A-05 merge commit; the six
Phase-0 branches are verifiably in `git branch -r --merged origin/main`. Proceeding.

| Task | Status | Branch | Notes / blockers |
|---|---|---|---|
| T-C-01 | тЬЕ merged 2026-09-27 | track-c/T-C-01-base-learning-model | BaseLearningModel ($connection='learning', extends A's BaseDomainModel тЖТ audited), `database/migrations/learning/` prefix 20260924_03 with schema marker; lint test: all `Models/Learning` on learning connection; cross-DB-FK test via SQLite `foreign_key_list` introspection (every FK target must live on `learning`). 82 passing, 0 failing (full suite incl. T-C-01). |
| T-C-02 | тЬЕ merged 2026-09-27 тАФ **CONTRACT FOR D** | track-c/T-C-02-progress-service | `App\Services\Learning\ProgressService` frozen to README signatures (reflection-locked in test): `courseProgress(userId)`, `topicMastery(userId)`, `streak(userId)`, `attemptsSummary(userId)`, `timeSpent(userId, window)`, `weakTopics(userId)`. **D: bind/inject `ProgressService` (interface) now тАФ Phase-0 stub `ProgressServiceStub` returns empty iterables; real impl replaces the binding in T-C-16 (Gate 3). No container binding added (AppServiceProvider is A-owned) тАФ D binds the interface in its own provider.** 85 passing, 0 failing (full suite, incl. merged T-C-01). |
| T-C-03 | тЬЕ merged 2026-09-28 (verified in `git branch -r --merged origin/main`) | track-c/T-C-03-access-gate | `AccessGate::evaluate(userId, ContentAccess, contentScope)` тЖТ `AccessDecision` (allow/preview/deny + reason). Order = entitlement(A's README-frozen `App\Services\Billing\SubscriptionService` тАФ T-A-17 impl not on main yet, so tested via Mockery against the frozen FQCN, same pattern as B's merged T-B-03; **A: no action needed, your impl binds by FQCN**) тИй enrollment (port, T-C-04) тИй parental (port, T-C-13) тИй DRM (B T-B-06 flag on `ContentAccess` value object тАФ never a direct catalog query). Preview only: unentitled + sample access + all other factors green; previews never bypass enrollment/parental/DRM. 10 AC tests, each flips exactly ONE factor. |
| T-C-04 | тЬЕ merged 2026-09-29 (commit f7197b9 `[merge][T-C-04]`) | track-c/T-C-04-enrollment-progress | **ROOT CAUSE FIXED (2026-09-29):** verified clean main = 296/296 green тАФ the 3 `ResourceLibraryTest` failures were NOT pre-existing on main; they were caused by C's materialized `app/Services/Billing/SubscriptionService.php` interface file (deleted; A's T-A-17 creates it at the frozen FQCN) + test doubles switched to Mockery (T-C-03/B pattern). No C production code change. Ownership-clean: provider file + `bootstrap/providers.php` edit removed; container bindings moved into C-owned `routes/api_learning.php`. Full suite **328/328** on merge base. | Enrollment/drop/re-enroll (drop = status change, progress preserved; re-enroll restores completed status without re-emitting events). content_progress: position/speed, sticky completion at тЙе90% of duration (watched-to-end), re-watch never reverts, resume = last saved position (row is the cross-device store). Course completion = all REQUIRED content (optional never blocks); progress % recomputed on complete AND read. Locked-in-order units: next unit 403 until prior unit's required items complete (new `unit_locked` deny reason on AccessDecision); rule changes never invalidate completed rows. Every playback/read path runs the full AccessGate (entitlement тИй enrollment тИй parental тИй DRM) тАФ enroll checks entitlement directly (bootstrap action, gate factor "enrollment" is out of scope pre-enrollment). Emits frozen `ContentCompleted` (userId, contentItemId тАФ exact A T-A-06 double shape) + additive `CourseCompleted` (userId, courseId, README Change Log). B DEPSтЪб via new `CourseStructure` port тЖТ `CatalogCourseStructure` adapter over B's `CatalogService::courseTree`; A DEPSтЪб: entitlement via the README-frozen FQCN `App\Services\Billing\SubscriptionService` (per-test Mockery mocks until A's T-A-17 ships). 34 new Learning tests (Learning namespace 47/47 on merge head), all ACs covered. |
| T-C-05 | тЬЕ merged 2026-09-30 (d9871d6 `[merge][T-C-05]`, branch head d527a62) тАФ **338/338 passing, 0 failing** (full suite; +10 new; Learning ns 57/57) on origin/main **d9871d6** (post T-C-04 + T-C-05) | track-c/T-C-05-attempts-grading | Attempts, results, grading (quiz/exam/mock/past-paper). `attempts` + `answers` on the learning DB (migrations 20260924_030301/02, no cross-DB FKs). Per-question state answered/flagged/unanswered + marks_awarded/grading_mode/ai_rationale/human_override_reason. **Timer**: expired тЖТ auto-submitted ONCE, unanswered materialized as UNANSWERED with is_correct NULL (never zero-marked-answered). **Grading** (`GradingEngine`): MCQ exact; **multi-select full marks only** (exact set); fill-blank trimmed exact; true/false; **numeric with configured tolerance**; **short_answer/essay тЖТ AI rubric** (keyword coverage; rationale in ai_rationale). **Adaptive exam**: difficulty target + ability estimate updated per answer in `adaptive_state` (hidden тАФ raw response asserted to contain no ability/difficulty/adaptive/question_limit), ends at configured question limit. **Practice**: unlimited retakes = NEW attempt rows (attempt_number increments, never overwrite), the B-owned exam/mock `attempt_count` allowance NEVER bumped. **Mock**: fixed time, single submission per window, **no feedback until `feedback_unlocked_at`**; 2nd start 409. **Results**: score, is_passed vs passing_score, per-question breakdown, weak_topics. **Topic statistics**: per-topic attempts/avg %/classification (strong/weak thresholds from B's grading_config) тЖТ `weakTopics` (feeds C focus mode + D insights). **Human override** (B's config): reason required, AI grade + rationale PRESERVED, is_correct re-derived, score recomputed. Emits the FROZEN `AttemptSubmitted(int userId, int assessmentId, int score, array weakTopics)` exactly per README contract. B DEPSтЪб via new C-owned `AssessmentBank` port тЖТ `CatalogAssessmentBank` adapter over B's T-B-08 `AssessmentService`/`QuestionService` (no cross-DB query; `AssessmentBankDouble` per-test, production binding in C-owned routes). **Every path runs the FULL AccessGate** against the assessment's course (test flips exactly one factor тЖТ 403, no rows). ACs (all 5): timer auto-submit + unanswered marked; multi-select partial = 0; adaptive hidden (field absent); practice retake doesn't decrement exam allowance; mock no-feedback until window closes. |
| T-C-06 | тЬЕ merged 2026-09-30 (d947a3d `[merge][T-C-06]`) тАФ **349/349 passing, 0 failing** on origin/main (post T-C-04 + T-C-05 + T-C-06; Learning ns 68/68) | track-c/T-C-06-bookmarks-notes-highlights | Bookmarks, notes, highlights (learning DB; migrations 20260924_030401/02, no cross-DB FKs; every model carries `protected $connection='learning'` for A's app-wide ModelConnectionLintTest). **`bookmarks`**: one per (learner, content item) тАФ the row IS the toggle (present = bookmarked); carries video `timestamp_seconds` + attached `note`; toggle-off deletes the row (note goes with it). **`notes`**: char-limited (2000), autosave = `updateOrCreate` (one row per learner+scope+course+content), scope `content`/`course`. **`highlights`**: text span (start/end offset, must be non-empty), color, attached note ON THE SAME ROW тАФ deleting the highlight cascades its note by design (no FK). `BookmarkNoteService` validates the note limit + span range (`InvalidNoteDataException` тЖТ 422); course/content existence + membership through B's `CourseStructure` port (never a catalog query). `BookmarkNoteController` mirrors the T-C-04 gate pattern: **every CONTENT path (bookmark/content-note/highlight) runs `requireCourse` (enrollment тЖТ existence-safe 404) THEN the FULL AccessGate** (entitlement тИй enrollment тИй parental тИй DRM тАФ deny тЖТ 403); course-level listings (`GET /courses/{id}/bookmarks|notes`) require enrollment only (T-C-04 course-level pattern). Private + cross-device: other learners' rows are invisible (404 for non-enrolled; empty own-state for a co-enrolled learner). Routes under /api/learner/courses/* (8 endpoints, `auth:sanctum`). ACs (all 4): toggle bookmark add-then-again=remove; delete highlight cascades its note; note char-limit 422 over; private other-user 404. |
| T-C-07 | тЬЕ merged 2026-09-24 (84382b3) | track-c/T-C-07-gamification | Gamification STATE on the learning DB (migrations 20260924_030501, no cross-DB FKs; 6 models all `protected $connection='learning'` for A's ModelConnectionLintTest). **`points_ledger`** IMMUTABLE (INSERT-only; `spendPoints` debits via a new negative row, never an UPDATE; rolling `expires_at` excluded from `balance()`; `source_type` nullable тАФ redemptions have no content source). **`awarded_badges`** unique (user_id, badge_code) тАФ `awardBadge` via `insertOrIgnore`: 2nd award is a NO-OP (AC), `evaluateBadges` auto-awards config criteria once. **`streaks`** (one row/learner): consecutive study days extend; **protection/grace rule** тАФ a missed day does NOT always break the streak: with D-configured protection (`streakProtectionDays`) a gap bridges the streak (KEPT, one freeze consumed), beyond grace it resets to 1; `last_study_date` always advances; back-dated/same-day actions are idempotent no-ops. **`personal_goals`** auto-complete at target. **`challenges`**/`achievements` state present (deadline/status). CONFIG is DEPSтЪб D's `App\Services\Engagement\GamificationConfigService` (T-D-03/T-D-08 тАФ NOT on main): C's `GamificationService` resolves it LAZILY by FQCN (injected instance > container binding > C-owned `GamificationConfigFallback` with empty rules) тАФ C never materializes a file in D's namespace, nothing 500s before D lands, tests inject a double (the T-C-03 SubscriptionService / T-C-05 AssessmentBank pattern). **Frozen event `StreakUpdated(int userId, int currentStreakDays)`** (exact A T-A-06 double shape) emitted on every streak change. `RecordStudyAction` listener (event discovery, queued) on `ContentCompleted` тЖТ study action (idempotent/day) + `content_completed` points тАФ so completing content drives streak+points end-to-end. Routes under /api/learner/gamification/* (8 endpoints; learner-scoped state, no content AccessGate тАФ the gate rule covers playback/attempt/download paths). ACs (all 4): badge awarded once (2nd no-op); redeem insufficient points тЖТ graceful 409; streak protection (1 protected miss keeps streak 2, no-protection gap resets to 1); ledger immutable (2 distinct rows, no update path). |
| T-C-08 | тЬЕ merged 2026-09-24 | track-c/T-C-08-srs-runtime | Per-learner SRS state on the learning DB (migration 20260924_030601: `srs_items` + `srs_review_logs` + `srs_daily_counters`, no cross-DB FKs; all 3 models `protected $connection='learning'`). `SrsService`: due-queue (due-first тЖТ priority тЖТ oldest-due tie-break), rating loop (Again = bounded reset to floor, Good/Easy extend per B's `rating_map`), **config snapshot on the item** (mid-session config change: existing items keep their own interval; new items pick up the new one тАФ AC), daily cap with carry-over (cap stored on the counter row; unused allowance of the last active day rolls into today), error-weighted resurfacing (never below floor, per-question cap, overdue re-error resurfaces), video revision reminder with skip-if-rewatched, mastery levels DERIVED from recall-rate thresholds (no manual override), graduation/archive + re-entry on failed recall (AC), retention prediction from B's Ebbinghaus curve. B DEPSтЪб via `App\Services\Catalog\SrsConfigService` (T-B-10 merged) тАФ tests bind `SrsConfigServiceDouble`; production resolves the real service at its FQCN, zero C change needed. Listeners (queued, event discovery): `ScheduleCompletedContentForSrs` on frozen `ContentCompleted` (video_tutorial/podcast only, when eligible), `ResurfaceIncorrectAnswers` on frozen `AttemptSubmitted` (every question enters the SRS idempotently; incorrect ones resurface early). Routes `GET /api/learner/srs/due-queue`, `GET /api/learner/srs`, `POST /api/learner/srs/{itemId}/rate` (`auth:sanctum`; rating an unknown item тЖТ 404 existence-safe; bad rating тЖТ 422). ACs (all 4): Again bounded reset + Good extension; config-change mid-session keeps old interval; daily cap carry-over; archived + re-entry on failed recall. **507/507 passing, 0 failing** (full suite on origin/main 8c06d7f; Learning ns 101/101, +19 new). |
| T-C-09 | тЬЕ merged 2026-09-24 (1c7d2d3) | track-c/T-C-09-ai-companion | AI study companion runtime (learning DB, migration 20260924_030701: 8 tables, all `protected $connection='learning'`, no cross-DB FKs). `AiCompanionService` consumes B's merged `AiFeatureConfigService` (DEPSтЪб T-B-11) at its FQCN тАФ per-plan question limit (exceed тЖТ **429 plan-upgrade**, snapshot on first use, non-retroactive), escalation rules, companion/break settings; tests bind C-owned `AiFeatureConfigDouble`. Conversations (context retained тАФ transcript BEFORE this question replayed; **2nd answer references 1st**), doubts (AI-resolved OR escalated: student-requested + auto-low-confidence тЖТ teacher handoff; async-ticket fallback in-band), mood check-ins (score тЙд 4 or 3-run low streak тЖТ burnout flag + break suggestion from B's break_rules), recommendations (acted-on tracked), chapter summaries (regenerable, version bumps), peer benchmark (**strictly anonymous** тАФ cohort aggregates + learner percentile/coarse tier only; no peer id/name stored so a response can never identify an individual). **ADDITIVE queued event** `DoubtEscalatedToTeacher(int userId, int doubtId, string mode, ?string topic)` (not one of the frozen 15) тЖТ D parent-teacher comms; persistent record = learning.ai_doubts. Course-grounded paths enrollment-gated (requireCourse тЖТ 404 existence-safe). 9 learner-scoped routes under /api/learner/companion/*. ACs (all 4): question over plan limit тЖТ 429; benchmark response no peer names/IDs; doubt escalation event emitted; context retained across a conversation. README: one append-only Contract Change Log line. |
| T-C-10 | тЬЕ merged 2026-09-24 (7694d27) тАФ **611/611 passing, 0 failing** (full suite on origin/main 0d1fd76 post T-C-12 + T-A-11; +17 new Learning tests) | track-c/T-C-10-study-plans-target-exams | Study plans + target-exam runtime (learning DB, migration 20260924_030801: `target_exams` / `target_exam_mappings` / `study_plans` / `study_plan_tasks` / `study_plan_milestones` / `study_plan_predictions`, all 6 models `protected $connection='learning'`, no cross-DB FKs). **Target exams**: dates, subject-topic mappings (importance high/medium/low + time_split_pct **must total 100% else 422**), **score prediction** (real-time; confidence interval widens with less data; **insufficient data тЖТ "not shown" + practice prompt, never false precision** тАФ below B's `min_data_points`), **what-if scenarios** (hypothetical topic scores, no persistence), **readiness** (coverage + mastery + days-remaining label), **actual result post-exam тЖТ accuracy delta** stored on the latest prediction, post-exam reflection. Paused exam halts its study plan + reminders (next_reminder_at nulled) until resumed. **Study plans**: AI planner + target-exam plans (gap-first, learn-before-practice ordering from B's config), tasks spread over horizon, adherence tracking (on-track/behind/slid), **plan regenerates when prediction changes** (version bump, old plan тЖТ completed), check-in prompt fires ONCE when adherence drops below threshold (NotificationRequested `study_plan_checkin`), **milestone triggers fire once per student per plan** (`study_plan_milestone`, from B's config), **parent approval required for minors** (plan NOT applied until `approvePlan`). Consumes B's merged services (DEPSтЪб T-B-11): `AiFeatureConfigService::studyPlanConfig()` (horizon, task templates, milestone triggers, at-risk threshold) + `PredictionConfigService::effectiveConfig()/dataSufficiency()` (global config via `?ExamType=null`) тАФ never a direct catalog query. Emits FROZEN `TargetExamRecorded(int userId, string examName, string examDate)` + `StudyPlanGenerated(int userId, int planId)` тАФ shapes exactly match A's T-A-06 test doubles (`tests/Feature/IdentityBilling/TestDoubles/Events/`) тЖТ D. B cross-track contract: `target_exams.exam_type_id` column (nullable, indexed) тАФ B's `ExamAiConfigService::deleteType` delete-guard does a plain cross-DB existence check against it (name/exam_date nullable at the DB level so B's probe row inserts; both enforced required at C's API layer). 17 learner-scoped routes under /api/learner/target-exams/* + /api/learner/study-plans/*. ACs (all 5): time-splitтЙа100% тЖТ 422 (API + service); prediction below data threshold тЖТ null + practice prompt (API + service); paused exam тЖТ plan + reminders halted; minor plan not applied until parent approval; actual result тЖТ accuracy delta stored. |
| T-C-11 | тЬЕ merged 2026-09-24 (1123728) тАФ **622/622 passing, 0 failing** (full suite on origin/main 7694d27, post T-C-10 merge; +6 new Learning tests, Learning ns 138/138) | track-c/T-C-11-learning-paths | Learning-path runtime (institute/corporate; B T-B-05 owns the DEFINITIONS тАФ catalog DB тАФ C owns the learner STATE + runtimes). Learning DB, migration 20260924_030901: `learning_path_enrollments` (one per learner+path, status in_progress/completed, deadline_at, total/completed_steps) + `learning_path_step_progress` (per-step course row, sequence snapshot, locked/in_progress/completed) + `certificates` (one per learner+course, UUID `certificate_id`, nullable `learning_path_id` = triggering path) тАФ all 3 models `protected $connection='learning'`, plain indexed cross-domain columns, NO cross-DB FKs (cross-DB-FK guard passes). **Cross-DB boundary (DEPSтЪб T-B-05):** B's path surface is model-in/data-out with no id-based reader, so C's domain layer consumes path definitions ONLY through the new C-owned `PathCatalog` PORT (`pathDetail(id)`, `learnerPaths(userId)`); production binding = thin `CatalogPathCatalog` adapter (the single integration seam that reads B's `LearningPath`/`LearningPathCourse`/`LearningPathAssignment` to build C value objects тАФ no join into learning DB, no cross-DB FK), tests bind `PathCatalogDouble` (C's suite stays hermetic тАФ zero catalog tables). **`LearningPathService`:** enroll (idempotent, snapshots definition, first step unlocked, rest locked); **prerequisite step-lock** (step stays locked while ANY earlier step is incomplete тЖТ `step_prerequisite` тЖТ 403 at controller; first step never locked; a completed path is fully re-visitable); **criteria-gated step completion** тАФ a step completes ONLY when its course actually completes (T-C-04 `CourseCompleted` event), a partial watch never advances the path; unlock-next on completion; **deadline** (settable) тЖТ derived `behind_pace` (deadline passed + path incomplete; never stored); **path completion** when all steps complete; **certificates** тАФ one per (learner, course), idempotent, UUID, records triggering path. **Certificate runtime:** `OnCourseCompleted` listener (queued, event discovery) on T-C-04's frozen `CourseCompleted` тЖТ issues the certificate + advances the path runtime. Emits the FROZEN `App\Events\Learning\CertificateIssued(int userId, int courseId, string certificateId)` тАФ shape exactly matches A's T-A-06 test double тЖТ **D: ingest `CertificateIssued` for the certificate/achievement surface** (persistent record is C's `learning.certificates` row). 5 learner routes under `/api/learner/learning-paths/*` (index, enroll, show, deadline, steps/{course}/start) behind `auth:sanctum`. **AccessGate rule preserved:** the path layer only ADDS the prerequisite lock; every playback/attempt/download of a step's content STILL runs the full T-C-04 AccessGate (entitlement тИй enrollment тИй parental тИй DRM) тАФ path enrollment alone confers NO content access (tested: path-enrolled but not course-enrolled тЖТ 404 on content; after course enrollment тЖТ allowed). ACs (all 4): prerequisite unmet тЖТ next step 403; criteria-gated completion (partial тЙа complete); deadline exceeded тЖТ behind_pace; full completion тЖТ certificate + `CertificateIssued` ├ЧN. |
| T-C-16 | ✅ merged 2026-10-02 (`5c8120d`); **633/633 passing at merge** (+11 new Learning tests, Learning ns 149/149, incl. A's app-wide `ModelConnectionLintTest` + the cross-DB-FK guard) | track-c/T-C-16-progress-read-models | **Phase 4 read models + REAL `ProgressService`** (Gate 3 obligation "ProgressService real by Gate 3" now met). Learning DB, migration 20260924_031001: `study_sessions` (the time-spent session log тАФ one per completed content item, duration from B's `CourseStructure` port) + `learner_activity` (append-only activity log) + `shared_reports` (weekly/monthly snapshots, shareable) тАФ all 3 models `protected $connection='learning'`, plain indexed user_id columns, NO cross-DB FKs (cross-DB-FK guard passes). **REAL `App\Services\Learning\ProgressServiceImpl`** (replaces the T-C-02 stub binding in C-owned `routes/api_learning.php` тАФ the frozen `ProgressService` interface is UNCHANGED, so D's injected mock/double still works and D now injects live data; reads the learning DB only, via C-owned `CourseStructureResolver` + `AssessmentBank` ports тАФ no catalog/identity query). **`LearnerReadModel`** composes the frozen `ProgressService` + T-C-07 gamification + T-C-10 readiness into: progress %, time-spent (per session log), read-only activity feed, performance dashboard (score trend + accuracy), strength/weakness (topic accuracy vs B's `topic_weak_threshold` тЖТ "focus on"/"maintain"), grade-level **benchmark percentile + gap** via the new C-owned `GradeBenchmark` PORT (DEPSтЪб тАФ D supplies the analytics cohort aggregates `cohortForGrade(gradeLevel)`; `NullGradeBenchmark` default until D lands, so the benchmark degrades gracefully to "no cohort" тАФ no 500), learning-pattern insight (peak study hour, 7-day consistency label), streak/consistency. **Automated reports** (weekly/monthly): snapshot of time/completion/scores/weak-topics/streak; **downloadable** as CSV (`GET /api/learner/reports/{id}/download`, ownership-scoped 404) + **shareable with the parent** (`POST /api/learner/reports/{id}/share` тЖТ appears in the parent dashboard). **6 thin queued listeners** (event discovery, no EventServiceProvider) append `learner_activity` rows on the frozen events: `ContentCompleted`тЖТ`content_completed`+study session, `AttemptSubmitted`тЖТ`attempt_submitted`+score-drop alert, `CourseCompleted`тЖТ`course_completed`, `StreakUpdated`тЖТ`streak_updated`, `CertificateIssued`тЖТ`certificate_issued`, `StudyPlanGenerated`тЖТ`study_plan_generated`. **`ActivityLogger`** (C-owned) is the single append point тАФ the learner can only READ the log (no write endpoint; AC-tested on the route table). **`PerformanceAlert`** ADDITIVE queued event (`int $userId, string $type, array $payload`) тАФ the "performance alerts тЖТ D" requirement (score_drop | missed_plan | streak_risk); fired on a genuine planted score drop (current < previous on the same assessment; an improving attempt does NOT fire тАФ both tested). **Parent-facing projections** (`/api/parent/children/{childId}/{activity,exam-prep,reports}`): child-scoped, read-only; the per-child monitoring-consent scope is enforced by T-C-13; the current local integration binds A's real adapter, while `origin/main` retains the deny-by-default port fallback until A10 is merged there тАФ these are the data contract D's parent dashboard consumes. **No AccessGate:** read-model projections consume no catalog content (the content was already gated at T-C-04/05). **D:** (1) bind your real cohort service to `App\Services\Learning\GradeBenchmark` to activate grade-level benchmarking (one-line, no C change); (2) ingest the ADDITIVE `PerformanceAlert` event for the performance-alert surface. **`CertificateIssued` emission verified complete:** every course completion (path or not) тЖТ `OnCourseCompleted` тЖТ `LearningPathService::issueCertificate` тЖТ frozen `CertificateIssued(userId, courseId, certificateId)` тАФ T-C-11's runtime, unchanged. ACs (all 5): activity log immutable to learner (no write endpoint тАФ route-table test); report share тЖТ visible in parent dashboard (unshared invisible); weak-topic = accuracy below threshold; benchmark percentile grade-level (cohort via the port + graceful no-cohort); alert on planted score drop (improving does not fire). |
| T-C-13 | ✅ merged 2026-10-02 (`a697819`); **663/663 passing at merge** (+28 new Learning tests, Learning ns 179/179, incl. A's app-wide `ModelConnectionLintTest` + the cross-DB-FK guard) | track-c/T-C-13-parental-controls | **Parent-child link runtime + parental controls.** Link records live in A's `identity_billing` (T-A-10); C never queries that DB — it consumes the link facts through the NEW C-owned `ParentChildLink` PORT (`isLinked`, `isVerified`, `consentScope`, `childrenOf`) returning plain value objects. **DEPS⚡ resolved by port (NOT a hard block on T-A-10):** the merged C13 code uses `NullParentChildLink` as a deny-by-default fallback, and C tests bind `ParentChildLinkDouble` (hermetic; no identity tables). In the current local integration checkout, A's `AccountLinkParentChildAdapter` is wired from A-owned `AppServiceProvider`; adapter coverage is in `tests/Feature/IdentityBilling/AccountLinkTest.php`. T-A-10 is still not an ancestor of `origin/main`, so this real binding is integration-local; no claim of an upstream A10 merge is made. **Parental-control settings live on the `learning` connection** (migration 20260924_031101: `parental_control_settings` / `parental_age_overrides` / `parental_content_requests` / `parental_study_sessions` / `parental_session_schedules` / `parental_plan_approvals` / `parental_control_activity` — all 7 models `protected $connection='learning'`, plain indexed user_id columns, NO cross-DB FKs). **`DefaultParentalRestrictionAccess`** (the real T-C-13 gate factor, replacing the T-C-03 port note) reads settings + overrides + approved requests and composes: **screen-time** (daily limit, resets daily, access PAUSED at limit, extendable; warn approaching), **time-of-day windows**, **content restrictions** (non-core only — **core learning NEVER blockable: attempting to restrict a core type → 409**), **age filter** (grade-aligned; core exempt; override requires a reason → 422, logged), **restricted-content requests** (child submits → parent approves time-boxed / denies; an approval lifts the restriction for the box). `ContentAccess` (T-C-03 value object) gains two ADDITIVE optional fields `type` + `gradeLevel` so the gate carries catalog content facts without a cross-DB query; wired at the 3 production call sites (Enrollment/Attempt/BookmarkNote). **Mandatory study sessions** (parent schedules, child completes; missed → alert) + **recurring schedules** (conflict detection → 409, weekly requires a weekday, pause for holidays). **AI study-plan approval:** a minor's plan is NOT applied until parent approval (`markTaskDone` on an unapplied plan → 409 via `StudyPlanNotAppliedException`; modify-then-approve supported; approval history row). Control presets (Balanced/Strict/Relaxed) + control **activity log**. **ADDITIVE queued event `App\Events\Learning\ParentalAlert(int userId, string type, array payload)`** (not one of the frozen 15; `session_missed` | `screen_time_limit` | `approaching_limit`) → **D:** ingest `ParentalAlert` for the parent-notification surface. **Parent-facing projections** (T-C-16's `ParentProgressController`) are now LINK-CHECKED: not-linked/unknown → existence-safe 404; linked-but-unverified → LIMITED (fine-grained `recent_activity` + exam-prep withheld until verified); verified → full; shared reports shown in both scopes (child's explicit share is the consent). **No AccessGate on the control endpoints** (no catalog content consumed); every playback/attempt path STILL runs the full AccessGate — the parental factor is now the REAL `DefaultParentalRestrictionAccess`. **ACs (all 6, tested):** unverified link → LIMITED child data; verified → full; core-learning restriction attempt → 409; screen-time limit reached → playback denied (real gate) until reset/extend; unapproved minor AI plan not applied (markTaskDone 409); override without reason → 422. Ownership audit — ALL changed paths C-owned: `app/Services/Learning/{ParentChildLink,NullParentChildLink,DefaultParentalRestrictionAccess,ParentalControlService,Exceptions/{ParentalControlConflictException,StudyPlanNotAppliedException}}.php`, `app/Models/Learning/Parental{ControlSettings,AgeOverride,ContentRequest,StudySession,SessionSchedule,PlanApproval,ControlActivity}.php`, `app/Http/Controllers/Learning/LearnerParentalController.php`, `app/Http/Controllers/Parent/{ParentalControlController,ParentProgressController}.php`, `app/Events/Learning/ParentalAlert.php`, `database/migrations/learning/20260924_031101_create_parental_control_tables.php`, `routes/api_learning.php` (+20 parent/learner routes + 3 container bindings), `tests/Feature/Learning/{ParentalControlTest.php (28 tests), TestDoubles/ParentChildLinkDouble.php}`. No A/B/D paths, no `app/Providers/`, no `bootstrap/providers.php`. README: one append-only Contract Change Log line (ParentalAlert). |
| T-C-14 | ✅ local integration 2026-10-02 (not in origin/main) | codex/T-C-14-offline-learning-runtime | Learning DB offline device/download/sync state; subscription, DRM, and full `AccessGate` consumption checks are injected through contracts. Downloads expire at the earlier of 30 days or subscription-period end; API marks expiry and exposes a 72-hour warning; renewal requires a fresh download. Active-device cap is 3, removal revokes grants and pending sync. Quiz/deck/paper answers remain ungraded until sync; sync applies map merges/latest-change-wins and sends essay/answer conflicts to manual review. Focused acceptance tests: 7/7 (67 assertions); full suite at initial implementation: 831/831. D seam: warning is exposed in the downloads response; no D notification/cache contract was needed or queried by this C runtime. |
| T-C-15 | ✅ local integration 2026-10-02 (not in origin/main) | codex/T-C-15-mobile-app-state | Learning DB mobile state reuses active T-C-14 devices: account-synced dark mode; device data-saver quality/auto-play cap and gestures; PIN-hash/opaque app-lock token with idle invalidation; ProgressService-backed widgets; confirmation-token voice dispatch and quick actions; gated PiP note state; per-device usage ledger. Upcoming tests use additive `ProgressService::upcomingTests(userId)` (earliest active exams dated today or later, owner-scoped, max 3). The mobile client stores returned app-lock token in platform secure storage and owns biometric unlock; server stores only the credential/token hashes. Current focused ProgressServiceContractTest + MobileAppStateTest: 9/9 (87 assertions). |
| T-C-17 | ✅ local integration | codex/T-C-17-e2e | HTTP learner/parent journey runs against the real catalog course-tree contract, A subscription entitlement, C enrollment/progress/assessment adapters, guardian consent, parental controls, target-exam prediction and parent progress. The seeded journey returns non-empty course progress. Focused E2E: 1/1 (50 assertions). |
| (blocker тАФ RESOLVED 2026-09-29) | тЬЕ was "T-B-07 tests fail on origin/main" тАФ **WRONG DIAGNOSIS, retracted** | тАФ | The earlier claim that 3 `ResourceLibraryTest` failures were pre-existing on main is retracted: clean main runs **296/296 green** (verified by detaching to `origin/main` and running the suite). The failures appeared **only on the T-C-04 branch**, caused by C's materialized `app/Services/Billing/SubscriptionService.php` interface file (container: "interface not instantiable" when B's controller is built; B's other tests pass because Mockery auto-defines a concrete class for the absent FQCN). Resolved by deleting C's interface file and switching C's test doubles to Mockery mocks (T-C-03/B pattern). **No outstanding blocker for B; full suite on the T-C-04 head is 314/314, 0 failing.** |
| T-C-12 | тЬЕ merged 2026-09-27 (skip-ahead) | track-c/T-C-12-learner-profile | `learner_profiles` (+ append-only `learner_profile_changes`, no cross-DB FKs; user_id plain indexed col). Learner profile + 3-step onboarding (basic info: grade/board/тЙе1 subject; prefs: learning style exactly-one visual/auditory/kinesthetic/mixed, study-time 30minтАУ4h slider, avoid list) + goals (target_score 0тАУ100, future exam_date). Avoid-subjects excluded from `recommendedSubjects()` only тАФ required assessments still built from focus. Change history on every post-onboarding preference update (applies from point of change). Routes: `GET/PUT /api/learner/profile`, `POST /api/learner/onboarding/{basic,preferences,goals}` behind `auth:sanctum` (learner-owned; RBAC roles are admin-surface тАФ no `student` role exists, file as A question). ACs: missing grade 422; target 105 422; avoid-vs-assessment; style uniqueness. 128 passing, 0 failing (full suite, post-rebase incl. T-B-01..03). |

## Gates

- Gate 1 (end Phase 0): **passed 2026-09-28** тАФ all Phase-0 tasks merged to main
  (verified `git branch -r --merged origin/main`): T-C-01 (learning base model +
  no-cross-DB-FK test), T-C-02 (ProgressService frozen contract for D), T-C-03
  (AccessGate). D can compile against ProgressService and the full gate.

### Current state (re-verified 2026-09-30 тАФ T-C-04/05/06 MERGED, T-C-07 PUSHED)

**тЬЕ T-C-04 merged** (f7197b9) and **тЬЕ T-C-05 merged** (d9871d6) тАФ both on origin/main. (The 18+ cycle ownership hold тАФ provider file + `bootstrap/providers.php` тАФ is closed; history in the git log of `track-c/T-C-04-enrollment-progress` and earlier cycle notes.)

**тЬЕ T-C-06 (bookmarks, notes, highlights) MERGED 2026-09-30** (d947a3d `[merge][T-C-06]`). Full suite on origin/main: **349/349 passing, 0 failing** (Learning ns 68/68), including A's app-wide `ModelConnectionLintTest` (all 3 new models declare `protected $connection = 'learning'`) and the cross-DB-FK guard.

**ЁЯЪА T-C-07 (gamification: points, streaks, badges, goals) PUSHED 2026-09-30** on `track-c/T-C-07-gamification`, off origin/main d947a3d (T-C-06 merge). Full suite on THIS head: **363/363 passing, 0 failing** (+14 new tests, Learning ns 81/81), including A's app-wide `ModelConnectionLintTest` (all 6 new models declare `protected $connection = 'learning'`) and the cross-DB-FK guard (plain indexed user_id columns, no FKs). Emits the FROZEN `StreakUpdated(userId, currentStreakDays)` exactly per the README contract / A's T-A-06 double.

**T-C-07 DEPSтЪб note (D):** gamification CONFIG is consumed from D's `App\Services\Engagement\GamificationConfigService` (T-D-03/T-D-08 тАФ NOT on main yet). C does NOT materialize a file in D's namespace (that would collide with D's ownership and 500 on "class not instantiable" before D lands тАФ the exact T-C-04 mistake, avoided). Instead `GamificationService` resolves the config LAZILY by FQCN string (injected instance > container binding > C-owned `GamificationConfigFallback` with empty rules), so: nothing 500s before D lands, C's tests inject a double (T-C-03/T-C-05 pattern), and the moment D binds its real service at the FQCN it is picked up with **zero C code change**. D: no action needed in Phase 0 тАФ just ship T-D-03 with the FQCN + the 4 methods (`pointRules`, `badgeCriteria`, `pointsExpiryDays`, `streakProtectionDays`).

**Ownership audit (T-C-07 head) тАФ ALL changed paths are C-owned:** `app/Models/Learning/{PointsLedger,AwardedBadge,Achievement,Challenge,PersonalGoal,Streak}.php`, `app/Services/Learning/{GamificationService,GamificationConfigFallback}.php`, `app/Services/Learning/Exceptions/GamificationConflictException.php`, `app/Events/Learning/StreakUpdated.php`, `app/Listeners/Learning/RecordStudyAction.php`, `app/Http/Controllers/Learning/GamificationController.php`, `database/migrations/learning/20260924_030501_create_gamification_tables.php`, `routes/api_learning.php` (+8 routes), `tests/Feature/Learning/{GamificationTest.php, TestDoubles/GamificationConfigDouble.php}`, `docs/status/track_c.md`, `docs/developer_C.md`. No A/B/D paths, no `app/Providers/`, no `bootstrap/providers.php`. `docs/README.md` intentionally UNCHANGED тАФ `StreakUpdated` is an EXISTING frozen event (already in the README contract + A's T-A-06 double); C is only now its first real emitter (mirrors T-C-05's first `AttemptSubmitted` emission), so no new contract is introduced.

**Merge-note for the orchestrator:** this T-C-07 head descends from d947a3d (T-C-06 merge) and its `track_c.md` is the most current snapshot тАФ on any conflict in that file, take the T-C-07 side. It also updates `docs/developer_C.md` (T-C-05/T-C-06 checked off as merged). README is untouched here, so it merges cleanly on top of d947a3d.

**Next: T-C-08 (SRS runtime state, DEPS: T-C-07 + DEPSтЪб T-B-10) PUSHED 2026-09-24** on `track-c/T-C-08-srs-runtime`, off origin/main 8c06d7f (T-D-16 merge). Full suite on THIS head: **507/507 passing, 0 failing** (+19 new tests, Learning ns 101/101), including A's app-wide `ModelConnectionLintTest` (all 3 new models declare `protected $connection = 'learning'`) and the cross-DB-FK guard (plain indexed user_id columns, no FKs). B's DEPSтЪб `App\Services\Catalog\SrsConfigService` (T-B-10) is merged; C consumes it at its FQCN тАФ tests bind `SrsConfigServiceDouble`, production needs zero C change. README unchanged (no new contract: SRS consumes B's merged `SrsConfigService` + frozen events `ContentCompleted`/`AttemptSubmitted`; no additive event introduced). Next after merge: T-C-09 (AI study companion, DEPS: T-C-08 + DEPSтЪб T-B-11/T-A-13) and T-C-11 (learning paths, deps all merged).

**ЁЯЪА T-C-09 (AI study companion runtime) PUSHED 2026-09-30** on `track-c/T-C-09-ai-companion`, rebased onto latest origin/main (post T-C-08 merge + T-D-10/T-D-11/T-D-17 + T-A-10). Full suite on THIS head: **115/115 Learning passing, 0 failing** (+14 new tests). C owns the learner AI STATE (learning DB, migration 20260924_030701: ai_conversations / ai_messages / ai_doubts / ai_mood_checkins / ai_recommendations / ai_chapter_summaries / ai_benchmarks / ai_question_usage тАФ 8 tables, all `protected $connection = 'learning'`, plain indexed user_id columns, no cross-DB FKs). Consumes B's merged `AiFeatureConfigService` (DEPSтЪб T-B-11) at its FQCN тАФ per-plan question limit (exceed тЖТ 429 plan-upgrade, snapshot on first use, non-retroactive), escalation rules (`student_requested_always` / `low_confidence_pct` / `async_ticket_fallback`), companion/break settings; tests bind C-owned `AiFeatureConfigDouble`, production zero C change (same pattern as T-C-08). Features: conversations (context retained тАФ transcript-before-this-question replayed, 2nd answer references 1st), doubts (AI-resolved OR escalated: student-requested + auto-low-confidence тЖТ teacher handoff, async-ticket fallback kept in-band), mood check-ins (score тЙд 4 or a 3-run low streak тЖТ burnout flag + break suggestion from B's break_rules), recommendations (acted-on tracked), chapter summaries (regenerable, version bumps), peer benchmark (strictly anonymous тАФ cohort aggregates + learner percentile/coarse tier only, no peer id/name stored, so a response can never identify an individual). **ADDITIVE queued event** `App\Events\Learning\DoubtEscalatedToTeacher(int $userId, int $doubtId, string $mode, ?string $topic)` (not one of the frozen 15) тЖТ D for parent-teacher comms; the persistent record is C's learning.ai_doubts row (emitted only on genuine teacher handoffs: student_requested + auto_low_confidence). README Contract Change Log: one append-only line (see git diff). Ownership audit тАФ ALL changed paths C-owned: `app/Models/Learning/Ai{Conversation,Message,Doubt,MoodCheckin,Recommendation,ChapterSummary,Benchmark,QuestionUsage}.php`, `app/Services/Learning/AiCompanionService.php` + `Exceptions/AiQuestionLimitExceededException.php`, `app/Events/Learning/DoubtEscalatedToTeacher.php`, `app/Http/Controllers/Learning/AiCompanionController.php`, `database/migrations/learning/20260924_030701_create_ai_companion_tables.php`, `routes/api_learning.php` (+9 companion routes, learner-scoped), `tests/Feature/Learning/{AiCompanionTest.php, TestDoubles/AiFeatureConfigDouble.php}`, `docs/status/track_c.md`, `docs/developer_C.md`, `docs/README.md` (append-only line). No A/B/D paths, no `app/Providers/`, no `bootstrap/providers.php`. Course-grounded paths (summary, course-ask) are enrollment-gated (T-C-04 requireCourse тЖТ existence-safe 404); free conversations need no gate (no content access). **D:** consume `DoubtEscalatedToTeacher` for parent-teacher comms (payload carries doubt_id тЖТ join-free read of your own engagement comms; the doubt detail lives in C's learning DB).


## Historical checkpoint (verified 2026-09-24; superseded by task rows above)

All of T-C-07 (84382b3), T-C-08, T-C-09 (1c7d2d3), T-C-10 (7694d27) and T-C-11 (1123728) are now on origin/main. Full suite at T-C-11 head: **622/622 passing, 0 failing** (Learning ns 138/138).

**Historical blocker (as of 2026-09-24, since resolved for T-C-13):** T-A-10 was not yet merged, so the then-current note marked T-C-13 blocked. T-C-13 was subsequently merged to origin/main in `a697819` on 2026-10-02; its current row records the merge. T-C-14 and T-C-15 are implemented in the local integration checkout and are not in origin/main as of this status audit.

**ЁЯЪА T-C-10 (study plans + target-exam runtime) PUSHED 2026-09-24** on `track-c/T-C-10-study-plans-target-exams`, rebased onto origin/main 0d1fd76 (post T-C-12 merge + T-A-11). Full suite on THIS head: **611/611 passing, 0 failing** (+17 new Learning tests, Learning ns 132/132), including A's app-wide `ModelConnectionLintTest` (all 6 new models declare `protected $connection = 'learning'`) and the cross-DB-FK guard (plain indexed user_id/topic_id columns, no FKs). **B cross-track contract surfaced by T-C-10:** B's merged `ExamAiConfigService::deleteType` delete-guard does a plain cross-DB existence check on `target_exams.exam_type_id` (B's test even simulates C's table) тАФ so C's `target_exams` ships an `exam_type_id` nullable indexed column; B's probe inserts a bare `{exam_type_id,user_id,status}` row, which is why `name`/`exam_date` are nullable at the DB level (both remain required at C's API validation). **D:** consume the two new frozen events `TargetExamRecorded` + `StudyPlanGenerated` (shapes in the table row + `app/Events/Learning/`). Next after merge: T-C-11 (learning paths, all deps merged).

**ЁЯЪА T-C-16 (Phase 4 read models + REAL ProgressService) PUSHED 2026-09-24** on `track-c/T-C-16-progress-read-models`, off origin/main head. Full suite on THIS head: **633/633 passing, 0 failing** (+11 new Learning tests, Learning ns 149/149), including A's app-wide `ModelConnectionLintTest` (all 3 new models declare `protected $connection = 'learning'`) and the cross-DB-FK guard (plain indexed user_id columns, no FKs). **Gate 3 obligation met:** the REAL `App\Services\Learning\ProgressServiceImpl` now backs the frozen `ProgressService` contract (T-C-02) тАФ the stub binding in C-owned `routes/api_learning.php` is swapped for the implementation; the frozen interface is UNCHANGED so D's injected mock/double still works. **NEW C-owned `GradeBenchmark` PORT** (DEPSтЪб тАФ D's T-B-13-style cohort analytics supplies `cohortForGrade(gradeLevel)`; `NullGradeBenchmark` is the default binding until D lands, so grade-level benchmarking degrades gracefully to "no cohort" тАФ no 500). **ADDITIVE queued event** `App\Events\Learning\PerformanceAlert(int $userId, string $type, array $payload)` (not one of the frozen 15) тАФ the "performance alerts тЖТ D" requirement (score_drop | missed_plan | streak_risk); fired on a genuine planted score drop. **D:** (1) bind your real cohort service to `App\Services\Learning\GradeBenchmark` to activate benchmarking (one-line, no C change); (2) ingest `PerformanceAlert` for the performance-alert surface. README: one append-only Contract Change Log line. `CertificateIssued` emission verified complete (T-C-11 runtime: every course completion тЖТ `OnCourseCompleted` тЖТ `issueCertificate` тЖТ frozen `CertificateIssued`). Ownership audit тАФ ALL changed paths C-owned: `app/Models/Learning/{StudySession,LearnerActivity,SharedReport}.php`, `app/Services/Learning/{ProgressServiceImpl,LearnerReadModel,GradeBenchmark,NullGradeBenchmark,ActivityLogger}.php`, `app/Events/Learning/PerformanceAlert.php`, `app/Listeners/Learning/{LogAttemptActivity,LogCertificateIssued,LogCourseCompleted,LogStreakActivity,LogStudyPlanGenerated,RecordStudySession}.php`, `app/Http/Controllers/Learning/LearnerReadModelController.php`, `app/Http/Controllers/Parent/ParentProgressController.php`, `database/migrations/learning/20260924_031001_create_progress_read_model_tables.php`, `routes/api_learning.php` (+learner/parent routes + GradeBenchmark binding), `tests/Feature/Learning/ReadModelsProgressTest.php` (11 tests, 116 assertions), `docs/status/track_c.md`, `docs/README.md` (append-only line). No A/B/D paths, no `app/Providers/`, no `bootstrap/providers.php`. Next after merge: **T-C-17 (E2E + seeding)** тАФ deps T-C-16 (this) + DEPSтЪб T-B-13/T-A-07. T-C-13/T-C-14/T-C-15 remain тЫФ BLOCKED on T-A-10/T-A-17 (not yet merged to main).

## T-C-17 E2E progress — 2026-10-02

Added `LearningJourneyE2ETest` on `codex/T-C-17-e2e`. The test runs `catalog:seed` and drives
the learner and parent journey through HTTP: student + parent registration, three onboarding
steps, real A subscription entitlement, enrollment, guardian-link verification and consent grants,
playback completion, quiz submission, SRS review, target-exam prediction, parent progress read,
parental controls, and plan approval. It uses C's production `CatalogCourseStructure` and
`CatalogAssessmentBank` adapters over B's `CatalogService`; the E2E invokes the real catalog
course tree and approved question-bank contract. The real `ProgressService` returns non-empty
course progress. Focused test: 1 passing / 50 assertions. Root integrated B's course-tree
producer fix as commit `d656b3e`; no production C code or bootstrap was changed.

## Environment note

Local worktree `worktrees/track-C` was found with its checkout missing (only `.env` +
`vendor` present); restored via `git worktree add` onto the `track-C` branch (origin/main)
and `composer install` (79 packages). No repo files touched by the restore.

## T-A-25 learning export source adapter — 2026-10-02

C-owned `LearningExportDataSource` implements A's `ExportDataSource` and is tagged
`data_protection.export_sources` from a C-owned `LearningServiceProvider`, loaded through
the C-owned `routes/api_learning.php` seam. It supports the `user_data` export scope and
reads only the `learning` connection. User rows are filtered by numeric `user_id`; answer
and study-plan child rows are resolved through same-DB owner-scoped IDs without joins.
Authentication/access columns (token, secret, password, credential, hashes, device key,
share token) are excluded. Focused export-mechanism test covers inclusion of the requester,
exclusion of another learner, and omission of offline-device/app-lock secrets.

## T-C-16 alert follow-up — 2026-10-02 (local main)

The study-plan adherence transition now emits the existing `PerformanceAlert` contract
with type `missed_plan` when an applied plan first enters `behind` or `slid`. The last
adherence state is persisted on the plan and updated under a learning-DB row lock, so
repeated task updates while the plan remains at risk do not enqueue duplicate alerts.
Recovery to `on_track` permits a later new at-risk transition to alert again. Existing
study-plan check-ins remain limited to once per day. Added migration
`20260924_031401_add_adherence_state_to_study_plans.php` and feature assertions in
`StudyPlanTest`; this is a local `main` change and has not been pushed upstream.

## T-C-16 scheduled report follow-up — 2026-10-02 (local main)

Manual and scheduled weekly/monthly reports now use the latest completed calendar
period in the application timezone. The learning-owned `learning:reports` command
selects active learners from in-period activity, study-session, submitted-attempt,
and completed `content_progress` rows on the learning connection only. Including the
synchronous completion source prevents queue lag from omitting a learner whose event
listener has not yet written activity/session rows. A unique learner/period/period-label key makes
repeated and racing generation idempotent; existing shared snapshots are preserved.
Migration `20261002_031003_add_content_progress_completion_index.php` indexes
completion time and learner ID for the scheduled cohort scan.
Time-spent data is period-bounded. At the synchronous completion transition,
`content_progress.completed_duration_seconds` now snapshots the catalog item's
duration, so delayed or missing `RecordStudySession` work and later catalog edits do
not change the reported historical duration. Legacy rows without a duration snapshot
fall back to stored session data where available. Completion, score, weak-topic, and
streak values from the frozen `ProgressService` remain current-state methods. A
separate period-bounded summary records completion count, submitted attempts, average
score, pass rate, and a daily score trend. No identity, catalog, or engagement data is
queried. Migration `20261003_031004_add_completed_duration_to_content_progress.php`
and focused completion/report regressions cover this behavior.
`EnrollmentProgressTest` plus `ReadModelsProgressTest` pass 25 tests (225
assertions); the combined current regression run passes 129 tests (1,065
assertions).

## T-C-07 point-award follow-up — 2026-10-03 (local main)

Point rules now award only passing practice quizzes (`quiz_completed`), any
submitted exam/mock (`exam_completed`), the one-time 7-day streak milestone
(`streak_7_day`), and human-approved creator decks (`quality_flashcard_deck`). The
deck award consumes B's `FlashcardDeckQualityApproved` after-commit contract and
does not query catalog data. Content-completion, attempt, milestone, and deck awards
use `awardPointsOnce`, which derives a SHA-256 idempotency key from
`(user_id, reason, source_type, source_id)` and enforces uniqueness on
`(user_id, idempotency_key)`; actual inserts are audit-logged through A's
`AuditService`. Migration: `20261003_031005_add_points_ledger_idempotency_key.php`.
Successful badge inserts are explicitly audit-logged once (the idempotent `insertOrIgnore` path
bypasses model audit hooks). `GamificationTest` passes 17 tests / 93 assertions;
`AttemptsGradingTest` passes 10
tests / 107 assertions. Flashcard study/review points are now implemented through
B's learner-safe `CatalogService::approvedFlashcardDeck(int)` contract. C adds the
`flashcard` SRS type, honors B's `flashcard_sets` eligibility rule, and exposes
`POST /api/learner/srs/flashcard-sets/{deckId}/schedule`. Scheduling rejects missing,
unapproved, and approved-empty decks with the same existence-safe 404; it schedules
only card IDs/topics from B's projection and performs no catalog DB query. The SRS
unique learner/type/card key also recovers concurrent scheduling collisions by
returning the already-created learner item. Each persisted flashcard review log
awards configured `flashcard_studied` points once,
source-scoped to `(srs_review, review_log_id)`. The focused suite covers eligibility,
approval denial, idempotent deck scheduling, and duplicate awards for a review log.

The C-owned learning-path migration names its composite unique index
`learning_path_step_owner_course_unique` to stay within MySQL/MariaDB's
64-character identifier limit. The D-owned sponsor and corporate-portal migrations
also use explicit short names for their composite indexes. Fresh MariaDB migrations
were validated across all five domain databases; the previously reported
`sponsor_payment_schedules_tenant_id_placement_id_installment_number_unique` limit
is resolved and is no longer a full-install blocker.

## T-C-14 offline mock feedback gate — 2026-10-03

`POST /api/learner/offline/sync` now reuses `Attempt::isFeedbackUnlocked()` before
including a graded offline attempt's score fields in its response. A mock synced
before its feedback window closes returns completion and attempt identifiers without
score or total marks; unlocked mocks and other assessment kinds retain the existing
score response. `OfflineLearningTest` covers both locked and unlocked mock sync, and
the existing practice-sync test covers compatibility.

## T-C-14 offline podcast and download-policy integration — 2026-10-03

Offline podcast requests are now accepted by the learner endpoint. Catalog content items
must carry B's `downloadable` opt-in, and C checks A's effective download policy before
the parental, DRM, and license gates. It consumes A's policy atomically after those checks
and before creating an offline download. A policy cap race is rejected without creating a
download; a denied catalog opt-in does not consume policy usage, and a later opt-in removal
blocks offline authorization. `OfflineLearningTest` and
`CatalogCourseStructureAdapterTest` cover the consumer contract.

## T-C-16 grade benchmark producer — 2026-10-03

C now emits the additive aggregate-only `GradeBenchmarkCohortSnapshot` event after a
submitted attempt. The queued producer computes the submitter's numeric-grade cohort
inside the learning database using the same score rule as the benchmark read model
(mean topic mastery, otherwise best attempt), excludes profiles with no score, and sends
only cohort size, mean, population standard deviation, grade, and snapshot time to D.
No learner IDs or individual scores cross the boundary. Groups with fewer than five
scored learners publish a zeroed empty snapshot, preventing small-group inference and
clearing any older aggregate for that grade. The producer regression covers valid
aggregates, suppression, and exclusion behavior. The producer batches attempts and
answers for at most 500 learners per query pair, resolves assessment topics through C's
`AssessmentBank` port, and has a parity regression against the scalar read model; a six-
learner cohort uses two learning queries for score calculation. Attempt histories are
also paged in batches of 500; a 501-attempt regression verifies two answer batches and
score parity. D's ingestion adapter is implemented against this event contract.
Full integrated validation on 2026-10-03: `php artisan test --compact` passed **1,033
tests / 6,397 assertions** with 2 skips, including the batched scorer, producer and D's analytics
consumer.
