4.6 KiB
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;CoverAddressForBytesis 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
coversrow is untouched — only the Series row moves). Nothing reclaims them today; a later sweep is a small query overcoversaddresses not referenced by anyseriesrow. SetSeriesCoverkeeps itscover_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
coverAddressReand 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.