# Physical Device Registry, Evidence and Ownership — Phases 2B.3–2B.5

`apps.devices` owns physical units and their permanently reserved identifier history.
ProductModel is a commercial definition; ProductVariant is an optional configuration;
Device is one physical unit with its own UUID. Neither a variant nor an IMEI is the
Device itself. No sequential device number is introduced.

## Models and boundaries

`Device` fields are `id` (UUID), `product_model` (required protected FK),
`product_variant` (optional protected FK), `is_active` (default true), `created_at`
and `updated_at`. Display uses Brand code, ProductModel code and a short UUID, not
an identifier value. Both catalog relationships are immutable after registration,
including changing an initially unknown variant to a known one. A future controlled
product-reassignment workflow must handle registration mistakes deliberately.

`DeviceIdentifier` fields are `id` (UUID), `device` (protected FK), `identifier_type`
(IMEI1/IMEI2/SERIAL), `value`, `normalized_value`, `identity_key`, `is_active`
(default true), `created_at` and `updated_at`. All three text fields have a maximum
length of 128. The two derived fields are not editable. Identifier ownership, type
and recorded values never change through supported writes. The app-local abstract
record adds no table. There is no GenericForeignKey, JSON storage or copied policy.

Devices have no Customer, Company, ServiceCenter, ownership history, purchase,
invoice, warranty, stock or service-job fields directly on Device. Phase 2B.4 adds
separate purchase-evidence and coverage records below. Phase 2B.5 adds separate
Customer ownership periods; repair-claim decisions remain future work. No public endpoints are introduced.
Native Django model permissions exist; business authorization adapters and roles
are unchanged. These global registry services do not make caller-authorization
decisions and remain unsuitable for direct unprotected end-user exposure.

## Identification policy

Registration uses `apps.catalog.identification.get_identification_policy()` under
the ProductModel lock coordinated with the catalog policy writer. It never copies
or duplicates configuration. A missing policy is **UNCONFIGURED** and registration
fails. It is not interpreted as optional or all-not-applicable.

For every identifier slot:

- REQUIRED must be supplied.
- OPTIONAL may be supplied or omitted.
- NOT_APPLICABLE must not be supplied.

The empty string means omission. Supplied whitespace-only or non-string values
are invalid. A configured policy that permits all slots to be absent allows a
Device with no identifiers; its UUID remains its identity.

New registration requires active Brand, ProductCategory, ProductModel and supplied
ProductVariant, and the variant must belong to the model. Policy changes and
catalog deactivation do not delete or alter existing devices/identifiers. Existing
records may therefore no longer satisfy a later policy. Reactivation and correction
validate the complete resulting active configuration against the current policy;
queries do not retroactively rewrite or automatically repair it.

## IMEI normalization and validation

IMEI1/IMEI2 accept surrounding whitespace and internal ASCII spaces/hyphens.
Normalization removes these separators, then requires exactly 15 ASCII digits.
Letters, non-ASCII digits and internal tabs/newlines are rejected. The Luhn sum
must be divisible by ten: for a 15-digit value, double digits at zero-based odd
positions, sum each doubled result's decimal digits, and add the other digits.
The final digit is included unchanged. A bad check digit is rejected, not repaired.

This follows the check-digit rule in
[GSMA TS.06, section 5.1.5](https://www.gsma.com/newsroom/wp-content/uploads/TS.06-v25.0-IMEI-Allocation-and-Approval-Process.pdf).
Luhn checks transcription consistency; it does not prove TAC allocation, device
existence, ownership, blacklist status or authenticity. No external registry call
or telephony dependency is added. Tests generate fabricated values using an explicit
synthetic prefix/payload and an independent check-digit helper; no real unit's IMEI
is copied into the source or documentation.

## Serial normalization and exact identity keys

Serials are trimmed and uppercased for case-insensitive identity; meaningful
internal characters/spaces are preserved. There is no manufacturer-specific pattern.
Blank strings, control/format characters and values over 128 canonical characters
are rejected. `value` retains entered formatting/case after outer trimming;
`normalized_value` contains the canonical representation.

The initial serial rule is deliberately conservative: serials are globally reserved,
not scoped by ProductModel or Brand. A repeated manufacturer serial is a registration
conflict requiring review; the system never guesses that two physical units share
the same serial identity. Relaxing this requires a deliberate schema/lookup design.

An additional derived **`identity_key`** makes an untyped exact lookup unambiguous:

1. Trim and uppercase the supplied text.
2. If it consists only of ASCII digits/spaces/hyphens and removing separators yields
   exactly 15 digits, use those digits as the key.
3. Otherwise use the trimmed uppercase text unchanged.

This reserves IMEI-shaped numeric serial aliases conservatively, even when a serial
does not have a valid IMEI check digit. A numeric serial's `normalized_value` still
preserves its internal characters; the shared key prevents a differently formatted
IMEI/serial from resolving the same input to another device. It can reject two
otherwise distinct manufacturer serial spellings that differ only by separators
around 15 digits. This is an explicit conservative identity rule, not fuzzy matching.
The same identity key cannot occupy multiple types/slots, even on one device.

## Permanent reservation and database constraints

Identifiers remain reserved across **active and inactive history**, including when
their Device is inactive:

| Constraint | Scope |
| --- | --- |
| `devices_identifier_type_valid` | IMEI1, IMEI2 or SERIAL only |
| `devices_identifier_nonblank` | Entered, normalized and identity-key fields nonempty |
| `devices_imei_canonical` | IMEI normalized values are 15 ASCII digits and key equals normalized value |
| `devices_active_slot_uniq` | At most one active identifier per Device/type |
| `devices_imei_reserved_uniq` | Normalized IMEI globally unique across IMEI1 **and** IMEI2, with no active filter |
| `devices_serial_reserved_uniq` | Normalized serial globally unique, with no active filter |
| `devices_identity_key_reserved_uniq` | Shared identity key globally unique across all types/history |

An IMEI recorded as Device A's IMEI1 can never be Device B's IMEI2, including after
correction or deactivation. The extra shared-key constraint is stronger than the
separate type-family constraints and prevents cross-family lookup ambiguity.

Device/identifier model and QuerySet deletion are disabled in normal workflows;
admin deletion is disabled too. Identifier-to-Device and Device-to-catalog FKs use
`PROTECT`, preventing parent deletion from silently destroying history. Privileged
raw SQL can still delete rows or bypass service invariants; permanence is the
supported application contract, not a claim that a database owner cannot erase data.

## Public services

Import from `apps.devices.services`:

```python
register_device(
    *, product_model, product_variant=None, imei1="", imei2="", serial="",
)
replace_device_identifier(*, device, identifier_type, new_value,
                          expected_identifier_revision=_UNSET)
deactivate_device(*, device)
reactivate_device(*, device)
```

All return a Device. Inputs must be persisted default-database model instances.
Mutable input state is not trusted; services retrieve current catalog/device state.
Validation failures raise `ValidationError`; deleted targets can raise the relevant
model's `DoesNotExist`. Concurrent unique-constraint conflicts are translated into
a generic ValidationError after rolling back the service savepoint, without copying
PostgreSQL's identifier-bearing error text into the user message.

Registration is atomic: validate the locked current catalog/policy, create Device,
then reserve its identifier rows in common identity-key order. Any failure removes
the entire attempted Device and identifiers. There is no orphan partial registration.
Database uniqueness is the final arbiter when concurrent registrations have both
passed application validation before either commits.

### Corrections and restoration

Replacement locks current Device and identifier state, checks current catalog,
normalizes the new value, and validates the complete resulting configuration:

- Existing current row becomes inactive; new value receives a new active row.
- The old value stays linked and reserved permanently, and remains discoverable.
- Replacing with the already active value is an idempotent no-op.
- Restoring a historical value to the **same Device and original slot** reactivates
  its original row and deactivates the current row. It does not create duplicate
  history or overwrite the original entered representation.
- A value reserved to another Device or another slot is rejected, even if inactive.
- A previously absent slot may be added only when current policy permits it.
- Empty replacement values are rejected: no general identifier-removal service
  exists, and REQUIRED identifiers cannot be removed through this API.

The previous active row and newly inserted row changes share one transaction.
Conflict or a late insertion failure restores the prior complete configuration.
Corrections are allowed while Device itself is inactive, to permit repair before
reactivation; they never reactivate the Device. Catalog hierarchy must still be active.

Timestamps record row creation/latest activation-state change, not a full immutable
timeline of every restore event. No actor/audit-event framework is added. Corrections
change identifier timestamps, not Device's timestamp. A policy migration requiring
several simultaneous repairs, or making an existing identifier NOT_APPLICABLE, may
need a future deliberate configuration-migration/removal workflow; this single-slot
API does not silently delete identifiers or temporarily accept an invalid final set.

### Device lifecycle

Device deactivation changes only Device's active flag; all identifier rows and their
active-slot state/reservations remain. Repeated calls are idempotent. Reactivation
requires active current catalog hierarchy and a configured policy satisfied by the
complete current identifier set, even for an already-active Device. It is deliberate
and does not rewrite identifiers.

Catalog deactivation preserves Device flags and identifiers. Operational catalog
queries exclude affected devices. Reactivating catalog definitions can make devices
whose own active flags remained true visible again; it does not activate individually
deactivated devices. No frozen catalog lifecycle hooks/services were modified.

## Query APIs

Import from `apps.devices.queries`:

```python
find_device_by_identifier(value)
active_identifiers_for_device(device)
identifier_history_for_device(device)
active_devices_for_product_model(product_model)
```

`find_device_by_identifier` computes one exact identity key and returns Device or
`None`, using one indexed SQL lookup with the device/model/brand joined. It includes
inactive historical identifier rows and inactive Devices/catalog definitions; it is
an identity/history lookup, **not an operational eligibility or authorization check**.
Unknown/invalid input returns `None`. No substring establishes identity. Numeric
serial inputs are valid identity candidates, so this untyped lookup itself does not
require an IMEI check digit; registration/correction validate IMEI-typed values.

`active_identifiers_for_device` returns a lazy QuerySet of the current identifier
slots, even when Device is inactive. `identifier_history_for_device` includes all
historical/current rows. Both are limited to that persisted Device; invalid/unsaved
input yields an empty QuerySet. Ordering is creation timestamp then UUID.

`active_devices_for_product_model` returns a lazy QuerySet restricted to that exact
model, active Device, active Brand/Category/Model and active supplied Variant.
It does not claim to assess policy drift for every result. New service transactions
must deliberately revalidate configuration/lifecycle and enforce their own access
policy. List construction is zero queries and list evaluation is one query in the
tested paths. Evaluated QuerySets/lists are snapshots and must be rebuilt for a
later decision. No customer-based query is provided.

## Catalog locking and concurrency

The default PostgreSQL database is the only supported write database. Lock order:

1. Read the ProductModel's ancestor IDs.
2. Brand `FOR SHARE`.
3. ProductCategory `FOR SHARE`.
4. ProductModel `FOR SHARE`.
5. Optional ProductVariant `FOR SHARE`, then validate its model.
6. Existing Device `SELECT FOR UPDATE` when mutating that device.
7. Current identifier rows, in UUID order, then any historical restoration row.

Registration acquires only the necessary shared catalog locks, allowing unrelated
registrations under one model to proceed concurrently. ProductModel SHARE conflicts
with the existing policy writer's ProductModel UPDATE lock, including first-policy
creation, so registration sees one coherent supported policy state. Ancestor/model/
variant deactivation uses incompatible catalog locks and therefore serializes with
registration/reactivation. No exclusive global registry lock is introduced.

Because ProductModel category correction is supported, the model is reread after
locking. If its ancestor IDs changed while waiting, the operation fails with a retry
message instead of acquiring a new ancestor lock behind the model. This preserves
the established catalog order. No catalog source was changed.

Same-device corrections and lifecycle operations serialize on Device, regardless
of stale caller instances. Identifier reservations across different devices use
database unique indexes; registration inserts multiple keys in a common sorted
order. Rejected operations roll back cleanly. Passing tested schedules is not a
formal deadlock-freedom proof for arbitrary outer transactions or unsupported raw
writes. Callers composing new workflows must retain the same lock order.

## Django Admin and privacy

Device admin lists model, optional variant, current identifiers, active state and
creation date. Creation has explicit IMEI1/IMEI2/serial inputs and delegates to
registration. Catalog references and lifecycle flags are readonly on existing
devices. A deliberate correction type/value pair invokes the replacement service;
existing identifiers are not prefilled into editable inputs.

Explicit corrections require a signed identifier revision from the displayed
page. Missing, tampered and stale snapshots reject. The selected slot's row UUID
and update timestamp are also checked under the Device lock, preventing a change
between form validation and mutation. No identifier values appear in the token.
For service callers, omitted `expected_identifier_revision` retains deliberate
serialized correction behavior; explicit `None` expects an empty slot, otherwise
pass `[str(row.pk), row.updated_at.isoformat()]` from the reviewed identifier.
This also detects correction away from and back to the same historical row.

An ordinary existing-device save has no editable model metadata and performs no
write: it refreshes the Device instead of persisting a stale active flag. Posted old
registration identifier fields cannot undo correction. Lifecycle actions require
native change permission and invoke per-record services. Validation conflicts roll
back and show a generic retry/review message. No batch all-or-nothing promise is made.

Identifier admin is audit-only: visible with native view permission, direct add,
change and delete disabled. CSRF and native model permissions remain enabled.
The frozen business scope engine still rejects Device/DeviceIdentifier as unsupported
targets, including active superusers through that API.

Admin intentionally displays identifiers to authorized administrators; there are no
public endpoints. Device/identifier string labels and admin change messages do not
include physical identifier values. No real customer/device identifiers, external
registry credentials, notification integration or new dependencies are introduced.

## Normal model saves and raw/bulk boundary

Device has no ordinary editable metadata. Normal `save()` always directs callers
to registration/lifecycle services; identifier `save()` directs callers to correction.
`full_clean()` validates variant/model consistency and immutability; internal
service persistence also runs it under locks. `_persist()` is a private implementation
method, not an authorization mechanism or supported external write API.

`QuerySet.update()`, `bulk_create()`, `bulk_update()`, base-model/private persistence
calls and raw SQL can bypass service-only rules. Database constraints still enforce
the listed uniqueness, shape, type, cardinality and FK guarantees. They do not
enforce Luhn, policy-required presence, catalog active state, matching variant/model,
ownership/value immutability, serial uppercase normalization or correct serial
identity-key derivation. Raw deletion can also erase reservations. No cross-table
CHECK or trigger enforcement is falsely claimed.

## Migration and focused verification

`devices.0001_initial` depends on `catalog.0002_deviceidentificationpolicy` and
creates only Device and DeviceIdentifier, without seeds. The migration and
`sqlmigrate devices 0001` output were inspected before application. In particular,
the IMEI/serial reservation indexes have **no `is_active` condition**, while the
per-device/type slot index does.

Focused tests on PostgreSQL 18.6:

- `python manage.py test apps.devices.tests --noinput`: **33 passed, 0 failed,
  0 skipped**, 9.131s. Includes policy combinations, format attacks, history,
  rollback, database constraints, exact lookup, privacy and actual admin writes.
- `python manage.py test apps.devices.test_concurrency --noinput`: **18 passed,
  0 failed, 0 skipped**, 18.376s. Separate connections and `pg_blocking_pids` verify
  actual waits, or explicitly prove no wait for independent operations.

Concurrency schedules cover duplicate IMEI registration; cross-model IMEI1/IMEI2
collision; duplicate serial registration; distinct concurrent registrations;
same-device corrections; competing devices claiming a new value; correction and
Device deactivation in both orders; Brand/Category/Model/Variant deactivation before
registration; registration before model deactivation; model deactivation before
Device reactivation; policy update and registration in both orders; category
correction while acquiring locks; and stale HTTP admin editing after a correction
and lifecycle change. Failed registrations leave no orphan Device.

## Phase 2B.3 verification (historical)

Verification date: 2026-09-26. Commands used `.venv\Scripts\python.exe` from the
project root against PostgreSQL 18.6.

| Command | Result |
| --- | --- |
| `python manage.py check` | No issues |
| `python manage.py makemigrations --check` | No changes detected |
| `python manage.py migrate` | `devices.0001_initial` applied successfully |
| `python manage.py showmigrations devices` | `[X] 0001_initial` |
| `python manage.py test --noinput` | **515 total, 515 passed, 0 failed, 0 skipped**, 196.635s |

All **464 previous tests remain unchanged and passed**; 51 device tests were added.
The 80 pre-existing app Python files, including tests and historical migrations,
match their beginning-of-phase byte hashes. This includes the Phase 2B.2 work that
was initially uncommitted and subsequently checkpointed during this session.
No prior Customer/catalog/organization/access source was changed for Device support.
Migration history is consistent; conflict detection returned `{}`. The full test
runner created, migrated and destroyed an isolated PostgreSQL test database; the
normal developer database was not reset.

Git status/diff/stat and whitespace review show the new devices app/documentation,
one app-registration line and the architecture note. `.env` remains ignored and
untracked. A bounded tracked-tree/new-file scan found no credential patterns,
tracked environment/database dumps or literal 15-digit numeric data in the new
device sources/documentation. Identifier test values are generated synthetic data.
The implementation did not stage, commit or push changes. No Phase 2B.4 work began.

## Phase 2B.4 — Purchase and warranty evidence

This phase adds Device-owned evidence in the existing app. It distinguishes:

- Device: the physical unit.
- Purchase evidence: current recorded acquisition facts and an internal review.
- Warranty coverage: explicit recorded terms/date interval and its source.
- Warranty claim decision: a future repair-specific decision, not implemented here.

There is no mutable warranty boolean/status on Device, automatic warranty term,
Customer/purchaser/presenter relation, attachment/upload, OCR, manufacturer API,
payment data or claim-approval/exclusions engine.

### Purchase evidence model

`DevicePurchaseEvidence` contains:

| Field | Definition |
| --- | --- |
| `id` | UUID primary key |
| `device` | Protected one-to-one Device FK; immutable ownership |
| `purchase_date` | Optional DateField; unknown remains NULL |
| `seller_name` | Optional text, 200 characters |
| `seller_reference` | Optional text, 128 characters |
| `invoice_number` | Optional text, 128 characters; not unique |
| `evidence_status` | UNVERIFIED (default), VERIFIED, REJECTED |
| `verification_note` | Optional review note, 2000 characters |
| `reviewed_at` | Nullable review timestamp |
| `reviewed_by` | Nullable protected `AUTH_USER_MODEL` FK |
| `created_at`, `updated_at` | Existing timestamp convention |

There is at most one current structured purchase-evidence record per Device.
Seller/invoice/reference text is trimmed only; meaningful case and punctuation
remain. Duplicate invoice numbers on different devices/sellers are allowed and
never establish device identity. An empty initial UNVERIFIED record is allowed
when acquisition facts are unknown; the application does not manufacture facts.

Purchase dates use Django DateField conversion (date objects or valid ISO date
strings). No date is inferred from Device creation, invoice text or identifiers.
Dates later than `timezone.localdate()` are rejected by supported writes. This is
the service date in the configured application timezone, not a database CHECK
involving a moving clock. Tests fix the service date to avoid midnight/timezone
assumptions. Coverage future dates have a different rule and are allowed.

### Review semantics and corrections

UNVERIFIED means no accepted/rejected review is recorded; reviewer and time are NULL
and verification note is empty. VERIFIED and REJECTED both require reviewer and
review timestamp; note is optional. Reviewer must be an active persisted User at
decision time. The review service locks that User in shared mode so an ordinary
concurrent deactivation cannot race the active-state check. Historical reviewer
deactivation does not erase the prior review; deletion is protected.

Staff status is not a business-authorization check. Services do not infer permission
from `is_staff`; future RBAC integration remains deferred. Native Django Admin
permissions gate the current trusted administrative interface.

Material fields are exactly purchase date, seller name, seller reference and invoice
number. A material change resets VERIFIED **or REJECTED** to UNVERIFIED and clears
all review metadata. Comparison follows normalization: an outer-whitespace-only
edit that changes no stored fact leaves status/timestamps intact. Review notes are
not ordinary editable facts; changing a note is a new explicit review decision.
Each accepted review records a new reviewer/time/note tuple atomically.

Reviews require the supplied evidence instance's `updated_at` to match the locked
current record. An outdated review attempt fails rather than approving unseen
changed facts or undoing a newer rejection. Callers must use the returned/refetched
current evidence for a subsequent deliberate review.

Purchase editing is a patch API: omitted fields preserve fresh database values.
Optional `expected_updated_at` rejects stale editor snapshots; admin always supplies
it. A caller omitting this precondition intentionally applies its supplied fields
to the latest row, with same-field writes serialized in arrival order. Passing a
stale Device instance never copies mutable Device/evidence fields from that object.

This is one current record, **not full purchase revision history**. Old facts and
previous review metadata are overwritten by controlled operations; admin logs are
not a complete business audit trail. Full evidence revisions/actors beyond the
current reviewer are deferred.

### Warranty coverage model and sources

`DeviceWarrantyCoverage` contains:

| Field | Definition |
| --- | --- |
| `id` | UUID primary key |
| `device` | Protected Device FK; immutable ownership |
| `coverage_start_date` | Required DateField |
| `coverage_end_date` | Required DateField; end >= start |
| `coverage_source` | PURCHASE_EVIDENCE, MANUFACTURER_RECORD, MANUAL_OVERRIDE |
| `reference` | Optional text, 200 characters |
| `note` | Optional text, 2000 characters |
| `is_active` | True for current coverage; false for retained history |
| `created_at`, `updated_at` | Existing timestamp convention |

Both dates are required for every record, including retained history. Equal dates
represent a one-day inclusive interval; future intervals are valid. No open-ended
coverage or default duration is inferred. References/notes are trimmed; no seller,
dealer or manufacturer master/API is introduced.

- PURCHASE_EVIDENCE requires currently VERIFIED purchase evidence when establishing
  coverage. Dates are explicitly supplied; no purchase-date-plus-365 calculation.
- MANUFACTURER_RECORD records externally obtained dates/reference. It can exist
  without any purchase evidence; this is not an API lookup integration.
- MANUAL_OVERRIDE requires a meaningful nonblank reference or note. Authorization
  for future operational overrides must be added with the access-integration phase.

One active coverage per Device is protected by a PostgreSQL partial unique index.
Replacement deactivates the current row and inserts a new active row atomically;
old dates/source/reference/note are never edited in place. Clearing only deactivates
current coverage and keeps history. Repeating clear when none exists is a no-op.

`expected_current_id` is an optional replacement/clear precondition. Admin uses it
so a stale old-row action cannot deactivate or overwrite a newer active coverage.
`None` means the caller expects no active coverage. A replacement with unchanged
facts still creates a new historical/current pair when explicitly requested; it
does not silently reactivate an older row.

### Purchase-based coverage dependency

Material purchase-fact changes and rejected reviews atomically retire any active
PURCHASE_EVIDENCE coverage. This conservative rule prevents terms supported by
invalidated evidence from remaining operationally active. The coverage row remains
historical; no dates are rewritten. Reverification alone does not restore or
manufacture coverage: a new explicit coverage-setting operation is required.

Manufacturer/manual coverage is independent and is not retired by purchase edits
or rejection. VERIFIED evidence alone never creates coverage. A verification note
change through a new VERIFIED decision does not materially change purchase facts
or retire existing purchase-based coverage.

This preserves period history but not the overwritten purchase revision that once
supported it. A future evidence-history design may add explicit revision references;
this phase does not claim such snapshots already exist.

### Public services

Import from `apps.devices.services`; implementations live in `evidence_services.py`:

```python
set_device_purchase_evidence(
    *, device, purchase_date=_UNSET, seller_name=_UNSET,
    seller_reference=_UNSET, invoice_number=_UNSET,
    expected_updated_at=_UNSET,
)
verify_device_purchase_evidence(*, evidence, reviewed_by, note="")
reject_device_purchase_evidence(*, evidence, reviewed_by, note="")

set_device_warranty_coverage(
    *, device, coverage_start_date, coverage_end_date,
    coverage_source, reference="", note="", expected_current_id=_UNSET,
)
clear_device_warranty_coverage(*, device, expected_current_id=_UNSET)
```

`_UNSET` is an internal omission sentinel; callers omit unchanged fields. Explicit
`None` clears purchase date, and `""` clears optional text. A new evidence row defaults
to unknown date/empty text. Setting/reviewing purchase evidence returns its current
record. Coverage setting returns the new row. Clearing returns the deactivated row
or `None` when there was no active coverage.

Validation/precondition failures raise ValidationError; disappeared records may
raise model DoesNotExist. Ownership, review status and reviewer/time are not arbitrary
editable service inputs. All mutations operate on fresh locked state.

### Public query APIs and date meaning

Import from `apps.devices.queries`:

```python
get_device_purchase_evidence(device)
current_warranty_coverage(device)
warranty_coverage_history(device)
device_has_recorded_warranty_coverage(*, device, on_date)
```

The first two return a record or None, including records attached to inactive
Devices/catalog definitions for historical/admin inspection. `current` means the
coverage row's active flag, not that today's date falls within its interval. History
is a lazy QuerySet of all coverage rows, ordered by creation timestamp then UUID.
Invalid/unsaved Device input yields no records.

The boolean helper requires an explicit Python `date` (not a datetime/string), an
active Device, an active coverage row, and **start <= on_date <= end**. Both endpoints
are inclusive. It uses one SQL existence query and reads current persisted Device
state rather than trusting a stale instance. It does not additionally require active
catalog definitions: this is a factual recorded-period question, not catalog
selectability. It never approves a repair claim or promises free parts/labor.

Purchase lookup, current coverage lookup and the boolean helper each use one query
in the tested paths. History construction is zero queries and evaluation one query.
Materialized results remain snapshots; subsequent decisions must query again.
These helpers do not implement caller RBAC, claim exclusions, damage assessment,
tampering checks or a service transaction decision.

### Device/catalog lifecycle and identifier independence

Purchase facts and review decisions may be entered/corrected for inactive Devices
or inactive catalog definitions as historical administrative evidence. New active
coverage requires an active Device; catalog state is irrelevant to recording these
terms. Clearing remains allowed when Device is inactive.

Device deactivation preserves purchase facts, review metadata and coverage flags/
history; it only makes the boolean helper return false. Reactivation does not
fabricate evidence or rewrite dates/status and makes existing active coverage
eligible for date evaluation again. Catalog lifecycle remains unchanged and never
erases these records. IMEI correction leaves all evidence attached to the same
Device UUID; no evidence FK points at an identifier row.

### Transactions and locks

Evidence services do **not** reuse the registration helper's catalog locks because
catalog active/configuration state is not needed for these facts. Ordinary order:

1. Device `SELECT FOR UPDATE` (also coordinates absent one-to-one evidence creation).
2. Purchase evidence `SELECT FOR UPDATE`, if used.
3. Current coverage `SELECT FOR UPDATE`, if used.

Review adds an active-reviewer User `FOR SHARE` **before** Device. No service takes
catalog locks after acquiring Device. Existing Device lifecycle retains its earlier
catalog-before-Device order; evidence writes never wait back on catalog, so they
can finish while lifecycle waits for Device. Independent catalog locking does not
block evidence editing in the tested path.

One atomic transaction covers evidence mutation plus review invalidation and any
purchase-based coverage retirement. Another covers old-coverage retirement and new
coverage insertion. Validation happens before retirement where practical; late
failure still rolls the entire transaction back. There are no new bulk-update
shortcuts bypassing these paths.

Concurrent operations serialize by Device; stale review/precondition requests fail
instead of merging incompatible decisions. Separate-connection PostgreSQL tests use
`pg_blocking_pids` to observe actual waits. Passing these schedules is not a formal
deadlock-freedom proof for arbitrary enclosing transactions; composed workflows must
respect reviewer/catalog-before-Device ordering where those locks are involved.

### Admin safety

Purchase admin shows purchase facts, status, reviewer and review time. Device becomes
readonly after creation; status/reviewer/time/note are workflow-controlled. Verify
and reject actions use the requesting User through the review services. Optional
review notes are available through the public review API; the basic admin actions
record no note. Material edits reset review state through the same service.

Existing evidence forms carry a signed revision token. A form opened before a newer
edit/review is rejected; a race after form validation is caught by the locked service
precondition. Missing/tampered revision tokens are rejected. Ordinary unchanged
fields are not copied over current state.

Coverage admin creation only establishes coverage when none is active; replacing
existing coverage is an explicit replacement section on its current-row page.
Original coverage fields remain readonly. Replacement creates a new row; historical
rows are audit-only. A stale old-row clear or replacement cannot affect a newer row.
Signed revision checks and service current-ID preconditions protect both stages.

Deletion is disabled. Native permissions and CSRF are retained, with view-only
staff unable to mutate. Post-validation conflicts roll back before a generic retry
message is displayed. String labels/admin change messages omit invoice/reference
contents. No invoice file/image storage is provided.

### Database and supported-write boundary

`devices.0002_purchase_warranty_evidence` creates only these two models, depending
on `devices.0001_initial` and the swappable User model. No seeds or earlier migration
changes are needed. The migration and resulting SQL were inspected.

Database constraints enforce one purchase record per Device, valid evidence status,
coherent status/reviewer/time/note fields, valid coverage source, required dates,
end >= start, basic nonempty manual-override justification and at most one active
coverage per Device. Device/reviewer FKs preserve references; Django deletion uses
PROTECT. A reviewer cannot be silently removed from a retained decision.

Purchase-future-date validation, meaningful trimmed justification, reviewer activity,
Device activity, immutability, material-change invalidation and source-evidence
dependency are application/service rules. Raw SQL, QuerySet.update, bulk operations
and private/base persistence can bypass them. No cross-table CHECK enforcement is
claimed. Normal model saves and model/QuerySet deletes are disabled in favor of
services, but privileged raw deletion remains possible.

### Phase 2B.4 focused verification

- `python manage.py test apps.devices.test_evidence --noinput`: **34 passed,
  0 failed, 0 skipped**, 6.602s.
- `python manage.py test apps.devices.test_evidence_concurrency --noinput`:
  **15 passed, 0 failed, 0 skipped**, 15.647s.

Concurrency scenarios: simultaneous first purchase writes; verify-before-edit;
edit-before-stale-verify; verify versus stale rejection; reviewer deactivation versus
review; competing coverage replacements; clear/replace in both orders; Device
deactivation before evidence edit or coverage replacement; coverage before Device
deactivation; evidence editing alongside unrelated catalog locks; purchase-based
coverage and material evidence edits in both orders; and actual stale admin editing
behind a review. Functional tests additionally inject late failures to verify
rollback, exercise database constraints, test inclusive date boundaries, and verify
identifier/lifecycle independence and admin integrity.

### Phase 2B.4 final verification

Verified on 2026-09-26 using the project virtual-environment Python and PostgreSQL
18.6. Baseline checkpoint: `a09a2ec`.

| Command | Result |
| --- | --- |
| `python manage.py check` | No issues |
| `python manage.py makemigrations --check` | No changes detected |
| `python manage.py migrate` | `devices.0002_purchase_warranty_evidence` applied successfully |
| `python manage.py showmigrations devices` | 0001 and 0002 applied |
| `python manage.py test --noinput` | **564 total, 564 passed, 0 failed, 0 skipped**, 229.291s |

All **515 previous tests remain unchanged and passed**; 49 tests were added. The
41 tracked previous test/migration files match checkpoint `a09a2ec`, including
`devices.0001_initial` and all earlier migrations. Migration history is consistent
and conflict detection returned `{}`. The test runner built/migrated/destroyed an
isolated PostgreSQL test database; the normal developer database was not reset.

Git status/diff/stat and whitespace checks show only the new evidence modules,
migration/tests, existing-device module integration and necessary documentation.
`.env` remains ignored/untracked; a bounded tracked-tree/new-source scan found no
credential patterns or tracked environment/database dumps. Tests use synthetic
invoice/reference text and the existing synthetic IMEI generator. No real device
or invoice data was introduced. Nothing was staged, committed or pushed, and no
Phase 2B.5 work began.


## Phase 2B.5 — Customer ownership and history

`CustomerDeviceRelationship` lives in `apps.devices`. Its exact fields are UUID
`id`, protected `customer` and `device` foreign keys, `relationship_type` (only
`OWNER`), aware `started_at` (default application time), nullable `ended_at`,
`end_reason` and `note` (blank allowed, maximum 2,000 characters each), `created_at`
and `updated_at`. Current means `ended_at IS NULL`; there is no stored current
flag, Device owner/company cache or Customer device counter. A Device remains
valid with no owner; registration is unchanged. A Customer can own many Devices.

The first OWNER period establishes permanent company affinity, derived from
**all** OWNER history through Customer.company. Every assignment/transfer checks
that no history belongs to another company, after locking Device. Ending all
current ownership never releases affinity. Customer's existing immutable company
contract is unchanged. Transfer A -> B -> A creates three distinct periods.

### Services and timeline

Public imports from `apps.devices.services`:

```python
assign_device_owner(*, device, customer, started_at=None, note="")
end_device_ownership(*, device, reason="", ended_at=None,
                     expected_current_relationship_id=_UNSET)
transfer_device_ownership(*, device, new_customer, reason="",
                          transferred_at=None, note="",
                          expected_current_relationship_id=_UNSET)
```

They return the inserted or closed relationship. Assignment requires no current
owner; end/transfer require one; same-customer transfer is rejected. Transfer
closes the existing period and inserts the new period with exactly the same
timestamp, in one transaction. Insert failure rolls back the closure. Preconditions
compare the current UUID under lock; omitted preconditions mean operate on the
current owner at execution time, permitting serial transfers. Interactive callers
must pass the relationship they reviewed; Admin always does. Explicit `None`
is not an expectation of an existing current relationship and is rejected.

Default timestamps use `timezone.now()` after locks. Supplied timestamps must be
aware and no later than application time. End cannot precede start. New periods
are append-only: a backdated start is allowed only at or after the latest closed
end. Arbitrary insertion into old gaps and history correction are unsupported.
Periods are half-open `[started_at, ended_at)`; null end means unbounded. Adjacent
periods are valid. Equal start/end is an empty, instantaneous closed period and
is allowed; it occupies no time. History order is `started_at DESC, id ASC`, so
UUID breaks ties deterministically without asserting an event order for ties.

Current customer, device, type, start and note cannot be edited. Closure records
end/reason once; closed rows, including notes/reasons, are immutable through
supported paths. Ordinary `save()`, instance deletion and QuerySet deletion are
rejected. Customer and Device FKs use PROTECT. There is no history correction API.

### Lifecycle and independent facts

New assignment and transfer require a freshly loaded active Device, target
Customer and target Company. Customer/Company/Device deactivation preserves both
current recorded ownership and historical periods. Reactivation creates/restores
no relationship. Explicit ending is allowed with inactive Device, Customer or
Company. Catalog activity is not required for ownership operations, and catalog
lifecycle remains unchanged.

Recorded Device Owner ≠ Purchase Evidence ≠ Warranty Holder assumption ≠ Service
Presenter ≠ Service Requester. Ownership changes do not alter purchase evidence,
reviewer/status, warranty dates/coverage or identifiers. Identifier correction
preserves Device UUID and its relationships. No automatic owner inference occurs.
Future service transactions must explicitly capture their own actors and must not
implicitly use the current owner as the ServiceCase customer.

### Queries

Public imports from `apps.devices.queries`:

```python
current_device_owner(device)                  # Customer or None
current_ownership_relationship(device)        # relationship or None
device_ownership_history(device)              # lazy relationship QuerySet
currently_owned_devices(customer)             # lazy Device QuerySet
currently_owned_devices_for_company(company)  # lazy Device QuerySet
```

Recorded owner/history remain visible regardless of lifecycle state. Operational
Device queries require active Customer, Company and Device plus current OWNER,
with company/customer isolation applied in SQL. Catalog flags are deliberately
not part of these ownership queries. Invalid/unsaved input yields None or an empty
QuerySet. Related customer/company and device/catalog display data are joined to
avoid N+1 reads. Identifier lookup remains a separate API.

### Transactions and database protection

Assignment/transfer lock order is target Company **FOR SHARE**, target Customer
**FOR UPDATE**, Device **FOR UPDATE**, then current relationship **FOR UPDATE**.
Company/Customer locking reuses `customers.detail_locks.locked_customer`. All
history checks occur under the Device lock; old history rows need no additional
locks because supported writers also serialize on Device. No catalog locks or
old-Customer locks are acquired. Ending locks Device then current relationship.
Existing Customer lifecycle locks Company before Customer; Device lifecycle takes
catalog locks before Device. Ownership does not reverse either order. Operations
should not be combined with arbitrary externally pre-acquired locks; callers
composing multiple operations must retain the same lock ordering.

After a lifecycle operation wins the lock, new assignment/transfer rejects its
inactive state. When ownership commits first, later deactivation succeeds and
preserves the recorded owner, while operational queries omit it. This is intended
serialization, not a requirement to erase recorded ownership upon deactivation.

`devices.0003_customer_device_relationship` depends on devices.0002 and
customers.0002. It installs PostgreSQL `btree_gist` then creates the model with:

- `devices_relationship_type_valid`: only OWNER.
- `devices_owner_period_valid`: null end or end >= start.
- `devices_one_current_owner`: partial unique Device for current OWNER.
- `devices_owner_period_no_overlap`: GiST exclusion on Device equality and
  `TSTZRANGE(started_at, ended_at, '[)')` overlap for OWNER.

This exclusion protects historical as well as current intervals, including raw
inserts. Django supports this directly; no custom triggers or runtime-dependent
migration functions are needed. Deployment requires permission to install the
available `btree_gist` extension (or an administrator-installed extension).
Historical migrations are unchanged. Fresh test databases apply the migration.

### Admin and security limits

Admin Add calls assignment. Current relationship facts are readonly; an explicit
operation selects transfer/new customer or end, with optional timestamp/reason.
The signed revision and expected current relationship UUID reject stale or
tampered submissions, including changes between validation and service execution.
Historical pages are audit-only; no delete or bulk mutation action is offered.
Lists show company and current/history state, filter by company/type/state, search
customer number/name or exact normalized identifiers, and join related displays.
Customer choices include company and display name to distinguish tenant-local
customer numbers. Native model permissions govern this administrative interface.

These services and queries do **not** authorize users. Customer/Device business
RBAC integration remains deferred; company filtering does not grant access.
Frozen authorization adapters, groups, roles and lifecycle code are unchanged.
Privileged raw SQL, QuerySet.update or bulk writes can bypass immutability,
company affinity and active-state service rules. Database constraints still
protect type, temporal order, overlap, current cardinality and FK references.
Database owners can disable constraints; this is not a tamper-proof audit ledger.
No production identities, real identifier/invoice data or new public endpoints
are introduced.

### Verification coverage

Ownership tests cover model fields and protected deletion, transfer-back,
append-only timestamps, database constraints, rollback, stale objects and admin,
lifecycle preservation, operational isolation, joined queries, purchase/warranty
and identifier independence. Real PostgreSQL lock-wait tests cover competing
initial assignments (same/cross company), competing transfers with and without
preconditions, end versus transfer, Customer/Company/Device deactivation before
assignment or transfer, assignment before deactivation, cross-company reassignment
after closure, and competing backdated transitions. Existing tests are unchanged.

Verification on 2026-09-26: `manage.py check`, `makemigrations --check`, `migrate`
and `showmigrations devices` succeeded; devices.0003 is applied. A fresh PostgreSQL
test database migrated successfully and `manage.py test --noinput` passed **625
tests, 0 failed, 0 skipped** (295.174 seconds): all previous 564 unchanged tests
plus 61 ownership tests, including 18 real concurrency scenarios. Historical
migrations and frozen Phase 1/2A source are unchanged. `.env` remains ignored;
only synthetic fixtures were added. Nothing was staged, committed or pushed.
The Phase 2B audit and Phase 3 have not begun.

The subsequent [Phase 2B final audit](PHASE_2B_AUDIT.md) records adversarial
verification, identifier Admin revision protection, reviewer query optimization,
and the final freeze decision. The figures above are the Phase 2B.5 checkpoint.
