0.1.0b10 — 2026-10-02 — Tenth Beta
==================================

Two rounds of hardening and a startup report. The second set of production field reports closed
eleven places where SnapAdmin silently did the wrong thing — a manager that replaced a mixin's own
and leaked across scopes, a REST action reachable with read-only rights, a retention purge or an
Elasticsearch delete that reported success after failing, spreadsheet formulas in CSV exports, an
audit trail that stored related objects' labels past masking — each now loud, with a way back
listed under ``Breaking``. The development server now says which capabilities are switched on,
which are off and which lack their extra, so a feature nobody remembers stops being invisible.

The quality program behind it now runs in full on every push: branch coverage gated at 100%,
property-based and fuzz tests, mutation testing (advisory), browser end-to-end tests on both admin
themes, a security regression suite naming the test behind every shipped fix, and a job that
installs every dependency at its declared minimum — whose first run raised five minimums that could
never have worked. ``python scripts/gates.py`` reproduces all of it locally. The README is half as
long, and both of its quickstarts are now executed by the test suite.

Breaking
--------

Twelve entries. Eleven come from the second round of production field reports and the quality work behind them: each is a
deliberate change of something that was silently wrong, and each comes with a one-line way back. The twelfth raises declared
dependency minimums that could not work, and its way forward is an upgrade.

* **``snapadmin.E027`` can stop ``manage.py check``.** It fires when ``SnapModel``'s own
  ``objects = EsManager()`` silently replaces a manager a mixin declares (``class
  Order(SnapModel, OwnerScopedMixin)`` — Django resolves a manager name by first match in MRO
  order), or when a ``tenant_scoped`` model's ``objects`` is anything but an ``EsManager``, which is
  where tenant isolation is enforced. Both are cross-scope data leaks that nothing reported before.
  Fix: declare a manager inheriting from both on the model (``class OrderManager(OwnerScopedManager,
  EsManager): pass``). If the plain ``EsManager`` really is meant on a model that is not
  tenant-scoped, add ``'snapadmin.E027'`` to ``SILENCED_SYSTEM_CHECKS``.
* **The ``APIToken`` admin is no longer registered while no API accepts tokens.** With both
  ``SNAPADMIN_REST_API_ENABLED`` and ``SNAPADMIN_GRAPHQL_ENABLED`` off, ``register_all_admins()``
  used to offer a screen for minting credentials to a surface that does not exist. A project that
  authenticates its own views with ``APITokenAuthentication`` sets
  ``SNAPADMIN_TOKEN_ADMIN_ENABLED = True`` to keep it.
* **The changelist hides a non-integer primary key.** A UUID ``id`` column was forced first into
  every generated changelist; it is now shown for integer keys only. ``admin_list_display_pk =
  True`` on the model brings it back. A key not named ``id`` used to produce a column that did not
  exist; it is now referenced by its real name.
* **An unmapped REST action is refused.** ``TokenModelPermission`` used to give any viewset action
  missing from its map the ``view`` floor — so a write action added later, and not mapped, would
  have been reachable with read-only rights. ``count``/``export``/``fetch_by``/``OPTIONS`` are now
  mapped to ``view`` explicitly and anything else is denied, superusers included. A project that
  added its own action to a subclassed ``DynamicModelViewSet`` maps it with
  ``SNAPADMIN_API_ACTION_PERMISSIONS = {"my_action": "view"}``. ``@snap_action`` is unaffected.
* **An ``ES_ONLY`` retention purge that fails now raises ``SnapPurgeError``** instead of returning
  ``0``. The task and the management command already report a raising model under ``errors`` /
  ``ERROR``, so a failed purge stops being reported as a clean one. Code calling
  ``purge_expired()`` directly on an ``ES_ONLY`` model should expect the exception.
* **``EsQuerySet.delete()`` on an ``ES_ONLY`` model raises ``SnapEsUnavailable`` when
  Elasticsearch cannot delete** instead of counting the documents as deleted. A GDPR subject-access
  erasure (``snapadmin_subject_request delete``) used to print "Deleted N row(s)" after such a
  failure; it now stops with the error. Code calling ``delete()`` on an ES search result directly
  should expect the exception.
* **The SFTP location in the backup summary and in ``db_backup_stored`` is the directory the server
  resolved**, not the configured string. With a relative ``SNAPADMIN_BACKUP_SFTP_DIR`` the reported
  path therefore now starts at the login directory (``sftp://host:22/home/backup/backups/...``
  instead of ``sftp://host:22/backups/...``). Only code that parses that string is affected.
* **CSV exports neutralise spreadsheet formulas.** A text value starting with ``=``, ``+``, ``-``,
  ``@``, a tab or a carriage return is written behind a leading ``'`` in ``export_format="csv"`` and
  in ``snapadmin_audit_export --format csv`` (see Security). A program that reads those files and
  needs the raw text either strips one leading ``'`` from such cells or switches to the JSON format,
  which is unchanged.
* **``snapadmin.E028`` can stop ``manage.py check``.** It fires for a masking setting written in a
  shape it cannot mean — a string or a number where ``SNAPADMIN_MASKED_FIELDS`` expects a list of
  field names, a list where ``SNAPADMIN_MASKING_RULES`` expects a field → rule map, or either
  setting not being a dict at all. Such a setting used to mask nothing (or crash every masked
  request and the check run itself). Fix: write it in the documented shape.
* **``POST /api/exports/`` refuses ``filters`` that are not a JSON object of scalars** with a
  ``400`` naming the key, and reports every ``filters`` error — including the key allowlist, which
  used ``non_field_errors`` — under ``filters``. A client that read the allowlist error from
  ``non_field_errors`` reads it from ``filters`` instead. ``__in`` now takes a list; a string there
  used to match its individual characters.
* **The audit trail records a relation by its key.** A foreign key or one-to-one is stored as the
  key it holds (``{"old": 3, "new": 7}``) and a many-to-many as the sorted list of primary keys,
  on both sides of a change and on a created entry. A created entry used to store the related
  object's label (``str(row)``), and a changed foreign key mixed the two —
  ``{"old": 1, "new": "Jones, Bob"}``. A label is ambiguous (two customers can share a name, and
  moving an order from one to the other was not recorded at all once both sides were compared as
  labels) and it is a way around masking: it is whatever the related model's ``__str__`` prints,
  and masking only knows the audited model's own field names. Django's admin history message
  follows the same keys. Rows already written keep what they stored. Way back: resolve a key
  against the related model when a label is needed for display.
* **Five declared dependency minimums rise to the lowest version that actually works.** The first
  run of the lowest-dependencies CI job — every dependency installed at its declared minimum —
  found five that could not: ``django-filter>=24.1`` (``24.0`` was never published);
  ``django-celery-beat>=2.8.0`` (``2.6`` and ``2.7`` require Django < 5.2, below SnapAdmin's own
  floor); ``django-ckeditor-5>=0.2.0`` (earlier releases import ``ugettext_lazy``, which Django 4.0
  removed, and do not declare the Pillow they import); ``djangorestframework>=3.15.1`` (``3.15.0``
  formats every validation message with ``%``, so an API filter key containing ``%`` answered
  ``500`` instead of ``400``); and ``structlog>=24.3.0`` (earlier releases record
  ``log.exception()`` in ``structlog.testing.capture_logs`` under the level ``exception`` rather
  than ``error``; production output was already ``error``). A project pinned below one of these
  must upgrade it — for the first three, no working install was possible below the new floor.

Added
-----

* **Restore drills — ``snapadmin_restore --database <alias>``.** Restores the ``db`` part into
  another ``DATABASES`` alias and leaves ``default`` untouched, then prints the row count of every
  table; a restore that produced no tables fails instead of reporting success. An alias that
  resolves to the same database as ``default`` is refused, ``media``/``env`` cannot be sent there
  (they would land on the live system), and on PostgreSQL the drill database is emptied by
  recreating its ``public`` schema, so the drill role needs no ``CREATEDB``. Public helpers
  ``snapadmin.restore.table_row_counts()`` and ``verify_restored_database()`` expose the same check.
* **``SNAPADMIN_PURGE_EXTERNAL = True``** declares that an external scheduler (cron, a Kubernetes
  CronJob) runs ``manage.py snapadmin_purge_expired_data``, so ``snapadmin.W012`` stops asking for a
  Celery Beat entry. Before, following the warning's own advice kept the warning forever.
* **Renaming an API token** — ``PATCH /api/tokens/<id>/`` with ``{"token_name": "…"}``, for the
  owner or a superuser, audited. Any other field in the body is a ``400`` naming it: scope and
  expiry are fixed at creation, and a change to them means a new token.
* **``SNAPADMIN_TOKEN_ADMIN_ENABLED``** — see Breaking.
* **``SNAPADMIN_API_ACTION_PERMISSIONS``** — see Breaking.
* **The test suite ships in the sdist** (never the wheel), with the demo project, docs and
  ``pytest.ini`` it runs against: unpack the source distribution and run ``python -m pytest``
  inside to hold an installed version to the checks CI runs. Verified: the full suite passes from
  an unpacked sdist.
* **``snapadmin-new --admin-only``** generates the admin alone — no REST API or GraphQL apps, both
  switches off, a base-install ``requirements.txt``, and a ``--full`` health check that does not
  depend on the API. The default project still serves all three surfaces (token-authenticated,
  permission-gated), since showing them from one model is what the generator is for.
* **The demo project wires a restore drill** (``RESTORE_DRILL_DB_NAME`` adds a ``restore_drill``
  alias) and shows ``admin_list_display_pk = False`` on its ``Tag`` model.
* **``admin_list_display_pk``** on ``SnapModel`` — see Breaking.
* **Snap fields accept a positional ``verbose_name``**, like every Django field:
  ``SnapCharField("Label", max_length=200)`` used to raise a ``TypeError`` that named neither the
  argument nor the fix, on every field of a model being moved onto Snap fields.
* **New system checks** for configurations that looked complete and silently did nothing:
  ``snapadmin.W023`` (backups configured — a ``snapadmin.run_db_backups`` Beat entry, a destination
  or AGE recipients — while ``SNAPADMIN_BACKUP_ENABLED`` is off, so every run stores nothing),
  ``snapadmin.W024`` (``env`` in ``SNAPADMIN_BACKUP_INCLUDE`` with no file behind
  ``SNAPADMIN_BACKUP_ENV_FILE``), ``snapadmin.W025`` (a model with masked fields served by a
  hand-written ``ModelAdmin`` without ``PIIMaskingAdminMixin``, which shows them raw), and
  ``snapadmin.E027`` (see Breaking).
* **``SnapPurgeResult``** — what ``SnapModel.purge_expired()`` now returns: still an ``int`` (the
  purged count), with a ``skipped_protected`` attribute. Existing callers are unaffected.
* **Browser end-to-end tests over the generated admin.** Playwright drives Chromium through the
  demo project's admin over real HTTP — signing in, search, filters, pagination, a bulk delete, an
  order saved with an autocomplete customer and an inline line item, validation errors, permission
  refusals, relations saved from a form, the dashboard chart and the outage guard — and every page
  it opens also fails its test on an uncaught JavaScript error, a missing asset or a 5xx. A new CI
  job runs it once on Unfold and once on Django's stock admin (``SNAPADMIN_TEST_ADMIN_THEME=stock``),
  and releases are gated on it. Locally it is opt-in and needs no browser otherwise:
  ``pip install "playwright>=1.45" && playwright install chromium`` then ``pytest -m e2e``.
  Its first run found the defects listed under Fixed below, each of which every test-client test
  had passed; each is also pinned by a fast regression test in the ordinary run.
* **Every CI gate as one command: ``python scripts/gates.py``.** It runs what CI runs — the suite
  with its coverage gate, Ruff and mypy, ``pip check``, the browser suite under both themes, and
  the PostgreSQL + Elasticsearch job when those services and the PostgreSQL client are present
  (the service variables reach that gate only, as in CI); ``security``,
  ``lowest-deps`` and ``dist`` run by name — and reports a gate it cannot run on this machine as
  *not run*, with what it needs, never as passed; a gate asked for by name that cannot run fails. ``--list`` prints each gate's commands and the CI job that runs them;
  ``--release`` adds the sdist/wheel build with ``twine check --strict`` and fails if any gate could
  not run. A test reads the CI workflow and fails when it gains a step no gate reproduces.
* **A security regression suite.** ``tests/security_regressions.py`` maps every security fix the
  package has shipped — each entry of every release note's *Security* section since 0.1.0b1, plus
  the fail-open fixes published elsewhere — to the tests that fail if it comes back, 31 today;
  ``pytest -m security_regression`` runs them. A new *Security* note with no registry entry, or a
  named test that disappears or is skipped, fails the build.
* **The declared minimum versions are tested.** Every CI job installed the newest release of each
  dependency, so the lower bounds ``pyproject.toml`` publishes (Django 5.2, DRF 3.15,
  django-unfold 0.40, …) were never run by anything. A new ``lowest-deps`` job installs each one at
  its lowest allowed version on Python 3.10 and runs the suite, and fails when a declared minimum
  cannot be installed with the rest. Tests also pin the Python/Django classifiers to the CI matrix,
  and that CI installs every declared dependency at the declared range.
* **A lint over every translation catalog.** Whether a German sentence reads naturally needs a German
  speaker; what a machine can decide is now checked on every run, over all twenty catalogs (the
  package's and the demo's): every placeholder of the source kept (``%(name)s`` misspelt is a
  ``KeyError`` when the string renders), the same leading and trailing newlines and HTML tags, a
  header naming its own locale in UTF-8 and declaring its plural rule, every plural form filled, no
  untranslated entry, and a compiled ``django.mo`` that holds exactly what its ``django.po`` says. The docs now say which
  catalogs a native speaker has reviewed — ``en`` and ``ru`` — and which are machine-assisted and
  await one (de, de_CH, es, fr, fr_CH, it, nl, pl).
* **A model change without its migration fails CI.** ``makemigrations --check`` ran only in a local
  checklist; it is a test now, in every matrix job and on PostgreSQL.
* **The development server says what SnapAdmin has switched on.** A capability that is installed but
  switched off is invisible — nobody runs a diagnostics command for a feature they have forgotten
  exists. Under ``DEBUG``, ``runserver`` now prints one short block to stderr: the capabilities that
  are on, the ones that are off, any switched on without their optional package (with the
  ``pip install`` line), whether the installed dependencies allow commercial use, and how to see
  more or silence it. It is built from the ``features`` collector in a configuration-only mode — no
  query, no connection, no key resolution, because it runs inside ``django.setup()`` — and never
  contains a secret. ``SNAPADMIN_STARTUP_REPORT`` decides when: ``"auto"`` (the default: the
  development server under ``DEBUG``, printed once, by the process that serves requests), ``True``
  (every process except commands whose output a program reads — as one JSON line on stderr,
  ``{"event": "snapadmin.startup", …}``, when stderr is not a terminal and ``DEBUG`` is off) or
  ``False``; any other value is ``snapadmin.W027``. A capability that is switched on but cannot
  resolve its configuration is shown as misconfigured, not as off.
  ``SNAPADMIN_STARTUP_REPORT_PROBES = True`` adds the health probes. ``manage.py snapadmin_info
  --startup`` prints the identical block, and the ``features`` section now also lists a capability
  switched on without its extra under ``needs_extra``, with the ``pip install`` line.

Changed
-------

* **``snapadmin_info`` spells its labels the way the docs do.** Keys rendered as ``Rest api``,
  ``Graphql``, ``Pii masking``, ``Api tokens`` and ``Sso``; they now read ``REST API``, ``GraphQL``,
  ``PII masking``, ``API tokens`` and ``SSO``, the same labels the new startup report uses (one
  shared helper, so the two cannot drift). A wrapped on/off run also no longer splits a name across
  two lines (``Pii`` at the end of one, ``masking`` at the start of the next). Text output only —
  ``--json`` keys are unchanged.
* **``llms.txt`` is now an operating manual, not only a docs map.** Both copies (the repository root,
  shipped in the sdist, and ``docs/llms.txt``, published at ``/llms.txt``) carry four new layers for an
  assistant helping someone run SnapAdmin in production: the *Decisions to settle with the developer*
  interview grew from 12 questions to 18 — the load profile the project must carry, ``AUTH_USER_MODEL``
  and whether the login is an e-mail or a username, what guards the login door (SnapAdmin ships no SSO,
  no second factor and no login throttling of its own), how the application reaches the database and as
  whom, what the audit trail records and what it silently does not (admin writes, not REST/GraphQL
  writes or ``QuerySet.update()``), and where a failure becomes visible — followed by a *Build order for
  a new project*, a *Testing a project built on SnapAdmin* checklist, a *Production readiness and high
  load* section that states the shipped defaults (page size 25 capped at 500, 60/min and 600/min
  throttles, the 100 000-row estimated-count threshold, the JSON-filter scan cap, the export chunk
  size), and *Development norms in a SnapAdmin project*. No behaviour changed; what changed is how much
  a model has to infer. The same material is now on the documentation site as a **Production Playbook**
  section (``#playbook``), linked from the README, so the human-facing and the machine-facing copies say
  the same thing.
* **Short values are masked harder.** An e-mail whose local part is one or two characters masks to
  ``***@domain`` — ``a***@x.io`` for ``a@x.io`` printed the whole local part. A
  ``SNAPADMIN_MASKING_RULES`` ``pattern`` that matches nothing in a non-empty value falls back to
  the built-in masker instead of returning the value unchanged.
* **``snapadmin.W022``'s hint names both SFTP cases.** On several managed SFTP products the login
  directory *is* the absolute path; dropping the leading slash, as the hint used to advise, created
  a same-named directory inside it and put every dump one level too deep, with a success report
  either way. The hint now says when to keep the absolute form, the reported location is the
  directory the server resolved, and a directory created on the fly is logged as the warning
  ``sftp_backup_dir_created``.
* **Query counts are pinned on the N+1-prone surfaces.** ``tests/test_query_counts.py`` drives the
  changelist with foreign keys and a many-to-many, the REST list endpoint, the audit timeline and
  the masked changelist/list/timeline through real HTTP requests and counts their SQL twice — at 3
  rows and at 12. The two numbers must match (a per-row query anywhere makes them differ) and the
  larger one is pinned exactly, so a new query on a hot path has to be a decision rather than an
  accident. None of the surfaces had an N+1 today; the pins keep it that way.
* **Property-based and fuzz tests.** ``hypothesis`` (a dev dependency only — it never reaches
  ``pip install django-snapadmin``) now checks laws over generated input rather than a handful of
  examples: ``decrypt(encrypt(x)) == x`` for any text and any keyset, a field rebuilt from its own
  ``deconstruct()`` deconstructs identically for any combination of kwargs, a DSN never leaks any
  part of its password, a masked character class never survives its rule, CSV and NDJSON exports
  round-trip, and hostile REST query strings, export filters, masking settings, key configurations
  and HTML are refused correctly. 100 examples per test locally, 300 in CI. The first pass found the
  defects listed under Security and Fixed.
* **The type check is blocking.** ``mypy`` ran on every push but reported rather than failed,
  because 136 findings sat in the dynamic admin and field layers. They are gone, so a type error
  now fails CI. Most of them were one shape: a mixin that calls ``super().handle_exception()`` or
  reads ``self._meta`` has no base class saying those exist, so the call was never checked at all.
  Each mixin now declares what it is mixed into in an ``if TYPE_CHECKING:`` block — the DRF view or
  serializer it extends, or the attributes a ``SnapModel`` supplies — which changes nothing at
  runtime. ``encryption/``, ``crypto.py``, ``sharding/``, ``backup.py``, ``validators.py``,
  ``logging_config.py`` and ``quickstart/`` additionally run under raised strictness: every function
  annotated, every untyped body checked. Two public signatures are now honest about accepting
  ``None`` (``SnapFileValidator``'s three upload limits, ``SnapStatusBadgeField``'s ``verbose_name``
  and ``style_arguments``) — a documentation fix, not a behaviour change.
* **The README is half as long, nothing in it is collapsed, and its quickstarts now work as
  written.** It had grown to over a thousand lines behind ten ``<details>`` blocks — which a PyPI
  page renders as a bare arrow and a plain-text reader never opens. It now starts with the 60-second
  try, then shows both ways in, each complete and visible: ``SnapModel`` in three steps for a new
  project, and ``@snap_model`` for an existing one you cannot rewrite, including the
  ``INSTALLED_APPS``, URL and setting lines it needs. The pitch, the cheat sheet, the command table,
  the proof and enterprise tables and every quality claim stay; reference material moved to the
  documentation site, which gained *The smallest install that works* (``#installed-apps-minimal``),
  *SnapAdmin and admin themes* (``#vs-themes``) and the per-area test floors
  (``#testing-breakdown``). Most README section anchors survive; those of sections that merged or
  moved are gone — ``#getting-started``, ``#start-where-you-are``, ``#your-first-model--3-steps``,
  ``#already-have-models-you-cannot-rewrite``, ``#install``, ``#configuration``,
  ``#database-sharding-and-replica-routing``, ``#encrypted-model-fields`` and the subsections of
  *Quality & compatibility* (whose full text lives at the documentation site's ``#testing``). Every
  link into the documentation site is unchanged.

  Rewriting it found four things the old README told a reader that were not true, all fixed: the
  60-second try installed the bare package, but the project ``snapadmin-new`` generates lists the
  ``[api]`` and ``[graphql]`` apps and cannot start without them; neither example ``Product``
  declared ``subject_path``, so ``snapadmin.E011`` stopped ``migrate`` right after "That is the
  whole setup"; the result table promised a ``/dashboard/`` that only the demo project mounts; and
  the minimal install's ``urls.py`` comment promised a health check that ``snapadmin.urls`` serves
  only with the REST API on. ``tests/test_readme_quickstarts.py`` now executes both example models
  against the subject-access check and the REST search surface, and holds the install line to what
  the generated project requires (``snapadmin-new``'s own *Next steps* are under Fixed). The
  Testing section also stopped saying mutation testing does not run yet — it runs, advisory, as
  the next entry describes.
* **Mutation testing, advisory.** ``mutmut`` now answers the question coverage cannot: would a test
  *notice* the code being wrong? One command runs it — ``python scripts/mutation.py diff`` mutates
  only the functions a change touched (minutes, and what CI runs on every push), ``python
  scripts/mutation.py modules`` mutates the encryption, crypto, sharding-router, masking, API-filter,
  backup, restore and task modules whole (what CI runs weekly). Both **report and never block**: a
  surviving mutant is either killed by a stronger test or accepted in writing, and an equivalent
  mutant cannot be killed at all. The mechanism — what gets mutated, how the diff scoping picks it,
  what "survived" means and what to do about one — is documented in ``CONTRIBUTING.md`` and in the
  docs site's Testing section.

Fixed
-----

* **Sanitising rich text twice gives the same HTML as sanitising it once.** nh3 unwraps a
  disallowed ``<object>`` and can leave a ``<p>`` nested inside a ``<p>``, which the next parse
  re-nests into siblings — so an unchanged rich-text value was rewritten by every save that ran
  sanitize-on-write, and its audit entry showed a change nobody made. ``sanitize_html`` now runs the
  built-in sanitizer until the result is stable (at most four passes; every pass is a full
  sanitization). Found by the property test ``test_a_second_pass_changes_nothing`` under the CI
  hypothesis profile, and pinned by an explicit example.
* **The documentation no longer promises field-flag parity a decorated plain model does not have.**
  *Two Ways to Declare a Model* said both routes give "the same searchable/filterable fields", and its
  decorator example relied on ``snap_field(..., searchable=True)`` alone. A plain model registered with
  ``@snap_model`` has no generated admin, and its REST ``?search=`` reads only the decorator's
  ``search_fields`` — so that example served an unfiltered search. The example now declares
  ``search_fields`` (and ``subject_path``, which ``snapadmin.E011`` requires on both sides), and the
  capability table says which flags need a reader.
* **``THIRD_PARTY_NOTICES.md`` no longer says the REST API and GraphQL default to on.** Two releases
  after both switches became opt-in, the extras table still told a reader that "most installs want"
  ``[api]`` because both "default to ``True``". The guard that keeps prescriptive docs from claiming
  the old default now reads ``THIRD_PARTY_NOTICES.md`` and ``SECURITY.md`` too. The ``wysiwyg`` row
  also names the Pillow dependency the new ``django-ckeditor-5`` floor brings.
* **``snapadmin-new``'s *Next steps* install the project's requirements.** The generated
  ``requirements.txt`` names the ``[api,graphql]`` extras the generated settings import; the steps
  went straight from ``cd`` to ``migrate``, which fails on a bare install. The documentation site's
  walk-through had the same gap.
* **A single ``PROTECT``-ed row no longer stops a model's retention purge.** One due row still
  referenced through a ``PROTECT``/``RESTRICT`` foreign key (a tree whose parent is due before its
  children) made the single bulk delete raise, so nothing was purged for that model, run after run
  — and the ``data_retention_files`` pass had already deleted the files of every due row, leaving
  rows that point at files that no longer exist. Protected rows are now kept and retried on the next
  run, a fully due subtree goes leaves first, files go only with rows actually deleted, and the kept
  rows are reported: ``SnapPurgeResult.skipped_protected``, a ``SKIPPED`` line in
  ``snapadmin_purge_expired_data``, and a ``skipped_protected`` map in the Celery task's summary.
* **Deleting an Elasticsearch search result of a ``DUAL`` model deletes the rows.** ``snap_search``
  / ``es_search`` return an ``EsQuerySet`` for ``DUAL`` models too; its ``delete()`` used to remove
  nothing and report every row as deleted. It now deletes them from the database (the mirror is
  cleared by the existing ``post_delete`` receiver) and returns Django's own counts.
* **``snapadmin_restore`` and ``take_snapshot`` refuse a part they cannot handle** instead of
  skipping it — ``perform_restore`` used to log such a part as applied.
* **``send_error_digest`` / ``send_health_alert`` always return a ``status``.** An unrecognised
  reason used to fall through and return ``None``, invisible to a ``status != "ok"`` monitoring
  rule; it is now a ``partial`` naming the reason.
* **Swallowed exceptions are logged.** Four ``except: pass`` sites (the dashboard's model counts and
  Elasticsearch ping, the diagnostics token count, Django's inline history entry) now log what they
  swallowed, so a failure there is diagnosable instead of invisible.
* **PII masking and ``api_field_permissions`` no longer switch off on a hand-built serializer.**
  ``PIIMaskingSerializerMixin`` and ``FieldPermissionSerializerMixin`` read the model only from the
  ``_snap_model`` attribute SnapAdmin sets on the serializers it generates; a project's own
  ``ModelSerializer`` mixing them in never had it, and the mixins then returned every field raw —
  behind a coverage pragma that called the path unreachable. They now fall back to ``Meta.model``.
* **``delete_pks_from_es()`` reports a partial failure.** A ``delete_by_query`` answer carrying a
  ``failures`` list now returns ``False`` (so ``DUAL`` purges raise ``SnapPurgeError``) instead of
  counting as a cleared mirror.
* **A masked changelist column keeps the field's ``verbose_name``**, translation included, instead
  of a title-cased field name.
* **Generated ``ModelAdmin`` classes report the model's module as ``__module__``**, not a
  ``django.forms`` internal — tracebacks and "is this admin generated?" tests now name the right
  place.
* **The demo project schedules its backup task only while backups are on**, so it no longer
  exhibits the very state ``snapadmin.W023`` reports.
* **``snap_field()`` no longer rewrites a field's migration state through ``editable``.** Passing
  ``editable=`` to the wrapper as well as ``editable=False`` to the Django field removed the
  constructor's own ``editable=False`` from ``deconstruct()``, so ``makemigrations`` wrote an
  ``AlterField``; and ``editable=True`` on a wrapped ``auto_now``/``auto_now_add`` field made
  Django's own ``deconstruct()`` raise ``KeyError`` — ``makemigrations`` crashed. The wrapper now
  reports exactly what the Django constructor was given, and keeps an ``auto_now`` field read-only
  the way Django and ``Snap*Field`` already do.
* **Configuring an encryption key as bytes or a number is a configuration error** naming the key
  id, not an ``AttributeError`` out of configuration loading.
* **The ``SnapField`` docstring and ``llms.txt`` state the second migration exception**: on
  ``SnapFileField``/``SnapImageField`` the upload limits are recorded in ``deconstruct()`` the way
  Django records ``validators=``, so changing one is an ``AlterField`` that runs no SQL.
* **The dashboard chart is drawn again.** Since 0.1.0b6 the "Data Distribution" chart on the
  system dashboard stayed empty: a two-line ``{# #}`` comment above the chart data — a form Django
  only recognises on a single line — was rendered as page text, and that text contained a literal
  ``<script>``, so the browser opened a script element there and swallowed the chart's JSON into
  it. The comments are ``{% comment %}`` blocks now, and a test fails on any multi-line ``{# #}`` in
  a shipped template.
* **The outage guard works on Unfold.** ``connectivity.js`` disables every Save button and blocks
  form submits while ``/api/health/`` keeps failing, so nobody types into a form that cannot be
  saved. On Unfold — the default theme — it did nothing at all: it looked for Django's
  ``.submit-row`` inside the ``<form>``, and Unfold renders ``#submit-row`` in a footer outside it,
  its buttons tied to the form with a ``form=`` attribute. The guard now finds the add/change form
  itself (``<form id="<model>_form">``, the same in both themes) and every Save button tied to it,
  so Unfold's related-object popups — where that footer has no id at all — are guarded too.
* **"Back online" is announced only after an outage.** The toast meant for a recovery appeared on
  every healthy page load, because the first-probe check read a flag that had already been set.
* **Forward one-to-one and many-to-many fields reach the generated form.** A
  ``SnapOneToOneField`` or ``SnapManyToManyField`` declared ``show_in_form=True`` was silently left
  out of the add/change form since the first release — a many-to-many could not be edited in the
  admin at all, and a one-to-one row could only be saved without its target. Form fields now
  follow declaration order, as in Django's own ``ModelForm``. A many-to-many with a ``through``
  model of its own stays out, as Django requires (``admin.E013``). ``autocomplete=True`` on a
  ``SnapOneToOneField`` is honoured; a one-to-one without the flag — a plain ``OneToOneField``, a
  multi-table parent link — is not made an autocomplete field, so no admin on its target is
  suddenly required. A reverse many-to-many accessor is no longer listed in a model's
  ``autocomplete_fields``. ``snapadmin.W015`` (a form that would render empty) counts these
  fields too. No migration.
* **The audit trail stores JSON values as JSON.** A ``JSONField`` holding a list or an object
  was stored as its Python repr (``"[{'a': 1}]"``) although the trail's own contract is that
  JSON-native values keep their type; lists and objects are stored as JSON now, item by item. A
  masked JSON field is therefore masked in the audit trail the way the admin, REST, GraphQL and
  exports already masked it — value by value, keys kept — where the old repr string was masked
  as one opaque string. Masking a JSON value whose *keys* are personal data needs a rule of its
  own on the surfaces that serve it; the trail follows the same policy as every other surface.
* **The rich-text editor stays inside its column.** Its toolbar made the whole form wider than the
  window on Unfold, and was clipped on the stock admin, so CKEditor never grouped the buttons that
  did not fit into its "more" menu. On the stock admin the editor now takes a full-width line under
  its label, and an editor grouped into a ``row=`` takes a full-width line of its own in both
  themes instead of a sliver of the row (the demo's ``Showcase`` shows one). Its minimum width
  never exceeds a phone's window.
* **Every translation catalog declares its language and plural rule.** Nine demo catalogs still
  carried the template header ``makemessages`` writes into a new file — an empty ``Language``, a
  ``fuzzy`` header, ``PACKAGE VERSION`` — and none of the package's ten declared ``Plural-Forms``,
  so gettext would have applied English plural rules to the first Russian or Polish plural string.
  Found by the new catalog lint; the headers now carry the language and the plural rule Django
  itself uses for it, and every catalog is recompiled.
* **Selects on the stock admin show their value.** Every single-choice select — a boolean, a
  foreign key, the changelist's action picker — rendered its chosen value invisibly: the stock
  admin's fixed 30px height left no room inside SnapAdmin's padding.
* **Demo project.** ``/admin/`` answered 500 when Unfold was removed from ``INSTALLED_APPS`` (the
  demo's index extended an Unfold-only layout); it now renders under either theme. The Unfold
  settings no longer reference a login background and a favicon that were never shipped.

Security
--------

* **The audit trail no longer stores a related object's label.** A relation's diff held
  ``str(row)`` of the related object — typically a name or an email — which the trail's masking
  could not see, because masking applies to the audited model's own fields; a reader allowed the
  audit log but not the related model's PII read it there. Relations are stored as keys now (see
  Breaking).
* **An audit row's object label is masked like the fields it is made of.** ``object_repr`` is
  ``str(instance)`` at the moment of the change — for a customer, typically a name or an email —
  and every audit surface printed it raw: the changelist column, the change form, the entry's own
  title and breadcrumb, the per-object timeline and ``snapadmin_audit_export``; the admin search
  answered for it too, which confirms a name as surely as printing it. Which fields ``__str__``
  uses cannot be known, so on a model with any masked field the label is treated as covering all
  of them: a reader who may see every masked field of that model raw (a superuser, a holder of
  ``snapadmin.view_raw_pii``, or of every per-field rule ``permission``) reads it; anyone else,
  and the export unless ``--reveal-pii`` is passed, reads ``"Customer #12"`` instead, and the
  label is not searchable for them. Rows already written are covered, since the label is masked
  when it is read. New public helpers: ``snapadmin.masking.mask_object_repr``,
  ``object_repr_hidden`` and ``hidden_object_repr_models``.
* **Django's own admin history message stores a masked field masked.** Next to the SnapAdmin trail,
  the generated admin writes a ``"Field: 'old' -> 'new'"`` message into Django's ``LogEntry``,
  which the object's *History* page shows to anyone who may view the object. Encrypted fields were
  already redacted there; a field listed in ``SNAPADMIN_MASKED_FIELDS`` or
  ``SNAPADMIN_MASKING_RULES`` was written raw. It is stored masked now, through the field's own
  rule — the message is stored text with no reader to decide for, so it is masked for everyone.
  The SnapAdmin trail keeps the raw diff and masks it per reader.

Found by the new static-analysis pass (Ruff's bandit rules), each fixed with a regression test:

* **``formatted_id`` escaped a non-integer primary key into the changelist as markup.** A
  character primary key is data — possibly user-supplied — and was rendered through
  ``mark_safe``, a stored-XSS path for any changelist using that column. It is now escaped.
* **Alert webhooks refuse any scheme but ``http``/``https``.** ``urllib`` would otherwise also follow
  ``file:``, ``ftp:`` or a custom handler for a misconfigured ``SNAPADMIN_ALERT_WEBHOOKS`` entry.
* **``snapadmin_restore`` quotes the database name in the SQL it sends to ``psql``.** A name
  containing a quote produced invalid (or different) SQL; it is now escaped as an SQL literal.

Found by the new property-based and fuzz tests, each fixed with a regression test:

* **CSV exports never open as spreadsheet formulas (CWE-1236).** The XLSX writer already pinned a
  ``=``-cell to text; the CSV writer wrote ``=HYPERLINK(…)`` raw, and so did
  ``snapadmin_audit_export``, whose ``user_agent`` column is whatever the client sent. Text cells
  opening with ``= + - @``, a tab or a carriage return now carry a leading ``'``.
* **No part of a sharding DSN's password reaches an error message.** A generated password with a
  raw ``/``, ``#`` or ``?`` ended the URL's authority early: ``redact_dsn`` found no password and
  echoed the whole DSN into ``ImproperlyConfigured`` (and so into ``manage.py check`` and the logs),
  and the port error quoted the password's first half. A digits-only password followed by ``/``
  even parsed — into the wrong host, with no password. Redaction now works on the text as written,
  and an ``@`` after the host is refused with a percent-encoding hint.
* **A masking setting in the wrong shape no longer fails open.** ``"email"`` instead of
  ``["email"]`` masked the fields ``e``, ``m``, ``a``… (none of them real); it now masks ``email``.
  A list of names under ``SNAPADMIN_MASKING_RULES`` masks those fields with the built-in masker
  instead of crashing. ``snapadmin.E028`` reports both (see Breaking).
* **Export ``filters`` are validated by shape before the allowlist runs.** A JSON list passed the
  key allowlist (its elements were read as keys) and a list of numbers answered ``500``.

Deprecated
----------

* **``SnapModel.admin_sections``** has never been read by any code path. Setting it now raises
  ``snapadmin.W026``; it will be removed in a future release. Group form fields with the per-field
  ``tab`` and ``row`` flags, or pass ``fieldsets`` through ``admin_overrides``.
