Android half of ios#32, spec §6.21. `att` becomes optional and the
caption becomes the story.
🔴 The decode sits before the attachment branch, and that placement is
the feature. The branch requires `att` for a story; a text story failing
it falls through to the ordinary-message path and shows as a chat bubble
in the conversation that never expires — both of the things §6.6 says a
story must not be. There is a test named for it.
`Vault.Story.ref` is nullable rather than carrying a sentinel, so nothing
can read a blob id off a story that has none. That forced a `key` for
seen-marks and dedup, and an `expiresAt` that folds the two cases so no
caller has to know which kind it is holding.
⚠️ The stored record omits `ref` entirely rather than writing an empty
object — a reload would otherwise resurrect a blob that fails to fetch,
and `loadStories` had to stop treating an absent ref as malformed.
A "Write" entry on the Stories tab posts one, and says who enforces the
expiry: there is no photo to delete, so it relies on the receiving app
honouring `exp`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>