# Product catalog foundation (Phase 2A.1)

`apps.catalog` owns global product classification and definitions. It is a normal
Django app in the modular monolith, independent of organizational ownership and
business access rules. It reuses only the existing abstract TimeStampedModel;
its small CatalogRecord base is local to this app.

## Records and hierarchy

Brand and ProductCategory each classify ProductModel. ProductVariant belongs to
exactly one ProductModel. All four records have UUID v4 identity, created_at,
updated_at, is_active (default True), code, name and optional description.
Codes are at most 64 characters; names are at most 200 characters. Codes are
trimmed and uppercased; names and descriptions are not normalized by model saves.
Codes start with an ASCII letter/digit and otherwise use letters, digits, hyphens
or underscores. Ordinary saves validate required fields and these rules.

| Record | Meaning | Code uniqueness |
| --- | --- | --- |
| Brand | Brand definition, for example HONOR | Global |
| ProductCategory | Classification, for example SMARTPHONE | Global |
| ProductModel | Product definition, for example X9C / HONOR X9c | Within Brand |
| ProductVariant | Model configuration, for example 8-256 / 8GB + 256GB | Within ProductModel |

Examples are documentation only; no catalog data or roles are seeded. Every FK
uses PROTECT. Referential identity is UUID, so validated code correction is
allowed. Inactive records retain their codes and continue to reserve uniqueness.

## Activation and lifecycle

An active model requires active Brand and Category. An active variant requires
active Model, Brand and Category. Inactive children may remain under inactive
parents. No save() silently cascades: direct deactivation with active descendants
raises ValidationError and directs callers to a lifecycle service.

```python
from apps.catalog import services

services.deactivate_brand(brand=brand)
services.deactivate_category(category=category)
services.deactivate_product_model(product_model=model)
services.deactivate_variant(variant=variant)

services.reactivate_brand(brand=brand)
services.reactivate_category(category=category)
services.reactivate_product_model(product_model=model)
services.reactivate_variant(variant=variant)
```

Brand deactivation ends its models and their variants. Category deactivation ends
all models classified in it, across brands, and their variants. Model deactivation
ends its variants; variant deactivation affects only itself. Each operation is
atomic, timestamped and retains records. Unrelated branches remain unchanged.
Already inactive records retain their timestamps during deactivation cascades.

Reactivation affects only the selected record, revalidates current parent state,
and never restores descendants. Reactivate parents first, then models, then
variants deliberately. Services operate on persisted instances, reload database
state and ignore unsaved unrelated edits on the supplied object. A repeated
reactivation may update updated_at even if the record was already active.

## Ownership and category correction

A model cannot change Brand after creation. A variant cannot change ProductModel.
Both rules are validated on full/partial saves and readonly in admin. Codes may
be corrected independently of UUID relationships.

Category correction is supported through validated ProductModel.save() and the
admin form, including when catalog variants already exist. An active model can
only move to an active category; its variants remain attached to the model. An
inactive model may move to an inactive category. No transaction/device dependencies
exist yet. Future domains must review and constrain this policy before introducing
historically significant transactional references; no reassignment framework is
provided here.

## Validation and write boundary

PostgreSQL CHECK constraints enforce canonical codes. Unique constraints enforce
global Brand/Category codes, (brand, code) and (product_model, code). Foreign keys
protect referenced records. Cross-table activation and stable ownership rules are
model/form/service validation, not cross-table SQL CHECK constraints.

Normal objects.create(), save() and admin forms validate. update_fields saves also
validate the effective persisted record so unrelated in-memory changes cannot
hide an invalid partial write. Stale instances should be refreshed before editing.
Small objects.active() queryset helpers are provided for every model.

QuerySet.update(), bulk_create(), bulk_update(), raw SQL and fixture loading bypass
cross-table validation and locking and are unsupported for ordinary domain writes.
The only production bulk writes in catalog are deliberate, scoped lifecycle
updates inside the locking protocol, with explicit timestamps. Tests intentionally
use raw updates/bulk inserts to verify database protections and corrupt-parent
rejection. These records are not an immutable audit log or a soft-delete framework;
unreferenced records can be deleted, while protected references prevent cascaded
master-data deletion. Deactivation is the normal lifecycle operation.

## Transactions and concurrency

The protocol targets the existing single default PostgreSQL database with Read
Committed. No organization/company locks are used.

- ProductModel.save takes Brand FOR SHARE, then proposed Category FOR SHARE, then
  its own existing model row FOR UPDATE, before fresh validation/persistence.
  Different models can share a Brand and Category without exclusive serialization.
- ProductVariant.save takes its model FOR SHARE and then its own existing variant
  row FOR UPDATE. Ancestor cascades must lock that model exclusively before
  touching variants, so variant creation/activation needs no brand-wide lock.
- Brand/Category writes lock their own row FOR UPDATE. Deactivation services lock
  that root, then all affected model rows FOR UPDATE in UUID order, then update
  active variants/models and the root in one transaction. Including inactive models
  makes the cascade robust to active descendants introduced by unsupported writes.
- ProductModel deactivation locks the model FOR UPDATE before updating variants.
  Variant deactivation locks only that variant. These downward-only operations
  do not reacquire ancestor locks. Reactivation uses the validated save protocol.
- Parent cascades cannot finish while a child creation/update holds a conflicting
  parent/model lock. Writes waiting on a deactivation validate fresh state and
  reject invalid activation. Destination Category share locks coordinate category
  correction with Category deactivation.

Fifteen PostgreSQL concurrency tests use separate connections and observable
pg_blocking_pids() waits, rather than timing-only assumptions. They cover Brand
and Category deactivation versus model creation/reactivation, model deactivation
versus variant creation/reactivation, reverse creation/deactivation order,
ancestor cascades versus variant creation, overlapping Brand/Category cascades,
compatible distinct-model writers and category reclassification in both orders
against destination deactivation. Injected-failure tests verify cascade rollback.
These tests do not establish formal deadlock freedom. Caller-composed multi-step
transactions and additional pre-acquired locks require their own ordering review.

## Administration and authorization

All four models have admin lists, search, active filters, stable ordering and
relationship visibility/autocomplete where applicable. Existing is_active fields
are readonly: use the change-permission-gated deactivate/reactivate actions.
Creation allows selecting initial active/inactive state with normal validation.
The actions invoke services and report validation errors; each selected record is
one atomic operation, not a single transaction across the whole batch. UUIDs and
timestamps are readonly. Standard Django delete permissions and PROTECT apply.

Django generates four normal add/change/delete/view permissions per model. The
existing Role permission selector can consume them, but no roles or grants are
seeded. Admin retains native staff/model-permission behavior.

Catalog has no Company FK or object-level organizational adapter. The Phase 1B
authorization engine remains unchanged and denies catalog targets, including for
superusers, because they are unsupported target types. Native Django admin
permissions operate normally. Future application views must deliberately choose
their catalog permission policy; scoped service transactions remain independently
organization-scoped. Do not treat global catalog records as company-owned objects.

## Domain boundaries

ProductModel is a definition; ProductVariant is its configuration. Neither is a
physical customer device. No IMEI, serial number, customer, purchase or warranty
fields exist. Catalog is also not inventory: there is no quantity, stock, warehouse,
price or cost. There are no service workflow fields, color/SKU/barcode extensions,
custom UI, API, tasks, notifications or reports. Physical-device records remain future work.

## Migration and verification

`catalog.0001_initial` creates only the four tables, protected relations and
constraints. It has no migration dependency on the reused abstract timestamp base
because its fields are materialized in this migration. It contains no RunPython
or data seed. Existing accounts/organization/access migrations are unchanged.

Commands use the existing virtual-environment Python:

```text
python manage.py check
python manage.py makemigrations --check
python manage.py migrate
python manage.py showmigrations catalog
python manage.py test --noinput
```

System checks passed, model/migration state is clean, and catalog.0001_initial is
applied. Initial catalog run: 38 tests passed; four additional concurrency cases
were then added for full regression verification. Final suite result: 284 total,
284 passed, 0 failed, 0 skipped (93.913 seconds). All original 242 tests passed
unchanged, plus 42 catalog tests including 15 PostgreSQL concurrency tests.
No production catalog rows were seeded; all four catalog tables were empty at
verification. No secrets, environment files or generated artifacts were added.


## Device identification policy (Phase 2A.2)

DeviceIdentificationPolicy is optional one-to-one ProductModel configuration with
UUID identity and created_at/updated_at. It defines only the requirement states for
IMEI1, IMEI2 and Serial Number. It stores no actual identifier values. No category
code or variant attribute determines these requirements. No identifier syntax,
normalization, Luhn check, uniqueness, device lookup or service intake is implemented.

Each field uses DeviceIdentificationPolicy.Requirement with these explicit choices:

| State | Meaning for future intake |
| --- | --- |
| NOT_APPLICABLE | This identifier is not expected for the model. |
| OPTIONAL | It may be captured when available. |
| REQUIRED | It must eventually be supplied, subject to future intake exception rules. |

There are no field defaults. Creation requires a deliberate choice for all three
states, including when choosing NOT_APPLICABLE for every identifier. The IMEI2 rule
is: IMEI2 OPTIONAL or REQUIRED requires IMEI1 OPTIONAL or REQUIRED. IMEI1 OPTIONAL
with IMEI2 REQUIRED is allowed; the rule does not imply that IMEI1 must be REQUIRED.
Serial is independent. All 27 combinations are tested through validated writes and
direct database writes; the six combinations violating this dependency are rejected.

### Unconfigured versus explicitly no identifiers

No policy row means UNCONFIGURED: requirements have not been decided. Existing
ProductModels remain valid without a policy and the migration does not seed one.
A policy whose three states are NOT_APPLICABLE means CONFIGURED with explicitly
no supported identifiers expected. A policy with OPTIONAL fields also has no
mandatory identifiers, but permits capturing those optional types. Do not collapse
these distinctions or invent a default policy for future intake.

### Public service and query API

Both functions are in apps.catalog.identification:

```python
set_identification_policy(
    *, product_model, imei1_requirement, imei2_requirement, serial_requirement
)
get_identification_policy(product_model)
```

The write API creates the first policy or replaces all three states on the existing
policy. It returns the saved DeviceIdentificationPolicy, retains its UUID and
created_at on updates, and updates updated_at. Invalid writes raise ValidationError
and leave the prior row unchanged; failed first writes create nothing.

The read API returns the current DeviceIdentificationPolicy or None, where None
means UNCONFIGURED. An explicit all-NOT_APPLICABLE configuration is returned as a
real policy object. It reads fresh data rather than the ProductModel reverse-relation
cache (two queries: model existence, then policy). Both helpers require a saved
ProductModel from the default database, otherwise ValueError; a model deleted since
loading raises ProductModel.DoesNotExist. This is a configuration snapshot, not a
transactional guarantee for a later intake operation.

### Ownership, editing, lifecycle and deletion

A policy cannot move to another ProductModel through supported saves, including
partial saves. Correct the requirements on the appropriate model instead. The
policy has no independent is_active flag and can be configured or edited while its
model is inactive. Brand/Category/Model deactivation preserves the policy and its
requirement values/timestamps. Deliberate model reactivation makes the retained
configuration available without restoring variants. Existing catalog services are
unchanged.

The ProductModel FK uses PROTECT: deleting a referenced model cannot silently delete
its policy. Policy deletion itself is allowed with native delete permission and
returns the model to UNCONFIGURED. There is no policy versioning, soft deletion or
configuration audit trail. Once devices/transactions exist, policy edits/deletion
will need a compatibility and audit decision; none is imposed prematurely here.

### Constraints and concurrency

The one-to-one database uniqueness protects at most one policy per model. Three
CHECK constraints restrict enum values and catalog_policy_imei2_needs_imei1 enforces
the IMEI dependency. Stable ownership is application validation, not a database
cross-row CHECK. Normal saves run full_clean and partial saves validate the effective
persisted combination. Raw/bulk writes remain unsupported for ordinary changes;
they can bypass stable ownership, while enum/IMEI/uniqueness/FK constraints remain.

The service holds a ProductModel FOR UPDATE lock inside transaction.atomic before
looking up or creating the policy. Policy.save uses the same stable parent lock,
then locks the existing policy row, validates and writes. Lock order is always
model then policy for these supported writes. There is no missing-row pre-check
race. Competing service calls serialize and the later lock holder replaces the
three states; this does not promise FIFO scheduling or prevent intentional
last-writer replacement. Plain model/admin creation remains strict creation:
a duplicate is rejected, not silently treated as an update.

Four PostgreSQL concurrency tests verify first service creation, subsequent service
updates, raw duplicate insert uniqueness, and policy writing versus model
deactivation. Tests use separate connections and pg_blocking_pids to observe waits.
The raw competing insert receives IntegrityError; valid serialized service calls
both succeed with exactly one policy. Policy edits take no Brand/Category locks
and do not change existing lifecycle lock ordering. This covers the default
PostgreSQL/Read Committed setup, not arbitrary multi-operation transactions or a
formal deadlock-freedom guarantee.

### Native administration and permissions

A dedicated DeviceIdentificationPolicy admin displays ProductModel and all three
requirements, with search, filters and model autocomplete. ProductModel is readonly
on existing policies; UUID/timestamps are readonly. Native model forms and the
validated, locking model.save path enforce enum/dependency/uniqueness/ownership.
The upsert service is a convenience for callers, not an exclusive correctness
boundary: admin intentionally uses strict create/update semantics so add permission
cannot silently overwrite an existing policy. Server-side and database checks are
authoritative. Invalid forms and forged re-parenting are covered by admin tests.

Django generates normal model permissions. No Roles, grants or policies are seeded.
Catalog remains global; no organization scope adapter or frozen authorization
semantics change was made.

### Migration and verification

catalog.0002_deviceidentificationpolicy depends only on catalog.0001_initial and
creates the policy table, one-to-one FK and checks. It contains no data migration
and does not change existing catalog rows. The initial catalog migration and all
Phase 1 migrations remain unchanged. Migration was inspected and applied.

Targeted run: 27 identification tests passed, including four PostgreSQL concurrency
tests. Full suite result: 311 total, 311 passed, 0 failed, 0 skipped (110.084
seconds), including all original 284 tests unchanged. System checks and
makemigrations --check passed;
showmigrations catalog lists both migrations applied.
