0.1.0b8 — 2026-09-06 — Eighth Beta
==================================

This release closes the beta-series deprecation window announced across several ``0.1.0b``
releases — the deprecated command aliases and underscored console scripts named under
``Removed`` below are the result — and adds the largest batch of new capability the project has
shipped in one release: row-level multi-tenancy, GDPR subject-access export/deletion, CSV/NDJSON
import, a cache-backed quota primitive, user-defined REST actions, field-level permission
guards, field-encryption key management, declarative database sharding and read-replica
routing, and a from-scratch documentation completeness sweep. ``models.py`` (2882 lines) was
also split into ``snapadmin.es``/``snapadmin.jobs``/``snapadmin.admin_gen`` behind an unchanged
public facade — an internal reorganisation with no import-path impact, mentioned here for
completeness rather than under a heading below. This is a beta release: the public API is not
yet covered by semantic versioning (see ``SECURITY.md``'s current API-stability policy) —
breaking changes remain possible in the ``0.x`` series, always announced here and in
``CHANGELOG.md``, with a migration guide when manual steps are involved.

Breaking
--------

* **``SNAPADMIN_PROFILE = "full"`` (and ``"api"``) now really turn the REST and GraphQL surfaces on
  — check this one before upgrading if you set either.** Read the Fixed entry above for why; the
  consequence is what matters here. Originally, both presets were empty dicts that fell
  through to the built-in defaults, and those defaults had just flipped to ``False`` — so a project
  carrying ``SNAPADMIN_PROFILE = "full"`` (documented at the time as a no-op) was actually running
  **admin-only, with no API mounted at all**. After this release the same setting mounts REST,
  GraphQL and Swagger for every registered model, which is what the profile table in the
  documentation has always promised and what the name says.

  That is a widening of your HTTP attack surface on upgrade, not a cosmetic change, and it lands
  without any edit to your settings. It matters most because ``api_write_fields`` is unset by
  default: on a model that never restricted it, every field not named in ``api_exclude_fields``
  becomes writable through the generated API (``snapadmin.W004`` warns about exactly this). Before
  upgrading, either confirm you want those surfaces — and audit the write allowlists and PII
  masking on every registered model first — or pin ``SNAPADMIN_REST_API_ENABLED = False`` and
  ``SNAPADMIN_GRAPHQL_ENABLED = False`` explicitly, which still wins over any profile. Removing
  ``SNAPADMIN_PROFILE`` entirely also restores the admin-only behaviour, since an unset profile
  applies no preset at all.

* **The shipped ``admin.js``'s select2 auto-init is now opt-in.** Only a ``<select>`` carrying a
  ``snapadmin-select2`` class (or ``data-snapadmin-select2`` attribute) gets initialised — not
  every ``<select>`` on the page minus a denylist. The old broad selector reached the changelist's
  own action dropdown; a themed admin renders that dropdown through Alpine, and select2 taking the
  element over silently broke bulk actions, with no error anywhere. Add the class to a field's
  widget to opt it back in.

* **``SNAPADMIN_CONNECTIVITY_ENABLED`` now defaults to ``False`` (previously always on).** The
  admin-wide health poll, save-blocking guard and sidebar sync badge no longer load unless
  explicitly enabled *and* at least one registered model has ``offline_mode = True``. A deployment
  with ``SNAPADMIN_REST_API_ENABLED = False`` (a documented, supported combination —
  ``SNAPADMIN_PROFILE = "admin"`` produces it) used to poll a permanently-404ing ``/api/health/``
  and block every Save button after the first interval. Set
  ``SNAPADMIN_CONNECTIVITY_ENABLED = True`` to restore the previous behaviour. See `Offline Mode
  <https://drofji.github.io/django-snapadmin/#offline>`_.

* **``SNAPADMIN_REST_API_ENABLED`` and ``SNAPADMIN_GRAPHQL_ENABLED`` now default to ``False``**
  (previously ``True``). A project migrating from a plain Django admin no longer gets a writable
  REST/GraphQL surface for every registered model just by including ``snapadmin.urls`` — it has to
  ask for one. This is the flip the beta series announced via ``snapadmin.W014`` (now retired —
  its premise, "left unset and still mounted," is unreachable once unset means off); pin either
  setting to ``True`` to restore the previous behaviour. See the migration guide:
  ``docs/migrations/0.1.0b7_to_0.1.0b8.md``.

* **``djangorestframework``, ``drf-spectacular``, ``django-filter`` and ``graphene-django`` are no
  longer core dependencies.** They moved behind two new extras: ``[api]`` (the first three — REST
  plus its OpenAPI schema/Swagger/ReDoc) and ``[graphql]`` (graphene-django, independent of
  ``[api]``). A bare ``pip install django-snapadmin`` now pulls only Django, structlog and nh3. Both
  features above default to ``False``, so most installs only need the matching extra once they turn
  a surface on: ``pip install django-snapadmin[api,graphql]`` (or ``[all]``, which reproduces the
  pre-0.1.0b8 dependency graph — a no-op upgrade for an install that already has everything). Turning a
  feature on with its extra missing fails loudly — ``snapadmin.urls`` raises
  ``ImproperlyConfigured`` naming the extra, and the new check ``snapadmin.E010`` catches it even
  earlier, at ``manage.py check``. See the migration guide: ``docs/migrations/0.1.0b7_to_0.1.0b8.md``.

Retro-note (this heading is new — two known historical cases predate it and were never called out
per release):

* ``SnapModel.get_admin_fields()``'s return arity silently grew from four values to five in an
  earlier pre-1.0 release, with no changelog entry. Downstream code that unpacked it positionally
  broke with ``ValueError: too many values to unpack`` during admin autodiscovery. The shape is
  pinned going forward (see the generated-admin fix in this same release) so it cannot shift
  silently again.

* ``django-admin-rangefilter`` stopped being a dependency in ``0.1.0b6`` — already documented
  under that release's ``Removed`` heading; see ``docs/releases/0.1.0b6.txt`` rather than
  duplicating the note here.

* **``snapadmin.purge_expired_data`` now also purges the audit log.** ``SNAPADMIN_AUDIT_RETENTION_DAYS``
  already documented a 365-day default, but until now the only thing that ever read it was
  ``snapadmin_audit_export --purge`` — the scheduled task/command never touched
  ``SnapadminAuditLog`` at all. A project that already schedules ``purge_expired_data`` via Celery
  Beat and has audit rows older than 365 days will see them deleted on the first run after
  upgrading. Set ``SNAPADMIN_AUDIT_RETENTION_DAYS = 0`` to keep every audit row indefinitely, as
  before.

* **Every registered model must now declare ``subject_path``.** New check ``snapadmin.E011`` fails
  ``manage.py check`` for *any* registered ``SnapModel``/``@snap_model`` model that never declares
  ``subject_path`` at all — ``subject_path = None`` is a valid, explicit answer ("this model carries
  nothing reachable from a GDPR data subject"), but silence is not. This is unconditional, not behind
  a feature flag: it is the declaration the new ``snapadmin_subject_request`` export/deletion command
  depends on to know what it can safely reach, and the whole point of the check is that a model
  nobody looked at cannot silently opt itself out of a legally-binding export. **Every existing
  registered model in every project needs one line added** before ``manage.py check`` passes again
  after upgrading::

      class Product(SnapModel):
          ...
          subject_path = None   # add this — or a real path for a model that does carry PII

  A model whose rows are genuinely reachable from a subject (an order, a support ticket, anything with
  a foreign key back to a customer) should get a real path instead — see `GDPR Subject-Access Requests
  <https://drofji.github.io/django-snapadmin/#gdpr-subject-request>`_ for the declaration shape.

Added
-----

* **Encrypted model fields get their key management layer.** Field-level encryption — ciphertext at
  rest in the database, ordinary Python values in application code — rests entirely on one thing
  being configured correctly, and this release ships that part: the keyset. One dict,
  ``SNAPADMIN_ENCRYPTION``, is resolved from four sources, most secure first, and the **first one
  configured wins — they are never merged**, so a stray environment variable cannot half-override a
  secret store. In order: ``KEY_PROVIDER`` (a dotted path to a callable, the hook for KMS, Vault or
  Secrets Manager, so nothing secret touches settings *or* the environment; called once per process,
  never per query), ``KEY_FILE`` (a mounted container secret, also settable as
  ``SNAPADMIN_ENCRYPTION_KEY_FILE``), the ``SNAPADMIN_ENCRYPTION_KEYS`` environment variable
  (``id:key`` entries, comma- or newline-separated — the ``.env`` path), and finally literal
  ``KEYS`` in the settings module, which works but is warned about whenever ``DEBUG`` is off,
  because a key in a settings module is a key in version control.

  ``python manage.py snapadmin_encryption_key`` generates a key and prints it once, as the
  environment line to paste into a secret store — it writes it nowhere. ``--rotate`` prints a key to
  prepend plus the ids already configured, and never their material: the keyset is ordered, the
  first key encrypts, every key decrypts, and each ciphertext records the id of the key that wrote
  it, so rotation is prepending one line, deploying, re-encrypting, and only then dropping the old
  key.

  Two properties are enforced rather than documented. The encryption key can never be Django's
  ``SECRET_KEY`` (``snapadmin.E017`` fails ``manage.py check``): ``SECRET_KEY`` is rotated for
  session and CSRF reasons, and reusing it means the damage only becomes visible after a rotation,
  when every encrypted column is already unreadable. And key material is never rendered — not into a
  ``repr``, a ``str``, a log line, an exception message or any ``snapadmin_info`` section; only a key
  id and a short domain-separated fingerprint, which exists so that a restore into an environment
  holding a *different* keyset is diagnosable instead of looking like data corruption.

  The design is fail-closed. An encrypted field declared with no resolvable keyset stops startup
  (``snapadmin.E018``); a malformed keyset is reported (``snapadmin.E019``) rather than skipped; a
  mounted key file readable by group or others is flagged (``snapadmin.W016``); and
  ``STRICT: False`` relaxes only the startup check, never the runtime refusal to touch an encrypted
  field without a key (``snapadmin.W018`` says so out loud). ``snapadmin_info --section features``
  now reports ``field_encryption`` with the key source, key count and keyset fingerprint.

  Existing projects are unaffected in every respect: with no encrypted field declared, the setting is
  never read, no dependency is imported and no query changes. The ``SnapEncrypted*Field`` family that
  consumes this layer ships next.

* **Declarative database sharding and read-replica routing.** A project outgrowing one database can
  now add shards and read replicas by editing one settings dict —
  ``SNAPADMIN_SHARDING = {"ENABLED": True, ...}`` — instead of hand-rolling Django's ``DATABASES``/
  ``DATABASE_ROUTERS`` plumbing. Two configuration shapes resolve to the same thing: a flat
  ``DATABASES`` list of DSNs that the module auto-slices into shards and replicas
  (``SHARDING_ENABLED``/``MIRRORING_ENABLED``/``REPLICAS_PER_SHARD``), or an explicit ``SHARDS``
  mapping naming exactly which DSN is which shard's primary/replica. ``SnapAdminRouter`` (registered
  automatically) resolves a query's shard by ``modulo``, ``hash``, ``range`` or a ``CUSTOM_ROUTER_FUNC``,
  fails writes over to a live replica when the primary is down (``HA_SETTINGS['AUTO_FAILOVER']``,
  off by default — enable it only against a replica that can genuinely be promoted, since a
  read-only standby rejects the write anyway and one that accepts it diverges from the primary and
  loses those rows when replication resumes), and
  selects a read replica by ``random``/``round_robin``/``first_available`` — falling back to the
  primary when every replica is down, or raising when ``HA_SETTINGS['FALLBACK_TO_PRIMARY']`` is
  ``False`` rather than silently loading the primary with the traffic replicas exist to shield it
  from. ``snap_master_only()``/``snap_target(shard=..., replica=...)`` force routing for one block of
  code — both work as a context manager and as a decorator, on a plain function or an ``async def``
  one alike, via ``contextvars`` so one request's override never leaks into a concurrent one. A model
  ``STRATEGY`` defaults to ``modulo``, which maps a key to a shard with ``int(value) % shard_count``
  and therefore needs an integer key: shard by a string or ``UUID`` key with ``STRATEGY = "hash"``
  instead, or the router raises ``ShardResolutionError`` naming the field, the strategy and the
  value's *type* — never the value itself, since a shard key is frequently a natural key such as an
  email address and that message reaches logs and error pages. ``snapadmin_info --section features``
  reports the shard count, the replica count and the active strategy, and reports a shard map
  holding an unparseable DSN as off rather than claiming a capability that cannot resolve. A model
  opts in with a ``shard_key`` attribute (or the matching ``snap_model()`` keyword) — mirroring how
  ``tenant_scoped`` opts a model into multi-tenancy — so nothing, including Django's own ``auth``/
  ``sessions``/``admin`` tables, is ever sharded without asking. ``python manage.py snap_migrate``
  runs migrations against every shard's primary (sequentially, or all at once with ``--parallel``),
  and ``manage.py snapadmin_db_backup`` now backs up every shard's primary too, one independently
  checksummed manifest part per shard — a replica is never touched by either command. A new
  ``snapadmin.sharding.ids.uuid7()`` helper generates RFC 9562 time-ordered UUIDs for a model that
  wants a collision-free primary key across shards (opt-in — switching a primary key type is the one
  schema change this feature accepts, and it is entirely the adopting project's own choice). With
  ``SNAPADMIN_SHARDING`` unset or ``ENABLED: False``, nothing here runs: no new ``DATABASES`` entry,
  no extra router, no query overhead — an existing single-database project is unaffected.

* **Generate AGE backup-encryption keypairs from the library.** ``SNAPADMIN_BACKUP_AGE_RECIPIENTS``
  has always *consumed* age public keys generated some other way; ``python manage.py
  snapadmin_age_keygen`` now generates one, using the same ``pyrage``/``age-keygen`` backend
  resolution the backup encryption pipeline already uses. The keypair is written to a new ``.age/``
  directory at the project root — only the public recipient is ever printed, the private key is
  written once and never logged. Before writing anything, the command checks the project's
  ``.gitignore`` for a rule that would already exclude ``.age/`` — recognising not just a literal
  ``.age``/``.age/`` line but broader patterns too (a leading-slash anchor, a ``**/`` prefix or
  ``/**`` suffix, and a blanket dotfile rule like ``.*``) — and appends one with a clear message if
  none is found, rather than silently trusting the private key won't end up committed. Every run
  closes with an unmissable reminder to move the private key to a secure location and delete the
  local ``.age/`` directory, which is a convenience for generation, never a place to keep a private
  key long-term.

* **``snap_field()`` reaches full parity with ``Snap*Field`` — every constructor kwarg is now
  accepted.** Three that used to raise ``ValueError`` are now supported: ``required=True``
  (mutates ``null``/``blank`` directly, exactly what a hand-built ``Snap*Field(required=True)``
  produces — the one kwarg that can add a migration, since it is schema-affecting by design) and
  the file-upload trio ``allowed_extensions``/``allowed_encodings``/``max_size_bytes`` (attaches the
  same upload validator ``SnapFileField``/``SnapImageField`` build, on a ``FileField`` or
  ``ImageField`` — a non-file field now raises a clear error instead of failing later on a
  confusing ``AttributeError``). A parametrised parity matrix and a drift guard pin the wrapper
  against the class route going forward: a new ``Snap*Field`` attribute added without a
  ``snap_field()`` equivalent now fails the test suite until it is either wired in or recorded as
  a documented, reasoned gap.

* **``SNAPADMIN_PROFILE`` presets collapse ~90 settings to one line.** Set it to ``"admin"``
  (admin UI only — REST, GraphQL, Swagger and GraphiQL off), ``"api"`` (REST + GraphQL on), or
  ``"full"`` (today's defaults, and the implicit value when unset). An explicit setting always
  wins over the profile, and leaving ``SNAPADMIN_PROFILE`` unset changes nothing — every existing
  install resolves exactly as it did before, pinned by a test walking every setting the package
  reads. ``snapadmin.conf.get_setting(name, default)`` is the new single resolution point every
  ``SNAPADMIN_*`` read site goes through (explicit setting > active profile > built-in default).
  Misconfiguration is caught by ``manage.py check``: an unrecognised profile name is
  ``snapadmin.E006``, and an explicit setting that quietly overrides what the active profile would
  otherwise set is ``snapadmin.W009``. See the `SNAPADMIN_PROFILE presets
  <https://drofji.github.io/django-snapadmin/#profiles>`_ docs.

* **``snapadmin_info`` now names which door each model came through, and what that costs it.**
  The ``inventory`` section (``snapadmin_info --section inventory``) gains two columns per model:
  ``door`` (``subclass`` for a ``SnapModel`` subclass, ``decorator`` for a plain model registered
  with ``@snap_model``) and ``inactive_capabilities`` — the exact machinery that door does not get
  (Elasticsearch mirroring, retention purge, the generated admin), read off the same markers the
  runtime gates already check rather than guessed from the model's shape. Previously a decorated
  model's missing capabilities were invisible to the report; now they are named, per model, right
  next to the settings that look identical on both routes.

* **``@snap_property`` — a computed admin column written as a method, not a field assignment.**
  The decorator form of ``SnapFunctionField``::

      class OrderItem(SnapModel):
          @snap_property(verbose_name="Line total")
          def line_total(self):
              return f"{self.quantity * self.price:.2f}"

  is exactly the same column as ``line_total = SnapFunctionField(func=lambda obj: ..., verbose_name=...)``
  — no database column, no migration, HTML-escaped unless ``safe_html=True``. It is not a second
  rendering path: the decorator builds the identical ``SnapFunctionField`` instance the field form
  builds and stores it under the method's name, so the admin's existing scan for one picks it up
  unmodified. Works on both doors — a ``SnapModel`` subclass renders the column immediately; on a
  ``@snap_model``-decorated plain model the computation is already correct, but has nowhere to
  display until the decorated-model generated-admin gap closes (tracked separately, unreleased).

* **``get_model_meta()`` gains a third precedence tier — a project-wide ``SNAPADMIN_<NAME>``
  setting.** The full order is now: explicit decorator argument > class attribute > a
  ``SNAPADMIN_<NAME>`` setting (``name`` upper-cased, e.g. ``api_read_only`` consults
  ``SNAPADMIN_API_READ_ONLY``) > the caller's built-in default — resolved through
  ``snapadmin.conf.get_setting``, so a ``SNAPADMIN_PROFILE`` preset can supply it too. The new tier
  is only ever reachable on the ``@snap_model`` route: a ``SnapModel`` subclass always has a class
  attribute to answer from (its own, or an inherited base-class default), so it never falls through
  to a setting — this closes one more gap between the two doors, letting a decorated plain model
  inherit a project-wide posture instead of always landing on the library's hard-coded default.

* **A runnable integration checklist — and ``snapadmin-init`` now prints it.** The new `Integration
  Checklist <https://drofji.github.io/django-snapadmin/#integration-checklist>`_ docs page groups
  the "is my integration actually correct?" questions into Must work / Should be configured before
  production / Data safety / Optional-scale, and every row names the exact command that proves it.
  ``snapadmin-init``'s report *is* this checklist now: each row prints ✅ (present), ❌ (missing —
  with the snippet to add) or ⚠️ (not checked — this doctor is read-only and runs before a project
  may even be configured, so anything needing a live database or server degrades to "not checked"
  rather than a false green). Data safety includes backup encryption, framed as strongly
  recommended, and a reminder to actually test a restore.

* **Database backups can be encrypted in-stream with AGE.** Set ``SNAPADMIN_BACKUP_AGE_RECIPIENTS``
  (a list of one or more age/SSH public keys) and every dump is encrypted before a single byte
  reaches disk — ``pg_dump``/SQLite → gzip → age → the ``.age``-suffixed file. Any one of the
  configured recipients' private keys decrypts a bundle independently — no shared secret, no
  re-encryption to add a reader. Two interchangeable backends
  (``SNAPADMIN_BACKUP_AGE_BACKEND``): the in-process ``pyrage`` library (new optional ``[age]``
  extra) or the ``age`` command-line tool. With the setting empty (the default), nothing changes.
  See `Encrypting backups <https://drofji.github.io/django-snapadmin/#backup-encryption>`_.

* **A backup run can now bundle media and an encrypted ``.env`` alongside the database.** Set
  ``SNAPADMIN_BACKUP_INCLUDE`` (default ``["db"]``, so this is entirely opt-in) to any subset of
  ``db``, ``media``, ``env``. Media (``MEDIA_ROOT``) is tarred and streamed — never built in
  memory — with an exclude-glob setting (``SNAPADMIN_BACKUP_MEDIA_EXCLUDE``) and a size guard that
  warns rather than aborts past a configurable threshold; an unreadable file is skipped with a
  warning instead of failing the whole backup. Every run now also produces one always-unencrypted
  ``manifest.json`` sidecar listing the parts actually shipped, their ciphertext checksums, package
  versions and a ready-to-paste restore command. Retention (``SNAPADMIN_BACKUP_KEEP``) applies per
  part, so opting into media never starves the database dump's own retention headroom. See `Media
  and .env in the bundle <https://drofji.github.io/django-snapadmin/#backup-bundle>`_.

* **A real restore command: ``manage.py snapadmin_restore``.** Backups nobody has restored are not
  backups. ``<source>`` is a local manifest path or ``<destination>:<name>`` to pull straight from
  any configured destination; ``--list`` enumerates what is available without downloading anything.
  **Dry-run by default** — without ``--confirm`` it prints exactly what would happen (which parts,
  which database, whether ``.env`` would be overwritten) and touches nothing. Every part's checksum
  is verified against the manifest before anything is applied, so a truncated or corrupted upload is
  refused rather than half-restored; an encrypted bundle restored with no ``--identity`` prints the
  recipient count and fingerprints instead of failing on an opaque parse error. ``--only``/``--skip``
  select parts (comma-separated ``db,media,env``); ``env`` is never restored by a bare ``--confirm``
  — it must be named explicitly, since it overwrites secrets. Restoring ``db`` is not live-safe:
  existing connections are terminated and, for PostgreSQL, the database is dropped and recreated
  before the dump loads. See `Restoring a backup
  <https://drofji.github.io/django-snapadmin/#restore>`_.

* **An automatic pre-restore safety net: ``manage.py snapadmin_rollback``.** Before a
  ``--confirm``ed restore touches anything, the current live state of every part it is about to
  overwrite is automatically snapshotted (encrypted the same way a real backup would be) into
  ``SNAPADMIN_RESTORE_SNAPSHOT_DIR`` (default: a ``rollback/`` subdirectory of the local backup
  directory), with the snapshot id printed prominently. **If the snapshot itself fails, the restore
  is aborted** rather than proceeding on a best-effort basis. ``snapadmin_rollback [<id>]`` restores
  a snapshot — with no id, the most recent one, named in the output; also dry-run by default, with
  the same ``--confirm``. Snapshots have their own short retention
  (``SNAPADMIN_RESTORE_SNAPSHOT_KEEP``, default 3), separate from ``SNAPADMIN_BACKUP_KEEP``, so they
  never compete with the real backup policy for disk. ``--no-snapshot`` exists for the operator who
  knows better and prints a loud warning when used. See `The pre-restore safety net
  <https://drofji.github.io/django-snapadmin/#restore-rollback>`_.

* **A fifth backup destination: any S3-compatible object store.** Set
  ``SNAPADMIN_BACKUP_S3_BUCKET`` (new optional ``[s3]`` extra, ``boto3``) and dumps ship there too,
  on their own schedule (``SNAPADMIN_BACKUP_S3_EVERY_HOURS``). ``SNAPADMIN_BACKUP_S3_ENDPOINT_URL``
  is the one setting that turns this into a transport for AWS S3, MinIO, Backblaze B2, Hetzner
  **Object Storage** or Wasabi — leave it unset for AWS. Uploads go through boto3's own managed
  transfer (``upload_file``), which multiparts automatically above its default threshold, so a large
  media bundle needs no special handling. Leaving ``SNAPADMIN_BACKUP_S3_ACCESS_KEY_ID``/
  ``_SECRET_ACCESS_KEY`` unset uses boto3's ambient credential chain (environment variables, a shared
  config file, an IAM role / instance profile) — the right choice on AWS, where a static key would be
  a downgrade. A new check, ``snapadmin.W011``, warns when a bucket is configured with neither an
  explicit key pair nor a detectable ambient credential source, or with a malformed
  ``SNAPADMIN_BACKUP_S3_ENDPOINT_URL``. **Hetzner Storage Box is not S3** — it speaks SFTP/SCP/WebDAV
  and already had a home in the ``sftp`` destination; the docs now carry a worked recipe for it (port
  23, a sub-account, key auth, pre-populating ``known_hosts``). See `3-2-1 Database Backups
  <https://drofji.github.io/django-snapadmin/#backups>`_.

* **``snapadmin_info`` reports on backups, and the feature-adoption checklist knows about restores.**
  A new ``backups`` section lists every active destination, its last-run time, whether AGE encryption
  is on (with recipient fingerprints — never the identity) and whether a restore has ever completed.
  The existing ``features`` section's backup line now also names the active destinations and reports
  once a restore has run.

* **``SnapModel.get_admin_fields()`` returns a pinned ``AdminFieldSets`` named tuple** —
  ``(form_fields, list_display, search_fields, list_filter, autocomplete_fields)`` — instead of a
  bare 5-tuple. Backward-compatible by construction: positional unpacking, indexing and ``len()``
  all keep working exactly as before; a future sixth member is still a breaking change, just an
  announced one now instead of a silent ``ValueError`` at admin autodiscover.

* **``SnapModel.get_admin_media()``** — the base admin ``(js, css)`` asset lists (theme-sheet
  selection, the ``connectivity.js``/``offline.js`` gating and de-duplication with
  ``js_admin_files``/``css_admin_files`` all included) as a public, typed classmethod. A project
  overriding ``register_admin()`` can call it to extend the real lists instead of copying a snapshot
  that rots at the next release. See `Extending the generated admin
  <https://drofji.github.io/django-snapadmin/#admin-extension-surface>`_.

* **API tokens can be scoped to a project's own endpoints, not just SnapAdmin's generated model
  routes.** ``APIToken.allowed_scopes`` (new field, one migration) carries free-form strings a
  custom view checks with ``token_has_scope(token, "reports:read")`` — SnapAdmin only stores and
  matches them, the meaning is entirely the project's. Unlike ``allowed_models``, an **empty**
  ``allowed_scopes`` denies every scope check (fail-closed): there is no Django-permission
  equivalent an opaque, project-defined string could delegate to, so a freshly minted token gates
  something by default instead of passing every check until explicitly restricted.

* **``POST /api/tokens/<id>/rotate/`` — replace a leaked key without losing the row.** Mints a new
  secret in place: same row, id, scopes and history survive; the new raw key is returned exactly
  once, the way creation returns it, and the old key stops authenticating immediately. Also
  available as ``APIToken.rotate()``. Every rotation is written to the audit trail (never the raw
  key — only the non-secret prefix).

* **``POST /api/tokens/<id>/deactivate/`` — the documented revocation path that doesn't delete the
  row.** Flips ``is_active`` off; ``DELETE`` remains for administrators who want the row gone
  outright. A regular user manages their own tokens — list, create, rotate, deactivate — without
  needing to be a superuser, scoped by the existing ``token.user == request.user`` queryset filter
  rather than a new permission class.

* **``manage.py snapadmin_reindex --verify`` catches a reindex that quietly came up short.** The
  command used to report success on the strength of its own loop counter, which looks identical
  whether the index ended up complete or fell short. ``--verify`` asks Elasticsearch for the
  index's actual document count once a model's run finishes and compares it against the source row
  count the run itself recorded (honouring ``--limit``), discounting documents Elasticsearch itself
  rejected — those were never going to be indexed regardless of how correct the run was — and
  skipping the check entirely for ``ES_ONLY`` models, which have no independent source to compare
  against. **A mismatch exits non-zero**, the same as any other per-model failure.

* **``--progress-interval SECONDS`` (default 5) throttles the reindex command's progress line.**
  Previously one line was printed per chunk — for a multi-hour run in a detached container, the
  standard way these are run, that meant tens of thousands of log lines. The first line and the
  line reporting a model's completion, cancellation or failure always print regardless of the
  interval, so a run's outcome is never swallowed by the throttle.

* **A reusable quota primitive: ``snapadmin.limits.reserve(key, windows, concurrency)``.** Projects
  kept rebuilding the same cache-backed sliding window for a per-tenant or per-token quota across
  several time windows at once (per second *and* per minute *and* per day), a concurrency cap, and
  a cooldown after an upstream service answers ``429`` — including for calls the project makes
  *outbound* to a third party, which a request-scoped DRF throttle has no way to express. This has
  no opinion about what ``key`` means, so it serves an inbound API guard and an outbound client
  call identically::

      from snapadmin.limits import reserve

      with reserve(f"tenant:{tenant_id}", windows={60: 100, 3600: 1000}, concurrency=5) as slot:
          if not slot.allowed:
              return too_many_requests(retry_after=slot.retry_after)
          call_the_upstream_api()

  ``snapadmin.limits.cooldown(key, seconds)`` is the separate, explicit signal for the one thing
  ``reserve()`` cannot see on its own — that an upstream call just answered ``429`` — and blocks
  every ``reserve()`` for that key until it expires. Two honest limitations, documented in the
  docstring and the docs: counters are per-process unless ``SNAPADMIN_LIMITS_CACHE_ALIAS`` points
  at a shared cache, and a counter evicted under cache pressure fails open (allows), never closed.
  Demonstrated wired into an actual outbound call in the demo project's ``sync_exchange_rates
  --rate-limit N``. See `Quotas & Rate Limits <https://drofji.github.io/django-snapadmin/#quotas>`_.

* **Retention now takes uploaded files with it: ``SnapModel.data_retention_files``.** Deleting a
  row was never the whole GDPR story for a model whose row owns a file — the row is what remembers
  the file's name, so a purged row used to leave its file unreachable *and* undeletable without a
  separate storage sweep. Set ``data_retention_files = ["field_name", ...]`` to the
  ``SnapFileField``/``SnapImageField`` names to delete alongside an expiring row. Files are deleted
  **before** the row, so a storage failure leaves the row (and the file's name) intact and the purge
  retryable — ``purge_expired()`` raises ``SnapPurgeError`` rather than silently continuing. A path
  another live row still references is skipped, never deleted out from under it. ``dry_run=True``
  touches nothing, as before. Unset (the default) changes nothing. See `GDPR Data Retention
  <https://drofji.github.io/django-snapadmin/#gdpr>`_.

* **Export/reindex job housekeeping: ``SNAPADMIN_EXPORT_RETENTION_DAYS``.** Export and reindex job
  rows — and the CSV/JSON/XLSX files an export wrote — were never cleaned up at all, quietly filling
  a disk on a project that runs scheduled exports. Set this to a number of days (unset/off by
  default, since unlike the two retention sweeps above this one deletes files a project may want to
  keep) and ``snapadmin.purge_expired_data`` deletes finished ``SnapExportJob``/``SnapReindexJob``
  rows past the window along with their published files, plus a sweep for any export file left
  behind with no job row at all (the state a worker that died mid-export leaves). Assumes the export
  storage location is dedicated to SnapAdmin exports.

* **The audit log now purges itself automatically.** ``SnapadminAuditLog`` is not a ``SnapModel``,
  so ``snapadmin.purge_expired_data`` never reached it — on a default install (the audit log is on
  by default) the table grew forever unless someone remembered to run
  ``snapadmin_audit_export --purge`` by hand. The same task/command now purges it explicitly against
  ``SNAPADMIN_AUDIT_RETENTION_DAYS`` (default 365, unchanged), every time it runs. Rows stay
  append-only — the purge uses ``QuerySet.delete()``, the one sanctioned bypass of that guard.
  ``snapadmin_audit_export --purge`` is unaffected and keeps working exactly as before.

* **New check ``snapadmin.W012``** warns when retention is configured somewhere — a model's
  ``data_retention_days``, the audit log's on-by-default window, or
  ``SNAPADMIN_EXPORT_RETENTION_DAYS`` — but no ``CELERY_BEAT_SCHEDULE`` entry runs
  ``snapadmin.purge_expired_data`` to actually enforce it. That combination — retention configured,
  nothing scheduled — is the state every retention report behind this release turned out to be in.

* **One table listing every purge SnapAdmin performs.** The audit log, error events, export/reindex
  jobs, expired API tokens and model-level ``data_retention_days`` were each documented separately,
  so a reader could see one sweep was automatic and reasonably assume the rest were too. `The full
  purge table <https://drofji.github.io/django-snapadmin/#retention-table>`_ lists all five: what
  table, what setting, what removes it, and the recommended schedule.

* **``@snap_action`` — a user-defined REST action on the generated viewset.** A decorated model
  method becomes a callable endpoint::

      class Order(SnapModel):
          @snap_action()
          def recalculate_total(self, request):
              self.total = sum(item.quantity * item.price for item in self.items.all())
              self.save(update_fields=["total"])
              return {"total": str(self.total)}

  reachable at ``POST /api/models/<app_label>/<Model>/<pk>/recalculate_total/`` (detail-level, the
  default) or the list-level route with ``detail=False``. Bound by the model's own
  ``api_read_only``/``api_http_method_names`` policy through the same ``http_method_names``
  descriptor a regular ``PATCH``/``DELETE`` is measured against — a write action can never reach a
  model configured read-only, structurally, not via a second check that could drift out of sync —
  and by a Django permission (``view_<model>``/``change_<model>``, derived from the action's own
  methods, or an explicit ``permission="app_label.codename"``). Works on both a ``SnapModel``
  subclass and a ``@snap_model``-decorated plain model, since discovery reads the method straight
  off the class, no model-class machinery involved. **GraphQL has no mutation counterpart and none
  is planned** — the schema is read-only by design; this is a REST-only surface, stated explicitly
  rather than left to be discovered. Every model's registered actions (name, scope, methods, URL)
  are listed at ``GET /api/models/schema/``. New check ``snapadmin.E008`` catches an action whose
  declared methods conflict with its own model's CRUD policy at boot — dead configuration that would
  otherwise always answer ``405`` at first request. See `User-Defined REST Actions
  <https://drofji.github.io/django-snapadmin/#snap-action>`_.

* **``api_field_permissions`` — declarative field-level read/write guards for REST and GraphQL.**
  A model-level mapping, resolved through ``get_model_meta`` exactly like ``api_write_fields``/
  ``api_exclude_fields`` already are::

      class Employee(SnapModel):
          salary = SnapDecimalField(...)
          api_field_permissions = {
              "salary": {"read": "hr.view_salary", "write": "hr.change_salary"},
          }

  A caller lacking the named permission never sees the field at all — **absent** from a REST
  response (not ``null``, not an error) or **nulled** in GraphQL (the schema is built once at
  import time, so a per-request field cannot be removed from the response shape the way REST's
  serializer can — a documented, deliberate asymmetry) — and a denied **write** answers an explicit
  ``400`` naming the field, because a silently dropped write is a data-loss bug the caller cannot
  detect. This is a **third**, orthogonal guard alongside ``api_exclude_fields`` (absolute, wins
  over everything) and ``api_write_fields`` (silently forces a field read-only, its older,
  deliberately different contract, left unchanged) — and it composes with PII masking in one fixed
  order: the permission gate decides whether a field appears at all, masking decides whether what
  appears is raw or starred. Extends the same permission mechanism ``SNAPADMIN_MASKING_RULES``' own
  per-field ``permission`` grant already established, rather than a second, incompatible one. Wired
  into REST (the serializer, plus the ``?field=``/``?ordering=``/``?search=`` oracle-prevention
  filters) and GraphQL this round; the admin form and background export gain the identical guard in
  a follow-up (both hooks are already named precisely in the design). See `Field-Level Permission
  Guards <https://drofji.github.io/django-snapadmin/#field-permissions>`_.

* **``manage.py snapadmin_import`` — CSV/NDJSON import, the write-side counterpart to async
  export.** The export side was complete — jobs, formats, streaming, resumability, pluggable
  sources — with no import at all, so every project feeding master data in from another system
  wrote the same thing from scratch. A new ``SnapImportJob`` (one migration) mirrors
  ``SnapExportJob``'s architecture in reverse::

      python manage.py snapadmin_import --model demo.Product --file products.csv
      python manage.py snapadmin_import --model demo.Product --file p.csv \
          --map '{"Product Name": "name"}' --natural-key name --on-conflict update

  Column mapping is header-name matching by default (case/whitespace/underscore-insensitive,
  against the field name and its ``verbose_name``), plus an explicit ``--map`` override; an
  unmapped column is reported and skipped, never guessed at. The duplicate key
  (``--natural-key``) defaults to the model's first ``unique=True`` field, or the mapped primary
  key, or nothing (every row a create) if neither applies. **``--on-conflict`` defaults to
  ``fail``** — ``skip``/``update`` are opt-in — because an import that silently overwrites
  production rows over a missing flag is exactly the class of bug this project's write-surface
  hardening exists to close; a "fail" duplicate is reported as a failed *row*, never a failed
  *run*. Validation runs through the model's own ``full_clean()`` — no parallel layer. The run's
  report is one NDJSON line per row (``row``/``action``/``pk``/``errors``) plus a summary line,
  written through the same storage seam the export API downloads from. **Crash-safe, chunked
  resume:** every row's write, the job's own progress counters and the report's confirmed byte
  length commit together as one transaction per chunk, so a crash loses at most one chunk's
  progress and ``--resume`` can never re-create a row an earlier attempt already committed.
  **The import path is a write surface from the first line, not a follow-up** —
  ``api_write_fields``, ``api_exclude_fields``, ``api_read_only``/``api_http_method_names`` and PII
  masking are enforced against the resolved column mapping before a single row is processed; a
  column targeting an excluded, non-allowlisted or (with no ``--requested-by`` user holding PII
  access) masked field fails the whole run, naming the field, rather than writing it silently. See
  `Bulk Import <https://drofji.github.io/django-snapadmin/#bulk-import>`_.

* **``POST .../fetch-by/`` fetches a large explicit key set in one call.** ``export`` streams a
  *filtered* result set; there was no way to ask for a large, explicitly enumerated set of records
  by key in one request — exactly what a project synchronising against another system needs.
  ``POST /api/models/<app>/<Model>/fetch-by/`` with body ``{"field": "sku", "values": [...]}``
  answers that, as a small delta on the existing export streaming path. ``field`` must be
  ``unique=True`` or ``db_index=True`` on the target model — a ``400`` names the constraint
  otherwise, closing the unindexed-full-table-scan foot-gun a free-form field name would open.
  **``values`` is capped at ``SNAPADMIN_FETCH_BY_MAX_VALUES`` (default ``10000``); over the cap is a
  ``400``, never a truncation** — the cap exists before the route does, since an unbounded list is a
  denial-of-service vector. New check ``snapadmin.W013`` warns if the cap is raised so high it stops
  meaningfully bounding anything. Same NDJSON streaming, permissions and masking as ``export`` — a
  masked field can't be used as the lookup key either. **Reachable via ``POST`` even on an
  ``api_read_only`` model**, since fetch-by never writes anything; the read/write policy that gates
  ``create`` on the same URL segment does not apply to it. Not supported for ``ES_ONLY`` models,
  which have no DB column to index in the first place. See `fetch-by
  <https://drofji.github.io/django-snapadmin/#api-rest>`_.

* **GDPR subject-access requests: ``manage.py snapadmin_subject_request export|delete``.** Answers a
  different question than time-based retention — not "how old is too old" but "show (or delete)
  everything about this one person, right now" — by walking every registered model's own
  ``subject_path`` declaration (see the ``Breaking`` note above)::

      python manage.py snapadmin_subject_request export \\
          --model demo.Customer --identifier alice@example.com --user dpo_operator

      python manage.py snapadmin_subject_request delete \\
          --model demo.Customer --identifier alice@example.com --user dpo_operator --confirm

  **Gated on ``snapadmin.view_raw_pii``** — a SAR export is unmasked by design (it goes to the
  subject), which makes it a high-value artefact, so ``--user`` must already be trusted with raw PII;
  every run is written to the audit trail against that operator either way. **Export reuses the
  existing async-export machinery** (one ``SnapExportJob`` per matched model, the same masking bypass
  a PII-privileged requester already gets elsewhere), so there is no second "skip masking" code path
  to get wrong; ``--recipient`` AGE-encrypts the finished bundle and removes the plaintext.
  **Deletion is dry-run by default**, and both modes run the identical pre-flight — a Django deletion
  ``Collector`` walk over every matched row, which also discovers cascade spillover the
  ``subject_path`` declarations alone would not show, so the preview matches what ``--confirm``
  actually does. **Any protected relation (``on_delete=PROTECT``) refuses the whole run up front and
  deletes nothing**, rather than deleting in dependency order to route around it. The deletion audit
  entry cannot itself be swept away by a later request for the same subject: ``SnapadminAuditLog`` is
  deliberately outside the general SnapAdmin registry, so it carries no ``subject_path`` at all.
  **Honest limits, printed on every run:** this command cannot see or touch a backup bundle, an
  Elasticsearch copy a model does not itself mirror, or any third-party store outside SnapAdmin. See
  `GDPR Subject-Access Requests <https://drofji.github.io/django-snapadmin/#gdpr-subject-request>`_.

* **The async surface: ``asave``/``adelete``/``arefresh_from_db`` on ``SnapModel``, and
  ``aget``/``afirst``/``alast`` on ``EsManager``/``EsQuerySet``.** The three ``SnapModel`` methods
  are Django's own native async model methods (available since Django 5.2) — each is a thin wrapper
  around the matching sync method, and Python resolves that through the instance's actual class, so
  they already reach ``SnapModel``'s own overrides (the Elasticsearch mirror, a wysiwyg field's
  sanitize-on-write) with zero SnapAdmin-specific code. A test now pins that this stays true.
  ``EsQuerySet`` (the lightweight ``ES_ONLY`` query layer, which does not subclass Django's
  ``QuerySet`` and so got none of this for free) gains ``aget``/``afirst``/``alast`` to match — a
  DB-backed model's own ``QuerySet`` already had them. **Out of scope, deliberately:** async DRF
  ViewSets, an async Elasticsearch client, and bulk async operations
  (``abulk_create``/``abulk_update``) — see `Async support
  <https://drofji.github.io/django-snapadmin/#async-support>`_.

* **Row-level multi-tenancy (``snapadmin.tenancy``).** A model opts in with ``tenant_scoped = True``
  plus a tenant column (``tenant_field()``, a nullable indexed ``CharField`` — override any keyword,
  or declare a real ``ForeignKey`` by hand if the tenant is itself a project model). Once opted in,
  **every generated surface requires a bound tenant to see or write a single row** — the admin, REST,
  GraphQL, Elasticsearch routing (``es_search``/``es_filter``/``es_aggregate``/``es_count``/``es_scan``
  — the tenant term is forced into the query body, overriding any caller-supplied value for the same
  field), async export/import jobs, and the offline cache. **Default-deny:** with no tenant bound, a
  read returns empty and a write is refused outright — never "every row". Resolve the current tenant
  per request with the new ``snapadmin.tenancy.SnapTenantMiddleware`` plus
  ``SNAPADMIN_TENANT_RESOLVER`` (a dotted path, ``resolver(request) -> tenant | None``); for an async
  export/import job — which runs on a Celery worker with no request of its own — the submitter's
  tenant is resolved once at job-creation time via ``SNAPADMIN_TENANT_USER_RESOLVER``
  (``resolver(user) -> tenant | None``) and stamped onto the job row for the worker to replay.
  ``use_all_tenants()`` is the one explicit, audited escape hatch, reserved for background code whose
  job is inherently cross-tenant: the retention purge (a row's age decides whether it is purged, not
  its tenant) and the Elasticsearch reindex (the index must stay complete across every tenant). New
  check ``snapadmin.E009`` flags a ``tenant_scoped = True`` declared but unenforceable — no resolvable
  tenant field, or the model is registered via ``@snap_model`` rather than subclassing ``SnapModel``
  (the scoping hook lives in ``SnapModel``'s ``EsManager``, never a plain registered model's default
  manager). **Adds a migration** — a nullable ``tenant_id`` column on the internal job models
  (``SnapExportJob``/``SnapReindexJob``/``SnapImportJob``) plus, in the demo, on ``Order`` — additive
  and backward compatible, no existing row or install is affected until a model opts in. **Honest
  limit, stated as plainly as the feature:** isolation is *logical*, not physical — one query path
  that bypasses the scoped manager still leaks — and ``snapadmin.backup``'s database dumps run below
  the ORM entirely, so a backup bundle is **not** tenant-scoped: it contains every tenant's data,
  restoring one is an all-tenants operation. See `Multi-Tenancy
  <https://drofji.github.io/django-snapadmin/#multi-tenancy>`_.

* **``snapadmin_license_check`` now reports how stale its own data is.** The command's curated
  licence map is hand-maintained, so every report ends with the date it was last checked against
  ``pyproject.toml`` and each package's own licence metadata, and warns loudly once that is over
  180 days old rather than letting a fork or a long-idle install trust a table nobody has reviewed
  in months. Also in the ``--json`` payload as ``curated_reviewed_on`` / ``curated_age_days`` /
  ``curated_stale``. See `Licence Audit <https://drofji.github.io/django-snapadmin/#license-check>`_.

Changed
-------

* The shipped ``admin.js``'s select2 initialisation is opt-in now — see Breaking, above, for the
  migration note.

* ``SNAPADMIN_CONNECTIVITY_ENABLED`` gates the admin-wide connectivity layer and now defaults to
  ``False`` — see Breaking, above.

Removed
-------

* **The deprecated command aliases and underscored console scripts are removed in this release**,
  closing the beta-series removal window announced since ``0.1.0b6`` and reiterated in
  ``SECURITY.md``'s API-stability policy:
  ``db_backup``/``purge_expired_data``/``send_error_digest`` (management commands — use
  ``snapadmin_db_backup``/``snapadmin_purge_expired_data``/``snapadmin_send_error_digest``) and the
  underscored ``snapadmin_info``/``snapadmin_license_check`` console scripts (use the dashed
  ``snapadmin-info``/``snapadmin-license-check``, or ``manage.py snapadmin_info``/
  ``manage.py snapadmin_license_check``, both of which are unaffected). There is no runtime
  fallback left — see the migration guide: ``docs/migrations/0.1.0b7_to_0.1.0b8.md``.

Fixed
-----

* **The REST/GraphQL default-off flip above now reaches every layer, not just the URLconf.** This
  release changed ``SNAPADMIN_REST_API_ENABLED`` and ``SNAPADMIN_GRAPHQL_ENABLED`` to default to
  ``False``, but the change landed only in ``snapadmin/urls.py`` — the module that decides what is *mounted*. Every
  module that decides what is *reported* kept its own ``True``, so a project that never set either
  switch got an install that mounted no API while insisting one was there: ``manage.py check``
  raised two ``snapadmin.E010`` errors demanding the ``[api]``/``[graphql]`` extras for surfaces
  that would never be served (blocking startup under ``DEBUG = False``),
  ``snapadmin_info --section features`` listed ``rest_api`` and ``graphql`` as adopted, the
  ``snapadmin_info`` API and GraphQL health probes ran against endpoints that were not mounted, and
  the system dashboard rendered links to ``/api/`` and ``/api/graphql/``. All eleven read sites now
  resolve the default from one place (``snapadmin.conf.REST_API_ENABLED_DEFAULT`` /
  ``GRAPHQL_ENABLED_DEFAULT``), and a test fails the build if a new read site spells the default out
  for itself again. A project that sets either switch explicitly is unaffected — this only changes
  what an install that never configured the API reports about itself.

* **``SNAPADMIN_PROFILE = "api"`` no longer turns the API off.** The ``api`` and ``full`` presets
  were empty dicts, on the reasoning that the built-in defaults already turned REST and GraphQL on,
  so there was nothing for a preset to move. When those defaults flipped to ``False`` above, both
  profiles inverted along with them, and ``api`` — a profile whose entire purpose is "REST +
  GraphQL on" — began resolving ``SNAPADMIN_REST_API_ENABLED``, ``SNAPADMIN_GRAPHQL_ENABLED`` and
  ``SNAPADMIN_SWAGGER_ENABLED`` to ``False``, contradicting the preset table in the documentation.
  Both profiles now state their values explicitly, so a profile means what its name says regardless
  of what any built-in default happens to be. One consequence worth noting: ``full`` and "no
  profile" used to be interchangeable and are not any more — ``full`` turns the surfaces on, while
  leaving ``SNAPADMIN_PROFILE`` unset remains exactly the pre-profile behaviour it has always been.

* **A project generated by ``snapadmin-new`` now installs from its own ``requirements.txt``.** The
  generated settings list ``rest_framework``, ``drf_spectacular``, ``django_filters`` and
  ``graphene_django`` in ``INSTALLED_APPS``, but the requirements file asked for a bare
  ``django-snapadmin`` — and those four packages moved behind the ``[api]`` and ``[graphql]``
  extras, so a clean environment got a project that died at ``django.setup()`` with
  ``ModuleNotFoundError: No module named 'rest_framework'``, before any SnapAdmin check could
  explain why. The requirements file now asks for ``django-snapadmin[api,graphql]``, and the
  generated README says to install from it rather than by package name. This is the scaffold's
  core promise — ``migrate`` then ``runserver``, no manual edits — so it is now pinned by a test
  that maps every third-party app in the generated ``INSTALLED_APPS`` back to the extra that ships
  it. (The existing end-to-end test could not catch this: it runs in a development environment
  where every extra is already present.)

* **The demo project now actually demonstrates the three capabilities it was silent about.**
  ``demo/README.md`` claimed the project "deliberately exercises" PII masking while
  ``SNAPADMIN_MASKED_FIELDS`` and ``SNAPADMIN_MASKING_RULES`` were both empty dicts and
  ``snapadmin_info --section features`` reported ``pii_masking: off``. Masking is now configured for
  real (``CustomerProfile.bio``, redacted behind one narrow permission). Two further capabilities had
  shipped with no demo surface at all and now have one: ``@snap_model`` — the second of the two
  documented ways to declare a model, which had zero usages even though the documentation gives it a
  whole capability matrix — and ``api_field_permissions``. Both land on one new plain
  ``django.db.models.Model``, ``LegacyStockLevel``, opted in with the decorator rather than by
  subclassing, with the trade-offs visible rather than described: no Elasticsearch, no retention
  purge, and a hand-written ``ModelAdmin`` because the generator has nothing to work from. Its
  ``reorder_cost`` is readable only with ``demo.view_stock_cost``, which is the field-permission
  guard doing the thing masking cannot — removing a field rather than starring it.

* **The system dashboard's REST and GraphQL links are reversed rather than spelled out.** They were
  the literals ``/api/`` and ``/api/graphql/`` while the Swagger link beside them already used
  ``reverse()``, so under ``SNAPADMIN_URL_PREFIX`` — or in any project that includes
  ``snapadmin.urls`` somewhere other than ``/api/`` — the dashboard advertised two endpoints that
  answered 404, on the very surfaces it had just reported as enabled. A surface that is switched on
  but whose URLconf was never included now costs that one link instead of raising inside the view.

* **Stale references cleaned out of shipped comments and test docstrings.** Fourteen ``issue #N``
  markers survived in ``snapadmin/nesting.py``, ``demo/core/settings.py``, ``demo/core/urls.py`` and
  ``demo/dist.env``; there is no public issue tracker, so GitHub rendered each as a link to nothing.
  The descriptive half of each is kept (an "issue #7 — DORA / ISO 27001" note is now simply
  "DORA / ISO 27001"); the historical release notes for 0.1.0a8/a9 keep theirs, because those record
  what was published at the time. Two test docstrings also documented modules that no longer exist
  (``snapadmin/api/tasks.py``, ``management/commands/purge_expired_data.py``, both renamed releases
  ago) — an AI assistant reading them for orientation was being pointed at files that are not there.

* **``snapadmin_license_check`` tests no longer depend on the day they are run.** The ``--json``
  report's ``curated_age_days`` was asserted to be ``0``, which is only true on the date the curated
  licence table was last reviewed; the suite turned red every day after. No change to the command's
  behaviour.

* **A project's own ``admin_overrides`` no longer loses to the generated admin.** The generated
  ``get_readonly_fields`` and ``safe_html_<field>`` display methods are now merged onto the admin
  class *before* ``admin_overrides``, never written into ``admin_overrides`` itself — so a
  project's own callable of the same name always wins, regardless of write order. Previously, a
  project's own ``get_readonly_fields`` (adding display-only virtual methods to the read-only set)
  could be silently replaced by the generated one, taking every change form on the site down with
  ``FieldError: Unknown field(s)`` — nothing logged, nothing warned.

* **Off ``DEBUG``, the shipped media no longer downloads jQuery twice.** The base admin JS now
  picks ``jquery.js`` / ``jquery.min.js`` the same way Django's own ``ModelAdmin.media`` does
  (``"" if settings.DEBUG else ".min"``), so the two media lists collapse into a single entry when
  merged with a stock ``ModelAdmin``'s.

* The system dashboard's GitHub link pointed at the retired ``drofji/snapadmin`` slug (dead)
  instead of ``drofji/django-snapadmin``.

* **A scheduled task that did nothing, or half-failed, no longer looks like a success.** All six
  Celery tasks (``run_db_backups``, ``purge_expired_data``, ``purge_expired_tokens``,
  ``send_error_digest``, ``run_es_reindex``, ``send_health_alert``) now return a ``status`` key —
  ``"ok"`` / ``"partial"`` / ``"noop"`` / ``"disabled"`` — and a ``failed`` list alongside every
  existing key (purely additive), and **raise** instead of returning when every unit of work failed.
  One monitoring rule now covers all six: alert when ``status != "ok"``, page when the Celery task
  state is ``FAILURE``. Fixes the reported incident where a disabled backup schedule ran
  "successfully" for weeks with no backup ever taken, and a silently-failing offsite destination
  never surfaced anywhere but a log line. See `Celery & Periodic Tasks
  <https://drofji.github.io/django-snapadmin/#celery>`_.

* **``run_db_backups``'s due-time check no longer skips a day** when a run completes even slightly
  later than the previous day's ideal slot — ``_is_due()`` now applies a small tolerance (2% of the
  destination's own interval) that absorbs realistic scheduler jitter without materially changing
  when a backup actually runs. A new check, ``snapadmin.W010``, warns when the Celery Beat entry for
  ``run_db_backups`` runs less often than the shortest configured
  ``SNAPADMIN_BACKUP_*_EVERY_HOURS`` — that combination silently drops days regardless of the
  tolerance above.

* **A third database engine for backups: MySQL.** ``create_db_dump()`` (and the AGE-encrypted path)
  now shells out to ``mysqldump`` the same way the existing branch shells out to ``pg_dump`` —
  credentials via the ``MYSQL_PWD`` environment variable, never a command-line argument, so they
  never appear in ``ps`` output. Previously only PostgreSQL and SQLite were supported; a MySQL-backed
  install could not use the backup feature at all.

* **The dynamic model API answers an unknown or unregistered model the same way on every action,
  including ``retrieve``/``update``/``partial_update``.** Those three are provided by DRF without an
  explicit override, so they used to fall through to filtering an empty queryset instead of the
  clean, consistent 404 body (``{"detail": "Model 'X' not found in app 'Y'."}``) the other five
  actions already built for themselves. The check now runs once, in ``initial()``, before any
  handler runs — every current action and anything added later inherits it automatically instead of
  needing its own copy of the same guard.

* **``POST /api/exports/<id>/cancel/`` now stamps ``finished_at``**, alongside ``status=cancelled``
  — matching what completion and failure already do. A cancelled export can leave a real partial
  file on disk, and until now that job was invisible to anything measuring a retention window on
  ``finished_at``, including the new ``SNAPADMIN_EXPORT_RETENTION_DAYS`` purge.

Security
--------

* **``wysiwyg=True`` sanitize-on-write now covers ``snap_field()`` too, not just ``Snap*Field``.**
  A rich-text field declared through the wrapper is sanitized on every ORM write path
  (``Model.save()``, the REST API, ``bulk_create()``) — identically to
  ``SnapRichTextField``/``SnapTextField(wysiwyg=True)``, reusing the exact same sanitizer rather
  than a second one, and producing byte-identical stored output for the same input on both routes.
  Previously a ``snap_field(field, wysiwyg=True)`` column looked identical to its class-route
  equivalent at the call site and stored raw HTML at runtime — walking around the fail-closed HTML
  sanitization guarantee with no error and no warning. ``safe_html=True`` and
  ``auto_sanitize=False`` opt out identically on both routes; ``QuerySet.update()`` stays
  uncovered on both, as before (Django never calls ``pre_save()`` for it).

* **An unresolvable model on the dynamic API now denies every HTTP verb instead of falling back to
  full CRUD.** ``_resolve_http_method_names()``'s own docstring used to document the permissive
  fallback as intentional; combined with the 404 gap above, that was the exact combination that let
  a disallowed verb reach a handler for a model that does not exist. The 404 guard now runs *after*
  authentication and permission checks (asserted by a dedicated test), so an anonymous probe cannot
  use a 404-vs-401 difference to enumerate which models are registered without any credentials.
