86 lines
4.6 KiB
Markdown
86 lines
4.6 KiB
Markdown
# 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. |