# 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\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)`, `topicMastery(userId)`
  (used by D for dashboards/insights; never by B)
- `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.

- (none yet)
- 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.