#150: address covers by bytes; ReplaceSeriesCover

This commit is contained in:
2026-08-22 09:05:53 +07:00
parent 889f0f3f38
commit b9fc83217d
8 changed files with 338 additions and 77 deletions
@@ -0,0 +1,86 @@
# ADR-0014: Cover addresses derived from the bytes, not the source URL
Date: 2026-08-22
Status: accepted
## Decision
A Cover's content address is the hex SHA-256 of its **bytes**, not of the
source URL it was fetched from. `CoverAddressForBytes(body)` names the address
`putCover` stores under, `SetSeriesCover` and `ReplaceSeriesCover` point the
Series row at it, and the wire URL is built from it exactly as before — same
route, same 64-hex-digit shape, same immutability, only the input to the hash
changes. Rows written before this ADR keep their URL-derived addresses
forever: they are never rehashed on read, and they heal into byte addressing
only when a Forced Poll replaces them.
`ReplaceSeriesCover(site, seriesID, sourceURL, body, contentType)`
`(previous, current, error)` is the one write that may move a Cover once one
exists. It stores the bytes, then in one transaction locks the Series row,
reads the old `cover_address`, writes the new one and the source URL, and
reports both addresses: `previous == ""` means there was no Cover,
`previous == current` means the Site served identical artwork, and any other
pair names the stranded address.
## Why a future reader will find this surprising
The address is what makes a re-art visible at all. URL addressing collapses
every image behind a stable URL into one address, so a Series whose Cover
changes (a big-budget CPI blitz on a light novel is the standing example)
keeps serving its original cover bytes: the poll refetches the same URL,
hashes it, and the store records the same address, everyone happy except the
Reader. Nothing in the system can detect the change, because the address is a
pure function of the fetch target, and identical bytes written 1,000 times
are one blob on disk. Storing bytes we already know how to store is only a
few lines of work. **Rejecting that work is the surprising part, and the
answer is the Forced Poll wave**: for a corrupt/blank cover the poll's
fill-if-blank installer already worked, but for a *wrong but non-blank* cover
there was no write that would move it at all — only a manual truth in
`series.cover_address`, which is exactly the thing that must never be set by
hand. Byte addressing gives the replacement write a **new address to write**,
and with it a legitimate, transaction-safe mover.
## Considered options
**Keep URL addressing and add a generic "clear the cover" write.**
Rejected: clearing is a two-phase action (blank it, wait for the poll to
re-fill, hope the bytes changed in between) that cannot report what the
write did, and it makes the Series render cover-less in between. The
replacement write is atomic, reports its displacement, and has one effect:
the Series now points at bytes that actually came from its source URL.
**Address by URL, but salt it so a re-art is a new address.**
Rejected: the salt would have to live somewhere addressable (a stored per-
Series nonce), turning the address from a content fact into a mutable fact —
two rows could then hold identical bytes under different addresses and the
invariant "same bytes object" is gone.
## Consequences
- `store.CoverAddress` (URL-hash) is deleted; `CoverAddressForBytes` is
public so tests and the forced-poll wave can predict addresses from the
bytes fakes serve.
- Legacy URL-addressed rows are read-only facts: `GetCover(sourceURL)` keeps
resolving them (the poll heal path), and they are re-addressed only by a
forced replacement. Until one happens, they are invisible to byte-derived
lookups — the reverse direction was always true, so this side has no
migration and no lookup fan-out.
- A replaced Cover's old bytes stay on disk under their address (the `covers`
row is untouched — only the Series row moves). Nothing reclaims them
today; a later sweep is a small query over `covers` addresses not
referenced by any `series` row.
- `SetSeriesCover` keeps its `cover_address = ''` guard untouched: the
acquisition-at-creation and poll fill paths still may not overwrite a
non-blank Cover. The two installers are now deliberately different
functions instead of one function with a conditional.
- The address is still a filesystem path (≤64 hex chars, no separators), so
`coverAddressRe` and the sharding stay exactly as they are.
## Cost of reversing
The URL-hash side of the current rows is uncomputable from the rows alone: a
rollback would need every stored blob's source URL, a join to a table that
does not store it, or a refetch of every Series. Keeping both derivations
resolvable is cheaper than either, so the two derivations are documented in
the 0009 migration comment: no component may assume which derivation a
stored address came from, because the 64-hex shape hides it.