# api_hub ‚Äî 4-Track Development Plan (Agent-Executable Task Lists)

This directory contains the complete, trackable, agent-executable task list for building
the `api_hub` Laravel backend from its current minimal scaffold (Sanctum auth only) up to
the full Mi Digital Academy platform, per the specifications in `/Documents`.

## Ground Rules (apply to every task in every track)

1. **5-database domain split** (enforced in `.github/copilot-instructions.md`):
   `identity_billing`, `catalog`, `learning`, `engagement`, `analytics`.
   Every Eloquent model declares `protected $connection`. Never the default connection.
2. **No cross-database joins.** Cross-domain data = two queries + PHP merge, or an
   `analytics` aggregate. Cross-domain references are plain indexed columns (no FK constraints).
3. **Analytics is write-once** ‚Äî fed only via queued jobs consuming domain events.
4. **Every mutating endpoint** writes an `audit_events` row (identity_billing) with
   actor, action, target, before/after, timestamp. Every module in `Documents/` requires
   audit logging as its final rule.
5. **All responses** use the envelope `{ "data": ... , "message": ... }` (matches existing
   `AuthController`) with proper HTTP status codes.
6. **RBAC is deny-by-default**, enforced server-side per request via the permission catalog
   (T-A-04 middleware). Institute isolation is a hard scope boundary.
7. **Verification tokens** (email/phone/OTP): single-use, time-limited, rate-limited,
   resend cooldown ‚Äî implemented once in Track A, reused by every track.
8. **Money, tax, identity never split** ‚Äî all billing tables live in `identity_billing`.
9. **Tests** (PHPUnit + Laravel feature tests) accompany every task per its acceptance
   criteria. A task is done only when its acceptance criteria pass.
10. **Contract-first:** cross-track integration points are listed in each task as
    `DEPS:` (hard) and `DEPS‚ö°:` (contract-only) task IDs. A task whose dependencies are
    in other tracks may start coding against the documented contract (endpoints/events
    named below) as soon as the dependency's *contract* is available ‚Äî not necessarily
    its full implementation.

## How to start an agent

- **Workflow rulebook** (git worktree flow, file-ownership matrix, contract freeze,
  definition of done, status reporting): `agent_instructions.md`
- **First-message prompts** for the 4 agents: `agent_prompts.md`
- **Sequence:** start Agent A ‚Üí wait for A's Phase-0 "GATE 1 passed" ‚Üí start B, C, D in
  parallel. Each agent maintains `docs/status/track_{x}.md` as the shared progress view.

## The 4 Tracks

| Track | File | Owner scope | DBs owned |
|---|---|---|---|
| **A** ‚Äî Identity, Billing & Platform Foundation | `developer_A.md` | auth, users (all 11 types), roles/permissions, account links, subscriptions, payments, invoices, refunds, revenue, tax, pricing, plans, affiliate program config, tenant accounts & verification, seats/licenses, system settings | `identity_billing` (+ `catalog` for plans/pricing/discounts per domain map) |
| **B** ‚Äî Catalog, Content & Assessment Definition | `developer_B.md` | curriculum tree, courses, content items + media pipeline, resources, question bank, exam/quiz definitions, approval workflow, SRS platform config, target-exam templates, AI feature config | `catalog` |
| **C** ‚Äî Learning Runtime (Learner-First) | `developer_C.md` | enrollment, progress, attempts & grading, SRS state, flashcards/mind maps/notes, study plans, target-exam instances, AI companion state, parent-child link runtime, parental controls | `learning` (+ `engagement` for parent-child link records & control settings per domain map) |
| **D** ‚Äî Engagement, Tenants & Analytics | `developer_D.md` | notifications platform, marketing campaigns, support tickets, forums/groups/live sessions, gamification, affiliate portal runtime, institute/CSR/ORG/SPON portals, webhooks/API keys, dashboards, custom reports, health/monitoring | `engagement` + `analytics` |

## Dependency Graph (coarse)

```
T-A-01..06 (foundation: base model, audit, tokens, RBAC, response conventions, events bus)
    ‚îÇ
    ‚îú‚îÄ‚îÄ‚ñ∫ T-A (rest of track A)  ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚ñ∫ everyone (auth guard, plans, billing)
    ‚îú‚îÄ‚îÄ‚ñ∫ T-B (rest of track B)  ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚ñ∫ T-C (content to learn), T-D (content to promote)
    ‚îú‚îÄ‚îÄ‚ñ∫ T-C (rest of track C)  ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚ñ∫ T-D (analytics sources, notifications triggers)
    ‚îî‚îÄ‚îÄ‚ñ∫ T-D (rest of track D)  ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚îÄ‚ñ∫ (consumes events from A/B/C; no one depends on D)
```

**Parallel start:** all 4 agents begin with their track's Phase 0 (foundation tasks T-X-01..06),
which are mutually independent. After that, A and B proceed fully in parallel; C's runtime
tasks need B's contract stubs; D's analytics tasks need A/B/C domain events (contracts defined
in Phase 0, implementation lands when producers exist).

## Cross-Track Event Contracts (defined in Phase 0, emitted by producers, consumed by D/A)

All events are Laravel queued events, namespaced `App\Events\{Domain}\*`:

- `App\Events\Identity\UserCreated` (userId, userType, email)
- `App\Events\Identity\UserSuspended` / `UserDeactivated`
- `App\Events\Billing\PaymentSucceeded` (userId, amount, currency, invoiceId)
- `App\Events\Billing\PaymentFailed` (retry count, grace status)
- `App\Events\Billing\SubscriptionActivated|Expired|Cancelled`
- `App\Events\Billing\RefundProcessed`, `CommissionSettled`, `PayoutIssued`
- `App\Events\Catalog\CoursePublished`, `ContentApproved`, `AssessmentRetired`
- `App\Events\Learning\ContentCompleted`, `AttemptSubmitted` (score, weakTopics)
- `App\Events\Learning\StreakUpdated`, `TargetExamRecorded`, `StudyPlanGenerated`
- `App\Events\Learning\CertificateIssued`
- `App\Events\Learning\GradeBenchmarkCohortSnapshot` (aggregate-only cohort size,
  mean, dispersion, grade, and source snapshot time; no learner identifier; cohorts with
  fewer than five scored learners emit an empty snapshot)
- `App\Events\Engagement\NotificationRequested` (consumed by D's dispatcher)

Consumers: Track D (analytics aggregation jobs, notification fan-out, dashboards),
Track A (billing interlocks: suspension pauses recurring billing).

### Queue & domain-event dispatch policy (T-A-06)

All domain events are **queued events** (every contract event implements
`Illuminate\Contracts\Queue\ShouldQueue`), so producers (A/B/C) are never blocked by
consumers (D/A) and a slow consumer cannot take down a request.

- **Driver**: production default `database` (`QUEUE_CONNECTION=database` in
  `.env.example`). Jobs live in the stock `jobs` table on the **default** connection;
  failures land in `failed_jobs` (both from the stock
  `0001_01_01_000002_create_jobs_table` migration). The 5-DB domain split does not
  apply to the queue: it is shared infrastructure, like cache and sessions.
  Tests run `QUEUE_CONNECTION=sync` (see `phpunit.xml`) so dispatches are
  deterministic and in-process.

| Call | Use when |
|---|---|
| `event($e)` | **Default.** All cross-track contract events above. Always queued, even under `sync` in tests. |
| `dispatch_sync($e)` | Only when the **caller's own next step depends on the consumer's side effect** (e.g. A's billing interlock before returning a response). Never for cross-track notifications. |
| `new $e(...); $e->dispatch()` | Same as `event($e)`; use when you need the instance. **Never** `Bus::dispatch($e)` ‚Äî queued events are fired with `event()`, not the job dispatcher. |

- **Consumer resilience**: a consumer that throws is retried up to **3 times**
  (`QUEUE_MAX_TRIES=3`) with framework backoff, then moved to `failed_jobs`.
  Consumers must be **idempotent** (dedupe on event id / payload keys) because a
  redelivery can happen after a partial write.
  Worker: `php artisan queue:work --tries=3 --timeout=60 --sleep=3`;
  inspect/re-queue: `php artisan queue:failed` / `php artisan queue:retry <id>`.
- **Owned vs. borrowed event classes**: Track A owns `app/Events/{Identity,Billing}`
  (`UserCreated`, `UserSuspended`, `UserDeactivated`, `PaymentSucceeded`,
  `PaymentFailed`, `SubscriptionActivated`, `SubscriptionExpired`,
  `SubscriptionCancelled`, `RefundProcessed`, `CommissionSettled`, `PayoutIssued`).
  The other ten belong to B/C/D and land with those tracks; same-shape test doubles
  live in `tests/Feature/IdentityBilling/TestDoubles/Events/` so the bus contract is
  testable before B/C/D merge.
- **Payload fields** per event are fixed by the contract list above ‚Äî do **not** add,
  remove or rename fields without a "Contract Change Log" entry here + blockers to the
  consuming tracks.

## Cross-Track API Contracts (consumed via service classes, never direct cross-DB queries)

Each track exposes a PHP **service interface** in `app/Services/` that other tracks depend on:

- `App\Services\Identity\UserService` ‚Äî `getUser`, `statusOf`, `verifyConsent`, `seatOf`
- `App\Services\Billing\SubscriptionService` ‚Äî `isEntitled(userId, contentScope)`,
  `activePlan`, `trialState` (used by B for access gating, C for playback/download gates)
- `App\Services\Catalog\CatalogService` ‚Äî `contentItem`, `courseTree`, `questionBank`
  (used by C for attempts/progress, D for campaign targeting)
- `App\Services\Learning\ProgressService` — `courseProgress(userId)`, `upcomingTests(userId)`,
  `topicMastery(userId)`, `streak(userId)`, `attemptsSummary(userId)`, `timeSpent(userId, window)`,
  `weakTopics(userId)` (used by D for dashboards/insights and C mobile widgets; never by B)
- `App\Services\Engagement\CourseGroupMembership` — `activeGroupIdsForUser(userId)` returns
  distinct ascending IDs for groups where the user's membership is active and the group is
  approved or active. B combines these engagement-owned IDs with catalog-owned course
  assignments; the contract accepts no caller-supplied IDs and performs no cross-database join.
- `App\Services\Engagement\NotificationService` ‚Äî `notify(userId, category, payload)`
  (used by A/B/C for all user-facing alerts)

## API Response & Error Conventions (T-A-05)

This is the **single source of truth** for how every api_hub endpoint answers; all tracks (A-D) must emit responses in this shape. Controllers adopt the `App\Exceptions\ApiResponse` trait; the `App\Exceptions\EnvelopeExceptionHandler` (wired in `bootstrap/app.php`) normalizes every uncaught exception into the error envelope automatically.

### 1. Success envelope

```json
{ "data": <payload>, "message": "optional human string" }
```

- `data` is required when there is a payload (object, array, scalar, or
  `null` for an empty result).
- `message` is optional on success. Omit it when there is nothing to say ‚Äî
  do NOT emit `"message": null`.
- Omitted keys are dropped entirely (no null placeholders).
- `200` for reads, `201` for creation, `204` (empty body) only where a truly
  empty response is the natural contract.

#### Example ‚Äî read
```
GET /api/roles  ‚Üí  200
{ "data": [ { "id": 1, "name": "super_admin", "immutable": true } ] }
```

#### Example ‚Äî create
```
POST /api/roles  ‚Üí  201
{ "data": { "id": 14, "name": "temp_role" }, "message": "Role created." }
```

#### Example ‚Äî mutation with no payload
```
POST /api/auth/verify/email  ‚Üí  200
{ "data": { "verified": true }, "message": "Email verified." }
```

### 2. Error envelope (4xx only)

```json
{ "message": "human-readable reason", "errors": { "field": ["rule message", ...] } }
```

- `message` is required ‚Äî always explain the failure in one line.
- `errors` is a **field ‚Üí [messages]** map. Present for validation failures
  (422). Omitted (not `null`) for non-field errors (403/404/409/‚Ä¶).
- `data` never appears in an error body.
- `5xx` bodies follow the same shape but the message is generic
  (`"Server error."`) ‚Äî never leak stack traces, SQL, or internal paths.

#### Example ‚Äî validation (422)
```
POST /api/roles  ‚Üí  422
{
  "message": "The given data was invalid.",
  "errors": { "name": ["The name field is required."] }
}
```

#### Example ‚Äî conflict (409)
```
DELETE /api/roles/3  ‚Üí  409
{ "message": "This role is held by one or more users. Reassign holders before deleting." }
```

### 3. Status-code semantics

| Code | Meaning | api_hub usage |
|---|---|---|
| `400` | Bad request | Malformed JSON / unsupported media type. |
| `401` | Unauthenticated | Missing/expired/invalid bearer token. |
| `403` | Forbidden | Authenticated but not permitted (RBAC deny-by-default) ‚Äî **see ¬ß4 for the existence-leak rule**. |
| `404` | Not found | Resource id does not exist. |
| `409` | Conflict | State clash: duplicate, already-processed, held/in-use, archived, expired/depleted. |
| `410` | Gone | Resource existed but is permanently gone (e.g. consumed/expired verification token). |
| `422` | Unprocessable entity | Field validation failure (always carries `errors`). Also exhausted OTP attempts / invalid code. |
| `423` | Locked | Account/resource locked by policy (e.g. repeated login failures). |
| `429` | Too many requests | Rate limited. Body may include `errors.retry_after` (seconds) for cooldowns. |

### 4. Existence-leak rule (user-scoped endpoints)

An endpoint that returns a *resource the caller is not allowed to see* must
NOT reveal whether the resource exists. **Missing** and **forbidden** return
the **identical** body and status (`404` + `{"message":"Resource not found."}`).
Use `ApiResponse::denyOrMissing()` so both paths share one implementation.
This applies to any endpoint addressed by a user/student/institute id where the
caller may not own it.

### 5. Which layer owns what

| Concern | Owner |
|---|---|
| Success shape | `ApiResponse::ok()/created()` in the controller. |
| Validation failure | Throw `ValidationException` (via `$request->validate`); handler renders 422 envelope. |
| Domain conflict | Throw / return 409 with a specific `message`. |
| Token lifecycle | `TokenStatusException` (410/429/422); handler delegates to its `toResponse()`. |
| Anything uncaught | `EnvelopeExceptionHandler` ‚Üí 4xx/500 envelope. |

### 6. Non-API (web) requests

The envelope handler returns `null` for non-API requests so Laravel renders its
own HTML pages. The `api/*` and `auth/*` prefixes plus `Accept: application/json`
(`expectsJson()`) are the trigger for JSON rendering.

## File Layout Convention (all tracks)

```
app/
‚îú‚îÄ‚îÄ Models/{Domain}/            # e.g. Models/Billing/Subscription.php (sets $connection)
‚îú‚îÄ‚îÄ Http/Controllers/{Area}/    # e.g. Controllers/Billing/RefundController.php
‚îú‚îÄ‚îÄ Http/Requests/{Area}/       # Form Requests = validation layer
‚îú‚îÄ‚îÄ Services/                   # cross-track interfaces + per-area implementations
‚îú‚îÄ‚îÄ Events/{Domain}/
‚îú‚îÄ‚îÄ Listeners/{Domain}/
‚îú‚îÄ‚îÄ Jobs/                       # queued jobs (billing runs, analytics feeds, exports)
‚îú‚îÄ‚îÄ Policies/                   # RBAC policies (deny-by-default)
database/
‚îú‚îÄ‚îÄ migrations/{db}/            # one folder per connection: identity_billing/, catalog/, ‚Ä¶
tests/Feature/{Area}/
```

Route groups per track: `routes/api.php` section headers `// ===== TRACK A =====` etc.
API base: `/api/v1/...`.

## Task ID Scheme

`T-{TRACK}-{NN}` ‚Äî e.g. `T-C-07` = Track C, task 7. Dependencies reference these IDs.

## Suggested Execution

1. Day 1 (all 4 agents): each completes its **Phase 0** (T-X-01..T-X-06). Merge order:
   A-01 first (BaseDomainModel), then B-01, C-01, D-01 (independent per DB).
2. Weeks 2+: tracks proceed by their own phase order; agents pull tasks whose
   `DEPENDS-ON` entries are all `DONE` (or contract-only, marked ‚ö°).
3. Integration gates at the end of each phase pair (see each file's "Phase gates").

## Contract Change Log

Append-only. Additive contract changes (new optional method/event field) go here as one
line: `task-id ‚Äî what ‚Äî why`. Breaking changes (rename, signature change, field removal)
are forbidden during the build; if believed necessary, file a blocker instead.

- T-D-07 follow-up — adds D-owned `AffiliateAccountStatus::isEligibleForReferrals(int userId): bool` consumed by affiliate referral eligibility gates; A binds the identity-backed implementation when status transitions are wired. Adds append-only D enrollment decision history and allows rejected enrollments to be re-approved after application reopen. No frozen contract changed.
- T-B-02 ‚Äî CatalogService gains 4 additive methods beyond the README core (questionsFor alias, topicCoverage(courseId), prebuiltMaps(subjectId), sampleContent(courseId)) and an optional userId param on contentItem ‚Äî access-state DTOs (T-B-03) need the callers entitlement context; frozen core signatures (contentItem(id), courseTree(courseId), questionBank(assessmentId)) are unchanged.
- T-B-04 ‚Äî new ADDITIVE queued event `App\Events\Catalog\BoardCertificationRevoked(int $boardId, int $learnerUserId, string $reason)` (not one of the frozen 15) ‚Äî board certification revocation is reason-recorded; the reason is carried to Track D for the learner notification, while the persistent reason lives on the learner's certificate record (Track C). No frozen event or method is modified.
- T-B-05 ‚Äî new ADDITIVE queued event `App\Events\Catalog\BatchAssessmentRescheduled(int $scheduleId, int $assessmentId, int $groupId, string $oldWindowStart, string $oldWindowEnd, string $newWindowStart, string $newWindowEnd, string $priorAttemptsDisposition)` (not one of the frozen 15) ‚Äî batch assessment reschedule re-notifies the group (D fans out per its notification platform) and carries the recorded prior-attempt disposition; no frozen event or method is modified.
- T-B-06 — additive internal catalog event `App\Events\Catalog\ContentMediaUploaded(int $contentItemId)` (not one of the frozen 15) + Track B's own queued listener `App\Listeners\Catalog\ProcessContentMedia` (self-registered from the B-owned ContentService; AppServiceProvider is A-owned) which advances uploaded → processing → playable. Also: `CatalogServiceStub::contentItem()` is now a REAL implementation — frozen signature `contentItem(int, ?int): ?ContentItemDto` unchanged; system reads (userId = null) return the full payload, learner reads compute access state via A's `SubscriptionService` only (never identity_billing tables) into full/sample/locked DTOs.
- T-B-07 — additive catalog surface: `App\Services\Catalog\ResourceService` (entitlement-gated downloads via A's `App\Services\Billing\SubscriptionService` only — DEPS⚡ T-A-23; unentitled → 403; no identity_billing query) + `App\Services\Catalog\FlashcardService` (AI decks are draft until human review — never auto-approved; community decks carry moderation state pending/approved/removed-with-reason). No frozen event or method is modified.
- T-B-08 — CatalogServiceStub::questionBank(int): QuestionDto[] and its alias questionsFor(int): QuestionDto[] are now REAL implementations (frozen signatures unchanged): they return only APPROVED, actively-attached questions of an active, non-retired assessment — draft / AI-unreviewed / rejected questions are never returned across the contract. Additive catalog surface: App\Services\Catalog\QuestionService (AI questions forced draft on create — human review is the only bank path; rejection recorded + never enters; duplicates flagged) + App\Services\Catalog\AssessmentService (passing-score change appends an append-only audit row + bumps config version — applies to FUTURE attempts only, recorded outcomes in C learning DB never retrochanged; adaptive floor≤ceiling 422; delete gate attempts>0 → 409; video-wise auto-quiz → AI-draft; content update → pending regeneration prompt, not a silent overwrite). No frozen event or method is modified.
- T-B-09 — additive to the frozen CatalogService contract: contentItem(int, ?int userId, ?int learnerAgeBandMax = null) gains an OPTIONAL learnerAgeBandMax param (existing 1- and 2-arg call sites are untouched) — the age-appropriate gate: content whose age_rating exceeds the learner age band is hidden (null) on learner reads, enforced not bypassable; system reads (userId=null) unaffected. New additive queued events (not one of the frozen 15): App\Events\Catalog\ContentRejected(int contentId, string contentType, int creatorId, string reason) — creator notification for D on definitive rejection; App\Events\Catalog\ReviewQueueAging(int openCount, int oldestSeconds) — queue aging metric for D dashboards. New B-owned surfaces: App\Services\Catalog\ApprovalService (review_items pending→in_review→approved|rejected|flagged; approval recorded reviewer+time; reassignment recorded; risk→workflow variant; queue filters + workload; age gate; flags with mandatory resolution; community moderation reports; aging metric). The frozen event App\Events\Catalog\ContentApproved is now emitted from the real approve path (video approval flips the item to published — content never auto-approved, never live until approved). No frozen method signature or frozen event is modified.
- T-B-10 — additive catalog surface only, no frozen contract modified. New B-owned SrsConfigService (catalog DB): the SRS platform CONFIG (C owns learner SRS STATE in learning). effectiveSchedule(?int learnerAge) resolves grade-band override > global; saveEbbinghausCurve rejects non-monotonic decay (422); rating map must be complete (every rating to exactly one schedule index); concept mappings reject orphan topics (422); snapshot(?int learnerAge) is the full config C snapshots at scheduling — config changes apply to NEWLY SCHEDULED reviews only (T-C-08). Reads: GET /srs/snapshot + /srs/curve (auth-only); writes under rbac.can:srs.manage.
- T-B-11 — additive catalog surface only, no frozen contract modified. New B-owned services (catalog DB): ExamTemplateService (exam types: duplicate 422, delete-with-active-students 409 via plain cross-DB existence check on C's learning.target_exams — read-only, defensive, never a join; versioned exam templates — sections sum ≤ total 422, edit creates v(n+1), old version frozen; topic importance chain topic → subject-default → global-medium; bulk import with row-level reject report), PredictionConfigService (override > global; versioned, in-use versions never deleted, rollback EXACT; zero-width confidence interval 422; additive queued event PredictionAccuracyDrift on calibration drift → D; insufficient-data contract with C: below min_data_points → 'not shown — prompt to practice'), AiFeatureConfigService (learn-before-practice ordering enforced, violations never generated 422; revision cycles clamped ≥ SRS minimum; milestone triggers once/plan; AI features incl. escalation (student-requested always succeeds, low-confidence threshold, async-ticket fallback); planner suppressed notifications queued + released). Config changes non-retroactive (config_version; C snapshots). Reads: GET /exams/* (auth-only); writes under rbac.can:exam.manage.
- T-B-12 — the three remaining Phase-0 stub methods of the frozen CatalogService contract become REAL implementations (frozen signatures UNCHANGED): `topicCoverage(int): TopicCoverageDto` (course's subject-topic universe → content/assessment coverage per topic; gaps VISIBLE, never silent), `prebuiltMaps(int): MindMapDto[]` (PUBLISHED mind maps only), `sampleContent(int): SampleContentDto[]` (published+playable unit content only; 2-minute preview bound with an unlock message; never the full transcript/media). New B-owned surfaces (catalog DB): `CoverageService` (board/subject node coverage, content-without-approval = the T-B-09 gate made visible, questions-without-topic-tag, orphan-resource reports) and `RetirementService` (content-update → dependent video_wise quiz PENDING regeneration prompt via additive B-internal listener `App\Listeners\Catalog\FlagContentUpdateRetirements` on the existing additive event ContentMediaUploaded; retire-course = impact-reviewed unassignment, learner history preserved, idempotent; external-link validation job — dead links flagged `external_link_broken`, never silent). No frozen method signature or frozen event is modified.
- T-B-09 follow-up — adds reviewer-only `POST /api/review/content/{contentItem}/age-rating`, requiring a corrected rating and reason. The catalog transaction locks the content row, updates the active rating, and appends an immutable `content_age_rating_corrections` record with old/new values, reviewer, reason, and timestamp. The existing learner age gate immediately applies the corrected rating; no frozen method or event changes.
- T-B-06 follow-up — add `CatalogService::recordCompletion(int contentItemId): bool`, `recordDropoff(int contentItemId): bool`, `recordFeedback(int contentItemId, int score): bool`, and `rawCounters(int contentItemId): ?array` so C can write/read T-B-06 raw effectiveness counters without crossing database boundaries. Each write call increments once and returns false for missing/deleted items; consumers must call once per learner action (no built-in event deduplication). Feedback scores are accumulated as a raw sum/count; aggregation remains D-owned. Existing CatalogService methods and call-site signatures are unchanged.
- T-B-06 follow-up — `ContentItemDto` additively exposes the item's `downloadable` opt-in (default false) so offline consumers can apply the content-level gate. This boolean is not a user authorization decision; consumers must also use A's `DownloadPolicyService` and preserve DRM/license checks before issuing a download. No CatalogService method signature changes.
- T-B-07 follow-up — additive queued event `App\Events\Catalog\FlashcardDeckQualityApproved(int deckId, int creatorUserId, string origin, ?int approvedBy, string approvedAt)` is emitted after commit only when a deck transitions into approved through human review and has a creator submitter. It is not emitted for removed decks or system-created decks. This producer signal represents B's human approval gate; C owns idempotent points award processing keyed by `(creatorUserId, deckId)`.
- T-B-07 follow-up — add `CatalogService::approvedFlashcardDeck(int deckId): ?FlashcardDeckDto` for Track C's flashcard-study/SRS adapter. It returns only approved decks, projecting `id`, `title`, `topicId`, and ordered cards with `id`, `front`, `back`, `topicId`, and `difficulty`; non-approved or missing IDs return null. It excludes origin, submitter/reviewer, moderation fields, and media storage keys. Existing method signatures are unchanged.
- T-C-16 follow-up — manual and scheduled weekly/monthly report generation resolve to the latest completed calendar period in the application timezone. Scheduled reports run for learners with activity, study sessions, or submitted attempts in that learning-database period; a unique `(user_id, period, period_label)` key preserves one snapshot if requests race. Reports add period-bounded completed-content count, submitted-attempt count, score/pass averages, and daily score trend. The frozen `ProgressService` completion, score, weak-topic, and streak projections remain current-state values.
- T-C-16 follow-up — adds the aggregate-only queued event `App\Events\Learning\GradeBenchmarkCohortSnapshot(int gradeLevel, int cohortSize, float meanPct, float stdPct, string snapshotAt)`. C suppresses cohorts with fewer than five scored learners by emitting zeroed aggregate values; D ingests the snapshot into the write-once analytics sink and binds C's `GradeBenchmark` port to the latest snapshot reader. Payloads contain no learner identifiers or peer-level rows.
- T-C-15 follow-up — add `ProgressService::upcomingTests(int userId): array`, returning up to three active target exams for that learner with dates on or after today, ordered by earliest exam date, as `{id, name, date}` rows. The real implementation reads only C's learning `TargetExam` rows and scopes by `user_id`; the Phase-0 stub returns an empty array. The mobile widget now obtains upcoming tests through the same ProgressService contract as its course progress and streak.

- T-C-04 — new ADDITIVE queued event `App\Events\Learning\CourseCompleted(int $userId, int $courseId)` (not one of the frozen 15) — course completion (all required content done) feeds Track D analytics/parent dashboard and Track C's T-C-10 certificates; the frozen `ContentCompleted` payload stays exactly as contracted (userId, contentItemId). No frozen event or method is modified.
- T-C-04 — `App\Services\Billing\SubscriptionService` interface FILE materialized at the Phase-0 frozen FQCN (isEntitled/activePlan/trialState, signatures unchanged) so DEPS⚡ consumers type-check while A's T-A-17 implementation is pending — the file lives in A's namespace; A's T-A-17 supersedes it at merge (implementation + file ownership both return to A; C's test double adapts in that cycle).
- T-C-04 — REVERT (supersedes the previous T-C-04 line above): C's materialized `App\Services\Billing\SubscriptionService` interface file is DELETED from the T-C-04 branch. Verified clean main is 296/296 green, so the 3 `ResourceLibraryTest` container failures were caused by that interface file ("not instantiable" when B's controller is built; B's tests pass only because Mockery auto-defines a concrete class for the absent FQCN). A's T-A-17 will create the interface at the frozen FQCN; until then C's tests mock it via Mockery (same pattern as T-C-03 and B's tests). No frozen contract is modified — C's production code type-hints the FQCN string only.
- T-C-05 — first real emitter of the FROZEN event `App\Events\Learning\AttemptSubmitted` (payload UNCHANGED, exactly as contracted for D's ingestion: userId, assessmentId, score, weakTopics). New C-owned learning surfaces only, no frozen contract modified: `App\Models\Learning\Attempt` + `Answer` (learning DB; timer + status + per-question answered/flagged/unanswered outcomes), `App\Services\Learning\GradingEngine` (per-type: MCQ / multi-select full-marks-only / fill-blank / true-false / numeric w/ tolerance / rubric short-answer with AI rationale), `AttemptService` (start/record/submit; auto-submit-on-expiry marks unanswered as UNANSWERED, never zero-marked-answered; adaptive difficulty+ability hidden from learner; practice retakes never bump the B-owned exam allowance; mock feedback gated until the window closes), and the C-owned `App\Services\Learning\AssessmentBank` port with `CatalogAssessmentBank` adapter over B's T-B-08 `AssessmentService`/`QuestionService` (no cross-DB query, no joins). Routes under /api/learner/assessments/*, every path full-AccessGate-checked (entitlement ∩ enrollment ∩ parental ∩ DRM).
- T-D-03 — ADDITIVE to the D-owned `App\Services\Engagement\GamificationConfigService` contract (C's T-C-07 gamification state consumes it via lazy FQCN resolution): `badgeCriteria(): array` (badgeCode => criterion, C-defined shape), `pointsExpiryDays(): ?int` (null = no expiry), `streakProtectionDays(): int` (missed-day freeze count). These were absent from the Phase-0 freeze while C's merged `GamificationService` already calls them — D's bound object (Phase-0 stub) now implements them with the same safe-minimum values C's own `GamificationConfigFallback` uses (nothing auto-earned, no expiry, no protection). No existing method renamed or re-shaped; T-D-08 back-fills real values from the config tables.
- T-D-07 — new D-owned DEPS⚡ contract `App\Services\Engagement\AffiliateProgramConfig` (methods: tierRates(): array, minimumPayoutThreshold(): float) + `AffiliateProgramConfigStub` (Phase-1 values) — T-A-16 (affiliate program config: structure/levels/payout) is not yet on main; the interface lets the affiliate portal (T-D-07) and gamification (T-D-08) compile now, and A rebinds the `AffiliateProgramConfig` singleton to its real implementation when T-A-16 lands (one-line provider change, no call-site churn). No frozen event or method is modified.
- T-D-05 — D defines `ForumTierGate::hasEntitledTier(int): bool`; A binds `ForumTierGateAdapter` to T-A-13 `SubscriptionService::isEntitled($userId, 'all')`. A forum marked `requires_entitled_tier` therefore requires full-catalogue entitlement; course/subject-only entitlements are excluded. This follows A’s entitlement semantics, including active/trial/grace subscriptions and qualifying full-access seats; the contract represents entitlement rather than proof of payment.
- T-D-05 follow-up — adds `CourseGroupMembership::activeGroupIdsForUser(int userId): array`, backed by engagement-only `StudyGroupMembershipReader` and bound in `EngagementServiceProvider`. It returns distinct ascending group IDs for the user's memberships in approved/active study groups, matching `StudyGroup::isJoinable()`; B combines those IDs with catalog-owned assignments without a cross-database join. No frozen contract changes.
- T-D-09 — additive engagement surface only, no frozen contract modified. New D-owned marketing stack (engagement DB): MarketingService (campaign state machine draft→scheduled→active→completed with illegal-transition 409; A/B variant assignment stable per user via crc32(campaign_id|user_id); popup frequency cap of one-per-user-per-day enforced in the service, not call sites, 409 popup_frequency_cap; template versioning appends immutable marketing_template_versions snapshots; discount-code grant gated behind DEPS⚡ T-A-15 via the CampaignDefinitionProvider seam) + additive queued event App\Events\Engagement\MarketingCampaignEvent(int $campaignId, string $type, string $state, array $performance, ?string $metric) (NOT one of the frozen 15 — performance goes to analytics only via T-D-02's async ingestion; the campaign row carries a live read-mirror so dashboard reads need no analytics join). DEPS⚡ T-A-15: new D-owned seam App\Services\Engagement\CampaignDefinitionProvider with Phase-1 binding MarketingCampaignDefinitionStub (MarketingServiceProvider) — swap the single binding when A's T-A-15 lands; SMS OTP delegates to A's concrete TokenService (issuePhoneOtp/verifyPhoneOtp), no D-owned OTP state.
- T-D-10 — D defines `CorporateTenantGate` (`tenantForUser`, `isVerifiedActive`, `verifiedDomain`) and `CorporateSeatService` (`allocateSeat`, `revokeSeat`, `transferSeat`). A binds the tenant contract through `CorporateTenantGateAdapter` over identity_billing T-A-12 tenant/member records and binds seats to T-A-21 `SeatLicenseService`; the contract adapters do not perform cross-database joins. Other tenant portal contracts (CSR/ORG/SPON) are similarly bound by A-owned adapters. T-D feature tests may bind explicit doubles for isolated portal behavior.
- T-D-17 — new D-owned DEPS⚡ feed contracts for the finance & revenue dashboard (T-D-17), both `metrics(?string asOf): array`, bound in D's `FinanceDashboardServiceProvider`: `App\Services\Analytics\Feeds\SubscriptionHealthFeed` (A's T-A-20 — active-subscription count/revenue, pending + failed payments WITH reasons, subscription health, renewal stats, the plan-price catalogue) + `SubscriptionHealthFeedStub`; and `App\Services\Analytics\Feeds\LicenseFeed` (A's T-A-21 — licensed seats used/available + expiry dates) + `LicenseFeedStub`. The finance dashboard reads the T-D-02 event stream (PaymentSucceeded/RefundProcessed/CommissionSettled/PayoutIssued) for transactional figures and delegates the billing-DB-only figures to these contracts — never a cross-DB query. A rebinds each singleton to its real implementation when T-A-20 / T-A-21 land (one line each, no call-site churn). No frozen event or method is modified; T-D-02's write-once `event_ingestion` sink is untouched (T-D-17's failed-payment-with-reason enforcement is additive, via D's own `FinanceEventIngestor`).
- T-D-11 — new D-owned DEPS⚡ contract `App\Services\Engagement\CsrTenantGate` (methods: membershipOf(int): ?int, isVerifiedActive(int): bool) + `CsrTenantGateStub` (deny-by-default, with a test seam) + D-owned `App\Providers\CsrServiceProvider` (registered in `bootstrap/providers.php`, append-only) — the CSR tenant portal (funding) gates every `/csr/*` route on a verified + active tenant and the caller's membership (403 otherwise) and scopes to the caller's tenant; CSR-admin approval (`csr_admin.approve`, deny-by-default; super_admin passes via '*') gates release. T-A-12 (TenantGate / entitlement) is not yet on main, so the interface lets the CSR portal compile now and A rebinds the `CsrTenantGate` singleton to its real implementation when T-A-12 lands (one-line provider change, no call-site churn). No frozen event or method is modified.
- T-C-09 — new ADDITIVE queued event `App\Events\Learning\DoubtEscalatedToTeacher(int $userId, int $doubtId, string $mode, ?string $topic = null)` (not one of the frozen 15) — an AI-companion doubt escalated to the teacher (mode: student_requested | auto_low_confidence | async_ticket_fallback | none, per B's AiFeatureConfigService escalation rules); Track D consumes it for parent-teacher comms, the persistent record is C's learning.ai_doubts row. No frozen event or method is modified. T-C-09 AI companion runtime (learning DB) consumes B's merged AiFeatureConfigService (DEPS⚡ T-B-11) at its FQCN — per-plan question limit (exceed → 429 plan-upgrade), escalation rules, companion/break settings; config is non-retroactive (the plan's question limit is snapshotted on first use).
- T-D-13 — two new D-owned DEPS⚡ contracts + one reusable table. (1) `App\Services\Engagement\OrgTenantGate` (methods: membershipOf(int userId): ?int, isVerifiedActive(int tenantId): bool) + `OrgTenantGateStub` (deny-by-default, with a test seam) — the organization-tenant flavor of A's T-A-12 (TenantGate); the org portal (T-D-13) authorizes + scopes to the caller's verified-active org tenant (missing membership / not verified+active → 403; cross-tenant read → 404). (2) `App\Services\Engagement\LmsConnector` (methods: syncCoursesIn(int tenantId): array, syncUsersIn(int tenantId): array, syncProgressOut(int tenantId, array progress): array) + `LmsConnectorStub` (A's T-A-21 LMS contract) — external LMS sync hook. The tenant gate is now bound by A's identity-backed adapter; the external LMS connector remains on its Phase-1 stub until a concrete integration is selected. (3) The **generic `org_approval_workflows`** table (engagement) + `OrgApprovalService` — the single approval-workflow surface for ALL tenant portals (org T-D-13, CSR T-D-11, SPON T-D-14): `entity_type`/`entity_id` + submit→pending→approved|rejected (reason + actor recorded, never silent), the **generic release gate**. This resolves the T-D-11 open question (SPON T-D-14's invoice-approval is placed here). Approval is deny-by-default (`org.approve` permission). No frozen event or method is modified; T-A-12/T-A-21 real verification lands in T-D-19 (cross-tenant E2E).

- T-C-16 — new ADDITIVE queued event `App\Events\Learning\PerformanceAlert(int $userId, string $type, array $payload = [])` (not one of the frozen 15) — performance alerts (score_drop | missed_plan | streak_risk) → Track D's learner/parent dashboards; the persistent records live on C's own learning tables (attempts / study plans / streaks), the event carries only the small detail (e.g. assessment_id, current/previous score). No frozen event or method is modified. T-C-16 read-model runtime (learning DB, migration 20260924_031001: study_sessions / learner_activity / shared_reports, no cross-DB FKs): the REAL `App\Services\Learning\ProgressService` (replaces the T-C-02 stub binding — frozen signatures UNCHANGED, D now injects live data), LearnerReadModel (progress %, time-spent session log, read-only activity log, performance dashboard, strength/weakness, grade-level benchmark percentile via the C-owned GradeBenchmark port [D supplies the cohort aggregates; NullGradeBenchmark default until D lands], learning-pattern insight, automated weekly/monthly reports — downloadable CSV + shareable with the parent), + the 6 thin event listeners that append learner_activity rows on the frozen events. Routes: learner read-model under /api/learner/progress/* + /api/learner/reports/*, parent projections under /api/parent/children/* (child-scoped, read-only; the per-child consent scope is enforced by the T-C-13 parent-link runtime). No AccessGate — read-model projections consume no catalog content (content was gated at T-C-04/05).
- T-C-13 — new ADDITIVE queued event `App\Events\Learning\ParentalAlert(int $userId, string $type, array $payload = [])` (not one of the frozen 15) — a parent alert (type: session_missed | screen_time_limit | approaching_limit) → Track D's parent-notification surface; the persistent records live on C's own `learning.parental_control_settings` / `parental_study_sessions` rows, the event carries only the small detail. No frozen event or method is modified. T-C-13 parent-child link runtime + parental controls (learning DB, migration 20260924_031101: parental_control_settings / parental_age_overrides / parental_content_requests / parental_study_sessions / parental_session_schedules / parental_plan_approvals / parental_control_activity, no cross-DB FKs): the C-owned `ParentChildLink` PORT (DEPS⚡ T-A-10 — production default `NullParentChildLink` deny-by-default, tests bind `ParentChildLinkDouble`; one-line rebind to A's adapter when T-A-10 merges), the REAL `DefaultParentalRestrictionAccess` gate factor (screen-time / time-of-day windows / content restrictions [core learning NEVER blockable] / age filter / restricted-content requests), `ContentAccess` gains two ADDITIVE optional fields (`type`, `gradeLevel`), mandatory study sessions + recurring schedules (conflict/pause), AI study-plan approval (a minor's plan is NOT applied until parent approval — `markTaskDone` on an unapplied plan → 409). Routes: parent controls under /api/parent/children/{childId}/* (link-checked; existence-safe 404), learner parental side under /api/learner/parental/*. No AccessGate on the control endpoints (no catalog content consumed); every playback/attempt path STILL runs the full AccessGate.

- T-D-14 — new D-owned DEPS⚡ contract `App\Services\Engagement\SponsorTenantGate` (methods: membershipOf(int): ?int, isVerifiedActive(int): bool) + `SponsorTenantGateStub` (deny-by-default, with test seams) — sponsor portal access requires a verified+active tenant (unverified sponsor → 403); A now binds the contract through an identity-backed adapter. Sponsorship content from T-B-06 is referenced by `target_ref`, without joins or cross-database queries. No frozen event or method is modified.
- T-D-06 (DEPS⚡ T-A-09) — D defines `SupportAccountGate::accountState(int): string`; A binds it to `SupportAccountGateAdapter`, which reads identity user lifecycle state. Isolated D feature tests may bind the local stub.
- T-A-22 follow-up — new additive event `App\Events\Identity\InappropriateContentReported(int reportId, int contentId, int reporterId, string reason, ?string details, string occurredAt)` for D's T-D-05 moderation intake. A dispatches only after persisting and auditing the report; D's listener queues cross-track delivery. The event carries report ownership and report content fields, with no reporter name/contact data. No frozen event or method is modified.
- T-D-06 — adds D-owned queued analytics events `SupportTicketOpened|SupportTicketResolved|SupportTicketBreached(int ticketId, int userId, string priority, string transitionId)` for actual support-ticket state transitions. `transitionId` makes distinct reopen/resolve cycles countable while redelivery of the same event remains idempotent in T-D-02's write-once sink; SLA breach emits only when the persisted state first changes to breached. No frozen cross-track event or method is modified.
- T-D-06 follow-up — adds D-owned queued event `App\Events\Engagement\SupportTicketFirstAgentResponseRecorded(int ticketId, int userId, int responseTimeSeconds, string transitionId)` and persists `first_agent_response_at` under an engagement ticket row lock. Response latency is measured once from ticket creation to its first `sender_type=agent` message; the dashboard reports the arithmetic mean in seconds and count of tickets with a recorded first response (no zero-fill for unanswered tickets). A timely response satisfies the response SLA; a late response breaches it, and an already-breached SLA never regresses. No frozen cross-track event or method is modified.

### Integration adoption (2026-10-02)

- A12 identity tenant and membership records now back D's corporate, CSR, organization, and sponsor gate contracts; the corporate seat contract uses A21 `SeatLicenseService`. C's `ParentChildLink` adapter also reads A10 guardian links and consent scopes. These are owner-scoped queries on `identity_billing`, not cross-database joins.
- D consumes C's `DoubtEscalatedToTeacher` event as an idempotent support ticket for explicit or low-confidence teacher handoffs. It consumes `ParentalAlert` through the consent-scoped parent-link port and sends a generic preference-aware notice. It ingests `PerformanceAlert` into analytics; no parent recipient is inferred because that event carries no guardian identifier.
- D's forum tier gate uses A's full-catalog entitlement (`all`) policy. The result means the learner is entitled to the full catalog under A's subscription/seat rules, not necessarily that a payment occurred.
