#150: address covers by bytes; ReplaceSeriesCover
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user