Encrypting a blank record is straightforward. Encrypting a Memory that is already being autosaved—with rich text, links, photos, files, and derived server data—is a state-transition problem. If half the operation succeeds, the result can be worse than a visible error: the page may claim to be protected while one old plaintext asset remains attached.
Why a normal update is not enough
A Memory's content and assets do not travel through one ordinary form request. The editor autosaves text while uploads can finish on a separate queue. Existing inline images are referenced from rich-text HTML. Photo-book pages may share an Active Storage blob originally attached to a Memory. Server-side embeddings or generated context must not remain associated with newly encrypted writing.
A correct transition therefore has to coordinate five things:
- the latest acknowledged title, content, and external links;
- every currently attached keepsake file;
- every inline image and its reference inside the HTML;
- the Memory's protection and publication state; and
- derived data that depended on server-readable content.
The normal autosave endpoint is not allowed to toggle the encrypted flag. Protection changes use a dedicated route and service so the invariants cannot be bypassed by adding one parameter to an update.
Phase one: prepare replacements in the browser
Before transformation begins, the protection controller asks the editor to flush pending text and file operations. The editor becomes temporarily inert and exposes a busy state so a second protection change cannot overlap the first one.
The browser then fetches every current asset with the owner's authenticated session. Each file is encrypted locally with AES-GCM and uploaded as a new, unattached staging blob. Inline images follow the same path. The server tags each staged blob with the Memory ID, target protection state, and asset kind, but does not attach it yet.
- Flush pending edits
- Snapshot
updated_at - Encrypt every current asset
- Upload unattached replacements
- Rewrite inline-image references
- Encrypt fields and submit manifest
- Lock the Memory row
- Reject a stale source timestamp
- Match every source and replacement
- Validate staged blob ownership
- Save fields and attachment references
- Purge superseded blobs later
The client sends both sides of the replacement manifest: all source signed IDs and all staged replacement signed IDs. An empty or partial list is not interpreted as “best effort.” It is a validation failure.
target: encrypted
source_updated_at: 2026-08-15T08:41:12.483921Z
source_file_signed_ids[]: old-file-a
replacement_file_signed_ids[]: staged-file-a
source_inline_image_signed_ids[]: old-image-b
replacement_inline_image_signed_ids[]: staged-image-b
memory[title]: <ciphertext>
memory[content]: <ciphertext>
Inline references are rewritten before the rich-text field itself is encrypted. Otherwise, the decrypted HTML would still point at the old plaintext blob even if a new encrypted image had been uploaded successfully.
Phase two: validate and commit in Rails
Rails first scopes the Journal and Memory through the signed-in identity. The service then acquires a row lock and compares the submitted source timestamp with the Memory's current microsecond timestamp. Only after that concurrency check does it resolve asset replacements.
For each asset group, the server verifies that:
- the submitted source set exactly matches the blobs attached now;
- each source has exactly one replacement;
- replacement signed IDs are unique;
- each replacement is still unattached;
- its staging metadata names the same Memory, target, and asset kind; and
- an encrypted target uses
application/octet-stream.
Rails then opens a database transaction. It assigns the transformed fields, sets the encrypted state, forces the Memory to private, clears server-derived summary and context fields, saves the Memory, replaces file attachments, replaces inline-image attachments, and updates any photo-book references that share the old blob.
If validation or persistence fails, the transaction rolls back. The old attachment rows and protection state remain. The endpoint returns an explicit error rather than claiming that a subset succeeded.
Rejecting stale transitions
Browser preparation takes time, especially when a Memory contains large files. During that interval another tab or request could save a newer version. Without optimistic concurrency, an older encrypted snapshot could overwrite the newer text.
The submitted source_updated_at is the last version the browser acknowledged after flushing. Under the row lock, Rails compares it with the current record. A mismatch returns a conflict: “Memory changed while protection was being prepared.” The owner can reload and retry from the newest state.
This is intentionally stricter than merging. A merge between plaintext source, ciphertext target, rich-text asset references, and two browser tabs is too ambiguous for a protection boundary. Rejecting the stale attempt is safer and easier to explain.
Cleaning up both outcomes
Successful transitions detach the old blobs inside the commit, then enqueue them for purge only after the transaction has finished and only when no attachment still references them. This prevents a shared photo-book asset from being deleted before its reference moves to the replacement.
Failed or abandoned preparation can leave unattached staging blobs. A background cleanup job removes old abandoned protection blobs later. That garbage-collection path is important: deleting every staged blob immediately on a browser error could race with a request that has already reached the commit phase.
The reverse transition—from encrypted to Standard protection—uses the same shape. The browser decrypts each asset locally, stages plaintext replacements with their real content types, and asks Rails to commit the complete manifest. Publication is still a separate, deliberate action.
What “atomic” means here
Atomic means the database-visible protection state, transformed fields, attachment references, and dependent photo-book references commit together. It does not mean the preceding network uploads are part of a global transaction. Staging blobs can exist temporarily before a successful commit, and cleanup is eventually consistent.
The approach favors a recoverable residue over a false protection claim. An unattached encrypted blob can be garbage-collected. A Memory marked encrypted while retaining a plaintext attachment would violate the trust boundary.
This transition is the application-level bridge between the browser encryption architecture and the threat model. The cryptography protects bytes; the transactional workflow protects the meaning of the product state.