metaedit

0016. Operator policy is stored in the database, not only in the environment

Context

ADR 0006 established that “the split points, the minimum tag count, the blacklist and the caps are all configuration, never hard-coded”. That was implemented as environment variables (TAG_BLACKLIST_EXTRA and friends), which is the right shape for deployment configuration: it is set once per environment, versioned with the deployment, and needs no UI.

It is the wrong shape for the blacklist. What belongs on a blacklist is a property of one particular library’s community tag set — seen live, bought at concert, and an artist’s own name are all real examples — and an operator only discovers them by watching what the tool proposes. The env-var implementation made that discovery loop expensive and, worse, made a change invisible: editing the value required changing compose config and restarting the container, so “saved but not in effect” was not a bug state but the normal one. The only signal was a restart the operator had to remember to perform.

Decision

Operator policy lives in Postgres, in a genre_blacklist table, editable at runtime through /api/settings/genre-blacklist. The environment variable continues to work and is merged, so an existing deployment that sets TAG_BLACKLIST_EXTRA keeps behaving exactly as before and no migration of intent is required.

The three sources are named and merged in one place. BlacklistState reports the built-in defaults, the env var and the stored rows separately and merged: separately, because an operator who saved an entry and did not see it take effect needs to know which source supplied what; merged, because effective is what the tag policy enforces. Every call site reads the merged value rather than assembling its own union — three sources with three normalisations is precisely how the env var and the table would come to disagree.

The merged set is read per request, never cached. One indexed query over tens of rows, against a value that is mutable at runtime. A cached copy would go stale the moment the operator saved, reintroducing the exact problem this replaced.

Stored entries go into TagPolicy.blacklist, not extra_blacklist, so a request body can add to the operator’s policy but cannot remove it. A caller must not be able to talk the server out of a setting the operator chose.

Saving replaces the stored list rather than merging into it. The UI presents a textarea, so what an operator sees when they press save is what they mean; a hidden union with previous entries would make deleting an entry impossible and the entry would keep applying with nothing on screen to explain why.

This is the third concern in one database, alongside editor state (snapshot, audit_log) and the Last.fm archive. It is separated from both by what it is: small, mutable, append-nothing, and derived from nothing. The other two are append-only histories.

A change to the blacklist never writes to Jellyfin. It alters what a future plan proposes. Removing a genre from a library is the separate, reviewed, revertible operation at /api/bulk/remove-genre/*. “Stop proposing this” and “delete this from 500 items” have different blast radii, and the second must not be a side effect of the first.

Consequences

Alternatives rejected