# Service and technical taxonomy (Phases 2A.3-2A.4)

`apps.service_catalog` owns global service classification vocabulary. ServiceCategory
answers what kind of service request this is; ComplaintSymptom records a
customer-reported or intake-observed problem. Neither represents a technician's
confirmed fault/diagnosis, root cause, or a repair action. The independent
technical masters below preserve these distinctions for operational meaning and analytics.

The app depends on catalog.ProductCategory for applicability and reuses the existing
abstract timestamp base. It does not modify catalog, organization or access logic.
It is not the future ServiceJob/intake transactional app. Taxonomy is flat: no
complaint hierarchy, synonyms, category-code branches or Company ownership.
ServiceCategory is independent; a complaint is not assigned to one service category.

## Master records

ServiceCategory and ComplaintSymptom have UUID v4 IDs, code, name, optional
description, is_active (default True), created_at and updated_at. Codes are trimmed,
uppercased, at most 64 characters, start with an ASCII letter/digit and allow only
letters/digits/hyphens/underscores. Names are required, at most 200 characters.
Code uniqueness is global within each table and remains reserved when inactive.
Codes may be deliberately corrected; relationships use UUIDs. Names/descriptions
are not case-normalized by model saves. Both managers expose objects.active().

ComplaintSymptom also has applies_to_all_product_categories, default False.
ComplaintSymptomProductCategory stores UUID, complaint_symptom, product_category
and timestamps, with unique (complaint_symptom, product_category). Both FKs use
PROTECT. It is explicit applicability configuration, not a technical finding,
transaction, independent active lifecycle or immutable history log. Endpoint changes
on an existing mapping are rejected: replace configuration through the service.

## Applicability semantics

| Complaint configuration | Meaning for new operational selections |
| --- | --- |
| Global=True, no mappings | Applies to every active ProductCategory if the complaint is active. |
| Global=False, explicit mappings | Applies only to mapped active categories if the complaint is active. |
| Global=False, no mappings | Applies nowhere; never interpreted as global. |
| Inactive complaint or inactive ProductCategory | No selectable applicability. |
| Global=True plus mappings | Invalid; supported writes reject it. Queries defensively return no results for this complaint if raw corruption creates it. |

Mapping an inactive category or configuring an inactive complaint is permitted.
This is stored configuration, not permission to offer inactive entries for intake.
No behavior is inferred from product/service category codes. Examples from business
vocabulary are illustrative only; no examples are seeded into the database.

## Public services and query API

```python
from apps.service_catalog.services import (
    set_complaint_applicability,
    deactivate_service_category, reactivate_service_category,
    deactivate_complaint, reactivate_complaint,
)
from apps.service_catalog.queries import (
    complaint_applies_to_category, applicable_complaints_for_category,
)

set_complaint_applicability(
    *, complaint, applies_to_all_product_categories, product_categories
)
deactivate_service_category(*, service_category)
reactivate_service_category(*, service_category)
deactivate_complaint(*, complaint)
reactivate_complaint(*, complaint)

complaint_applies_to_category(*, complaint, product_category)
applicable_complaints_for_category(product_category)
```

For example:

```python
from apps.service_catalog import services

services.set_complaint_applicability(
    complaint=complaint,
    applies_to_all_product_categories=False,
    product_categories=[category],
)
options = applicable_complaints_for_category(category)
```

The replacement service requires a saved default-database ComplaintSymptom, an
actual boolean, and an iterable of saved, existing ProductCategory objects. It
rejects global=True with a nonempty category selection. Duplicate inputs are
deduplicated. Invalid mode/category configuration raises ValidationError; an
unsaved/wrong complaint raises ValueError and a deleted complaint raises DoesNotExist.
It returns a freshly loaded/saved ComplaintSymptom, preserving unrelated pending
edits on the input object by not saving them. Retained mappings keep their UUIDs
and timestamps, removed mappings are deleted and newly selected mappings are created.
The complaint updated_at reflects the replacement operation.

Both query helpers read current stored state, not stale object flags. Invalid,
unsaved or missing records safely produce False/an empty queryset. The list helper
returns a lazy ComplaintSymptom queryset ordered by code then UUID; callers can
add normal filters. SQL Exists predicates evaluate active category, active complaint,
mode and mappings together without materializing IDs or duplicating complaints.
Building the queryset issues no query; evaluating it or the boolean helper takes
one query for valid saved inputs. There is no cache or hard-coded category logic.

These helpers are selection rules, not authorization. ServiceCategory.objects.active()
is the current helper for its independent selectable vocabulary. Future intake
must enforce appropriate permissions separately and coordinate selections/business
invariants within its own transaction. Query results are database snapshots, not a
promise that a category or complaint cannot change before a later write.

## Lifecycle and deletion

The four lifecycle services explicitly change the selected record's active flag
inside a transaction using fresh state. They never delete or add mappings.
Complaint deactivation stops new selection; reactivation makes the existing
configuration effective again if the mapped category is active. Reactivating a
restricted complaint without mappings still makes it apply nowhere.

ProductCategory lifecycle is unchanged. Deactivation may cascade to its existing
catalog models/variants as before, but does not delete complaint mappings. Query
helpers exclude the inactive category. Reactivation can make retained mappings
effective again. This is deliberate reuse of configuration, not automatic
reactivation of transactional history.

Referenced complaints and ProductCategories cannot be deleted while mappings
remain because their mapping FKs use PROTECT. Unreferenced masters may be deleted;
there is no generic soft delete. Configuration replacement can remove mappings.
Normal master-data lifecycle is deactivation. Future job history will need its own
reference protection and compatibility rules.

## Validation and concurrency

Database CHECK constraints protect canonical codes; unique constraints protect
master codes and mapping pairs. PostgreSQL foreign keys protect references. A
simple CHECK cannot inspect mapping rows to enforce the global/mapped contradiction.
That rule is enforced by supported model/form/service validation and locking.

Ordinary master save()/objects.create() calls run full_clean while holding the
existing master row FOR UPDATE. Partial saves validate the effective persisted
record as well as in-memory state. Directly setting a mapped complaint global
is rejected; it never secretly deletes mappings. Mapping saves lock their stable
complaint first and validate that it is restricted before writing. Model methods
have no automatic cross-record lifecycle effects.

set_complaint_applicability holds the complaint row FOR UPDATE inside one
transaction.atomic from category validation through removal, mode change and mapping
creation. Every applicability replacement and supported direct mapping creation
serializes on that same complaint. Other complaints can be configured concurrently.
Global/restricted transitions and mapping replacement roll back together on failure;
there is no clear-then-fail interval visible as a committed configuration. The later
lock holder's complete configuration wins; FIFO scheduling is not promised.

Category existence is validated and protected by FK integrity at persistence;
concurrent deletion can cause an error and rollback rather than a dangling mapping.
No Category/Company lifecycle locks are added, and no existing catalog code changes.
Normal map creation follows complaint -> mapping write order. Lifecycle for a
complaint takes the same complaint lock and preserves configuration.

Seven PostgreSQL concurrency tests use separate connections and pg_blocking_pids()
to establish expected waits: competing replacements, both global/restricted orders,
global update versus direct mapping creation in both orders, failed waiting update
rollback, and compatible updates to different complaints. Tests assert complete
final mapping sets rather than only their counts. These tests cover the existing
single default PostgreSQL database with Read Committed; they do not prove formal
deadlock freedom or cover arbitrary caller-composed transactions/pre-acquired locks.

Raw SQL, QuerySet.update(), bulk_create(), bulk_update(), fixtures and unsynchronized
raw mapping deletion bypass application checks/coordination. They are not supported
ordinary configuration writes. Database uniqueness/code/FK checks remain, but no
claim is made that the database alone prevents global-plus-mapping corruption.
Tests use low-level writes deliberately to attack these boundaries.

## Django Admin

Both master models have code/name/active displays, search, filters and ordering.
Existing activation flags are readonly; activate/deactivate actions call lifecycle
services and require native change permission. New records may be created active
or inactive. Actions commit each selected record separately, not the whole batch.

Complaint admin displays global/restricted mode and uses native form inputs for
global applicability and selected ProductCategories. The actual model global flag
is excluded from direct form assignment. Form validation rejects contradictory
inputs. After the ordinary master save, save_related invokes the applicability
service inside Django Admin's enclosing atomic change-form transaction. A later
service failure rolls back the master edit and mapping changes together. No direct
ManyToMany manager, editable mapping admin or inline bypass is provided.

Native model permissions are generated for both masters and the mapping model;
no roles or user grants are seeded. Applicability editing is part of ComplaintSymptom
administration and requires its native add/change permission. Global master-data
administration is not organizationally scoped. The Phase 1 authorization engine and
its supported target adapters remain unchanged.

## Migration and verification

service_catalog.0001_initial depends on catalog.0002_deviceidentificationpolicy and
creates only the three new tables, constraints and protective relationships. It
contains no RunPython/data seed. The generated migration was inspected before
application. All Phase 1 and catalog historical migrations remain unchanged.

```text
python manage.py check
python manage.py makemigrations --check
python manage.py migrate
python manage.py showmigrations
python manage.py test --noinput
```

System checks passed, no model/migration drift was detected, and the new migration
is applied. Targeted taxonomy run: 33 passed, 0 failed, 0 skipped, including seven
PostgreSQL concurrency tests. Full regression result: 344 total, 344 passed,
0 failed, 0 skipped (109.000 seconds). All original 311 tests remain unchanged
and passing. All three taxonomy tables were empty after migration; no production
taxonomy records, Roles or grants were seeded.

Phase 2A.3 introduced only intake vocabulary, its registration and documentation.
The Phase 2A.4 extension below adds technical definitions; Customer, Device,
ServiceJob, inventory, APIs and transactional functionality remain out of scope.


## Technical master definitions (Phase 2A.4)

| Concept | Meaning | What it does not store |
| --- | --- | --- |
| ComplaintSymptom | Customer-reported or intake-observed problem | Technician-confirmed findings |
| FaultDiagnosis | Vocabulary for technical conditions a technician can confirm | A diagnosis event, device, technician, date or notes |
| RootCause | Vocabulary for an underlying reason when known | A mandatory cause for every diagnosis |
| RepairAction | Vocabulary for technical actions that can be performed/recommended | Actual execution, completion, labor, duration or consumed parts |

The three new masters are independent tables in apps.service_catalog. They have
the same UUID, timestamps, canonical globally unique code, required name, optional
description, active flag and safe-default restricted applicability as the complaint
master. Code uniqueness is within each table; the same code can legitimately occur
in different vocabularies. All are global, not Company-owned. No values, including
UNKNOWN, are seeded or inferred from category codes.

There are no Complaint-to-Diagnosis, Diagnosis-to-RootCause or Diagnosis-to-RepairAction
relationships. Future service-event records will determine which findings, causes
and actions actually belong together. An unknown/unconfirmed cause can remain absent
at that future transaction layer without a fabricated master record. Suggested repair
recommendations, fault trees and job-level relationship designs are not implemented.

### Explicit relational applicability

Each master has applies_to_all_product_categories=False by default and its own
mapping table with UUID and timestamps:

- FaultDiagnosisProductCategory(fault_diagnosis, product_category)
- RootCauseProductCategory(root_cause, product_category)
- RepairActionProductCategory(repair_action, product_category)

All FKs use PROTECT, and each pair is unique at the database level. Mapping endpoints
are stable through validated writes; configuration replacement removes/adds mappings
as necessary. These tables are current configuration, not immutable historical events.

The same Phase 2A.3 semantics apply: global requires no explicit mappings; restricted
without mappings applies nowhere. Active master AND active ProductCategory are
required for selection. Stored mappings to inactive categories are permitted and
become effective again after deliberate reactivation. Raw contradictory global-plus-
mapping data fails closed in selection queries. No complaint behavior was broadened.

### Public service APIs

The following functions are in apps.service_catalog.services:

```text
set_fault_diagnosis_applicability(*, diagnosis, applies_to_all_product_categories, product_categories)
set_root_cause_applicability(*, root_cause, applies_to_all_product_categories, product_categories)
set_repair_action_applicability(*, repair_action, applies_to_all_product_categories, product_categories)

deactivate_fault_diagnosis(*, diagnosis)
reactivate_fault_diagnosis(*, diagnosis)
deactivate_root_cause(*, root_cause)
reactivate_root_cause(*, root_cause)
deactivate_repair_action(*, repair_action)
reactivate_repair_action(*, repair_action)
```

Applicability setters require the corresponding saved default-database master, an
actual boolean, and saved existing ProductCategory instances. They return the fresh
master. Invalid configuration raises ValidationError; an invalid/unsaved master
raises ValueError; a deleted master raises its DoesNotExist. Replacements retain
unchanged mapping identity/timestamps and atomically remove/add other selections.
The earlier set_complaint_applicability signature is unchanged.

Lifecycle uses explicit master-level atomic activation/deactivation. It retains all
mappings and changes no other master. Reactivation restores the stored configuration's
effect only where the category is also active and creates no mappings. Referenced
masters/categories cannot be deleted while mappings remain; normal lifecycle is
deactivation, without generic soft deletion.

### Public query APIs

The following functions are in apps.service_catalog.queries:

```text
fault_diagnosis_applies_to_category(*, diagnosis, product_category)
applicable_fault_diagnoses_for_category(product_category)
root_cause_applies_to_category(*, root_cause, product_category)
applicable_root_causes_for_category(product_category)
repair_action_applies_to_category(*, repair_action, product_category)
applicable_repair_actions_for_category(product_category)
```

Boolean checks use fresh stored state. List helpers return lazy querysets ordered by
code then UUID, supporting further caller filters. SQL Exists predicates keep active
state, mode and mappings in one statement and avoid duplicates. Invalid/unsaved/
missing inputs deny. These are vocabulary selection helpers, not authorization;
existing scope-aware authorization remains unchanged and has no technical-master
adapters. Future service writes must establish selection validity and authorization
within their own appropriate transaction, not trust a previously evaluated queryset.

### Reuse and transaction boundary

The existing TaxonomyRecord supplies UUID/code/timestamp/validated-save behavior.
TechnicalTaxonomyRecord adds identical applicability validation for the three new
masters. TechnicalCategoryMapping supplies only shared lock/validation methods;
each concrete mapping explicitly declares its FK fields and unique constraint.
There are no polymorphic tables, GenericForeignKeys, JSON relationships, content-type
applicability, model generation or runtime taxonomy registration.

Small private _set_applicability and _applicable_for_category helpers now serve the
four explicit complaint/technical public paths. The transaction sequence and SQL
predicates from Phase 2A.3 are retained. Complaint model/mapping schema and its 33
existing tests are unchanged. The original seven complaint concurrency tests also
exercise the extracted shared service boundary without weakened assertions.

Each applicability setter obtains FOR UPDATE on its own taxonomy master inside
transaction.atomic before validating stored categories and replacing configuration.
Direct mapping saves take the same stable parent-master lock before validation and
insertion. Master saves/lifecycle lock that master as well. There is no committed
clear-then-add gap: failed replacements restore both mode and the complete old mapping
set. Competing valid setters serialize; the later lock holder supplies the final
complete configuration, without a FIFO guarantee. Different master rows can proceed
independently. No catalog lifecycle service or Company-level lock was added.

### ProductCategory race semantics

Deactivation racing with applicability configuration is allowed to leave a stored
mapping to an inactive category. Mapping is configuration; queries require the
category's current active state before returning selections. ProductCategory remains
protected by its existing FK/deletion semantics. PostgreSQL's normal FK key locks
may wait on a concurrent category update; there is no additional broad cross-domain
locking protocol. A new concurrency test demonstrates category deactivation versus
RootCause applicability configuration: the mapping survives, is not selectable,
and becomes selectable after category reactivation.

Application validation is not a cross-table CHECK. The database protects canonical
codes, unique master codes, mapping pairs and foreign keys. Raw/bulk writes can
bypass global/mapping validation and stable endpoints; they remain unsupported
ordinary configuration writes. Guarantees target the single default PostgreSQL
Read Committed setup and tested operations, not arbitrary composed transactions or
formal deadlock freedom.

### Technical admin

FaultDiagnosis, RootCause and RepairAction each have a registered native admin with
code/name/active/global displays, search, filters and ordering. Shared applicability
form/admin plumbing calls the corresponding explicit public service after the normal
master save, inside Django Admin's change-form transaction. Global plus selected
categories is a form error; service/database checks remain authoritative. Existing
activation fields are readonly and lifecycle actions use the explicit services.
All three add/change/lifecycle paths and representative admin rollback are tested.

Native Django permissions are generated for the six new models. No Roles or user
grants are seeded, and no organization-specific semantics were added. Mapping models
are configured through their master's admin rather than a bypassing mapping editor.

### Phase 2A.4 migration and verification

service_catalog.0002_technical_taxonomy depends on service_catalog.0001_initial and
catalog.0002_deviceidentificationpolicy. It creates only the three technical masters
and their three mapping tables with constraints. The migration was inspected before
application and is applied. It neither alters existing tables nor seeds data.
Historical migrations and existing catalog/identification/Phase 1 code are unchanged.

Targeted run: 56 service-catalog tests passed, including the original 33 and 23 new
tests. Shared assertions test each public technical service/query/admin path; five
new PostgreSQL concurrency tests cover diagnosis global-to-restricted, root cause
restricted-to-global, repair-action replacement, diagnosis global/direct-mapping
collision, and the category-deactivation boundary. Expected waits are verified using
pg_blocking_pids and separate connections; final complete mapping sets are asserted.
The earlier seven complaint races remain part of the regression suite.

Required check and migration commands passed. Full suite result: 367 total,
367 passed, 0 failed, 0 skipped (119.417 seconds). All original 344 tests remain
unchanged and passing. The six new technical tables were empty after migration;
no technical taxonomy values, roles or grants were seeded.
No diagnosis event, repair execution, job/card, customer/device, inventory, pricing,
API or frontend was implemented. Phase 2A.5 is not started.
