# Track B Status

Pre-flight (2026-09-24): Track A Phase 0 complete — T-A-01…T-A-06 all merged to main
(T-A-05 landed as 4ad28ae, latest origin/main). BaseDomainModel, audit, RBAC
(`rbac.can:`), response envelope, and the queued events bus all present. GATE 1
pre-flight passed. (Old `track-b/status-pre-flight-blocked` branch is superseded.)

## CONTRACT ANNOUNCEMENT (for Tracks C & D) — T-B-02 shipped 2026-09-24

`App\Services\Catalog\CatalogService` is now the frozen read contract:

- `contentItem(int $contentItemId, ?int $userId = null): ?ContentItemDto`
- `courseTree(int $courseId): ?CourseDto`
- `questionBank(int $assessmentId): array<QuestionDto>`  (alias `questionsFor`)
- `topicCoverage(int $courseId): TopicCoverageDto`
- `prebuiltMaps(int $subjectId): array<MindMapDto>`
- `sampleContent(int $courseId): array<SampleContentDto>`

Inject via constructor (`CatalogService $catalog`). Phase 0 returns the
documented empty shape (`CatalogServiceStub`); real behavior lands with
T-B-05/06/08/12. DTOs live in `App\Services\Catalog\Dto\*` and carry an
`AccessState` (`full | sample | locked`) — sample = preview-only fields
(no full transcript/media URL). Queued events: `App\Events\Catalog\{
CoursePublished, ContentApproved, AssessmentRetired}` (frozen shapes).
Implementors read `catalog` only — no cross-DB joins or FKs.

## Integration follow-up — 2026-10-02

- Filled the remaining T-B-05 `CatalogService::courseTree` gap: it now returns
  the catalog-backed, position-ordered course/module/unit/content DTO tree,
  preserving unit content order and excluding dangling or cross-course content
  references. The Track C E2E can now exercise seeded catalog records through
  the frozen producer contract. Catalog contract suite: 183 tests passed.
- DMCA takedown consumer: Laravel-discovered `ApplyDmcaTakedown` consumes
  `App\Events\Identity\DmcaTakedownRequested` using only its scalar
  `contentId`. The Catalog row is locked and transitions from `published` to
  `unpublished`; duplicate delivery is a no-op, and draft/unpublished/archived/
  deleted states are preserved. This changes availability without removing
  content metadata or media history, and introduces no cross-database join or
  foreign key. The event contract has no acknowledgement channel, so the
  identity-side DMCA notice remains `takedown_requested` after dispatch; a
  future shared acknowledgement contract would be needed for a terminal
  takedown result there.

- **T-B-09 age-rating correction:** reviewer-only `POST
  /api/review/content/{contentItem}/age-rating` now corrects a content item's
  active rating and appends immutable history with the prior/new values,
  reviewer, reason, and timestamp in the catalog database. The learner age
  gate reads the corrected value immediately. Live-content flags now execute
  their type-specific action transactionally: video/article hidden, podcast and
  resource archived, deck removed, and question archived out of the approved
  bank; pending content retains its marker. Flag types and content IDs are
  validated, and approval is serialized against open flags. Added
  `POST /api/review/items/bulk-approve`: it records approval per review item
  and rolls back the batch if any item cannot be approved. `ApprovalWorkflowTest`:
  26 tests, 147 assertions; `ContentTest`: 26 tests, 118 assertions. This closes
  the plan's “mis-rated items corrected + recorded” criterion; it does not establish the
  separate T-B-12 media-duration guarantee for previews. T-B-09 version history
  is now covered by `GET /api/content/{item}/versions/{version}/preview`, which
  compares current and selected historical snapshots without mutation; restore
  remains a separate explicit action.

- **T-B-06 effectiveness contract:** Track C can now write completion, drop-off,
  and feedback raw counters through `CatalogService`, and read their unaggregated
  totals. Missing/deleted items return `false`/`null`; each write call is one
  increment, so consumers must invoke it once per learner action. Feedback
  aggregation remains Track D-owned. Contract coverage is in
  `CatalogServiceContractTest` and `ContentTest`.

- **T-B-06 podcast download flag:** the item's `downloadable` opt-in is now
  included in `ContentItemDto` (default false) for learner/offline consumers.
  It is only the catalog's per-item flag, not authorization. End-to-end
  enforcement still requires C's offline download path to reject false flags
  and consume A's `DownloadPolicyService` policy, while retaining DRM/license
  checks. `OfflineLearningService::createDownload()` currently checks
  subscription and DRM but does not consume either the per-item flag or A's
  download policy; that consumer-side integration remains with Track C.

- **T-B-07 quality-deck event:** `App\Events\Catalog\FlashcardDeckQualityApproved`
  is a queued, after-commit signal for human-approved decks with a creator
  submitter. It carries deck/creator IDs, origin, reviewer, and approval time;
  removed or system-created decks emit nothing. Human approval into the
  approved bank is the available B-owned quality gate; point awards remain C-owned.

- **T-B-07 learner deck contract:** `CatalogService::approvedFlashcardDeck(int)`
  returns a learner-safe `FlashcardDeckDto` only for approved IDs. It exposes
  deck/card identity and study content, excludes submitter/reviewer/moderation
  fields and storage keys, and returns null for missing or unapproved IDs.
  Coverage is in `ResourceLibraryTest` and `CatalogServiceContractTest`.

- **T-B-03/T-B-12 preview alias guard:** Learner sample access and the course
  sample list now require a distinct preview URL; scheme, query string, or
  fragment differences do not make the same host/path count as a separate
  asset. Regression tests cover both learner DTO and sample-list paths. This
  blocks accidental direct full-media URL exposure but does not verify
  encoded duration; that still depends on the external media/transcoding
  provider contract. Product spec
  `trial_freemium_plans.md` §7.2 also limits previews to the first two videos
  per subject; current learner access requires a distinct preview URL.
  The catalog does have course-local `position` on modules and units, and the
  JSON `content_item_ids` array preserves a unit-local sequence. However,
  `content_items` has no sequence field, `content_assignments` has no ordering
  field, and a subject can be represented by multiple courses; there is no
  defined way to compare their course-local sequences into one subject-wide
  first-two set. Sorting by content ID would be insertion order, not a product
  ordering contract. The spec's new-video update rule says the preview changes
  but does not define ordering across courses or placements. Therefore the
  first-two quota remains open pending a subject-wide ordering/selection
  contract; implementing it against IDs or an arbitrary course would make
  access unstable or omit valid subject content.

- **T-B-05 assignment-only course detail:** `GET /api/courses/{course}` keeps
  the existence-safe 404 for unpublished courses and for assignment-only
  courses without an active assignment to one of the learner's resolved active
  groups. The learner route gets trusted IDs from D's
  `CourseGroupMembership::activeGroupIdsForUser(userId)` contract; B compares
  them in one `whereIn` query against catalog-owned active `CourseAssignment`
  rows. Public published course detail and public browse remain unchanged, and
  caller-supplied group IDs are ignored. `CourseTest` covers assigned access,
  unassigned access (including a forged `group_id` query param), inactive
  assignment, and unpublished course denial (31 tests / 110 assertions).
  This grants detail visibility only: actual enrollment still requires A's
  `SubscriptionService::isEntitled(userId, "course:{id}")` check in C's
  `EnrollmentService::enroll()` (T-C-04). T-B-05's “enrollable by assigned
  group” wording is ambiguous between assignment enabling enrollment alongside
  subscription entitlement and assignment itself granting entitlement. Keep
  the T-C-04 entitlement gate until that cross-track policy is clarified;
  group assignment does not currently grant subscription entitlement.

- **T-B-07 learner resource reads:** direct resource details now return 404
  unless the resource is published; browse excludes drafts and archived records.
  Detail, browse, and version-history projections omit storage `media_key`s, so
  learners must use the entitlement-checked download route to obtain a download
  handle. `ResourceLearnerReadTest` (4 tests / 16 assertions) and
  `ResourceLibraryTest` (21 tests / 107 assertions) cover the learner boundary.

- **T-B-07/T-B-08 learner read projections:** the flashcard list, bank, and
  direct-ID routes now return approved decks only and omit card object-storage
  keys; unapproved deck IDs return 404. The learner question-bank HTTP response
  omits correct answers and explanations while the internal `CatalogService`
  retains its grading DTO for C. `FlashcardLearnerReadTest` (2 tests / 20
  assertions) and `QuestionBankAssessmentTest` (19 tests / 85 assertions) cover
  the HTTP boundaries. Correct answers and explanations are presented from the
  submitted assessment result per the grading spec, not from pre-attempt reads.

- **Learner reads scoped to parent course visibility:** content detail/browse
  and assessment question-bank/grading-summary now fail closed when a linked
  course is not both published and public. Content with no `course_id` keeps
  its existing learner behavior. `content.manage` and `assessment.manage`
  readers retain editorial access. Focused `ContentTest` and
  `QuestionBankAssessmentTest` coverage passes (51 tests / 235 assertions).
  Assignment-only course detail is separately enabled under T-B-05: it resolves
  trusted active group IDs through D's `CourseGroupMembership` contract and
  matches them against active catalog course assignments. This membership
  integration applies to course detail only; content and assessment learner
  reads still fail closed unless their parent course is published and public.
  It also does not establish free/sample assessment entitlement: question-bank
  and grading routes still lack a contract for applying the product's
  sample-assessment limits. Assessments without a parent course continue under
  their existing behavior.

- **T-B-09 reviewer assignment validation:** review assignment and
  reassignment now resolve the target through the identity-owned `User` model
  and require `content.review` or `content.review:all` RBAC before changing a
  queue item. Missing users and ordinary users are rejected without mutation.
  This uses the existing ID bridge and does not join domain databases.
  `ApprovalWorkflowTest` passes (27 tests / 153 assertions). Automatic
  subject/type/expertise matching remains undefined: catalog review items have
  a subject ID and content type, but there is no reviewer expertise mapping or
  assignment policy to safely infer a match from.

| Task | Status | Branch | Notes / blockers |
|---|---|---|---|
| T-B-01 | ✅ present in current local integration (`f448907`) | local integration | BaseCatalogModel (connection lock) + catalog migration baseline + test infra. 4 passing / 0 failing (Catalog filter); full suite 78 passing. |
| T-B-02 | ✅ present in current local integration (`98cd6cf`) | local integration | **CATALOG CONTRACT SHIPPED** — `App\Services\Catalog\CatalogService` (frozen interface) + `CatalogServiceStub` + access-state DTOs (full/sample/locked) + 3 queued catalog contract events. C & D: inject the interface via constructor; docblock states the cross-DB rule. Additive methods + optional `contentItem(userId)` logged in README Contract Change Log. Full suite 85 passing. |
| T-B-03 | ✅ present in current local integration (`c3d98ee`) | local integration | AccessGate (full/sample/locked via SubscriptionService mock) + CourseDto/ContentItemDto/AssessmentDto mappers (sample strips transcript/mediaUrl/mediaKey). Merged `df7ef31`. |
| GATE 1 | ✅ local contract gate is present (`98cd6cf`, `c3d98ee`) | local integration | C & D can compile against the CatalogService stub (T-C-02 already consumes the Learning contract; B contract announced above). Phase-0 branch merged to main. |
| T-B-04 | ✅ present in current local integration (`08a71b4`) | local integration | Curriculum organization (academic + professional trees). Merged to main as `bc0a862`. 12 models, 3 migrations, `CurriculumService`, `api_catalog.php` routes gated by `rbac.can:curriculum.manage`. Additive event `BoardCertificationRevoked` logged in README Contract Change Log. |
| T-B-05 | ✅ present in current local integration (`261141e`, tree contract `445a76a`) | local integration | Course management (academic + professional). Models: `Course`/`CourseModule`/`CourseUnit`/`CourseAssignment`/`BatchAssessmentSchedule`/`LearningPath{,Course,Assignment}`; migrations `20260924_020101…020103` (course structure, assignments/schedules/paths, `course.manage` RBAC seed). `CourseService`: one-time placement validation (nodes must be active, tree-consistent → 422), readiness gate (publish 409 with missing-unit list), archive-vs-delete (assignments → 409, archive preserves refs), separate visibility/availability, reviewed unassignment (impact_reviewed + reason), batch schedule reschedule (records prior-attempts disposition, emits re-notification event to D), learning paths (sequenced, learner-or-group). Routes: browse auth-only + tree-scoped, writes under `rbac.can:course.manage`. Additive event `BatchAssessmentRescheduled` logged in README Contract Change Log. Catalog filter 64 passing / 0 failing; full suite 197 passing, 0 failing. |
| T-B-06 | ✅ present in current local integration (`8b2227a`) | local integration | Content items + media pipeline. Models: `ContentItem` (type video_tutorial/podcast/article; lifecycle status + media pipeline state; raw effectiveness counters for C→D), `ContentVersion` (append-only), `ContentSubtitle` (timed text, multi-language), `ContentAssignment` (course/unit/topic targets). Migrations `20260924_020201…020203` (content + versions, subtitles + assignments, `content.manage` RBAC seed). `ContentService`: per-type media policy (format/quality/size → 422 with SPECIFIC rejection reason), uploaded→processing→playable via additive queued event `ContentMediaUploaded` + own queued listener `ProcessContentMedia` (self-registered from the B-owned service — AppServiceProvider is A-owned), append-only versioning (replace/restore append rows; restore records `restored_from_version`; learners see latest), subtitle coverage vs supported set (missing flagged, never silent), active-target assignment (422 on retired nodes), reviewed unassignment (confirmation + reason), delete dependency check (409 on active assignments/unit attachment; archive = default retirement), raw counters only. `CatalogServiceStub::contentItem()` → real impl (frozen signature unchanged; full/sample/locked via AccessGate→SubscriptionService only). Routes: `POST /content/videos` (AC) + read/write groups, writes under `rbac.can:content.manage`. 17 new tests; Catalog filter 81 passing; full suite 214 passing, 0 failing. |
| T-B-07 | ✅ present in current local integration (`9a04311`, policy follow-up `e2caecc`) | local integration | Resource library (PDFs, worksheets, formula sheets, case studies, research, infographics) + flashcard decks + mind maps + FAQs. Models: `Resource` (append-only versioning same as T-B-06; placement to video/topic/subject; external-link validation state), `ResourceVersion`, `FlashcardDeck` (AI → draft hard rule; community → moderation states pending/approved/removed-with-reason; source video mark), `Flashcard` (front/back/image/audio), `MindMap` (per topic; clickable nodes with content links; color-coded by importance), `Faq` (per video; pending→approved; teacher_verified with verifier). Migrations `20260924_020301…020303` (resources+versions+decks+flashcards, mind_maps+faqs, `resource.manage` RBAC seed). `ResourceService`: entitlement-gated downloads via A's `SubscriptionService` only (DEPS⚡ T-A-23 → unentitled = 403; download policy type set), delete dependency check (409 for published/referenced resources; archive = default retirement), append-only versioning (replace/restore), external link validation (resolve + correct-target), broken-link job (flags dead links, never silent). `FlashcardService`: AI decks always created as draft (hard rule), human review is the ONLY path into the bank; community moderation (approve/remove-with-reason); student FAQs → pending → moderated → (optional) teacher-verified. Routes: `/resources/*` + `/flashcards/*`, writes under `rbac.can:resource.manage`; downloads auth + entitlement-gated; bank surface learner-facing, approved-only. 14 new tests; Catalog filter 95 passing; full suite 228 passing, 0 failing. |
| T-B-07 consumer follow-up | ✅ present in current local integration (`e2caecc`) | local integration | Download authorization preserves B's `SubscriptionService` gate, consumes A's `DownloadPolicyService::effectivePolicy()` and `consumeSuccessfulDownload()` before issuing a handle, enforces A's `ContentProtectionService` download block and active-license checks for content-linked resources, and records blocked attempts through A's audit contract. Added Catalog feature coverage for disabled/lapsed/capped policy, DRM block, and missing license. Focused suite 19 passed; Pint and `git diff --check` pass. |
| T-B-08 | ✅ present in current local integration (`19026e4`) | local integration | Question bank + assessment definitions (catalog DB). Models: `Question` (7 types; difficulty + Bloom; topic/subject tags; source manual|ai-with-video; status draft→approved, rejected recorded; duplicates flagged), `AssessmentQuestion` (active/retired attach), `Assessment` (formats incl. video_wise; passing score future-attempts-only; retake policy; adaptive opt-in floor/ceiling+calibration; exam pattern; grading config; high-stakes; attempt_count plain counter read-only for B), `AssessmentConfigVersion` (append-only audit), `RegenerationRequest` (video-wise prompt, not silent overwrite), `PastPaper` (subject/year/exam, availability-conditional, batch-scoped, memo). Migrations `20260924_020401…020404` (questions+assessments-questions, assessments+config-versions+regeneration, past_papers, `assessment.manage` RBAC seed). `QuestionService`: AI questions forced draft on create (hard rule); human review = ONLY path into bank; rejection recorded (status+reason) and never enters; duplicates flagged (422); attach only approved; multi-select full-marks rule documented. `AssessmentService`: passing-score change appends audit row + bumps version (FUTURE attempts only — recorded outcomes in C's learning DB never retrochanged); adaptive bounds floor≤ceiling (422 else); delete gate (attempts>0 → 409, retire only); video-wise auto-quiz → AI-draft questions (DEPS T-B-06); content update → pending regeneration PROMPT (regenerate/discard-with-reason); past papers availability-conditional + batch-scoped. `CatalogServiceStub::questionBank/questionsFor` → REAL (frozen signature unchanged; approved-only across the contract). Routes: `/assessments/*` writes under `rbac.can:assessment.manage`; question-bank read is auth-only approved-only. 18 new tests; Catalog filter 113 passing; full suite 246 passing, 0 failing. |
| T-B-09 | ✅ present in current local integration (`4bd27e7`) | local integration | Content approval workflow + moderation (catalog DB). Migrations 20260924_020501…020503 (review_items; content_flags + moderation_reports; content.review RBAC seed). review_items: pending → in_review → approved | rejected | flagged; approval recorded (reviewer + time + risk level); reassignment recorded (from + reason); risk by content type → workflow variant. Flags: marker on pending OR live content, type-specific required action, resolution MANDATORY (never left open). Community moderation: POST /moderation/reports intake → review → action / dismiss-with-reason → close (never left open); external_record_id plain cross-DB ref, no engagement join. Content NEVER auto-approved, NEVER live until approved — approve is the only path that flips video to published + emits the frozen ContentApproved; rejection definitive (409 on re-review), specific reasons (422), resubmission = new review item, creator notified via ADDITIVE ContentRejected event (D). Queue filters type/subject/creator/status + per-reviewer workload + queue aging metric → additive ReviewQueueAging event (D dashboard). Age-appropriate gate: age_rating × learner age band, enforced on the catalog learner read — CatalogService::contentItem gains an ADDITIVE ?int $learnerAgeBandMax param (frozen 1/2-arg call sites untouched); over-age content → null, not bypassable. Routes /review/* + /moderation/* under deny-by-default rbac.can:content.review. 15 new tests. Rebased 2026-09-24 onto origin/main (post T-B-10/T-B-11) — full suite 296 passing, 0 failing; branch is now fast-forward-mergeable. |
| T-B-10 | ✅ present in current local integration (`aa5f1b1`) | local integration | SRS platform configuration (catalog DB — C owns the learner SRS STATE in learning; B owns the platform CONFIG). Migrations 20260924_020601…020602 (9 SRS config tables + srs.manage RBAC seed). srs_schedule_configs (interval ladder + floor/cap + config_version) + per-grade-band overrides (OVERRIDE > GLOBAL — the override covering the learner age wins); ebbinghaus_curve_points (MUST decay monotonically: days strictly increasing, retention non-increasing — invalid combos 422; preview endpoint renders the curve from the SAME model); recall_adjustment_rules (rating map COMPLETE: too_easy/good/hard → exactly one schedule index, no compounding; max extension / min reset caps); srs_eligibilities (flashcard sets / quiz questions / videos — non-eligible content never enters SRS, excluded from C's due-queue); srs_concept_mappings (NO ORPHAN CONCEPTS — every mapping must resolve to an existing catalog topic, else 422); srs_resurfacing_rules (on_error/on_schedule/both, error-weighting never below floor, per-question cap); srs_video_revision_reminders (cap + skip-if-rewatched); srs_mastery_model_configs (level thresholds from recall rate — NO manual override; confidence ratings; at-risk threshold; graduation criteria/archive/re-entry/periodic re-check). Config changes apply to NEWLY SCHEDULED reviews only — SrsConfigService::snapshot() is what C snapshots at scheduling (DEPS⚡ T-C-08). Routes: GET /srs/snapshot + /srs/curve auth-only; writes under deny-by-default rbac.can:srs.manage. 14 new tests; Catalog filter 127 passing; full suite 260 passing, 0 failing. |
| T-B-11 | ✅ present in current local integration (`92566d3`) | local integration | Target-exam templates + AI feature configuration (catalog DB — C owns the INSTANCE in learning; B owns the platform CONFIG). Migrations `20260924_020701…020703` (exam types/templates/topic mappings/prediction configs + study-plan + AI feature configs + `exam.manage` RBAC seed). `ExamTemplateService`: exam types (duplicate (name,kind) 422; deleting a type with active students returns 422 via plain cross-DB existence check on C's `learning.target_exams` (read-only, defensive — never a join); disable always OK), VERSIONED templates (sections sum ≤ total 422; edit creates v(n+1), old version frozen/archived read-only — never edited in place), topic importance default chain (topic mapping → subject default → global medium), bulk mapping import with ROW-LEVEL reject report (versioned on live types). `PredictionConfigService`: override > global, versioned (in-use versions never deleted; ROLLBACK EXACT — re-activate the historical version verbatim), zero-width confidence interval 422, drift → additive queued event `PredictionAccuracyDrift` (→D), insufficient-data contract with C (below min_data_points → "not shown — prompt to practice", never a guess). `AiFeatureConfigService`: learn-before-practice ordering enforced (violations NEVER generated, 422), revision cycles clamped ≥ SRS minimum (same-DB read of B's own SRS floor), milestone triggers (fire once/plan — C enforces at runtime; parent notification flag → D), AI features (tutor/mentor/planner/companion/score-prediction; escalation: student-requested ALWAYS succeeds, low-confidence threshold, handoff context auto-generated, async-ticket fallback), planner distraction-free (suppressed notifications QUEUED + released on re-entry). Config changes NON-RETROACTIVE (config_version; C snapshots). Routes: `GET /exams/*` auth-only; writes under deny-by-default `rbac.can:exam.manage`. 21 new tests; Catalog filter 148 passing; full suite 281 passing, 0 failing. |
| T-B-12 | ✅ present in current local integration (`a410e82`) | local integration | Catalog coverage, gaps & retirement jobs (catalog DB). `CoverageService`: `topicCoverage` (course's subject-topic universe → content/assessment per topic; planted gaps VISIBLE, never silent), node coverage (board/subject), content-without-approval report (the T-B-09 gate made visible: uploaded-but-unapproved = gap, approved = not), questions-without-topic-tag report, orphan-resource report. `RetirementService`: content-update → dependent video_wise quiz PENDING regeneration prompt (event `ContentMediaUploaded` → listener `FlagContentUpdateRetirements`, idempotent per content version, quiz flagged never silently overwritten), retire-course (impact-reviewed unassignment of active course-target assignments, history preserved, idempotent), external-link validation job (dead/malformed links flagged `external_link_broken`, never silent), orphan detection. `CatalogServiceStub::topicCoverage/prebuiltMaps/sampleContent` → REAL (frozen signatures unchanged; `prebuiltMaps` published-only; `sampleContent` published+playable-only, 2-min preview bound + unlock message). Controller `Catalog/Coverage/CatalogCoverageController` + `Question::topicTagIds()` helper. Routes: `/coverage/*` auth-only reads; `/retirement/courses/{course}` under `rbac.can:content.manage`, `/retirement/external-links/validate` under `rbac.can:resource.manage`. 14 new tests (CoverageAndRetirementTest). **REGRESSION FIX (Laravel 13 event auto-discovery):** `ProcessContentMedia::handle` was untyped so Laravel 13's `app/Listeners/*` discovery skipped it and it relied on `ContentService::__construct` self-registration; its `hasListeners()` guard is order-sensitive (any other listener on `ContentMediaUploaded` — now `FlagContentUpdateRetirements` — suppressed it, leaving media stuck in `uploaded`). Type-hinted `ProcessContentMedia::handle(ContentMediaUploaded $event)` so it is auto-discovered at boot, independent of service-construction order; constructor registration kept only as the discovery-disabled fallback. Catalog filter 177 passing / 0 failing; **full suite 310 passing, 0 failing.** |
| T-B-13 | ✅ present in current local integration (`5e3cf68`) | local integration | `artisan catalog:seed` creates stable, idempotent curriculum, academic/professional courses, units, mixed-state/version content, 20 mixed-type/source/review questions, timed/adaptive assessments, flashcards, SRS config and target-exam config. `CatalogSeedCommandTest` runs it twice and proves counts/relationships and the `CatalogServiceStub::sampleContent` contract. C/D harnesses can invoke the public command after migration; their bootstrap files are outside Track B ownership and need adoption by those track owners. |

## T-A-25 integration — catalog export source

- `CatalogExportDataSource` is tagged through the B-owned provider loaded by
  `routes/api_catalog.php`, so A's export service discovers it through the
  normal API route bootstrap. The adapter emits only catalog rows with a
  direct owner relation: content/resource version metadata (`created_by`),
  community decks and their cards (`submitted_by`), and FAQ submission
  questions (`submitted_by`). FAQ answers and reviewer identities are omitted.
- Media object keys, bytes, transcripts, and card image/audio keys are never
  exported. Primary content/resource records, courses, curriculum, questions,
  assessments, and mind maps have no reliable author/owner relation in their
  current catalog schema and are intentionally excluded pending an explicit
  ownership contract. All reads use the catalog connection; there are no
  cross-database joins. Feature coverage exercises the real `/api/exports`
  flow and verifies user scoping and redaction.
