"""ServiceSecrets — fetch-on-start, reload-without-restart, FAIL-CLOSED (rm#129 / rm#209 / rm#334).

THE ONE INVERSION THIS MODULE EXISTS FOR
----------------------------------------
`.env` (and the config-service) FIRST, the process environment LAST. That ordering is not a
style preference, it is the whole point:

    tier 1  config-service  GET /config/{service}?box=     re-readable (network)
    tier 2  declared env files, in order, FIRST WINS       re-readable (one stat)
    tier 3  the process environment SNAPSHOT               NOT re-readable, ever

A long-lived process inherits its environment once, at spawn. After a rotation that snapshot holds
a REVOKED secret while the file on disk holds the live one — which is exactly how infra#263 lost
eight days of ingestion and how rm#209's tasks-MCP 401 landed three days after its cause. Tier 3 is
therefore the only tier that CANNOT be re-read, so it is the only tier that can be stale, so it is
last. A re-readable source always beats a frozen one.

THE MECHANISM IS THE READ PATH, NOT THE ENDPOINT
------------------------------------------------
`POST /admin/reload-config` is an OPTIMIZATION. The mechanism is lazy re-resolution inside `get()`,
mtime-gated — the proven shape of `claude-hooks/mcp-common/mcp_auth.py:live_token()`. One inversion
buys three things at once:

  * multi-worker convergence — gunicorn runs 2..8 worker PROCESSES (gunicorn_conf.py); a POST only
    ever reaches one of them. Every worker stats; every worker converges.
  * the bootstrap chicken-and-egg — a service with no key cannot authenticate the endpoint that
    would give it one. A key dropped into a declared env file goes live on the next request with
    nobody calling anything.
  * rotation for services that never registered a `reload_url` with config-service.

WHAT THIS MODULE PROMISES
-------------------------
  * **It never mutates `os.environ`.** Tiers stay separate in memory, which is what makes
    `source_of()` answerable at all and what makes "roll back" mean simply "don't swap".
  * **It never carries a VALUE anywhere observable** (rm#206). Reports, `status()`, log lines and
    HTTP bodies carry names, provenance, classes, counts and sha256[:16] digests. Never values.
  * **A rejected reload applies NOTHING.** Not "the good ones" — nothing. RESOLVE builds a
    candidate, VALIDATE judges the whole candidate (required-present, validators, and a THROWAWAY
    connection probe for anything that classifies `db`), APPLY is a single reference swap under a
    lock. A bad value can therefore never blank a live credential.
  * **A name that disappears upstream is NOT unset** by default (ANDREA_CREDS_HONOR_WITHDRAWAL=1
    opts in) — dropping it could strip a live credential. But it stops being invisible: it lands in
    `ReloadReport.withdrawn` and logs at WARNING.

BACKWARD COMPATIBILITY
----------------------
`env_files` defaults to EMPTY and the shared `/usr/work/infrastructure/.env` is never a default
source for any service. Every break in the consumer inventory would have been caused by a loader
reaching for the SHARED .env (pdf-feed's GARAGE_GITLAB_SECRET and docwell's per-service key both
DIFFER from the shared file's). Nothing here reaches for it. An unmigrated service therefore
resolves exactly what it resolves today — from tier 3 — and simply gains the ability to say so.

THE ONE HONEST LIMITATION
-------------------------
If a service's only source is tier 3 (a container with no mounted env file and no config-service
entry), this contract CANNOT rotate it. There is nothing re-readable to re-read. What it does do is
print `sources=[process-env]` at every start, so that service is on a list instead of being a
surprise on the day the old secret is finally revoked.

Env knobs (all read from the process-env SNAPSHOT taken at construction, EXCEPT
ANDREA_SECRETS_ENV_FILES, which tier 1 may also declare — see `_sync_env_files`):
    ANDREA_SECRETS_ENV_FILES   ':'/',' separated declared env files, first wins   (default none)
    ANDREA_SECRETS_REQUIRED    ',' separated names that must resolve non-empty    (default none)
    ANDREA_SECRETS_IGNORE      ',' separated names this service refuses to resolve
    ANDREA_SECRETS_STRICT      1 => a missing required credential aborts startup  (default 0)
    ANDREA_CREDS_TTL           seconds; >0 refreshes tier 1 on the read path      (default 0 = off)
    ANDREA_CREDS_HONOR_WITHDRAWAL  1 => an upstream removal really removes        (default 0)
    ANDREA_CREDS_STAMP         0 => never write the generation stamp              (default 1)
    ANDREA_CREDS_STATE         path of the generation stamp   (default /run/andrea/creds/<svc>.json)
    ANDREA_CREDS_DB_PROBE      0 => skip the throwaway-connection validation      (default 1)
"""
from __future__ import annotations

import hashlib
import json
import logging
import os
import re
import threading
import time
from dataclasses import dataclass, field
from typing import Callable, Mapping, Sequence

from .config import ConfigFetch, fetch_config_result, is_auth_control

log = logging.getLogger("andrea_svc_web")

#: tier labels, used as `source_of()` answers and as `tiers` keys. An env file's label is its path.
CONFIG_SERVICE = "config-service"
PROCESS_ENV = "process-env"

#: the two names the auth layer will accept a service key under. settings.py derives
#: settings.ANDREA_SVC_KEYS from these, and reload.py has always treated them as equivalent.
AUTH_KEY_NAMES = ("ANDREA_SVC_KEYS", "ANDREA_SVC_API_KEY")

#: The names whose REMOVAL flips a service from enforcing to open — exactly what
#: `settings_auth_configured()` reads. A withdrawal never removes these (see `_apply`): losing a
#: credential is the one change that cannot fail closed, so it is the one change a live reload is
#: not allowed to make. Retiring a service key is a DEPLOY (restart), not a passive file read.
#:
#: ⚑ THIS LIST IS ALSO THE PROOF THAT ROUND 2's REFUSAL LIST WAS WRONG. It names OIDC_ISSUER and
#: OIDC_JWKS_URL *because removing them opens the service* — while `config.AUTH_CONTROL_PREFIXES`
#: named the same two *because delivering them was assumed to open the service*. Two lists, same
#: names, opposite conclusions, one tree. Round 3 resolves it in this direction: these are
#: MATERIAL. They are delivered from every tier (`config.CREDENTIAL_MATERIAL_KEYS`), mirrored onto
#: settings ROTATE-NEVER-BLANK (`_mirror_auth_material`), and never removed by a withdrawal.
AUTH_MATERIAL_NAMES = AUTH_KEY_NAMES + ("OIDC_ISSUER", "OIDC_JWKS_URL")
#: Historical spelling, kept so an importer outside this package does not break. Same tuple.
AUTH_ENFORCEMENT_NAMES = AUTH_MATERIAL_NAMES
#: The OIDC half, mirrored by NAME onto the same-named Django setting (the key half is mirrored
#: onto ANDREA_SVC_KEYS regardless of which spelling it resolved under). OIDC_AUDIENCE is mirrored
#: but is NOT part of `AUTH_MATERIAL_NAMES`: its absence does not open the service, it only widens
#: which tokens are accepted — so it gets the rotate-never-blank mirror (dropping an audience
#: restriction is the wrong direction to fail) without joining the enforcement predicate.
OIDC_MATERIAL_NAMES = ("OIDC_ISSUER", "OIDC_JWKS_URL", "OIDC_AUDIENCE")
#: Everything the GENERIC settings mirror must keep its hands off, because the generic mirror writes
#: `self._values.get(name, "")` — i.e. it can write an EMPTY string, and blanking any of these
#: weakens the auth check. They are written only by `_mirror_auth_material` (non-empty only).
MIRRORED_AUTH_MATERIAL = tuple(dict.fromkeys(AUTH_MATERIAL_NAMES + OIDC_MATERIAL_NAMES))
#: The tier-1 name that declares a tier-2 credential FILE. Consumed EARLY (before tier 2 is read),
#: which is why `_sync_env_files` runs at the top of `_resolve` rather than anywhere later.
ENV_FILES_NAME = "ANDREA_SECRETS_ENV_FILES"

# A key is DB-ish if it names a database host/port/name/user/password/socket or a DSN. Checked
# FIRST, so DB_PASSWORD classifies as `db` (→ reconnect) rather than `auth` (→ no reconnect).
# Kept VERBATIM from reload.py, which is where it was proven; reload.py now imports it from here so
# there is exactly one definition.
_DB_RE = re.compile(r"(^|_)(DB|DATABASE)(_|$)|(^|_)DSN$", re.I)
# Secret-ish: an API key, bearer token, shared secret, or non-DB password.
_AUTH_RE = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)", re.I)
# env name -> Django DATABASES entry field, for the generic (single 'default' alias) case.
_DB_FIELD = {"DB_HOST": "HOST", "DB_PORT": "PORT", "DB_NAME": "NAME",
             "DB_USER": "USER", "DB_PASSWORD": "PASSWORD"}

_TRUE = ("1", "true", "yes", "on")


# ── small pure helpers ──────────────────────────────────────────────────────────────────────────
def classify(key: str) -> str:
    """'db' | 'auth' | 'other' — DB wins over auth so DB_PASSWORD triggers a reconnect."""
    if _DB_RE.search(key):
        return "db"
    if _AUTH_RE.search(key):
        return "auth"
    return "other"


def digest_of(value: str | None) -> str:
    """sha256(value)[:16] — the ONLY thing about a value that may ever be published. Enough to
    answer 'did this change?', which is the only question (the precedent cred-drift-check.py set).
    Absent/empty => "" so a missing credential is distinguishable from a present one."""
    if not value:
        return ""
    return hashlib.sha256(str(value).encode("utf-8", "replace")).hexdigest()[:16]


def _flag(env: Mapping[str, str], name: str, default: str = "0") -> bool:
    return str(env.get(name, default) or "").strip().lower() in _TRUE


def _split(raw: str | None, seps: str = ",") -> list[str]:
    """Split a declaration list on any of `seps`, dropping blanks."""
    if not raw:
        return []
    out = [raw]
    for sep in seps:
        nxt: list[str] = []
        for chunk in out:
            nxt.extend(chunk.split(sep))
        out = nxt
    return [c.strip() for c in out if c.strip()]


def parse_env_file(path: str | os.PathLike) -> tuple[dict[str, str], bool]:
    """Minimal .env parser (KEY=VALUE, optional `export `, # comments, quotes) — the same shape as
    mcp_auth._parse_env, vendored so the base package stays dependency-free.

    Returns (values, present). `present` False means the file does not exist or could not be read —
    which is NOT the same as "the file is empty", and the tiers report keeps them distinct
    (rules/an-empty-read-is-not-evidence-of-absence)."""
    out: dict[str, str] = {}
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as fh:
            raw_text = fh.read()
    except OSError:
        return out, False
    for raw in raw_text.splitlines():
        line = raw.strip()
        if line.startswith("export "):
            line = line[len("export "):].strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, _, v = line.partition("=")
        out[k.strip()] = v.strip().strip('"').strip("'")
    return out, True


def read_env_file(path: str | os.PathLike) -> tuple[dict[str, str], str]:
    """`parse_env_file` with the third state the withdrawal decision actually needs:
    (values, "ok" | "absent" | "empty").

    absent — the file is not there or could not be read.
    empty  — it WAS read and carried no KEY=VALUE line at all: a 0-byte file, a comment-only file,
             or a non-atomic rewrite caught mid-flight.
    ok     — it was read and carried at least one name.

    ⚑ "empty" is deliberately not "ok". `parse_env_file` already documents
    rules/an-empty-read-is-not-evidence-of-absence — and the caller then committed exactly that
    error, treating a file that read as zero bytes as proof the secret had been withdrawn. A
    truncation race (or a stray `> file`) therefore blanked a live service key and opened the data
    plane. An empty read is evidence of NOTHING; it is the absence of evidence."""
    values, present = parse_env_file(path)
    if not present:
        return values, "absent"
    return values, ("ok" if values else "empty")


def _live_settings():
    """django.conf.settings ONLY when it is already configured, else None.

    ⚑ Load-bearing. `ServiceSecrets` is constructed from INSIDE andrea_svc_web/settings.py, while
    that very module is executing — at which point `settings._wrapped` is still `empty`. Touching
    any attribute there would re-enter the settings import. `.configured` is a plain property that
    does NOT trigger setup, so this is the safe probe. It returns None at settings-load time and
    the real object at request time, which is exactly the distinction every caller here needs."""
    try:
        from django.conf import settings as dj
        return dj if dj.configured else None
    except Exception:                                        # noqa: BLE001 — Django absent is fine
        return None


def settings_auth_configured() -> bool:
    """**THE ENFORCEMENT PREDICATE.** Is service auth configured, judged EXACTLY as it has always
    been judged: settings.ANDREA_SVC_KEYS, or an OIDC issuer+JWKS pair.

    This is deliberately the settings-only predicate — the same values the auth layer
    (andrea_svc_auth.django_auth) reads per request — so a permission class and the authenticator
    can never disagree about whether a credential could possibly work.

    ⚑ EVERYTHING THAT *REPORTS* POSTURE MUST DERIVE FROM THIS SAME CALL. `ServiceKeyRequiredIfConfigured`
    evaluates exactly this to decide whether an anonymous request is refused; if `/health` and
    `/admin/creds-status` answer a DIFFERENT question, a fleet sweep says "nobody is open" while a
    service is open — and a wrong answer from the observability spine is worse than no answer,
    because it ends the investigation. `ServiceSecrets.enforcement_visible()` is the one bridge,
    and `ServiceSecrets.posture` refuses to say "enforcing" when this returns False."""
    dj = _live_settings()
    if dj is None:
        return False
    if getattr(dj, "ANDREA_SVC_KEYS", None):
        return True
    return bool(getattr(dj, "OIDC_ISSUER", "") and getattr(dj, "OIDC_JWKS_URL", ""))


_open_divergence_announced = False


def announce_open_divergence(service: str, kind: str) -> None:
    """Say it ONCE per process, at ERROR: this process HOLDS auth material that the auth layer
    cannot see, so anonymous callers are being served by a service that has a credential.

    That state is always a defect — a mirroring gap, not a deployment choice — so it gets the
    loudest treatment the module has. NAMES and states only, never a value (rm#206)."""
    global _open_divergence_announced
    if _open_divergence_announced:
        return
    _open_divergence_announced = True
    log.error("creds svc=%s: OPEN WITH MATERIAL — this process holds auth material (%s) but "
              "settings.ANDREA_SVC_KEYS / OIDC_ISSUER+OIDC_JWKS_URL are empty, so "
              "ServiceKeyRequiredIfConfigured is answering anonymous requests. The credential "
              "never reached the settings the auth layer reads; posture reports 'open'.",
              service or "?", kind)


def apply_databases(values: Mapping[str, str]) -> list:
    """Re-point `settings.DATABASES['default']` from `values` and drop the pooled connections so the
    next query reconnects. Returns the aliases touched.

    Only the 'default' alias is mapped generically (DB_HOST/PORT/NAME/USER/PASSWORD); a service with
    several aliases or exotic naming uses SVC_CONFIG_RELOAD_HOOK / ServiceSecrets.on_change()."""
    touched: list[str] = []
    dj = _live_settings()
    if dj is not None:
        dbs = getattr(dj, "DATABASES", None) or {}
        default = dbs.get("default")
        if isinstance(default, dict):
            for env_name, field_name in _DB_FIELD.items():
                val = values.get(env_name)
                if val not in (None, "") and str(default.get(field_name, "")) != str(val):
                    default[field_name] = val
                    touched.append("default")
    try:                                                     # close AFTER re-pointing, so the next
        from django.db import connections                    # query dials the NEW endpoint
        connections.close_all()
    except Exception:                                        # noqa: BLE001 — no ORM configured is fine
        log.debug("creds: no Django DB connections to close")
    return sorted(set(touched))


def probe_databases(values: Mapping[str, str]) -> str:
    """VALIDATE phase (c): open a THROWAWAY connection with the CANDIDATE database credentials and
    probe it. Returns "" when it succeeded or when there is nothing to probe, else a short reason.

    ⚑ This is the DB-rotation validation rm#129 deferred as too risky to exercise unsupervised. A
    bad DB credential is caught here, BEFORE it can reach settings.DATABASES — which is what makes
    the supervised live rotation safe to run.

    The reason string carries an EXCEPTION CLASS NAME only. A driver's error text can echo a DSN,
    and a DSN can carry a password (rm#206)."""
    dj = _live_settings()
    if dj is None:
        return ""
    dbs = getattr(dj, "DATABASES", None) or {}
    default = dbs.get("default")
    if not isinstance(default, dict) or not default.get("ENGINE"):
        return ""                                            # no ORM configured => nothing to probe
    probe = dict(default)
    changed = False
    for env_name, field_name in _DB_FIELD.items():
        val = values.get(env_name)
        if val not in (None, "") and str(probe.get(field_name, "")) != str(val):
            probe[field_name] = val
            changed = True
    if not changed:
        return ""
    handler = None
    try:
        from django.db.utils import ConnectionHandler
        handler = ConnectionHandler({"default": probe})
        handler["default"].ensure_connection()
        return ""
    except Exception as e:                                   # noqa: BLE001 — the whole point is to catch it
        return f"connect probe failed ({type(e).__name__})"
    finally:
        if handler is not None:
            try:
                handler.close_all()
            except Exception:                                # noqa: BLE001 — best-effort teardown
                pass


# ── exceptions ──────────────────────────────────────────────────────────────────────────────────
class MissingCredential(Exception):
    """A DECLARED-REQUIRED credential is absent. Carries the NAME and the declared human PURPOSE —
    never a value. StormSafeAPIView maps it to HTTP 503 with exactly those two fields, because 503
    ("this endpoint is not serviceable in this posture") is the truth, where a 401 would falsely
    promise that some credential would get the caller in."""

    def __init__(self, name: str, purpose: str = ""):
        self.name = str(name)
        self.purpose = str(purpose or "")
        msg = f"credential not provisioned: {self.name}"
        if self.purpose:
            msg += f" ({self.purpose})"
        super().__init__(msg)


# ── report shapes (names, classes, digests, counts — NEVER values) ──────────────────────────────
@dataclass(frozen=True)
class LoadReport:
    service: str
    generation: int
    posture: str
    resolved: dict = field(default_factory=dict)             # {NAME: source} — provenance, no values
    missing: list = field(default_factory=list)              # declared-required and absent
    tiers: dict = field(default_factory=dict)                # {tier: ok|unreachable|rejected|absent|off}
    sources: list = field(default_factory=list)              # tiers actually backing this process
    auth: str = "none"                                       # keys | oidc | none

    def as_dict(self) -> dict:
        return {"service": self.service, "generation": self.generation, "posture": self.posture,
                "resolved": dict(self.resolved), "missing": list(self.missing),
                "tiers": dict(self.tiers), "sources": list(self.sources), "auth": self.auth}


@dataclass(frozen=True)
class ReloadReport:
    service: str
    generation: int
    applied: bool
    reason: str                                              # applied | no-change | rejected |
    #                                                          source-unreachable | source-rejected-our-key
    changed: dict = field(default_factory=lambda: {"db": [], "auth": [], "other": []})
    withdrawn: list = field(default_factory=list)            # present before, absent now
    rejected: dict = field(default_factory=dict)             # {NAME: why} — no values
    reconnected: list = field(default_factory=list)          # DB aliases
    hooks: list = field(default_factory=list)                # dotted/callable names that ran
    hook_errors: dict = field(default_factory=dict)
    tiers: dict = field(default_factory=dict)
    missing: list = field(default_factory=list)              # declared-required and STILL absent
    #                                                          after this reload. Names only.
    #   ⚑ Not in the original field list, added deliberately: `config-reload-fanout.py` output is
    #   what an operator watches during a rotation, and "applied 1 key" while a required credential
    #   is still absent is exactly the silence this whole contract exists to end.

    @property
    def changed_count(self) -> int:
        return sum(len(v) for v in self.changed.values())

    def as_dict(self) -> dict:
        """The HTTP body. Includes the pre-0.3 field names so `scripts/config-reload-fanout.py`
        (which reads `reloaded`, `changed`, `db_aliases_reconnected` and `reason`) and any other
        existing client keeps working unchanged — this is a SUPERSET, never a rename."""
        return {
            "service": self.service, "generation": self.generation, "applied": self.applied,
            "reason": self.reason, "changed": {k: list(v) for k, v in self.changed.items()},
            "withdrawn": list(self.withdrawn), "rejected": dict(self.rejected),
            "reconnected": list(self.reconnected), "hooks": list(self.hooks),
            "hook_errors": dict(self.hook_errors), "tiers": dict(self.tiers),
            "missing": list(self.missing),
            # ── legacy aliases (pre-0.3 clients: config-reload-fanout.py) ──
            "reloaded": self.applied, "changed_count": self.changed_count,
            "db_aliases_reconnected": list(self.reconnected),
        }


# ── the singleton ───────────────────────────────────────────────────────────────────────────────
class ServiceSecrets:
    """One process-wide credential resolver. Constructed once in andrea_svc_web/settings.py as
    `SECRETS`; reachable everywhere else as `andrea_svc_web.creds.current()`.

    Thread-safe: `_lock` is an RLock and every swap is a single reference assignment, so a reader
    never observes a half-applied set. Subscriber callbacks run OUTSIDE the lock."""

    def __init__(
        self,
        service: str,
        box: str | None = None,
        *,
        env_files: Sequence[str | os.PathLike] = (),
        config_service: bool = False,
        required: Mapping[str, str] | Sequence[str] = (),
        ignore: Sequence[str] = (),
        validators: Mapping[str, Callable[[str], bool]] = (),
        state_path: str | os.PathLike | None = None,
        env: Mapping[str, str] | None = None,
    ) -> None:
        self.service = str(service or "")
        self.box = box or None

        # ⚑ tier 3 is a SNAPSHOT, taken once, never refreshed. That is not an implementation
        # shortcut — it is the honest model of what a process's environment IS (rm#209).
        self._env: dict[str, str] = dict(os.environ if env is None else env)

        # LOCAL declarations: the service's own code first, then its unit/compose env. These are
        # operator-controlled, they always come FIRST (so they always win on resolve), and the
        # config plane may only APPEND to them — never reorder or replace one (`_sync_env_files`).
        declared = [str(p) for p in (env_files or ())]
        declared += _split(self._env.get(ENV_FILES_NAME), ":,")
        # de-duplicate while preserving declaration order — FIRST declaration wins on resolve.
        seen: set[str] = set()
        self._env_files = [p for p in declared if not (p in seen or seen.add(p))]
        self._config_env_files: tuple = ()                   # the subset tier 1 declared — reported
        #                                                      by status() so provenance is visible

        self._config_service = bool(config_service)

        if isinstance(required, Mapping):
            self._required: dict[str, str] = {str(k): str(v or "") for k, v in required.items()}
        else:
            self._required = {str(n): "" for n in (required or ())}
        for name in _split(self._env.get("ANDREA_SECRETS_REQUIRED")):
            self._required.setdefault(name, "")

        self._ignore = {str(n) for n in (ignore or ())}
        self._ignore |= set(_split(self._env.get("ANDREA_SECRETS_IGNORE")))

        self._validators: dict[str, Callable[[str], bool]] = dict(validators or {})

        self._stamp_enabled = _flag(self._env, "ANDREA_CREDS_STAMP", "1")
        self._state_path = str(state_path or self._env.get("ANDREA_CREDS_STATE")
                               or self._default_state_path())
        self.strict = _flag(self._env, "ANDREA_SECRETS_STRICT")
        self.honor_withdrawal = _flag(self._env, "ANDREA_CREDS_HONOR_WITHDRAWAL")
        self.db_probe = _flag(self._env, "ANDREA_CREDS_DB_PROBE", "1")
        try:
            self._ttl = float(self._env.get("ANDREA_CREDS_TTL", "0") or 0)
        except (TypeError, ValueError):
            self._ttl = 0.0

        self._lock = threading.RLock()
        self._values: dict[str, str] = {}
        self._sources: dict[str, str] = {}
        self._tiers: dict[str, str] = {}
        self._tier1: dict[str, str] = {}
        self._tier1_status: str = "pending" if self._config_service else "off"
        self._tier1_at: float = 0.0
        self._generation: int = 0
        self._loaded: bool = False
        self._loaded_at: float = 0.0
        self._announced: bool = False
        self._watched: tuple = ()
        self._rejected_fp: str = ""                          # quarantined candidate fingerprint
        self._policy_refused: set = set()                    # tier-1 policy names already logged
        self._subs: dict[str, list] = {}
        self._change_hooks: list = []
        self._counters = {"reload_applied": 0, "reload_no_change": 0, "reload_rejected": 0,
                          "source_unreachable": 0}
        self._last_load: LoadReport | None = None

        # Populate from the LOCAL tiers immediately, so the object is usable before load() (and so a
        # service that forgets to call load() still resolves exactly what it resolves today).
        with self._lock:
            self._swap(*self._candidate(), adopt_generation=0)
            self._watched = self._watch()

    # ── construction helpers ────────────────────────────────────────────────────────────────────
    def _default_state_path(self) -> str:
        safe = re.sub(r"[^A-Za-z0-9._-]", "_", self.service or "andrea-service")
        return f"/run/andrea/creds/{safe}.json"

    def rebind_service(self, name: str) -> bool:
        """Adopt the FINAL SERVICE_NAME once the whole settings module has loaded.

        ⚑ Same trap `LOGGING_CONFIG` already solves for the syslog tag: a service that does
        `from andrea_svc_web.settings import *` and THEN sets `SERVICE_NAME = "word-detect"` would
        otherwise be reported (and stamped) as "andrea-service" — the fleet-wide mis-tag, in the one
        place where a wrong service name means a wrong generation stamp. Called from
        AppConfig.ready(). Only the reporting name and the DEFAULT state path move; the tier-1
        lookup key is unchanged (it is taken from the SERVICE_NAME *env var*, as apply_to_env has
        always taken it, so this cannot silently re-point a config-service fetch)."""
        name = str(name or "")
        if not name or name == self.service:
            return False
        with self._lock:
            was_default = self._state_path == self._default_state_path()
            self.service = name
            if was_default:
                self._state_path = self._default_state_path()
                self._watched = self._watch()
        return True

    # ── RESOLVE ─────────────────────────────────────────────────────────────────────────────────
    def _sync_env_files(self, tier1: Mapping[str, str]) -> None:
        """Adopt a tier-1 `ANDREA_SECRETS_ENV_FILES` declaration onto the effective file list.

        ⚑ WHY THIS EXISTS. `ANDREA_SECRETS_ENV_FILES` declares a tier-2 credential FILE. Round 2
        refused to deliver it (it matched the `ANDREA_SECRETS_` prefix), so a service whose file was
        declared centrally simply never read that file — the key inside it never loaded, the service
        resolved `unconfigured`, and `ServiceKeyRequiredIfConfigured` opened the data plane. It is
        credential MATERIAL: a SOURCE declaration, not a policy switch.

        ⚑ ORDERING IS THE WHOLE DIFFICULTY. The declaration is consumed BEFORE tier 2 can be read —
        you cannot read a file you have not been told about. It therefore cannot be resolved like a
        value (tier 1 > tier 2 > tier 3); it has to be resolved from tier 1 FIRST, at the top of
        `_resolve`, before a single env file is opened. That is the one place in this module where
        something is mutated during RESOLVE, and it is deliberate: the file LIST is a property of
        the sources, not a value in the candidate.

        ⚑ APPENDED LAST, NEVER PREPENDED. Within tier 2 the FIRST declaration wins, so appending
        means a config-plane declaration can only supply names the local declarations do not
        already carry — it can never shadow a locally provisioned credential. The fail-safe
        direction: the config plane may ADD a source, never REPLACE one.

        A path is only ever added here, never dropped, for the same reason a withdrawal never
        removes an auth-enforcement name: losing a source is the one change that fails OPEN, and a
        config-plane blip must not be able to make it."""
        if not self._config_service:
            return
        extra = _split(str(tier1.get(ENV_FILES_NAME) or ""), ":,")
        added = [p for p in extra if p not in self._env_files]
        if not added:
            return
        self._env_files = self._env_files + added
        self._config_env_files = tuple(self._config_env_files) + tuple(added)
        # Paths, not values. An operator needs to see that the config plane widened this service's
        # credential sources — silently growing the source list is how provenance stops meaning
        # anything.
        log.warning("creds svc=%s: config-service declared %d additional tier-2 credential "
                    "file(s): %s (appended LAST — a local declaration always wins)",
                    self.service, len(added), added)

    def _resolve(self, tier1: Mapping[str, str], tier1_status: str):
        """Build a CANDIDATE map from all tiers. No VALUE is mutated and nothing is swapped here.

        Lowest tier applied first so the highest overwrites it: process env, then the declared env
        files in REVERSE declaration order (so the FIRST declared file wins), then config-service."""
        # ⚑ FIRST, before any file is opened: tier 1 may DECLARE a tier-2 file (see above).
        self._sync_env_files(tier1 or {})

        values: dict[str, str] = {}
        sources: dict[str, str] = {}
        tiers: dict[str, str] = {}

        # tier 3 — the spawn snapshot. Last-resort fallback, never a winner.
        for k, v in self._env.items():
            if k in self._ignore:
                continue
            values[k] = v
            sources[k] = PROCESS_ENV
        tiers[PROCESS_ENV] = "ok"

        # tier 2 — declared env files. Read in declaration order (so `tiers` reads naturally),
        # applied in reverse (so the first declaration wins).
        parsed: list[tuple[str, dict]] = []
        for path in self._env_files:
            vals, status = read_env_file(path)
            tiers[path] = status                             # ok | absent | EMPTY (read, no keys)
            parsed.append((path, vals))
        for path, vals in reversed(parsed):
            for k, v in vals.items():
                if k in self._ignore:
                    continue
                values[k] = v
                sources[k] = path

        # tier 1 — config-service. Consulted ONLY when this service opted in; otherwise "off".
        # (Pre-0.3 `reload_config()` fetched unconditionally, so a service that deliberately did not
        # opt into the overlay still got its env overwritten on POST. That is fixed here.)
        if self._config_service:
            tiers[CONFIG_SERVICE] = tier1_status
            for k, v in (tier1 or {}).items():
                name = str(k)
                if name in self._ignore:
                    continue
                # ⚑ AUTH POLICY NEVER ENTERS THE RESOLVED SET AT ALL — not just "is not mirrored".
                # Round 2 stopped policy at the two places that CONSUMED it (`apply_to_env` and
                # `_mirror_to_settings`), which made the guarantee a property of every call site
                # rather than of the data. Dropping it here makes "the config plane does not decide
                # whether this service authenticates" structural: there is no ANDREA_AUTH_MODE in
                # `_values` for a future `SECRETS.get()` to find. Tiers 2 and 3 are NOT filtered —
                # they are the operator's own env file and unit file, which are exactly where a
                # service is entitled to declare its own posture.
                if is_auth_control(name):
                    if name not in self._policy_refused:
                        self._policy_refused.add(name)
                        log.error("creds svc=%s: DROPPED auth-control name %s from the "
                                  "config-service payload — the config plane does not decide "
                                  "whether this service authenticates", self.service, name)
                    continue
                values[name] = str(v)
                sources[name] = CONFIG_SERVICE
        else:
            tiers[CONFIG_SERVICE] = "off"

        return values, sources, tiers

    def _fetch_tier1(self, prefetched: ConfigFetch | None = None,
                     *, timeout: float = 5) -> ConfigFetch:
        """Tier 1, with the three outcomes kept DISTINCT. Pre-0.3 `fetch_config()` collapsed
        unreachable / 401 / genuinely-empty into `{}`, so a revoked CLIENT key looked exactly like
        'nothing changed' — a reporting bug that made a rotation failure undiagnosable.

        `timeout` is shortened by read-path callers: a config-plane outage must degrade a request's
        latency, not hold a worker thread for the full startup budget."""
        self._tier1_at = time.time()
        if not self._config_service:
            return ConfigFetch(status="off", payload={}, detail="")
        if prefetched is not None:
            return prefetched
        res = fetch_config_result(self.service, self.box, timeout=timeout)
        if res.status == "unreachable":
            self._counters["source_unreachable"] += 1
            log.warning("creds svc=%s config-service unreachable (%s) — tiers 2-3 still resolve",
                        self.service, res.detail)
        elif res.status == "rejected":
            # LOUD: our own client key was refused. This is diagnosable in one call now.
            log.error("creds svc=%s config-service REJECTED our key (%s) — rotate/repair "
                      "ANDREA_SVC_API_KEY for this service", self.service, res.detail)
        return res

    # ── the read path ───────────────────────────────────────────────────────────────────────────
    def _watch(self) -> tuple:
        """One os.stat per declared env file plus one on the generation stamp. Cheap enough for the
        hot path; the ONLY thing that makes rotation converge across gunicorn worker PROCESSES."""
        out = []
        for path in self._env_files:
            try:
                out.append(os.stat(path).st_mtime)
            except OSError:
                out.append(None)
        try:
            out.append(os.stat(self._state_path).st_mtime)
        except OSError:
            out.append(None)
        return tuple(out)

    def _read_stamp(self) -> dict:
        try:
            with open(self._state_path, "r", encoding="utf-8") as fh:
                data = json.loads(fh.read() or "{}")
            return data if isinstance(data, dict) else {}
        except Exception:                                    # noqa: BLE001 — absent/unreadable is fine
            return {}

    def _write_stamp(self) -> None:
        """Atomic tmp + os.replace, 0600 — the shape cred-drift-check.py already uses. DIGESTS ONLY:
        a sibling worker learns THAT a name changed and re-derives the value from its own sources.
        Fail-soft: a read-only /run just means the passive mtime path carries the convergence."""
        if not self._stamp_enabled:
            return
        payload = {
            "service": self.service,
            "generation": self._generation,
            "ts": int(time.time()),
            "digests": {n: digest_of(v) for n, v in self._values.items()
                        if self._sources.get(n) != PROCESS_ENV or n in self._required
                        or n in AUTH_MATERIAL_NAMES},
        }
        tmp = f"{self._state_path}.{os.getpid()}.tmp"
        try:
            os.makedirs(os.path.dirname(self._state_path) or ".", exist_ok=True)
            with open(tmp, "w", encoding="utf-8") as fh:
                fh.write(json.dumps(payload))
            os.chmod(tmp, 0o600)                             # digests only, but still root-only
            os.replace(tmp, self._state_path)
        except Exception as e:                               # noqa: BLE001 — never fail a reload on this
            log.debug("creds svc=%s: generation stamp not written (%r)", self.service, e)
            try:
                os.unlink(tmp)
            except OSError:
                pass

    def _maybe_refresh(self) -> None:
        """Lazy re-resolution: the MECHANISM. Called from every read. Returns immediately unless a
        declared env file's mtime advanced, the generation stamp advanced (another worker applied a
        reload), or the opt-in tier-1 TTL came due."""
        # ⚑ Nothing re-readable and no TTL ⇒ nothing can ever change, so do not pay a stat per read.
        # This is the tier-3-only service (a container with no mounted env file and no
        # config-service entry): it CANNOT be rotated, and pretending to watch would only cost.
        if not (self._env_files or self._config_service or self._ttl):
            return
        watch = self._watch()
        ttl_due = (bool(self._ttl) and self._config_service
                   and (time.time() - self._tier1_at) >= self._ttl)
        if watch == self._watched and not ttl_due:
            return
        with self._lock:
            if watch == self._watched and not ttl_due:       # another thread won the race
                return
            self._watched = watch                            # set BEFORE callbacks, so a callback
            #                                                  calling get() short-circuits here
            stamp = self._read_stamp()
            adopt = None
            stamp_gen = int(stamp.get("generation") or 0)
            if stamp_gen > self._generation:
                adopt = stamp_gen
            # ⚑ Re-fetch tier 1 when a SIBLING WORKER advanced the generation. Without this a
            # config-service rotation applied by the worker that serviced the POST would converge
            # only for tiers 2-3 in every other worker — i.e. not at all for the values that
            # actually rotated. Short timeout: this is a request path.
            if ttl_due or (adopt is not None and self._config_service):
                fetch = self._fetch_tier1(timeout=2)
                if fetch.status == "ok":
                    self._tier1, self._tier1_status = dict(fetch.payload), fetch.status
                else:
                    self._tier1_status = fetch.status
            # ⚑ probe=True on the READ path too. It is tempting to skip the throwaway DB connection
            # here for latency, and that is exactly wrong: skipping it means a rejected reload is
            # silently UNDONE by the very next read, which re-applies the bad credential the reload
            # just refused. The probe only runs when a db-classified name actually changed (a real
            # rotation, once), the quarantine stops it repeating, and in the failure case it costs
            # no more than the failed connection the next query would have made anyway.
            report, before = self._apply(reason="passive", probe=True, adopt_generation=adopt)
        if report is not None and report.applied:
            self._fire(report, before)

    # ── VALIDATE + APPLY ────────────────────────────────────────────────────────────────────────
    def _swap(self, values, sources, tiers, *, adopt_generation=None) -> None:
        """The APPLY phase, in full: one reference assignment each. A reader holding the old dicts
        keeps a CONSISTENT old set; it never sees a half-applied one."""
        self._values = values
        self._sources = sources
        self._tiers = tiers
        if adopt_generation is not None:
            self._generation = int(adopt_generation)
        else:
            self._generation += 1

    def _candidate(self):
        return self._resolve(self._tier1, self._tier1_status)

    def _validate(self, candidate: Mapping[str, str], changed: Mapping[str, list],
                  *, probe: bool) -> dict:
        """Judge the WHOLE candidate. Any finding rejects the whole thing — never 'apply the good
        ones and skip the bad', because a partial apply is the state that cannot be rolled back."""
        rejected: dict[str, str] = {}
        for name, purpose in self._required.items():
            if candidate.get(name):
                continue
            if not self._values.get(name):
                # ⚑ ALREADY absent before this candidate — do NOT reject. Rejecting here would
                # FREEZE a degraded service: a reload could apply nothing until the missing
                # credential appeared, including the very key drop that would fix it (§4c is the
                # whole bootstrap story). It stays loudly visible instead — `ReloadReport.missing`,
                # `posture`, `/health` 503 and `/admin/creds-status` all carry the name.
                continue
            # Present before, absent now, and DECLARED REQUIRED. This candidate would strip a
            # credential the service cannot serve without: reject the whole thing.
            rejected[name] = "required credential would be removed by this reload"
            if purpose:
                rejected[name] += f" ({purpose})"
        for name, fn in self._validators.items():
            if name not in candidate:
                continue
            try:
                if not fn(candidate[name]):
                    rejected[name] = "validator rejected the value"
            except Exception as e:                           # noqa: BLE001 — a raising validator is a reject
                rejected[name] = f"validator raised {type(e).__name__}"
        if probe and self.db_probe and changed.get("db"):
            why = probe_databases(candidate)
            if why:
                for name in changed["db"]:
                    rejected[name] = why
        return rejected

    def _fingerprint(self, candidate: Mapping[str, str]) -> str:
        """A digest OF DIGESTS identifying a candidate set. Used to QUARANTINE a rejected candidate
        so the passive read path does not re-evaluate — and, for a db change, re-PROBE — the same
        bad values on every single request. Cleared as soon as the sources change again."""
        material = "\x00".join(f"{k}={digest_of(v)}" for k, v in sorted(candidate.items()))
        return hashlib.sha256(material.encode("utf-8", "replace")).hexdigest()

    def _withdrawal_is_evidenced(self, name: str, tiers: Mapping[str, str]) -> bool:
        """Is there POSITIVE EVIDENCE that `name` was really removed upstream?

        A name goes missing from the candidate for two very different reasons, and the shipped code
        could not tell them apart:

          * the operator really removed it from a source we READ SUCCESSFULLY — evidence;
          * we could not read the source, or read it as EMPTY (a 0-byte file, a comment-only file,
            a non-atomic rewrite caught mid-flight, a config-service outage) — NOT evidence.

        The second case is `rules/an-empty-read-is-not-evidence-of-absence`. Treating it as a
        withdrawal is how a stray `> /etc/andrea/svc.env` blanked a live key and opened the service.
        Tier 3 is the one source that cannot lie this way: it is a frozen snapshot, so a name can
        only leave it by being explicitly ignored."""
        src = self._sources.get(name, PROCESS_ENV)
        if src == PROCESS_ENV:
            return True
        if src == CONFIG_SERVICE:
            return tiers.get(CONFIG_SERVICE) == "ok"
        return tiers.get(src) == "ok"                        # a declared env file: read, non-empty

    def _honoured_withdrawals(self, withdrawn: Sequence[str], tiers: Mapping[str, str],
                              reason: str) -> list:
        """Which of `withdrawn` may actually be REMOVED from the live set. Everything else is
        carried forward (and still reported), because a carry-forward is recoverable and a blanked
        credential is not."""
        if not self.honor_withdrawal:
            return []
        out = []
        for name in withdrawn:
            if name in AUTH_ENFORCEMENT_NAMES:
                # ⚑ NEVER, on any path, opt-in or not. Removing the service key does not degrade
                # this service, it OPENS it: `settings_auth_configured()` goes False and
                # ServiceKeyRequiredIfConfigured answers 200 to anonymous callers. Every other
                # failure in this module fails closed; this is the only one that fails open, so it
                # is the only change a reload may not make. A key is retired by a RESTART, which
                # re-derives the whole set from the tiers with nothing carried over.
                log.error("creds svc=%s: REFUSED to withdraw %s (%s) — removing it would turn "
                          "enforcement OFF; retire a service key by restarting the unit",
                          self.service, name, reason)
                continue
            if not self._withdrawal_is_evidenced(name, tiers):
                log.warning("creds svc=%s: %s looks withdrawn but its source (%s) read as '%s' — "
                            "an empty/unreadable source is not evidence of removal, carrying the "
                            "existing value forward", self.service, name,
                            self._sources.get(name, PROCESS_ENV),
                            tiers.get(self._sources.get(name, PROCESS_ENV), "?"))
                continue
            out.append(name)
        return out

    def _missing_in(self, values: Mapping[str, str]) -> list:
        """Declared-required names that `values` does not satisfy. NAMES only."""
        return sorted(n for n in self._required if not values.get(n))

    def _diff(self, candidate: Mapping[str, str]):
        """(changed buckets, withdrawn). Compared by digest so no value is ever held for comparison
        longer than the expression that computes it."""
        buckets: dict[str, list] = {"db": [], "auth": [], "other": []}
        withdrawn: list[str] = []
        for name in sorted(set(candidate) | set(self._values)):
            before = self._values.get(name)
            after = candidate.get(name)
            if before and not after:
                withdrawn.append(name)
                continue
            if str(before or "") != str(after or ""):
                buckets[classify(name)].append(name)
        return buckets, withdrawn

    def _apply(self, *, reason: str, probe: bool, adopt_generation=None):
        """RESOLVE → VALIDATE → APPLY, under the lock.

        Returns `(report | None, before_digests)`. `None` means there was nothing to do on the
        passive path. `before_digests` is carried OUT rather than stashed on the (frozen) report,
        and the caller fires callbacks OUTSIDE the lock."""
        candidate, sources, tiers = self._candidate()

        # ── withdrawal: default is NOT to unset. Dropping a name that vanished upstream could
        # strip a live credential — but it stops being invisible (it lands in `withdrawn`).
        # ANDREA_CREDS_HONOR_WITHDRAWAL=1 opts in, and even then a removal must be EVIDENCED and
        # must not be an auth-enforcement name (`_honoured_withdrawals`). Everything not honoured is
        # carried forward at its existing value — still reported, never silently applied.
        buckets, withdrawn = self._diff(candidate)
        if withdrawn:
            honoured = self._honoured_withdrawals(withdrawn, tiers, reason)
            for name in withdrawn:
                if name in honoured:
                    # Honouring the withdrawal means a REMOVAL is itself a change — otherwise a
                    # generation whose only content is a removal would score zero changed names and
                    # be reported (and skipped) as "no-change", i.e. the opt-in would silently no-op.
                    buckets[classify(name)].append(name)
                else:
                    candidate[name] = self._values[name]
                    sources[name] = self._sources.get(name, PROCESS_ENV)

        # ⚑ QUARANTINE. Without this, a candidate that VALIDATE just refused would be re-resolved,
        # re-probed and re-refused on every subsequent read — a per-request connection attempt
        # against a database we already know rejects us. The quarantine lifts the moment the sources
        # change again (a different fingerprint), and an explicit POST always re-evaluates.
        fingerprint = self._fingerprint(candidate)
        if reason == "passive" and fingerprint == self._rejected_fp:
            return None, {}

        rejected = self._validate(candidate, buckets, probe=probe)
        if rejected:
            self._rejected_fp = fingerprint
            self._counters["reload_rejected"] += 1
            # ⚑ NOTHING is applied. Generation is unchanged. The live set is untouched, so a bad
            # value can never blank a good credential.
            log.error("creds reload svc=%s gen=%d applied=0 REJECTED=%s withdrawn=%s missing=%s",
                      self.service, self._generation, sorted(rejected), withdrawn,
                      self._missing_in(self._values))
            return ReloadReport(service=self.service, generation=self._generation, applied=False,
                                reason="rejected", changed=buckets, withdrawn=withdrawn,
                                rejected=rejected, tiers=tiers,
                                missing=self._missing_in(self._values)), {}

        changed_names = [n for v in buckets.values() for n in v]
        if not changed_names and adopt_generation is None:
            self._tiers = tiers                              # tier STATUS may have moved even when
            self._sources = sources                          # no value did (e.g. unreachable → ok)
            if reason == "passive":
                return None, {}
            self._counters["reload_no_change"] += 1
            return ReloadReport(service=self.service, generation=self._generation, applied=False,
                                reason=self._no_change_reason(), changed=buckets,
                                withdrawn=withdrawn, tiers=tiers,
                                missing=self._missing_in(self._values)), {}

        before_digests = {n: digest_of(self._values.get(n)) for n in changed_names}
        gen_before = self._generation
        self._rejected_fp = ""                               # this candidate passed: quarantine off
        self._swap(candidate, sources, tiers, adopt_generation=adopt_generation)
        self._counters["reload_applied"] += 1

        reconnected = apply_databases(self._values) if buckets["db"] else []
        self._mirror_to_settings(changed_names)

        still_missing = self._missing_in(self._values)
        report = ReloadReport(service=self.service, generation=self._generation, applied=True,
                              reason="applied", changed=buckets, withdrawn=withdrawn,
                              reconnected=reconnected, tiers=tiers, missing=still_missing)
        # §6.5 — names + classes + generation only. A reload that APPLIED while a declared-required
        # credential is still absent must say so on the same line; "applied 1 key" alone is the
        # silence this contract exists to end.
        (log.warning if (still_missing or withdrawn) else log.info)(
            "creds reload svc=%s gen=%d→%d applied=1 db=%s auth=%s other=%s withdrawn=%s "
            "rejected={} missing=%s reconnected=%s", self.service, gen_before, self._generation,
            buckets["db"], buckets["auth"], buckets["other"], withdrawn, still_missing, reconnected)
        return report, before_digests

    def _no_change_reason(self) -> str:
        if self._tier1_status == "unreachable":
            return "source-unreachable"
        if self._tier1_status == "rejected":
            return "source-rejected-our-key"
        return "no-change"

    def _mirror_to_settings(self, changed: Sequence[str]) -> None:
        """Update a Django setting of the SAME NAME when the service defined one, and keep
        settings.ANDREA_SVC_KEYS in step. This is what makes a rotated value 'live on the next
        query' for every service that reads settings — generically, with no per-service code.

        ⚑ os.environ is NEVER touched. Mutating it is how a partial apply becomes unrecoverable,
        and it is why `source_of()` is impossible in the pre-0.3 loader.

        ⚑ AUTH-CONTROL NAMES ARE NOT MIRRORED GENERICALLY. This loop is the second route by which
        a config-service row reached the auth decision: `settings.ANDREA_AUTH_MODE` is a setting of
        exactly that name, so a tier-1 payload carrying `ANDREA_AUTH_MODE=dev-open` was mirrored
        straight onto it — the same fleet kill switch as `apply_to_env`, wearing the new contract's
        clothes. Auth MATERIAL (the key, the OIDC pair) also skips this loop — but only because the
        generic `setattr` here would write an EMPTY value and blank a live credential. It is
        mirrored by `_mirror_auth_material` instead, which writes non-empty values only: rotate,
        never blank."""
        dj = _live_settings()
        if dj is None:
            return
        for name in changed:
            if is_auth_control(name) or name in MIRRORED_AUTH_MATERIAL:
                # Auth POLICY is refused outright; MATERIAL skips only this generic loop, because
                # the guarded mirror below is the one that may write it (rotate, never blank).
                if is_auth_control(name):
                    log.warning("creds svc=%s: not mirroring auth-control name %s onto settings — "
                                "the config plane does not decide whether this service "
                                "authenticates", self.service, name)
                continue
            if hasattr(dj, name):
                try:
                    setattr(dj, name, self._values.get(name, ""))
                except Exception:                            # noqa: BLE001 — a read-only setting is fine
                    log.debug("creds: could not mirror %s to settings", name)
        self._mirror_auth_material(dj)

    def _mirror_auth_material(self, dj) -> list:
        """Mirror the auth MATERIAL onto the settings the auth layer actually reads, ROTATE-ONLY.
        Returns the setting names written. Caller holds `_lock`.

        THE ONE RULE: **a non-empty value may be written; an empty resolution is never written.**
        That single guard is what makes this safe to run on the passive read path — enforcement can
        never be switched off by a value that failed to resolve, by a truncated file, or by a
        config-plane blip, because "failed to resolve" simply means "do not write".

        ⚑ OIDC IS MIRRORED HERE, and that is round 3. `settings.OIDC_ISSUER` / `OIDC_JWKS_URL` are
        half of `settings_auth_configured()` — the predicate `ServiceKeyRequiredIfConfigured`
        evaluates — yet nothing in this module ever wrote them: `_mirror_to_settings` explicitly
        skipped OIDC_*, and `refresh_auth_settings()` only ever handled ANDREA_SVC_KEYS. So an
        OIDC-configured service could hold a perfectly good issuer+JWKS pair in `_values` while the
        auth layer saw two empty strings and served anonymous 200. Delivering the credential and
        never handing it to the thing that checks it is not a refusal — it is a fail-open."""
        written: list[str] = []
        if dj is None:
            return written
        # ⚑ `if key` is a ROTATE-ONLY guard, and that is the point: an empty resolution never
        # reaches settings, so enforcement cannot be switched off by a value that failed to resolve.
        key = self._values.get("ANDREA_SVC_KEYS") or self._values.get("ANDREA_SVC_API_KEY") or None
        if key and getattr(dj, "ANDREA_SVC_KEYS", None) != key:
            dj.ANDREA_SVC_KEYS = key
            written.append("ANDREA_SVC_KEYS")
        for name in OIDC_MATERIAL_NAMES:
            val = self._values.get(name) or ""
            if not val:
                continue                                     # ROTATE-ONLY: never write an empty
            # ⚑ TIER 3 IS NEVER RE-MIRRORED, and this is a FLEET-COMPATIBILITY guard, not a
            # security one. Several services override an OIDC setting after the star-import and
            # NORMALISE it — docwell and logsmith-cite both do
            # `os.environ.get("OIDC_ISSUER", <default>).rstrip("/")`, and pod-gem the same. Their
            # settings value is therefore legitimately DIFFERENT from the raw spawn-snapshot string
            # this object holds, and blindly re-mirroring the raw one would silently un-normalise a
            # live issuer — an `iss` mismatch that 401s every legitimate user. It buys nothing
            # either: tier 3 is a frozen snapshot, settings.py already read it through `_material()`
            # at startup, and a snapshot cannot rotate — so there is never anything new to deliver
            # from it. The mirror exists for the RE-READABLE tiers, which is exactly what it now
            # covers.
            if self._sources.get(name) == PROCESS_ENV:
                continue
            if str(getattr(dj, name, "") or "") != val:
                try:
                    setattr(dj, name, val)
                    written.append(name)
                except Exception:                            # noqa: BLE001 — a read-only setting is fine
                    log.debug("creds: could not mirror %s to settings", name)
        if written:
            # NAMES only. That a credential moved is operational information; what it moved to is
            # not (rm#206).
            log.info("creds svc=%s: auth material refreshed onto settings: %s",
                     self.service, written)
        return written

    def _fire(self, report: ReloadReport, before: Mapping[str, str] | None = None) -> None:
        """Run subscriber + whole-generation callbacks OUTSIDE the lock. Errors are collected into
        the report, never raised — a broken hook must not take the service down."""
        before = dict(before or {})
        ran: list[str] = []
        errors: dict[str, str] = {}
        for name in [n for v in report.changed.values() for n in v]:
            for cb in self._subs.get(name, []):
                label = getattr(cb, "__qualname__", repr(cb))
                try:
                    cb(name, before.get(name, ""), self.digest(name))
                    ran.append(f"{name}:{label}")
                except Exception as e:                       # noqa: BLE001
                    errors[f"{name}:{label}"] = repr(e)
                    log.exception("creds: subscriber for %s failed", name)
        for cb in list(self._change_hooks):
            label = getattr(cb, "__qualname__", repr(cb))
            try:
                cb(report)
                ran.append(label)
            except Exception as e:                           # noqa: BLE001
                errors[label] = repr(e)
                log.exception("creds: on_change hook failed")
        report.hooks.extend(ran)
        report.hook_errors.update(errors)

    def _run_dotted_hook(self, report: ReloadReport) -> None:
        """The pre-0.3 escape hatch, kept working byte-for-byte: settings.SVC_CONFIG_RELOAD_HOOK =
        "module.path:callable", called with the legacy summary dict."""
        dj = _live_settings()
        dotted = getattr(dj, "SVC_CONFIG_RELOAD_HOOK", None) if dj is not None else None
        if not dotted:
            return
        try:
            from importlib import import_module
            mod_path, _, fn_name = str(dotted).partition(":")
            getattr(import_module(mod_path), fn_name)(report.as_dict())
            report.hooks.append(str(dotted))
        except Exception as e:                               # noqa: BLE001 — a broken hook must not 500
            log.exception("creds: SVC_CONFIG_RELOAD_HOOK failed")
            report.hook_errors[str(dotted)] = repr(e)

    # ── public read surface ─────────────────────────────────────────────────────────────────────
    def get(self, name: str, default: str | None = None) -> str | None:
        """The hot path: thread-safe, no network. Re-resolves tiers 2-3 first if any declared env
        file or the generation stamp advanced. Tier 1 is NEVER fetched here unless the opt-in
        ANDREA_CREDS_TTL is set — network I/O on a request path is not a default."""
        self._maybe_refresh()
        with self._lock:
            val = self._values.get(name)
        return default if val is None else val

    def require(self, name: str) -> str:
        """Fail-CLOSED read. Raises MissingCredential (→ HTTP 503 with the NAME and PURPOSE) rather
        than returning an empty string that a caller would silently send as a credential."""
        val = self.get(name)
        if not val:
            raise MissingCredential(name, self._required.get(name, ""))
        return val

    def source_of(self, name: str) -> str:
        """"config-service" | "<env file path>" | "process-env" | "" (absent). Answerable only
        because the tiers are kept separate in memory instead of flattened into os.environ."""
        self._maybe_refresh()
        with self._lock:
            return self._sources.get(name, "")

    def digest(self, name: str) -> str:
        """sha256(value)[:16], "" when absent. NEVER the value."""
        self._maybe_refresh()
        with self._lock:
            return digest_of(self._values.get(name))

    # ── posture / introspection ─────────────────────────────────────────────────────────────────
    def auth_material_present(self) -> bool:
        """Auth material in the RESOLVED set — independent of django.conf.settings, so it is
        answerable at settings-load time (when the startup announcement is computed).

        ⚑ THIS IS NOT THE ENFORCEMENT PREDICATE, and confusing the two is the round-3 defect. It
        answers "do we HOLD a credential?", which is a strictly different question from "will an
        anonymous request be refused?" — the latter is `settings_auth_configured()`. Holding a key
        the auth layer cannot see is precisely the open-with-material state; see `posture`."""
        if self._values.get("ANDREA_SVC_KEYS") or self._values.get("ANDREA_SVC_API_KEY"):
            return True
        return bool(self._values.get("OIDC_ISSUER") and self._values.get("OIDC_JWKS_URL"))

    def enforcement_visible(self):
        """THE BRIDGE between what we enforce and what we report. Returns:

            True   — `settings_auth_configured()` is True, so `ServiceKeyRequiredIfConfigured`
                     REFUSES an anonymous request. Exactly the predicate the permission class runs.
            False  — it is False, so an anonymous request is SERVED (mode auto/dev-open).
            None   — the question is not answerable ABOUT THIS OBJECT, so no claim is made:
                     Django settings are not configured yet (this runs from inside settings.py), or
                     this instance is not `settings.SECRETS` and therefore is not what the auth
                     layer reads. Every observable caller — `health._creds_state`,
                     `permissions._posture`, `CredsStatusView` — reaches this object through
                     `current()`, i.e. through `settings.SECRETS`, so the reported posture is always
                     the singleton's and always gets a real answer.
        """
        dj = _live_settings()
        if dj is None:
            return None
        if getattr(dj, "SECRETS", None) is not self:
            return None
        return settings_auth_configured()

    @property
    def configured(self) -> bool:
        """Auth material present — from the resolved set, OR from live settings once Django is up
        (a service may set ANDREA_SVC_KEYS in its own settings module rather than via env)."""
        return self.auth_material_present() or settings_auth_configured()

    @property
    def enforcing(self) -> bool:
        """Would an anonymous request be REFUSED right now? The single boolean a fleet sweep wants,
        derived from the permission class's own predicate. Falls back to "do we hold material" only
        where the predicate is unanswerable (`enforcement_visible() is None`)."""
        visible = self.enforcement_visible()
        return self.auth_material_present() if visible is None else bool(visible)

    @property
    def auth_kind(self) -> str:
        if self._values.get("ANDREA_SVC_KEYS") or self._values.get("ANDREA_SVC_API_KEY"):
            return "keys"
        if self._values.get("OIDC_ISSUER") and self._values.get("OIDC_JWKS_URL"):
            return "oidc"
        return "keys" if settings_auth_configured() else "none"

    def _auth_material_source(self) -> str:
        """The TIER that supplied this service's auth material — "config-service", an env file
        path, "process-env", or "" when there is none. Provenance, never a value."""
        for name in AUTH_MATERIAL_NAMES:
            if self._values.get(name):
                return self._sources.get(name, "")
        return ""

    @property
    def generation(self) -> int:
        return self._generation

    @property
    def missing(self) -> list:
        """Declared-required names currently absent or empty."""
        self._maybe_refresh()
        with self._lock:
            return sorted(n for n in self._required if not self._values.get(n))

    @property
    def posture(self) -> str:
        """"enforcing" | "open" | "unconfigured" | "degraded".

        enforcing    — an anonymous request is refused. See the invariant below.
        open         — ⚑ auth material IS held, but the auth layer cannot see it, so anonymous
                       requests are being SERVED by a service that has a credential. Always a
                       defect (a mirroring gap), never a deployment choice — distinguished from
                       `unconfigured` precisely because the two need opposite responses: one is a
                       dev service behaving as intended, the other is a hole.
        unconfigured — no auth material at all: there is no credential that would open the control
                       plane, so the control plane answers 503, not 401.
        degraded     — configured, but a declared-required credential is absent or tier 1 is
                       unreachable/rejected. The service still serves; /health only 503s on the
                       former (a config-plane outage must not evict a healthy service from rotation).

        ⚑ THE INVARIANT THIS PROPERTY EXISTS TO HOLD:
        **posture == "enforcing"  ⇒  ServiceKeyRequiredIfConfigured refuses an anonymous request.**

        It did not hold before round 3, and the divergence was the worst part of that defect.
        `configured` reads the RESOLVED set, which held the config-service OIDC pair; the permission
        class reads `settings_auth_configured()`, which did not — because nothing ever mirrored the
        pair onto settings. So an open service reported `posture=enforcing`, and a fleet sweep of
        /health + /admin/creds-status answered "nobody is open" while a service was open. A wrong
        answer from the observability spine is worse than no answer: it ends the investigation.

        The first branch is what makes the implication true. It cannot be satisfied by fixing the
        mirroring alone — a future auth-material name that someone forgets to mirror lands here too,
        which is the point of asserting agreement rather than trusting a call site."""
        if self.enforcement_visible() is False and self.auth_material_present():
            announce_open_divergence(self.service, self.auth_kind)
            return "open"
        if not self.configured:
            return "unconfigured"
        if self.missing or self._tiers.get(CONFIG_SERVICE) in ("unreachable", "rejected"):
            return "degraded"
        return "enforcing"

    def sources(self) -> list:
        """The tiers actually backing this process, highest first. `["process-env"]` alone is the
        honest confession that this process has NO re-readable credential source and therefore
        cannot be rotated without a restart — infra#242's gap, made visible per-service."""
        out = []
        if self._tiers.get(CONFIG_SERVICE) == "ok":
            out.append(CONFIG_SERVICE)
        out += [p for p in self._env_files if self._tiers.get(p) == "ok"]
        out.append(PROCESS_ENV)
        return out

    @property
    def rotatable(self) -> bool:
        """False when the only source is the un-re-readable spawn snapshot."""
        return self.sources() != [PROCESS_ENV]

    def status(self) -> dict:
        """The /admin/creds-status body: names + provenance + digests + counters. NEVER values.

        Only the credentials this service actually MANAGES are listed — declared-required names,
        anything sourced above tier 3, and the auth MATERIAL names. Dumping every process-env name
        would publish the shape of the whole environment for no diagnostic gain.

        ⚑ ALL AUTH MATERIAL IS LISTED WITH ITS PROVENANCE, including the OIDC pair. That is the
        answer to the one thing delivering OIDC material from the config plane genuinely adds: a
        swapped service key is noisy (the operator's own key stops working) but an ADDED issuer is
        silent. `source: config-service` on OIDC_ISSUER is the line that makes it not silent."""
        self._maybe_refresh()
        with self._lock:
            names = set(self._required)
            names |= {n for n, s in self._sources.items() if s != PROCESS_ENV}
            names |= {n for n in AUTH_MATERIAL_NAMES if self._values.get(n)}
            creds = {n: {"source": self._sources.get(n, ""), "digest": digest_of(self._values.get(n))}
                     for n in sorted(names)}
            tiers = dict(self._tiers)
            generation = self._generation
            loaded_at = self._loaded_at
            env_files = list(self._env_files)
            from_config = list(self._config_env_files)
        enforcing = self.enforcing
        return {
            "service": self.service, "generation": generation, "posture": self.posture,
            "configured": self.configured, "auth": self.auth_kind,
            # ⚑ `configured` answers "do we HOLD a credential?"; `enforcing` answers "is an
            # anonymous request refused?". They are different questions and reporting only the
            # first is what let an open service look enforcing — so both ship, side by side.
            "enforcing": enforcing,
            "loaded_at": (time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(loaded_at))
                          if loaded_at else ""),
            "sources": self.sources(), "rotatable": self.rotatable,
            "tiers": tiers, "creds": creds, "missing": self.missing,
            "declared_required": sorted(self._required),
            "env_files": env_files,                          # paths, in resolution order
            "env_files_from_config_service": from_config,    # the subset tier 1 declared
            "counters": {
                "creds_generation": generation,
                "creds_reload_total": {"applied": self._counters["reload_applied"],
                                       "no-change": self._counters["reload_no_change"],
                                       "rejected": self._counters["reload_rejected"]},
                "creds_source_unreachable_total": self._counters["source_unreachable"],
                # ⚑ Derived from `enforcing`, NOT from `configured`. This counter is the fleet's
                # "who is open?" metric, so it must count the open-with-material case as open —
                # counting held credentials there reported 0 for a service serving anonymous 200.
                "creds_unconfigured": 0 if enforcing else 1,
            },
        }

    # ── lifecycle ───────────────────────────────────────────────────────────────────────────────
    def load(self, *, prefetched: ConfigFetch | None = None) -> LoadReport:
        """Startup, idempotent. Called once from settings.py. This is the ONLY place (besides an
        explicit reload / the opt-in TTL) that touches the network."""
        with self._lock:
            fetch = self._fetch_tier1(prefetched)
            self._tier1 = dict(fetch.payload) if fetch.status == "ok" else {}
            self._tier1_status = fetch.status
            values, sources, tiers = self._candidate()
            self._swap(values, sources, tiers, adopt_generation=0)
            stamp = self._read_stamp()
            self._generation = max(1, int(stamp.get("generation") or 0))
            self._watched = self._watch()
            self._loaded = True
            self._loaded_at = time.time()
            report = LoadReport(
                service=self.service, generation=self._generation, posture=self.posture,
                resolved={n: s for n, s in sources.items()
                          if s != PROCESS_ENV or n in self._required or n in AUTH_MATERIAL_NAMES},
                missing=self.missing, tiers=tiers, sources=self.sources(), auth=self.auth_kind)
            self._last_load = report

        if report.missing and self.strict:
            # OPERATOR CHOICE 2, opt-in half: ANDREA_SECRETS_STRICT=1 turns start-and-503 into
            # refuse-to-start. Default is OFF because under Restart=always a hard exit is a
            # crash-loop, and a crash-loop destroys the observability this contract is building —
            # a 503 naming the credential is strictly more diagnosable than a restarting unit.
            raise MissingCredential(report.missing[0], self._required.get(report.missing[0], ""))
        return report

    def announce(self, force: bool = False) -> str:
        """§6.1 — ONE machine-parsable line, unconditionally, at every start. WARNING when the
        posture is not `enforcing`, INFO otherwise. An unconfigured service is never silent again.

        Called from AppConfig.ready() rather than from load(), because at settings-import time
        Django has not configured logging yet and an INFO record would be dropped."""
        if self._announced and not force:
            return ""
        self._announced = True
        posture = self.posture
        line = ("creds svc=%s gen=%d posture=%s auth=%s auth-source=%s sources=%s rotatable=%d "
                "declared=%d missing=%s config-service=%s" % (
                    self.service or "?", self._generation, posture, self.auth_kind,
                    # ⚑ WHERE the auth material came from, on the same line as the posture. A
                    # service whose credential arrives from the config plane is a different
                    # operational fact from one whose key is baked into its unit, and until now
                    # nothing said which. `auth-source=config-service` is the grep that finds
                    # every service whose enforcement depends on the config plane being honest.
                    self._auth_material_source() or "-",
                    "[" + ",".join(self.sources()) + "]", 1 if self.rotatable else 0,
                    len(self._required), "[" + ",".join(self.missing) + "]",
                    self._tiers.get(CONFIG_SERVICE, "off")))
        (log.warning if posture != "enforcing" else log.info)("%s", line)
        return line

    def reload(self, *, reason: str = "manual") -> ReloadReport:
        """The ACTIVE trigger (POST /admin/reload-config). Re-reads ALL THREE tiers in precedence
        order — not just config-service. A rotation that lands only in /etc/andrea/<svc>.env is
        invisible to a pre-0.3 running service forever; here it applies."""
        with self._lock:
            fetch = self._fetch_tier1()
            if fetch.status == "ok":
                self._tier1 = dict(fetch.payload)
            self._tier1_status = fetch.status
            report, before = self._apply(reason=reason, probe=True)
            if report is None:                               # defensive: _apply only returns None
                self._counters["reload_no_change"] += 1      # on the passive path
                report = ReloadReport(service=self.service, generation=self._generation,
                                      applied=False, reason=self._no_change_reason(),
                                      tiers=dict(self._tiers),
                                      missing=self._missing_in(self._values))
            # ⚑ AFTER the apply, because tier 1 may have DECLARED a new tier-2 file during it
            # (`_sync_env_files`). Re-stat with the FINAL file list so the new file's mtime is in
            # the watch tuple; otherwise the very next read would see a length mismatch and pay an
            # extra full re-resolution to converge.
            self._watched = self._watch()
            if report.applied:
                # The stamp is written ONLY by the active reload. A passive refresh must never
                # write it, or sibling workers would ping-pong generations off each other.
                self._write_stamp()
        if report.applied:
            self._fire(report, before)
            self._run_dotted_hook(report)
        return report

    # ── change notification ─────────────────────────────────────────────────────────────────────
    def subscribe(self, name: str, cb: Callable[[str, str, str], None]) -> None:
        """cb(name, old_digest, new_digest), fired AFTER a successful atomic swap. Never with
        values. For a module constant read once at import (docstore's TOKEN, config_store's
        DB_PASS) this is how it stops being pinned for the life of the process."""
        with self._lock:
            self._subs.setdefault(str(name), []).append(cb)

    def on_change(self, cb: Callable[[ReloadReport], None]) -> None:
        """Whole-generation hook: memory_server clears its lru_cache, uuid_service rebuilds its
        DBUtils pool. Fired after every applied generation."""
        with self._lock:
            self._change_hooks.append(cb)

    def add_validator(self, name: str, fn: Callable[[str], bool]) -> None:
        with self._lock:
            self._validators[str(name)] = fn

    def declare_required(self, name: str, purpose: str = "") -> None:
        """Declare a credential this service cannot serve without. Absence ⇒ require() raises,
        /health degrades, and the name appears in `missing`."""
        with self._lock:
            self._required[str(name)] = str(purpose or "")


# ── process-wide accessor ───────────────────────────────────────────────────────────────────────
def current() -> ServiceSecrets | None:
    """The process-wide ServiceSecrets (settings.SECRETS), or None for a service that does not
    inherit andrea_svc_web.settings. Every caller here degrades gracefully on None — that is what
    keeps the contract backward compatible for the non-basement Django services."""
    dj = _live_settings()
    if dj is None:
        return None
    obj = getattr(dj, "SECRETS", None)
    return obj if isinstance(obj, ServiceSecrets) else None


def refresh_auth_settings() -> bool:
    """Pull the CURRENT auth MATERIAL through the mtime-gated read path and mirror it onto the
    settings `andrea_svc_auth` reads per request — ANDREA_SVC_KEYS *and* the OIDC issuer+JWKS pair.

    ⚑ This is §4(c), the bootstrap answer: a credential dropped into a declared env file becomes
    live on the next request, with no POST and no restart — so provisioning never requires the
    endpoint that the provisioning would authorize. Wired as CredsRefreshMiddleware so it runs
    BEFORE DRF authentication (which happens inside the view's initial(), after middleware).

    ⚑ IT USED TO MIRROR ONLY ANDREA_SVC_KEYS, and that omission was half of the round-3 fail-open.
    `settings_auth_configured()` — the predicate the permission class evaluates — is satisfied by
    the service key OR by the OIDC pair, so a per-request refresh that carried only the key could
    never repair an OIDC-configured service whose issuer/JWKS had not reached settings. Both halves
    of the predicate are refreshed here, or the refresh is only half a contract."""
    secrets = current()
    if secrets is None:
        return False
    secrets._maybe_refresh()                                 # mtime-gated; no network
    dj = _live_settings()
    if dj is None:
        return False
    with secrets._lock:
        return bool(secrets._mirror_auth_material(dj))
