--- key: graphics.seeded-template-upgrade-by-fingerprint title: 2026-08-26 — A seeded graphics template is upgraded only when it still matches a shipped default (#732) status: active since: '2026-08-26' supersedes: none superseded-by: none rule: '`GraphicsElementSeeder` keeps every default it has ever shipped as a verbatim fingerprint; on an already-seeded database it rewrites the on-disk template only when the file still matches one of them (line-endings and trailing whitespace normalised), so an untouched install gets the new default while any operator edit is left alone.' signals: 'GraphicsElementSeeder, OnNowNextYamlV1, SupersededDefaults, UpgradeUnmodifiedTemplate, on-now-next.yml upgrade, graphics.on_now_next_seeded · paths: `ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementSeeder.cs` · issues: #732, #74' --- #74 seeded `on-now-next.yml` behind the `graphics.on_now_next_seeded` `ConfigElement` marker, writing the file only when absent so operator edits are never clobbered. That is the right rule for *content*, but it has a consequence nobody stated at the time: **an already-seeded installation never revisits the file at all**, so a change to the shipped default reaches new databases only. #732 hit this directly — the background box is useless if the one install that has the overlay keeps rendering the pre-#732 template forever. **Decision: upgrade by fingerprint, not by version number or by marker bump.** Every default we have shipped stays in the source as a verbatim constant (`OnNowNextYamlV1`, …) collected in `SupersededDefaults`. On an already-seeded database the seeder reads the file and rewrites it **only** if it still normalises equal to one of those. The comparison ignores line endings and trailing whitespace, because a volume mount or an editor rewrites those without the operator touching content. Why this shape: - **The safety property is derived, not asserted.** "Did the operator edit this file?" is answered by comparing bytes against what we wrote, rather than assumed — which is exactly the assumption #74 refused to make, and which a marker bump would have to make. The comparison is not byte-exact in one direction: line endings are normalised and the result is `TrimEnd`ed, so an edit consisting *only* of trailing whitespace at end-of-file does not opt the file out and would be overwritten. That is deliberate (a volume mount or editor rewrites those without operator intent) and the cost is bounded to whitespace nobody can see. Every edit with any visible effect opts out permanently. - **It is self-limiting, so it needs no new marker.** After the rewrite the content equals the *current* default, which is not in `SupersededDefaults`, so the next startup is a no-op. - **A fingerprint entry is not a template.** Never edit an entry in `SupersededDefaults` — it is a record of what we shipped. Changing the current default means adding a new constant and pushing the outgoing one into the list; editing an existing entry silently un-recognises every install carrying it, and the failure is invisible (the upgrade just never fires). **The rewrite is write-then-move, with no in-place fallback.** `WriteAllTextAsync` truncates before it writes, so an interrupted write would leave a partial file matching no fingerprint — never repairable by a later boot, and rejected outright by the loader. The temp name is random per call, because a fixed one is shared by two containers on the same config volume — and a process id is not random enough: the image's ENTRYPOINT is exec-form, so every container is PID 1 in its own namespace and would compute the same name. The accepted cost: a template bind-mounted as a single file cannot be replaced by `rename(2)` (EBUSY), so that install never receives the upgrade. Reaching that needs a pinned file that is *also* byte-identical to a shipped default, and the alternative — falling back to an in-place copy — reintroduces the truncation on every IO fault rather than just that one. **The upgrade is a one-way door, and a downgrade is lossy.** `GraphicsElementLoader.FromYaml` does not set `IgnoreUnmatchedProperties`, so an unknown YAML key throws and the element is dropped with only a logged warning. Once a template has been upgraded, rolling ErsatzTV back to a build that does not know the new keys makes that element fail to load on **every** channel it is attached to — which, since `graphics.on-now-next-on-by-default`, is every eligible channel that still has it (HLS Direct was never attached, and an operator may have cleared individual channels). The symptom is "the overlay vanished everywhere" and recovery is hand-editing the YAML back. Rollback is a supported operation here (`:prod` is a floating tag promoted manually), so this belongs in the release notes of any version that adds fields to a seeded template, not only in this record. **Verify the fingerprint against a real install, not against the constant it was copied from.** The #732 V1 entry was checked byte-for-byte against the live install (md5 `ef9afc088cf6dba252f725babbf3334f`, 2026-08-26) before being trusted. A fingerprint that does not match anything in the field is a permanent silent no-op, and no test written from the same source can detect that.