# andrea-svc-web — the common basement for Andrea web services

The shared web-service base. It pours the foundation once — framework-only Django settings, a
`/health` endpoint, a storm-safe base view, drf-spectacular OpenAPI, an API-key/OIDC auth class
(via `andrea_svc_auth`), and a **bounded gunicorn worker pool** — so authoring a new service means
writing **only its business logic**. It is the answer to the operator directive *"we do not need a
single-threaded HTTP server anywhere; Django fixes this brilliantly"* (the #250 lesson), built once.

Sibling to `andrea-svc-auth` (auth) — same shape: a versioned pip package installed from the devpi
proxy. **Composable, not prescriptive:** a service that wasn't ours from the start can adopt just
the pieces it wants (the auth class alone, or the storm-safe view alone) without a rewrite.

## Author a service in four small files

```python
# myservice/settings.py
from andrea_svc_web.settings import *                       # noqa: F401,F403
SERVICE_NAME       = "myservice"
INSTALLED_APPS     = INSTALLED_APPS + ["myservice.app"]     # noqa: F405
ROOT_URLCONF       = "myservice.urls"
WSGI_APPLICATION   = "myservice.wsgi.application"
SPECTACULAR_SETTINGS = {**SPECTACULAR_SETTINGS,             # noqa: F405
    "TITLE": "My Service", "VERSION": "1.0.0", "DESCRIPTION": "..."}
SVC_HEALTHCHECK    = "myservice.app.service:healthcheck"    # optional deep check
```
```python
# myservice/urls.py
from andrea_svc_web.urls import service_urls
from django.urls import path
from myservice.app.views import ThingView
urlpatterns = service_urls(extra=[path("thing", ThingView.as_view(), name="thing")])
```
```python
# myservice/wsgi.py
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myservice.settings")
application = get_wsgi_application()
```
```python
# myservice/app/views.py  — the ONLY real work
from andrea_svc_web.views import StormSafeAPIView
from andrea_svc_web.auth import ServiceOrOIDCAuthentication, ServiceKeyRequiredIfConfigured
from rest_framework.response import Response

class ThingView(StormSafeAPIView):
    authentication_classes = [ServiceOrOIDCAuthentication]
    permission_classes     = [ServiceKeyRequiredIfConfigured]   # ServiceKeyRequired = never open
    def post(self, request):
        return Response(do_the_thing(request.data))
```

## Run it

```
SVC_WSGI=myservice.wsgi:application SVC_BIND=0.0.0.0:8769 \
  gunicorn -c python:andrea_svc_web.gunicorn_conf "$SVC_WSGI"
```
or copy the shared `andrea_svc_web/entrypoint.sh` (adds the tcmalloc runtime toggle).

## What you inherit

| concern | where | note |
|---|---|---|
| Django/DRF settings | `andrea_svc_web.settings` | star-import; no ORM apps, `DATABASES={}` (override for ORM) |
| `/health` | `andrea_svc_web.health.HealthView` | pluggable deep-check via `SVC_HEALTHCHECK` |
| OpenAPI `/api/schema` + `/api/docs` | `andrea_svc_web.urls.service_urls` | drf-spectacular |
| never-500-into-a-retry-storm | `andrea_svc_web.views.StormSafeAPIView` | the #250 lesson |
| auth (API-key / OIDC) | `andrea_svc_web.auth` | composes `andrea_svc_auth`; config-driven via env |
| optional FastAPI-compat 422 | `andrea_svc_web.exceptions` | opt-in `ANDREA_FASTAPI_COMPAT_422=1` → validation errors render as FastAPI's 422 `{"detail":[{loc,msg,type}]}`; **default off** (standard DRF 400) |
| bounded **threaded** worker pool | `andrea_svc_web.gunicorn_conf` | defaults to `gthread` (NOT gunicorn's single-threaded `sync` default); concurrency = workers × threads; all env-tunable |
| **syslog logging** | `andrea_svc_web.logging_conf` | logs to stdout **and** syslog, tagged with `SERVICE_NAME`; env-tunable (`SYSLOG_ADDRESS`/`FACILITY`, `LOG_LEVEL`); fail-soft (no `/dev/log` → stdout only) |
| **ConfigServer** (secrets + tweaks) | `andrea_svc_web.config` | opt-in `ANDREA_CONFIG_SERVICE=1` → overlays central config-service values over env before settings load; retires dot-envs; stdlib-only + fail-soft |
| **credentials** (`settings.SECRETS`) | `andrea_svc_web.creds` | fetch-on-start, reload-without-restart, fail-closed. See below |
| control plane `/admin/reload-config` + `/admin/creds-status` | `andrea_svc_web.reload` | **fail-CLOSED**: unconfigured ⇒ 503, never anonymous |

## Credentials — `settings.SECRETS` (0.3.2)

One process-wide `ServiceSecrets`. **Precedence: config-service > declared env files (first wins) >
the process-env SNAPSHOT** — a re-readable source always beats a frozen one, because the spawn
snapshot is the only tier that can still hold a REVOKED secret an hour after the rotation.

```python
from django.conf import settings

settings.SECRETS.declare_required("DOCSTORE_TOKEN", "docstore auth")   # absence ⇒ 503, named
token = settings.SECRETS.require("DOCSTORE_TOKEN")     # never returns "" as a credential
settings.SECRETS.subscribe("DOCSTORE_TOKEN", rebuild)  # cb(name, old_digest, new_digest)
settings.SECRETS.on_change(rebuild_pool)               # cb(ReloadReport) — whole generation
```

**The mechanism is the READ path, not the endpoint.** `get()` re-resolves when a declared env file's
mtime or the generation stamp advances (one `stat` each), so a rotation converges in every gunicorn
worker with nobody calling anything — and a key dropped into a declared file bootstraps a service
that had none. `POST /admin/reload-config` just makes that immediate.

**Nothing is a default source.** `env_files` starts EMPTY and the shared `.env` is never consulted;
a service declares its own file in code or on its unit:

```
ANDREA_SECRETS_ENV_FILES=/etc/andrea/myservice.env      # unit/compose — no code change
```

| knob | default | |
|---|---|---|
| `ANDREA_SECRETS_ENV_FILES` | *(none)* | `:`/`,` separated declared files, first wins. **0.3.2: the config-service may also declare one** — appended LAST, so a local declaration always wins |
| `ANDREA_SECRETS_REQUIRED` / `ANDREA_SECRETS_IGNORE` | *(none)* | names that must / must not resolve |
| `ANDREA_SECRETS_STRICT` | `0` | `1` ⇒ a missing required credential ABORTS startup (default: start-and-503) |
| `ANDREA_AUTH_MODE` | `auto` | `auto` = today's behaviour · `enforce` = fail closed · `dev-open` = DECLARES an unconfigured service open. **0.3.1: it cannot open a service that HAS a credential** |
| `ANDREA_CREDS_TTL` | `0` (off) | seconds; refresh tier 1 on the read path |
| `ANDREA_CREDS_HONOR_WITHDRAWAL` | `0` | `1` ⇒ an upstream removal really removes. **0.3.1: only with EVIDENCE** (a source that read OK and non-empty — an empty/unreadable read is never a removal), and never for the service key or the OIDC pair |
| `ANDREA_CONFIG_ENV_ALLOW` | *(none)* | **0.3.1** — `:`/`,` separated names `apply_to_env()` may overlay onto `os.environ`; setting it makes that service default-deny |
| `ANDREA_CREDS_STATE` / `ANDREA_CREDS_STAMP` | `/run/andrea/creds/<svc>.json` / `1` | the multi-worker generation stamp (digests only, 0600) |
| `ANDREA_CREDS_DB_PROBE` | `1` | validate rotated DB credentials on a THROWAWAY connection before applying |
| `ANDREA_CONFIG_APPLY_TO_ENV` | `1` | `0` retires the legacy `os.environ` overlay for this service |

**No value ever leaves the process.** Reports, `status()`, log lines, HTTP bodies and the generation
stamp carry names, provenance, classes, counts and `sha256[:16]` digests — never a value.

**The config plane may not write the auth plane — POLICY vs MATERIAL (0.3.2).** The config-service
delivers a service's secrets and knobs; it does not decide whether they are ever CHECKED. Those are
two different questions, and 0.3.2 keeps two lists — because 0.3.1 kept one, and that was itself a
fail-open.

| | names | what happens |
|---|---|---|
| **POLICY** | `ANDREA_AUTH_MODE`, `ANDREA_API_KEY_HEADER`, `CONFIG_SERVICE_URL`, `ANDREA_CONFIG_*`, `ANDREA_CREDS_*`, `ANDREA_SECRETS_{IGNORE,REQUIRED,STRICT}`, the `OIDC_*` tuning knobs | **REFUSED** on every config-service path — `apply_to_env()`, the legacy `reload_config()`, the settings mirror, and (new) the RESOLVE step itself, so it never even reaches `_values`. Logged at ERROR, NAMES only. |
| **MATERIAL** | `ANDREA_SVC_KEYS` / `ANDREA_SVC_API_KEY`, `OIDC_ISSUER`, `OIDC_JWKS_URL`, `OIDC_AUDIENCE`, `ANDREA_SECRETS_ENV_FILES` | **DELIVERED** — through `SECRETS`, in memory, with provenance; never through `os.environ`. Mirrored onto settings ROTATE-NEVER-BLANK, and never removed by a withdrawal. |

0.3.1 refused the whole `OIDC_` and `ANDREA_SECRETS_` prefixes. An OIDC-configured service whose
issuer/JWKS came from the config plane therefore got two empty strings,
`settings_auth_configured()` returned False, and `ServiceKeyRequiredIfConfigured` served
**anonymous 200 on the whole data plane**. Refusing to deliver a credential does not harden a
service — it removes the credential. (Same for `ANDREA_SECRETS_ENV_FILES`, which un-declared a
tier-2 credential file so the key inside it never loaded.) The tree already held the contradiction
in plain sight: `creds.AUTH_MATERIAL_NAMES` refuses to REMOVE exactly the names `config.py` refused
to DELIVER. One config-service row (`ANDREA_AUTH_MODE=dev-open`) disabling authentication
fleet-wide — the 0.3.1 defect — is still closed, on every path.

**Posture must agree with enforcement (0.3.2).** `posture == "enforcing"` now IMPLIES that an
anonymous request is refused, because posture derives from `settings_auth_configured()` — the same
predicate `ServiceKeyRequiredIfConfigured` evaluates. A fourth value, **`open`**, names the state
where a process HOLDS auth material the auth layer cannot see. That state previously reported
`enforcing`, so a fleet sweep of `/health` + `/admin/creds-status` answered "nobody is open" about a
service that was open — a wrong answer from the observability spine, which ends an investigation
instead of starting one. `status()` now carries `enforcing` beside `configured`, and
`counters.creds_unconfigured` derives from the former.

**The unit suite does not execute `settings.py`** — `conftest.py` boots Django with
`settings.configure()`, and that is exactly where the 0.3.1 fail-open lived. `tests/realpath_probe.py`
runs the real startup path out-of-process instead: a real config-service on a real socket,
`django.setup()` executing `andrea_svc_web/settings.py`, and a real DRF request cycle. See
`tests/test_policy_vs_material.py` and `tests/test_failopen_controls.py`.

First consumer / worked example: `search/pdffeed/` (pdf_feed_service migrated off its hand-rolled
single-threaded `http.server`).
