# SkinbaseNova — Multi-Category Artwork Classification **Milestone:** SN-CAT-001 **Status:** Implemented **Scope:** Categories, artwork classification, upload flow, Nova AI suggestions, API, SEO, migration, tests **Date:** 2026-09-20 --- ## 1. Objective Upgrade SkinbaseNova so that an artwork can belong to more than one **category** while preserving one unambiguous **primary category**. The final model must support: - exactly one content type per artwork; - one primary category where a category is required; - zero to five secondary categories by default; - all selected categories belonging to the same content type as the artwork; - existing canonical artwork URLs; - deterministic breadcrumbs and SEO; - category browsing through all memberships; - Nova AI category suggestions; - backward-compatible migration from the current single-category application behavior. This feature must not turn categories into tags. Categories remain a curated taxonomy with strong semantic meaning. Tags continue to provide fine-grained descriptors. --- ## 2. Current State The existing database is already partly prepared for multi-category artwork classification. ### Relevant tables #### `categories` The category taxonomy contains, among other fields: - `id` - `content_type_id` - `parent_id` - `name` - `slug` The taxonomy is scoped by content type. A category slug can therefore exist under different content types, for example: - `Wallpapers / Landscape` - `Photography / Landscape` These are distinct category records. #### `artwork_category` The existing pivot table already models an artwork-to-category many-to-many relationship. Conceptually: ```text artworks ↕ artwork_category ↕ categories ``` The composite primary key prevents duplicate memberships: ```sql PRIMARY KEY (artwork_id, category_id) ``` The current schema contains a foreign key for `category_id -> categories.id`, but the implementation should verify whether a matching FK exists for: ```text artwork_category.artwork_id -> artworks.id ``` If missing, it must be added with `ON DELETE CASCADE`. #### `uploads` The current upload staging model contains a single: ```text uploads.category_id ``` This reflects the current application's single-category behavior and must be adapted. #### `artwork_ai_assists` The existing AI-assist structure contains: ```text category_suggestions_json ``` This is already suitable for multiple ranked category suggestions and should be reused rather than replaced unless the current code proves otherwise. --- ## 3. Terminology Do not call categories "groups" in this implementation. Skinbase has separate concepts and terminology. Use: ```text Content Type Category Primary Category Secondary Category Tag ``` Example: ```text Artwork: Morning Coffee in Venice Content Type: Wallpapers Primary Category: Cityscape Secondary Categories: Architecture Travel Tags: Venice Italy Grand Canal coffee sunrise golden hour ``` --- ## 4. Product Rules ### 4.1 Content type An artwork belongs to one content type. Examples: - Wallpapers - Photography - Digital Art - Skins Multi-category classification must not implicitly make one artwork belong to multiple content types. ### 4.2 Primary category An artwork has at most one primary category. The primary category is used for: - breadcrumb generation; - primary classification in artwork UI; - default SEO/category context; - legacy single-category compatibility; - admin/editor display; - any place that requires one deterministic category. ### 4.3 Secondary categories An artwork may have multiple secondary categories. Default application rule: ```text 1 primary category 0–5 secondary categories ``` The exact maximum should be configurable rather than hard-coded throughout the application. Suggested config: ```php 'max_secondary_categories' => 5, ``` Per-content-type overrides may be introduced if the existing architecture already has an appropriate configuration mechanism. For example, the application may eventually choose stricter rules for skin formats than for wallpapers or photography. ### 4.4 Same-content-type invariant Every selected category must belong to the same content type as the artwork. Valid: ```text Content Type: Wallpapers Primary: Wallpapers / Cityscape Secondary: Wallpapers / Architecture Wallpapers / Sci-Fi ``` Invalid: ```text Content Type: Wallpapers Primary: Wallpapers / Cityscape Secondary: Photography / Landscape Digital Art / 3D ``` This invariant must be enforced server-side. Never rely only on frontend filtering. ### 4.5 Categories are not tags Do not permit arbitrary category spam. Categories are broad curated browse/classification signals. Tags remain the detailed semantic descriptors. A category must not be automatically created merely because an AI suggestion contains an unknown label. --- ## 5. Target Data Model ### 5.1 `artworks.primary_category_id` Add a nullable primary category reference: ```sql ALTER TABLE artworks ADD COLUMN primary_category_id BIGINT UNSIGNED NULL; ``` Add an indexed FK to `categories.id`. Expected behavior: ```text artworks.primary_category_id -> categories.id ``` Recommended deletion behavior: - prefer `ON DELETE SET NULL` for `artworks.primary_category_id`; - category deletion logic should separately remove the pivot membership through the existing pivot FK/cascade behavior. The implementation must follow existing project migration conventions. ### Why store the primary category on `artworks`? The pivot remains the canonical set of memberships, while `primary_category_id` gives the application a deterministic and efficient answer to: > Which category represents this artwork in a single-category context? This avoids ordering-dependent behavior such as: ```php $artwork->categories->first() ``` which is not a valid definition of "primary". ### 5.2 `artwork_category` Keep the existing many-to-many table. Add or verify: ```text FK artwork_id -> artworks.id ON DELETE CASCADE FK category_id -> categories.id ON DELETE CASCADE ``` Retain the composite uniqueness: ```text PRIMARY KEY (artwork_id, category_id) ``` Consider adding metadata if it is consistent with existing project conventions: ```text source confidence created_at updated_at ``` Suggested semantics: ```text source: user ai moderator system migration ``` `confidence` should be nullable and should only have meaning for machine-generated or machine-assisted classification. Do not add unnecessary metadata if the current application already stores equivalent provenance elsewhere. ### 5.3 Membership invariant If: ```text artworks.primary_category_id = X ``` then the row: ```text (artwork_id, X) ``` must exist in `artwork_category`. All writes must preserve this invariant. ### 5.4 Legacy `category_source` If `artworks.category_source` currently describes the single selected category, do not remove it in the first deployment unless all usages have been audited. Prefer a staged migration: 1. support the new model; 2. migrate provenance to the new relation if appropriate; 3. update all readers/writers; 4. deprecate the legacy field; 5. remove it only in a later cleanup migration. --- ## 6. Central Domain Service Do not distribute category synchronization logic across controllers, jobs and model observers. Create or extend one domain/service-layer entry point, for example: ```php ArtworkCategoryService ``` Suggested operation: ```php sync( Artwork $artwork, int $primaryCategoryId, array $secondaryCategoryIds = [], ?string $source = null ): void ``` The exact class/signature should follow current SkinbaseNova architecture. ### The service must 1. load all requested categories; 2. reject nonexistent IDs; 3. remove duplicates; 4. verify the primary category is included exactly once; 5. validate the category content type against the artwork; 6. enforce the configured secondary-category limit; 7. update the pivot and `primary_category_id` in one DB transaction; 8. preserve or update provenance metadata correctly; 9. leave the artwork in a valid state if any operation fails; 10. emit existing domain events/cache invalidation/search-index updates if required by the codebase. Controllers, upload finalization, admin editing, imports and AI acceptance should call this service rather than implementing their own sync behavior. --- ## 7. Eloquent Model Changes Audit the existing models before changing them. The final `Artwork` model should conceptually support: ```php public function categories(): BelongsToMany { return $this->belongsToMany(Category::class); } public function primaryCategory(): BelongsTo { return $this->belongsTo(Category::class, 'primary_category_id'); } ``` The exact pivot configuration must match the existing schema. Audit for problematic patterns such as: ```php $artwork->categories()->first(); $artwork->categories->first(); sync([$categoryId]); firstOrFail(); ``` where the code assumes there can only be one category. Replace such assumptions only where they are category-related; do not make unrelated refactors. --- ## 8. Upload Flow ### 8.1 UX The upload/edit UI should present: ```text Primary category [ single select ] Additional categories [ multi-select, max 3 ] ``` Requirements: - secondary selection excludes the primary category; - only categories from the selected content type are available; - changing content type must revalidate/clear incompatible selections; - duplicate selections are impossible; - the UI displays the configured maximum; - server validation remains authoritative. Suggested copy: ```text Primary category Choose the category that best represents the artwork. Additional categories Optional. Add up to 5 other relevant categories. ``` ### 8.2 Upload staging schema Audit how the current `uploads.category_id` is used before changing it. Preferred target model: ```text uploads.primary_category_id ``` plus either: ```text upload_category --------------- upload_id category_id ``` or another existing staging mechanism that cleanly supports multiple category IDs. Do not introduce a second competing staging architecture if the project already has reusable draft metadata storage. ### 8.3 Safe migration strategy A staged approach is acceptable: **Phase A** - existing `uploads.category_id` continues to mean primary category; - add secondary-category staging support; - update finalization to use the central category service. **Phase B** - rename/deprecate `uploads.category_id` to `primary_category_id` if appropriate; - remove compatibility code after all writers/readers are migrated. Prefer deployability and rollback safety over a large destructive migration. --- ## 9. Nova AI Category Suggestions Reuse the existing AI-assist category suggestion facility where possible. Suggested normalized suggestion payload: ```json [ { "category_id": 123, "name": "Cityscape", "confidence": 0.96 }, { "category_id": 456, "name": "Architecture", "confidence": 0.89 } ] ``` The implementation should prefer IDs from the known taxonomy rather than free-form names. ### AI rules Nova may: - rank existing categories; - suggest a likely primary category; - suggest secondary categories; - show confidence where available. Nova must not: - create new categories automatically; - silently replace a user's confirmed primary category; - attach categories from another content type; - exceed the configured maximum. Initial rollout should require explicit user/editor confirmation for AI category changes. Future auto-accept behavior can be added separately after measuring accuracy. --- ## 10. API Contract Audit current endpoints and serializers/resources. The API should expose both the deterministic primary category and all category memberships. Conceptual response: ```json { "primary_category": { "id": 10, "name": "Cityscape", "slug": "cityscape" }, "categories": [ { "id": 10, "name": "Cityscape", "slug": "cityscape", "is_primary": true }, { "id": 12, "name": "Architecture", "slug": "architecture", "is_primary": false } ] } ``` If the current public API has a singular legacy `category` property: - keep it backward-compatible; - map it to the primary category; - add the plural structure additively; - document deprecation only if the project intends to remove the singular field later. For create/update operations, prefer an explicit contract such as: ```json { "primary_category_id": 10, "secondary_category_ids": [12, 18] } ``` This is clearer than relying on array order. --- ## 11. Category Pages and Browsing A category page such as: ```text /wallpapers/cityscape ``` must include every artwork attached to that category, regardless of whether it is the artwork's primary or secondary category. Conceptually query through: ```text artwork_category ``` not only: ```text artworks.primary_category_id ``` This is the main discovery benefit of the feature. Review: - pagination; - category counts; - sorting; - cache keys; - search indexing; - sitemap generation; - related-artwork queries; - category statistics. Any category count that currently assumes one category per artwork must be audited. --- ## 12. Artwork Page, Breadcrumbs and SEO ### 12.1 Canonical artwork URL Do not change the canonical artwork URL. Keep the current model: ```text /art/{id}/{slug} ``` An artwork must not gain a different canonical URL for every category. ### 12.2 Breadcrumb Use the primary category only. Example: ```text Home → Wallpapers → Cityscape → Morning Coffee in Venice ``` Do not select the breadcrumb category by pivot ordering. ### 12.3 Category display Artwork UI may display: ```text Category Cityscape Also in Architecture Travel ``` or a combined component where the primary category is visibly distinguished. ### 12.4 Structured data If the project emits `BreadcrumbList` or other structured data: - use the primary category path; - preserve one stable breadcrumb path; - avoid multiple competing breadcrumb definitions for the same artwork. ### 12.5 Internal links Secondary category links are beneficial discovery links, but they must all point to their normal category pages. All category pages must link back to the same canonical artwork URL. --- ## 13. Parent Categories The schema supports `categories.parent_id`. Do not automatically duplicate membership into ancestor categories unless the existing taxonomy explicitly requires this. Example: ```text Nature └── Flowers ``` If the artwork is directly classified as: ```text Flowers ``` store the direct membership once. If the `Nature` category page is intended to include descendants, compute or query descendant membership rather than writing redundant pivot rows. Audit current category hierarchy behavior before changing it. --- ## 14. Existing Data Migration The migration must not make arbitrary semantic choices silently. ### 14.1 Preflight report Before backfilling `primary_category_id`, inspect current production data. Required report: ```sql SELECT artwork_id, COUNT(*) AS category_count FROM artwork_category GROUP BY artwork_id HAVING COUNT(*) > 1 ORDER BY category_count DESC; ``` Distribution: ```sql SELECT category_count, COUNT(*) AS artworks FROM ( SELECT artwork_id, COUNT(*) AS category_count FROM artwork_category GROUP BY artwork_id ) x GROUP BY category_count ORDER BY category_count; ``` Also report artworks with no category if category membership is expected for their content type. ### 14.2 Backfill rules For artwork with exactly one category: ```text primary_category_id = that category ``` For artwork with zero categories: ```text primary_category_id = NULL ``` and include it in an audit report if the content type normally requires a category. For artwork with more than one category: - do not silently use `MIN(category_id)`, `first()` or database row order as semantic truth; - first inspect whether existing application data provides a deterministic primary source; - if no reliable source exists, produce an actionable report for explicit resolution. An Artisan command is preferred for migration/preflight because it can support dry-run and reporting. Suggested capabilities: ```text --dry-run --report --apply ``` Follow the project's command naming conventions. ### 14.3 Idempotency The migration/backfill process must be safe to run more than once. Never overwrite an already-confirmed primary category unless explicitly requested. --- ## 15. Database Integrity Verify/add appropriate indexes. At minimum: ```text artworks.primary_category_id artwork_category (artwork_id, category_id) PK/unique artwork_category category lookup index if not already covered ``` Verify both pivot FKs. Application-level validation must enforce: - primary is a member of the pivot; - same content type; - category-count limit. If project conventions use database constraints or triggers for these invariants, evaluate them, but avoid introducing database-specific complexity without a strong reason. --- ## 16. Search and Recommendation Systems Audit all consumers of artwork categories. Potential consumers include: - full-text/search indexing; - Meilisearch/Elasticsearch/OpenSearch if present; - recommendation scoring; - related artworks; - Nova embeddings/classification; - feeds; - filters; - analytics; - category statistics. Suggested representation in a search document: ```json { "primary_category_id": 10, "category_ids": [10, 12, 18] } ``` Recommendation systems may weight primary and secondary membership differently, for example: ```text primary category: strong signal secondary category: medium signal tags: fine-grained signal embeddings: semantic similarity signal ``` Do not introduce arbitrary scoring changes in this milestone unless the existing recommendation architecture clearly needs them. Make the data available first. --- ## 17. Admin / Moderation Admin/editor UI should clearly show: ```text Primary Secondary Source AI confidence ``` where those metadata exist. Editors must be able to: - replace the primary category; - add/remove secondary categories; - promote a secondary category to primary; - see incompatible selections rejected; - save everything transactionally. When promoting a secondary category to primary, the previous primary may remain as a secondary category unless the editor removes it. --- ## 18. Caching and Invalidation Audit cached data related to: - artwork detail; - category pages; - category artwork counts; - breadcrumbs; - related artwork; - search documents. Changing category membership must invalidate/rebuild all affected representations. A change from: ```text Cityscape ``` to: ```text Primary: Architecture Secondary: Cityscape ``` must invalidate both relevant category views as well as the artwork detail/breadcrumb cache. Use existing cache/event patterns rather than creating a parallel mechanism. --- ## 19. Authorization and Security The new fields must use the same authorization rules as the current category edit action. Do not allow users to assign: - inaccessible/private taxonomy entries; - disabled categories; - categories belonging to another content type; - more than the allowed maximum. Validate IDs server-side. Avoid mass-assignment of unvalidated category arrays. --- ## 20. Tests Add tests at the level appropriate to the current project. ### Database / model - artwork can have multiple categories; - duplicate pivot membership is rejected/prevented; - deleting an artwork removes pivot rows; - deleting a category handles pivot rows and primary reference safely; - primary category relationship works. ### Domain service - one primary only; - primary is always in the pivot; - secondary categories sync correctly; - duplicate IDs are normalized/rejected consistently; - category from another content type is rejected; - more than configured secondary categories is rejected; - transaction rolls back on failure; - source/confidence metadata is preserved if implemented. ### API - legacy singular category still maps to primary where required; - plural categories are returned; - create/update accepts primary + secondaries; - invalid cross-content-type categories return validation errors; - maximum is enforced. ### Upload - primary category selection works; - multiple secondary categories persist through staging/finalization; - changing content type invalidates incompatible categories; - upload finalization uses central domain service. ### Category pages - artwork appears on its primary category page; - artwork also appears on secondary category pages; - pagination/counts do not duplicate the same artwork; - category URL behavior remains unchanged. ### Artwork page / SEO - canonical artwork URL remains unchanged; - breadcrumb uses the primary category; - changing secondary categories does not change breadcrumb; - promoting a secondary category updates breadcrumb; - structured data uses the primary path. ### AI assist - suggestions are limited to known categories; - suggestions are filtered to matching content type; - AI cannot exceed the maximum; - confirmed user selection is not silently overwritten. ### Migration - zero-category artwork is handled; - one-category artwork is backfilled deterministically; - multi-category legacy artwork is reported instead of arbitrarily resolved; - rerunning backfill is idempotent. --- ## 21. Backward Compatibility This implementation must not break existing public artwork/category URLs. Do not change: ```text /art/{id}/{slug} ``` Do not change existing category route structure merely for this feature. If legacy API/UI code expects a singular category, map that concept to: ```text primary_category ``` during the compatibility period. Prefer additive schema/API changes first, cleanup later. --- ## 22. Observability / Audit During rollout, log or report: - artwork with no primary category where one is required; - primary category missing from pivot; - cross-content-type memberships; - artwork exceeding the configured category limit; - migration conflicts; - failed AI suggestion mappings. If the project already has health/audit commands, integrate with them rather than creating unnecessary infrastructure. --- ## 23. Rollout Plan ### Phase 1 — Audit - inspect current code paths; - inspect current data distribution; - identify all single-category assumptions; - confirm missing/existing foreign keys; - confirm API compatibility requirements. ### Phase 2 — Additive database changes - add `artworks.primary_category_id`; - add/verify pivot integrity; - add pivot metadata only if needed; - add upload staging support. ### Phase 3 — Domain logic - centralize category sync; - update models; - update validation; - update events/cache/search integration. ### Phase 4 — Backfill - run dry-run report; - automatically backfill artworks with exactly one category; - explicitly resolve any legacy multi-category conflicts; - verify invariants. ### Phase 5 — UI/API - upload editor; - artwork editor/admin; - API resources; - artwork display; - category pages; - breadcrumb/SEO. ### Phase 6 — Nova integration - reuse ranked category suggestions; - support primary + secondary acceptance; - preserve explicit user control. ### Phase 7 — Cleanup Only after production verification: - remove obsolete single-category assumptions; - deprecate/remove legacy fields where safe; - update documentation. --- ## 24. Acceptance Criteria The milestone is complete only when all of the following are true: - [ ] One artwork can belong to multiple categories. - [ ] Exactly one selected category can be marked as primary. - [ ] Primary category is always present in `artwork_category`. - [ ] Secondary category limit is enforced server-side. - [ ] Cross-content-type category assignment is impossible. - [ ] Existing artwork URLs remain unchanged. - [ ] Breadcrumb uses only the primary category. - [ ] Artwork is discoverable through every assigned category page. - [ ] Category pages do not duplicate an artwork. - [ ] Existing single-category API behavior remains compatible where required. - [ ] Upload/edit flow supports primary + secondary categories. - [ ] Nova category suggestions work with the new model. - [ ] Existing data can be migrated without arbitrary silent primary selection. - [ ] Missing `artwork_category.artwork_id` FK is fixed if confirmed absent. - [ ] Cache/search/index consumers are updated. - [ ] Automated tests cover the new invariants. - [ ] No unrelated refactors are included. - [ ] Documentation reflects the final implementation. --- ## 25. Definition of Done Before declaring the milestone finished: 1. run the complete relevant automated test suite; 2. run lint/static analysis/type checks used by the repository; 3. run the migration/backfill in dry-run mode against representative data; 4. verify at least one artwork with one primary and two secondary categories; 5. verify that the artwork appears on all three category pages; 6. verify its canonical URL is unchanged; 7. verify breadcrumb follows only the primary category; 8. verify a cross-content-type assignment is rejected; 9. verify category deletion/artwork deletion preserves DB integrity; 10. document migration evidence, test results and any compatibility decisions. Do not mark the feature complete if the database supports multiple categories but any main write path still collapses them back to one.