BareGit

Implement the card editing flow

- Reuse the card form for edit routes with revision and asset actions.
- Add transactional SQLite updates and atomic asset replacement rollback.
- Cover form rendering, persistence, stale edits, and image replacement.
Author: MetroWind <chris.corsair@gmail.com>
Date: Sun Aug 23 11:42:34 2026 -0700
Commit: 0d6d277a45dd83e3ad7b1aaeb7e5e4a935f79820

Changes

diff --git a/src/app.cpp b/src/app.cpp
index 3c7b3e7..27cb90b 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -265,6 +265,27 @@ mw::E<std::int64_t> parseRarity(
     return rarity;
 }
 
+mw::E<std::int64_t> parseRevision(
+    const std::unordered_map<std::string, std::string>& fields)
+{
+    const auto position = fields.find("revision");
+    if(position == fields.end())
+    {
+        return std::unexpected(mw::runtimeError("Revision is required"));
+    }
+    std::int64_t revision = 0;
+    const std::string& text = position->second;
+    const auto result = std::from_chars(
+        text.data(), text.data() + text.size(), revision);
+    if(text.empty() || result.ec != std::errc{} ||
+       result.ptr != text.data() + text.size() || revision < 1)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Revision must be a positive integer"));
+    }
+    return revision;
+}
+
 bool isRegularFile(const std::filesystem::path& path, std::int64_t card_id)
 {
     std::error_code filesystem_error;
@@ -344,9 +365,16 @@ void App::handleCardNew(
     const inja::json template_data = {
         {"action_url", urlFor("cards")},
         {"back_url", urlFor("card-index")},
+        {"display_id", "New addition"},
+        {"foil_action", "keep"},
         {"foil_url", ""},
         {"front_url", urlFor("static", {"card_placeholder.svg"})},
+        {"has_foil", false},
+        {"heading", "Create card"},
+        {"long_description", ""},
+        {"mode", "create"},
         {"model_url", urlFor("static", {"foil/model/card.obj"})},
+        {"name", ""},
         {"preview_script_url",
          urlFor("static", {"foil/card_preview.js"})},
         {"shader_fragment_url",
@@ -355,6 +383,10 @@ void App::handleCardNew(
          urlFor("static", {"foil/vert-shader.glsl"})},
         {"spectral_lut_url",
          urlFor("static", {"foil/spectral_xyz.bin"})},
+        {"rarity", 0},
+        {"revision", 0},
+        {"short_description", ""},
+        {"submit_label", "Create card"},
         {"thumbnail_long_side", config_.thumbnail_long_side},
         {"title", "Create card · Card Collection"},
     };
@@ -375,6 +407,113 @@ void App::handleCardNew(
     }
 }
 
+void App::handleCardEdit(
+    const Request& request,
+    Response& response)
+{
+    const auto id_parameter = request.path_params.find("id");
+    if(id_parameter == request.path_params.end())
+    {
+        respondNotFound(response);
+        return;
+    }
+    auto identity = parsePublicId(id_parameter->second);
+    if(!identity)
+    {
+        respondNotFound(response);
+        return;
+    }
+    auto card_result = data_source_->getCard(*identity);
+    if(!card_result)
+    {
+        spdlog::error(
+            "Failed to load card {} for editing: {}",
+            id_parameter->second,
+            card_result.error().msg());
+        respondInternalError(response);
+        return;
+    }
+    if(!*card_result)
+    {
+        respondNotFound(response);
+        return;
+    }
+    const Card& card = **card_result;
+    if(card.identity.game_short_name)
+    {
+        respondBadRequest(
+            response, "Game-specific cards are not implemented yet");
+        return;
+    }
+
+    auto public_id_result = formatPublicId(card.identity);
+    if(!public_id_result)
+    {
+        respondInternalError(response);
+        return;
+    }
+    const std::string& public_id = *public_id_result;
+    const std::string front_name =
+        "front-art." + card.front_extension;
+    const std::string front_url = urlFor(
+        "card-asset",
+        {public_id + "/" + front_name},
+        {{"v", std::to_string(card.revision)}});
+    std::string foil_url;
+    if(card.foil_extension)
+    {
+        foil_url = urlFor(
+            "card-asset",
+            {public_id + "/foil." + *card.foil_extension},
+            {{"v", std::to_string(card.revision)}});
+    }
+
+    const inja::json template_data = {
+        {"action_url", urlFor("card", {public_id})},
+        {"back_url", urlFor("card", {public_id})},
+        {"display_id", uppercaseAscii(public_id)},
+        {"foil_action", "keep"},
+        {"foil_url", foil_url},
+        {"front_url", front_url},
+        {"has_foil", card.foil_extension.has_value()},
+        {"heading", "Edit card"},
+        {"long_description", card.long_description.value_or("")},
+        {"mode", "edit"},
+        {"model_url", urlFor("static", {"foil/model/card.obj"})},
+        {"name", card.name},
+        {"preview_script_url",
+         urlFor("static", {"foil/card_preview.js"})},
+        {"rarity", card.rarity},
+        {"revision", card.revision},
+        {"shader_fragment_url",
+         urlFor("static", {"foil/frag-shader.glsl"})},
+        {"shader_vertex_url",
+         urlFor("static", {"foil/vert-shader.glsl"})},
+        {"short_description", card.short_description.value_or("")},
+        {"spectral_lut_url",
+         urlFor("static", {"foil/spectral_xyz.bin"})},
+        {"submit_label", "Save changes"},
+        {"thumbnail_long_side", config_.thumbnail_long_side},
+        {"title", "Edit " + card.name + " · Card Collection"},
+    };
+
+    try
+    {
+        response.status = 200;
+        response.set_content(
+            templates_.render(card_form_template_, template_data),
+            "text/html; charset=utf-8");
+    }
+    catch(const std::exception& error)
+    {
+        spdlog::error(
+            "Failed to render the edit form for card {}: {}",
+            card.id,
+            error.what());
+        respondInternalError(response);
+    }
+}
+
 void App::handleCardCreate(
     const Request& request,
     Response& response,
@@ -464,6 +603,176 @@ void App::handleCardCreate(
     response.set_header("Location", urlFor("card", {*created}));
 }
 
+void App::handleCardUpdate(
+    const Request& request,
+    Response& response,
+    const ContentReader& content_reader)
+{
+    const auto id_parameter = request.path_params.find("id");
+    if(id_parameter == request.path_params.end())
+    {
+        respondNotFound(response);
+        return;
+    }
+    auto identity = parsePublicId(id_parameter->second);
+    if(!identity)
+    {
+        respondNotFound(response);
+        return;
+    }
+    auto card_result = data_source_->getCard(*identity);
+    if(!card_result)
+    {
+        spdlog::error(
+            "Failed to load card {} for update: {}",
+            id_parameter->second,
+            card_result.error().msg());
+        respondInternalError(response);
+        return;
+    }
+    if(!*card_result)
+    {
+        respondNotFound(response);
+        return;
+    }
+    if((**card_result).identity.game_short_name)
+    {
+        respondBadRequest(
+            response, "Game-specific cards are not implemented yet");
+        return;
+    }
+    if(!request.is_multipart_form_data())
+    {
+        respondBadRequest(response, "Expected a multipart form upload");
+        return;
+    }
+
+    MultipartReader multipart_reader(config_.card_storage_root);
+    auto upload = multipart_reader.read(content_reader);
+    if(!upload)
+    {
+        respondOperationError(
+            response, upload.error(), "Failed to receive a card edit");
+        return;
+    }
+    if(upload->fields.contains("game"))
+    {
+        respondBadRequest(response, "Card identity cannot be edited");
+        return;
+    }
+    const auto source_mode = upload->fields.find("source_mode");
+    if(source_mode != upload->fields.end() &&
+       source_mode->second != "files" &&
+       source_mode->second != "urls")
+    {
+        respondBadRequest(response, "Unknown image source mode");
+        return;
+    }
+    if(!upload->series_ids.empty())
+    {
+        respondBadRequest(
+            response, "Loose cards cannot belong to a series");
+        return;
+    }
+
+    const auto name_position = upload->fields.find("name");
+    const std::string name = name_position == upload->fields.end()
+        ? std::string()
+        : trimAscii(name_position->second);
+    if(name.empty())
+    {
+        respondBadRequest(response, "Card name is required");
+        return;
+    }
+    if(name.size() > 200)
+    {
+        respondBadRequest(response, "Card name is too long");
+        return;
+    }
+    auto rarity = parseRarity(upload->fields);
+    if(!rarity)
+    {
+        respondBadRequest(response, rarity.error().msg());
+        return;
+    }
+    auto revision = parseRevision(upload->fields);
+    if(!revision)
+    {
+        respondBadRequest(response, revision.error().msg());
+        return;
+    }
+
+    const auto front_position = upload->fields.find("front_action");
+    FrontAssetAction front_action;
+    if(front_position == upload->fields.end())
+    {
+        respondBadRequest(response, "Front artwork action is required");
+        return;
+    }
+    if(front_position->second == "keep")
+    {
+        front_action = FrontAssetAction::KEEP;
+    }
+    else if(front_position->second == "replace")
+    {
+        front_action = FrontAssetAction::REPLACE;
+    }
+    else
+    {
+        respondBadRequest(response, "Unknown front artwork action");
+        return;
+    }
+
+    const auto foil_position = upload->fields.find("foil_action");
+    FoilAssetAction foil_action;
+    if(foil_position == upload->fields.end())
+    {
+        respondBadRequest(response, "Foil control action is required");
+        return;
+    }
+    if(foil_position->second == "keep")
+    {
+        foil_action = FoilAssetAction::KEEP;
+    }
+    else if(foil_position->second == "replace")
+    {
+        foil_action = FoilAssetAction::REPLACE;
+    }
+    else if(foil_position->second == "remove")
+    {
+        foil_action = FoilAssetAction::REMOVE;
+    }
+    else
+    {
+        respondBadRequest(response, "Unknown foil control action");
+        return;
+    }
+
+    auto updated = card_service_->updateLooseCard({
+        std::move(**card_result),
+        *revision,
+        name,
+        optionalText(upload->fields, "short_description"),
+        optionalText(upload->fields, "long_description"),
+        *rarity,
+        upload->staging_directory,
+        front_action,
+        foil_action,
+        upload->front,
+        upload->foil,
+        upload->thumbnail,
+    });
+    if(!updated)
+    {
+        respondOperationError(
+            response, updated.error(), "Failed to update a card");
+        return;
+    }
+
+    response.status = 303;
+    response.set_header("Location", urlFor("card", {*updated}));
+}
+
 void App::handleCardView(
     const Request& request,
     Response& response)
@@ -580,6 +889,7 @@ void App::handleCardView(
         {"asset_warning", !missing_assets.empty()},
         {"back_url", urlFor("card-index")},
         {"display_id", uppercaseAscii(public_id)},
+        {"edit_url", urlFor("card-edit", {public_id})},
         {"foil_url", foil_url.value_or("")},
         {"front_url", front_url},
         {"game", card.identity.game_short_name
@@ -765,12 +1075,18 @@ void App::setup()
     server.Get(
         getPath("card-new"),
         std::bind_front(&App::handleCardNew, this));
+    server.Get(
+        getPath("card-edit", {"id"}),
+        std::bind_front(&App::handleCardEdit, this));
     server.Get(
         getPath("card", {"id"}),
         std::bind_front(&App::handleCardView, this));
     server.Post(
         getPath("cards"),
         std::bind_front(&App::handleCardCreate, this));
+    server.Post(
+        getPath("card", {"id"}),
+        std::bind_front(&App::handleCardUpdate, this));
 }
 
 std::string App::getPath(
diff --git a/src/app.h b/src/app.h
index 684cc25..9bed86e 100644
--- a/src/app.h
+++ b/src/app.h
@@ -54,6 +54,15 @@ public:
     /// Render one read-only card page.
     void handleCardView(const Request& request, Response& response);
 
+    /// Render the edit form for one card.
+    void handleCardEdit(const Request& request, Response& response);
+
+    /// Accept and persist one card edit.
+    void handleCardUpdate(
+        const Request& request,
+        Response& response,
+        const ContentReader& content_reader);
+
 private:
     /// Register implemented handlers and static mounts.
     void setup() override;
diff --git a/src/asset_store.cpp b/src/asset_store.cpp
index c560e8d..a059aae 100644
--- a/src/asset_store.cpp
+++ b/src/asset_store.cpp
@@ -12,6 +12,12 @@ AssetStore::AssetStore(std::filesystem::path card_storage_root)
               std::move(card_storage_root) / "published")
 {}
 
+std::filesystem::path AssetStore::directory(
+    const std::string& public_id) const
+{
+    return published_root_ / public_id;
+}
+
 mw::E<void> AssetStore::publish(
     const std::filesystem::path& staging_directory,
     const std::string& public_id) const
@@ -50,6 +56,83 @@ mw::E<void> AssetStore::publish(
     return {};
 }
 
+mw::E<AssetReplacement> AssetStore::replace(
+    const std::filesystem::path& staging_directory,
+    const std::string& public_id) const
+{
+    const std::filesystem::path destination = published_root_ / public_id;
+    const std::filesystem::path previous =
+        staging_directory.parent_path() /
+        (staging_directory.filename().string() + ".previous");
+    std::error_code filesystem_error;
+    if(!std::filesystem::is_directory(destination, filesystem_error))
+    {
+        return std::unexpected(mw::runtimeError(
+            "The existing card asset directory is missing"));
+    }
+    if(filesystem_error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to inspect the existing card assets: " +
+            filesystem_error.message()));
+    }
+
+    std::filesystem::rename(destination, previous, filesystem_error);
+    if(filesystem_error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to retain the existing card assets: " +
+            filesystem_error.message()));
+    }
+    std::filesystem::rename(
+        staging_directory, destination, filesystem_error);
+    if(filesystem_error)
+    {
+        std::error_code restore_error;
+        std::filesystem::rename(previous, destination, restore_error);
+        return std::unexpected(mw::runtimeError(
+            "Failed to publish the edited card assets: " +
+            filesystem_error.message()));
+    }
+    return AssetReplacement{public_id, previous};
+}
+
+void AssetStore::restore(const AssetReplacement& replacement) const
+{
+    const std::filesystem::path destination =
+        published_root_ / replacement.public_id;
+    std::error_code filesystem_error;
+    std::filesystem::remove_all(destination, filesystem_error);
+    if(!filesystem_error)
+    {
+        std::filesystem::rename(
+            replacement.previous_directory,
+            destination,
+            filesystem_error);
+    }
+    if(filesystem_error)
+    {
+        spdlog::critical(
+            "Failed to restore rolled-back assets for card {}: {}",
+            replacement.public_id,
+            filesystem_error.message());
+    }
+}
+
+void AssetStore::finish(const AssetReplacement& replacement) const
+{
+    std::error_code filesystem_error;
+    std::filesystem::remove_all(
+        replacement.previous_directory, filesystem_error);
+    if(filesystem_error)
+    {
+        spdlog::warn(
+            "Failed to remove previous assets for card {}: {}",
+            replacement.public_id,
+            filesystem_error.message());
+    }
+}
+
 void AssetStore::removePublished(const std::string& public_id) const
 {
     std::error_code filesystem_error;
diff --git a/src/asset_store.h b/src/asset_store.h
index ea528a0..7a97840 100644
--- a/src/asset_store.h
+++ b/src/asset_store.h
@@ -5,6 +5,16 @@
 
 #include <mw/error.hpp>
 
+/// Published asset directory retained until an edit transaction commits.
+struct AssetReplacement
+{
+    /// Public ID whose directory was replaced.
+    std::string public_id;
+
+    /// Private path containing the previous published directory.
+    std::filesystem::path previous_directory;
+};
+
 /// Atomically publish and remove complete card asset directories.
 class AssetStore
 {
@@ -12,11 +22,25 @@ public:
     /// Construct an asset store below the configured private root.
     explicit AssetStore(std::filesystem::path card_storage_root);
 
+    /// Return the canonical published directory for one public ID.
+    std::filesystem::path directory(const std::string& public_id) const;
+
     /// Rename a complete staging directory into its public location.
     mw::E<void> publish(
         const std::filesystem::path& staging_directory,
         const std::string& public_id) const;
 
+    /// Swap a complete staging directory with existing published assets.
+    mw::E<AssetReplacement> replace(
+        const std::filesystem::path& staging_directory,
+        const std::string& public_id) const;
+
+    /// Restore the previous assets after a database rollback.
+    void restore(const AssetReplacement& replacement) const;
+
+    /// Remove previous assets after the database update commits.
+    void finish(const AssetReplacement& replacement) const;
+
     /// Remove one precisely identified published directory after rollback.
     void removePublished(const std::string& public_id) const;
 
diff --git a/src/card_service.cpp b/src/card_service.cpp
index aa648bd..c7c4c87 100644
--- a/src/card_service.cpp
+++ b/src/card_service.cpp
@@ -6,6 +6,7 @@
 #include <optional>
 #include <random>
 #include <string>
+#include <system_error>
 #include <utility>
 
 #include "public_id.h"
@@ -23,6 +24,25 @@ std::uint32_t randomLooseNumber()
     return distribution(generator);
 }
 
+mw::E<void> copyAsset(
+    const std::filesystem::path& source,
+    const std::filesystem::path& destination)
+{
+    std::error_code filesystem_error;
+    std::filesystem::copy_file(
+        source,
+        destination,
+        std::filesystem::copy_options::none,
+        filesystem_error);
+    if(filesystem_error)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Failed to stage an existing card asset: " +
+            filesystem_error.message()));
+    }
+    return {};
+}
+
 } // namespace
 
 CardService::CardService(
@@ -157,3 +177,208 @@ mw::E<std::string> CardService::createLooseCard(
     }
     return *public_id;
 }
+
+mw::E<std::string> CardService::updateLooseCard(
+    UpdateLooseCardInput input)
+{
+    Card& card = input.current_card;
+    if(card.id <= 0 || card.identity.game_short_name)
+    {
+        return std::unexpected(mw::httpError(
+            400, "Only existing loose cards can be edited"));
+    }
+    if(input.expected_revision != card.revision)
+    {
+        return std::unexpected(mw::httpError(
+            409, "The card was changed in another request"));
+    }
+
+    const bool rendering_changed =
+        input.front_action == FrontAssetAction::REPLACE ||
+        input.foil_action != FoilAssetAction::KEEP;
+    if((input.front_action == FrontAssetAction::REPLACE) !=
+       input.front.has_value())
+    {
+        return std::unexpected(mw::httpError(
+            422, "Front artwork does not match the requested action"));
+    }
+    if((input.foil_action == FoilAssetAction::REPLACE) !=
+       input.foil.has_value())
+    {
+        return std::unexpected(mw::httpError(
+            422, "Foil control does not match the requested action"));
+    }
+    if(!rendering_changed && input.thumbnail)
+    {
+        return std::unexpected(mw::httpError(
+            422, "A metadata-only edit must not include a thumbnail"));
+    }
+
+    auto public_id = formatPublicId(card.identity);
+    if(!public_id)
+    {
+        return std::unexpected(std::move(public_id.error()));
+    }
+
+    std::optional<AssetReplacement> replacement;
+    if(rendering_changed)
+    {
+        const std::filesystem::path published =
+            asset_store_.directory(*public_id);
+        ProcessedImage front;
+        if(input.front_action == FrontAssetAction::REPLACE)
+        {
+            auto processed = image_processor_.process(
+                *input.front, CardAssetType::FRONT_ART);
+            if(!processed)
+            {
+                return std::unexpected(std::move(processed.error()));
+            }
+            front = std::move(*processed);
+        }
+        else
+        {
+            const std::string name =
+                "front-art." + card.front_extension;
+            auto copied = copyAsset(
+                published / name, input.staging_directory / name);
+            if(!copied)
+            {
+                return std::unexpected(std::move(copied.error()));
+            }
+            front = {
+                input.staging_directory / name,
+                card.front_extension,
+                0,
+                0,
+                false,
+            };
+        }
+
+        std::optional<ProcessedImage> foil;
+        if(input.foil_action == FoilAssetAction::REPLACE)
+        {
+            auto processed = image_processor_.process(
+                *input.foil, CardAssetType::FOIL_CONTROL);
+            if(!processed)
+            {
+                return std::unexpected(std::move(processed.error()));
+            }
+            foil = std::move(*processed);
+        }
+        else if(input.foil_action == FoilAssetAction::KEEP &&
+                card.foil_extension)
+        {
+            const std::string name = "foil." + *card.foil_extension;
+            auto copied = copyAsset(
+                published / name, input.staging_directory / name);
+            if(!copied)
+            {
+                return std::unexpected(std::move(copied.error()));
+            }
+            foil = ProcessedImage{
+                input.staging_directory / name,
+                *card.foil_extension,
+                0,
+                0,
+                false,
+            };
+        }
+
+        ProcessedImage thumbnail;
+        if(foil)
+        {
+            if(!input.thumbnail)
+            {
+                return std::unexpected(mw::httpError(
+                    422,
+                    "A rendered thumbnail is required for this edit"));
+            }
+            auto processed = image_processor_.process(
+                *input.thumbnail, CardAssetType::THUMBNAIL);
+            if(!processed)
+            {
+                return std::unexpected(std::move(processed.error()));
+            }
+            thumbnail = std::move(*processed);
+        }
+        else
+        {
+            if(input.thumbnail)
+            {
+                return std::unexpected(mw::httpError(
+                    422,
+                    "A plain card must not include an uploaded thumbnail"));
+            }
+            auto generated = image_processor_.generatePlainThumbnail(
+                front, input.staging_directory / "thumb.avif");
+            if(!generated)
+            {
+                return std::unexpected(std::move(generated.error()));
+            }
+            thumbnail = std::move(*generated);
+        }
+        card.front_extension = std::move(front.extension);
+        card.foil_extension = foil
+            ? std::optional<std::string>(std::move(foil->extension))
+            : std::nullopt;
+        card.thumbnail_extension = std::move(thumbnail.extension);
+    }
+
+    card.name = std::move(input.name);
+    card.short_description = std::move(input.short_description);
+    card.long_description = std::move(input.long_description);
+    card.rarity = input.rarity;
+    ++card.revision;
+
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto current = (*transaction)->getCardForUpdate(card.id);
+    if(!current)
+    {
+        return std::unexpected(std::move(current.error()));
+    }
+    if(!*current)
+    {
+        return std::unexpected(mw::httpError(404, "Card not found"));
+    }
+    if((**current).revision != input.expected_revision)
+    {
+        return std::unexpected(mw::httpError(
+            409, "The card was changed in another request"));
+    }
+
+    auto updated = (*transaction)->updateCard(card, nullptr, nullptr, {});
+    if(!updated)
+    {
+        return std::unexpected(std::move(updated.error()));
+    }
+    if(rendering_changed)
+    {
+        auto replaced = asset_store_.replace(
+            input.staging_directory, *public_id);
+        if(!replaced)
+        {
+            return std::unexpected(std::move(replaced.error()));
+        }
+        replacement = std::move(*replaced);
+    }
+
+    auto committed = (*transaction)->commit();
+    if(!committed)
+    {
+        if(replacement)
+        {
+            asset_store_.restore(*replacement);
+        }
+        return std::unexpected(std::move(committed.error()));
+    }
+    if(replacement)
+    {
+        asset_store_.finish(*replacement);
+    }
+    return *public_id;
+}
diff --git a/src/card_service.h b/src/card_service.h
index 443fdfd..f91ae6b 100644
--- a/src/card_service.h
+++ b/src/card_service.h
@@ -40,6 +40,61 @@ struct CreateLooseCardInput
     std::optional<std::filesystem::path> thumbnail;
 };
 
+/// Requested handling for an existing required artwork asset.
+enum class FrontAssetAction
+{
+    KEEP,
+    REPLACE
+};
+
+/// Requested handling for an existing optional foil-control asset.
+enum class FoilAssetAction
+{
+    KEEP,
+    REPLACE,
+    REMOVE
+};
+
+/// Validated common fields and staged files for editing a loose card.
+struct UpdateLooseCardInput
+{
+    /// Card state observed while rendering the edit form or accepting it.
+    Card current_card;
+
+    /// Revision submitted by the browser for optimistic concurrency.
+    std::int64_t expected_revision;
+
+    /// Replacement human-readable card name.
+    std::string name;
+
+    /// Replacement optional short Markdown description.
+    std::optional<std::string> short_description;
+
+    /// Replacement optional long Markdown description.
+    std::optional<std::string> long_description;
+
+    /// Replacement nonnegative rarity value.
+    std::int64_t rarity;
+
+    /// Complete private staging directory for this request.
+    std::filesystem::path staging_directory;
+
+    /// Whether to retain or replace the front artwork.
+    FrontAssetAction front_action;
+
+    /// Whether to retain, replace, or remove the foil control.
+    FoilAssetAction foil_action;
+
+    /// Staged replacement front artwork, when requested.
+    std::optional<std::filesystem::path> front;
+
+    /// Staged replacement foil control, when requested.
+    std::optional<std::filesystem::path> foil;
+
+    /// Browser-rendered thumbnail for a changed resulting foil card.
+    std::optional<std::filesystem::path> thumbnail;
+};
+
 /// Coordinate image processing, persistence, and asset publication.
 class CardService
 {
@@ -53,6 +108,9 @@ public:
     /// Create a loose card and return its canonical public ID.
     mw::E<std::string> createLooseCard(CreateLooseCardInput input);
 
+    /// Update a loose card and return its unchanged canonical public ID.
+    mw::E<std::string> updateLooseCard(UpdateLooseCardInput input);
+
 private:
     DataSourceInterface& data_source_;
     ImageProcessor image_processor_;
diff --git a/src/data_sqlite.cpp b/src/data_sqlite.cpp
index f1ca818..097f924 100644
--- a/src/data_sqlite.cpp
+++ b/src/data_sqlite.cpp
@@ -235,9 +235,69 @@ public:
 
     /// Re-read a card while the transaction lock is held.
     mw::E<std::optional<Card>> getCardForUpdate(
-        [[maybe_unused]] std::int64_t card_id) override
+        std::int64_t card_id) override
     {
-        return std::unexpected(notImplemented("transactional card reads"));
+        auto statement = connection_.statementFromStr(
+            "SELECT id, game_short_name, card_number, name, "
+            "short_description, long_description, rarity, "
+            "front_extension, foil_extension, thumbnail_extension, "
+            "revision FROM cards WHERE id = ?;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind<std::int64_t>(card_id);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto rows = connection_.eval<
+            std::int64_t,
+            std::optional<std::string>,
+            std::int64_t,
+            std::string,
+            std::optional<std::string>,
+            std::optional<std::string>,
+            std::int64_t,
+            std::string,
+            std::optional<std::string>,
+            std::string,
+            std::int64_t>(std::move(*statement));
+        if(!rows)
+        {
+            return std::unexpected(std::move(rows.error()));
+        }
+        if(rows->empty())
+        {
+            return std::optional<Card>{};
+        }
+
+        auto& [
+            id,
+            game_short_name,
+            card_number,
+            name,
+            short_description,
+            long_description,
+            rarity,
+            front_extension,
+            foil_extension,
+            thumbnail_extension,
+            revision] = rows->front();
+        Card card = {
+            id,
+            {std::move(game_short_name),
+             static_cast<std::uint64_t>(card_number)},
+            std::move(name),
+            std::move(short_description),
+            std::move(long_description),
+            rarity,
+            std::move(front_extension),
+            std::move(foil_extension),
+            std::move(thumbnail_extension),
+            revision,
+        };
+        return std::optional<Card>(std::move(card));
     }
 
     /// Insert common and series-membership rows for a loose card.
@@ -341,13 +401,66 @@ public:
 
     /// Replace card rows after validation by the service layer.
     mw::E<void> updateCard(
-        [[maybe_unused]] const Card& card,
-        [[maybe_unused]] const GameDefinition* game,
-        [[maybe_unused]] const GameCardMetadata* metadata,
-        [[maybe_unused]] const std::vector<std::int64_t>& series_ids)
+        const Card& card,
+        const GameDefinition* game,
+        const GameCardMetadata* metadata,
+        const std::vector<std::int64_t>& series_ids)
         override
     {
-        return std::unexpected(notImplemented("card updates"));
+        if(card.id <= 0)
+        {
+            return std::unexpected(mw::runtimeError(
+                "An updated card requires an internal ID"));
+        }
+        if(card.identity.game_short_name || game != nullptr ||
+           metadata != nullptr || !series_ids.empty())
+        {
+            return std::unexpected(notImplemented(
+                "game-specific card updates"));
+        }
+
+        auto statement = connection_.statementFromStr(
+            "UPDATE cards SET name = ?, short_description = ?, "
+            "long_description = ?, rarity = ?, front_extension = ?, "
+            "foil_extension = ?, thumbnail_extension = ?, revision = ? "
+            "WHERE id = ? AND game_short_name IS NULL "
+            "AND card_number = ?;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(
+            card.name,
+            card.short_description,
+            card.long_description,
+            card.rarity,
+            card.front_extension,
+            card.foil_extension,
+            card.thumbnail_extension,
+            card.revision,
+            card.id,
+            static_cast<std::int64_t>(card.identity.card_number));
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto update = connection_.execute(std::move(*statement));
+        if(!update)
+        {
+            return std::unexpected(std::move(update.error()));
+        }
+        auto changes = connection_.evalToValue<std::int64_t>(
+            "SELECT changes();");
+        if(!changes)
+        {
+            return std::unexpected(std::move(changes.error()));
+        }
+        if(*changes != 1)
+        {
+            return std::unexpected(mw::runtimeError(
+                "The card disappeared while it was being updated"));
+        }
+        return {};
     }
 
     /// Delete a card and its dependent database rows.
diff --git a/src/multipart_reader.cpp b/src/multipart_reader.cpp
index 0a37ec9..ab2af04 100644
--- a/src/multipart_reader.cpp
+++ b/src/multipart_reader.cpp
@@ -33,6 +33,9 @@ bool isTextField(std::string_view name)
         "source_mode",
         "front_url",
         "foil_url",
+        "front_action",
+        "foil_action",
+        "revision",
         "series_id",
     };
     return names.contains(std::string(name));
diff --git a/static/card_form.js b/static/card_form.js
index bced84a..a73aa1e 100644
--- a/static/card_form.js
+++ b/static/card_form.js
@@ -1,4 +1,36 @@
-/** Show only the image-source fields selected for the create-card form. */
+/** Return the non-executable form configuration emitted by the server. */
+function getCardPageData()
+{
+    return JSON.parse(document.getElementById("CardPageData").textContent);
+}
+
+/** Return whether the current form is editing an existing card. */
+function isEditForm()
+{
+    return getCardPageData().mode == "edit";
+}
+
+/** Return whether the form requests replacement front artwork. */
+function replacesFront()
+{
+    if(!isEditForm())
+    {
+        return true;
+    }
+    return document.getElementById("CardFrontAction").value == "replace";
+}
+
+/** Return whether the form requests replacement foil control. */
+function replacesFoil()
+{
+    if(!isEditForm())
+    {
+        return true;
+    }
+    return document.getElementById("CardFoilAction").value == "replace";
+}
+
+/** Show and enable image-source fields required by the current actions. */
 function updateSourceMode()
 {
     const selected_mode = document.querySelector(
@@ -9,16 +41,18 @@ function updateSourceMode()
     file_fields.hidden = !uses_files;
     url_fields.hidden = uses_files;
 
-    for(const input of file_fields.querySelectorAll("input"))
-    {
-        input.disabled = !uses_files;
-    }
-    for(const input of url_fields.querySelectorAll("input"))
-    {
-        input.disabled = uses_files;
-    }
-    document.getElementById("CardFrontFile").required = uses_files;
-    document.getElementById("CardFrontUrl").required = !uses_files;
+    const front_file = document.getElementById("CardFrontFile");
+    const foil_file = document.getElementById("CardFoilFile");
+    const front_url = document.getElementById("CardFrontUrl");
+    const foil_url = document.getElementById("CardFoilUrl");
+    front_file.disabled = !uses_files || !replacesFront();
+    foil_file.disabled = !uses_files || !replacesFoil();
+    front_url.disabled = uses_files || !replacesFront();
+    foil_url.disabled = uses_files || !replacesFoil();
+    front_file.required = uses_files && replacesFront();
+    front_url.required = !uses_files && replacesFront();
+    foil_file.required = uses_files && isEditForm() && replacesFoil();
+    foil_url.required = !uses_files && isEditForm() && replacesFoil();
 }
 
 /** Display the selected image filename beside one file input. */
@@ -36,17 +70,66 @@ function setInputFile(input, blob, filename)
     input.files = transfer.files;
 }
 
-/** Refresh the WebGL preview from the currently selected local files. */
-function updateLocalPreview()
+/** Fetch an existing same-origin image for a mixed edit preview. */
+async function fetchExistingImage(url, filename)
+{
+    const response = await fetch(url, {credentials: "same-origin"});
+    if(!response.ok)
+    {
+        throw(new Error(
+            `Failed to load the current card image: HTTP ${response.status}`));
+    }
+    const blob = await response.blob();
+    return new File([blob], filename, {type: blob.type});
+}
+
+/** Resolve retained and replacement files for the current preview. */
+async function getPreviewFiles()
+{
+    const page_data = getCardPageData();
+    const selected_front = document.getElementById(
+        "CardFrontFile").files[0];
+    const selected_foil = document.getElementById(
+        "CardFoilFile").files[0];
+    if(page_data.mode == "create")
+    {
+        return {front: selected_front, foil: selected_foil};
+    }
+
+    let front = selected_front;
+    if(!replacesFront() || front == null)
+    {
+        front = await fetchExistingImage(
+            page_data.front_url, "current-front");
+    }
+    const foil_action = document.getElementById("CardFoilAction").value;
+    let foil = selected_foil;
+    if(foil_action == "remove")
+    {
+        foil = null;
+    }
+    else if((foil_action == "keep" || foil == null) &&
+            page_data.foil_url != null)
+    {
+        foil = await fetchExistingImage(
+            page_data.foil_url, "current-foil");
+    }
+    return {front, foil};
+}
+
+/** Refresh the WebGL preview from retained and selected image files. */
+async function updateLocalPreview()
 {
     const preview = window.cardPreview;
     if(preview == null)
     {
-        return Promise.resolve();
+        return;
+    }
+    const files = await getPreviewFiles();
+    if(files.front != null)
+    {
+        await preview.setFiles(files.front, files.foil);
     }
-    const front = document.getElementById("CardFrontFile").files[0];
-    const foil = document.getElementById("CardFoilFile").files[0];
-    return preview.setFiles(front, foil);
 }
 
 /** Fetch one CORS-enabled URL into its corresponding file input. */
@@ -91,28 +174,48 @@ async function prepareCardSubmission(event)
 
     try
     {
+        const page_data = getCardPageData();
+        const foil_action = page_data.mode == "edit"
+            ? document.getElementById("CardFoilAction").value
+            : "replace";
+        const rendering_changed = page_data.mode == "create" ||
+            replacesFront() || foil_action != "keep";
         const mode = document.querySelector(
             'input[name="source_mode"]:checked').value;
         const front_input = document.getElementById("CardFrontFile");
         const foil_input = document.getElementById("CardFoilFile");
         if(mode == "urls")
         {
-            foil_input.value = "";
-            await fetchImageInput(
-                document.getElementById("CardFrontUrl"),
-                front_input,
-                "front-upload");
-            const foil_url = document.getElementById("CardFoilUrl");
-            if(foil_url.value != "")
+            if(replacesFront())
+            {
+                await fetchImageInput(
+                    document.getElementById("CardFrontUrl"),
+                    front_input,
+                    "front-upload");
+                front_input.disabled = false;
+            }
+            if(replacesFoil())
             {
-                await fetchImageInput(foil_url, foil_input, "foil-upload");
+                foil_input.value = "";
+                const foil_url = document.getElementById("CardFoilUrl");
+                if(foil_url.value != "")
+                {
+                    await fetchImageInput(
+                        foil_url, foil_input, "foil-upload");
+                }
+                foil_input.disabled = false;
             }
-            front_input.disabled = false;
-            foil_input.disabled = false;
         }
 
-        await updateLocalPreview();
-        if(foil_input.files.length != 0)
+        if(rendering_changed)
+        {
+            await updateLocalPreview();
+        }
+        const resulting_foil = foil_action != "remove" &&
+            (foil_action == "replace"
+                ? foil_input.files.length != 0
+                : page_data.foil_url != null);
+        if(rendering_changed && resulting_foil)
         {
             if(window.cardPreview == null)
             {
@@ -170,6 +273,21 @@ function initializeCardForm()
             document.getElementById("CardFoilFileMeta"));
         updateLocalPreview().catch(console.error);
     });
+    if(isEditForm())
+    {
+        document.getElementById("CardFrontAction").addEventListener(
+            "change", function updateFrontAction()
+            {
+                updateSourceMode();
+                updateLocalPreview().catch(console.error);
+            });
+        document.getElementById("CardFoilAction").addEventListener(
+            "change", function updateFoilAction()
+            {
+                updateSourceMode();
+                updateLocalPreview().catch(console.error);
+            });
+    }
     document.getElementById("CardForm").addEventListener(
         "submit", prepareCardSubmission);
     updateSourceMode();
diff --git a/static/css/styles.css b/static/css/styles.css
index 0f2727e..5861370 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -520,6 +520,16 @@ h1 {
     transform: translateX(-0.2rem);
 }
 
+.card-actions {
+    display: flex;
+    justify-content: space-between;
+    margin: -0.5rem 0 1.5rem -0.65rem;
+}
+
+.card-actions .back-link {
+    margin: 0;
+}
+
 .card-view-id {
     margin: 0 0 0.4rem;
     color: var(--violet);
diff --git a/templates/card_form.html b/templates/card_form.html
index 9065b24..f07ffcd 100644
--- a/templates/card_form.html
+++ b/templates/card_form.html
@@ -15,33 +15,69 @@
     <aside class="card-info-panel card-form-panel"
            aria-labelledby="CardFormHeading">
         <a class="back-link" href="{{ back_url }}">← All cards</a>
-        <p class="card-view-id">New addition</p>
-        <h1 id="CardFormHeading">Create card</h1>
+        <p class="card-view-id">{{ display_id }}</p>
+        <h1 id="CardFormHeading">{{ heading }}</h1>
 
         <form id="CardForm" class="card-form" method="post"
               action="{{ action_url }}" enctype="multipart/form-data">
             <section class="form-section" aria-labelledby="IdentityHeading">
                 <h2 id="IdentityHeading">Identity</h2>
+                {% if mode == "edit" %}
+                <input name="revision" type="hidden" value="{{ revision }}">
+                {% endif %}
                 <label class="form-field" for="CardGame">
                     <span>Game</span>
-                    <select id="CardGame" name="game">
+                    <select id="CardGame" name="game"
+                            {% if mode == "edit" %}disabled{% endif %}>
                         <option value="">Loose card</option>
                     </select>
                 </label>
                 <label class="form-field" for="CardName">
                     <span>Name</span>
                     <input id="CardName" name="name" type="text"
-                           maxlength="200" autocomplete="off" required>
+                           maxlength="200" autocomplete="off"
+                           value="{{ name }}" required>
                 </label>
                 <label class="form-field" for="CardRarity">
                     <span>Rarity</span>
                     <input id="CardRarity" name="rarity" type="number"
-                           min="0" step="1" value="0" required>
+                           min="0" step="1" value="{{ rarity }}" required>
                 </label>
             </section>
 
             <fieldset class="form-section image-source-section">
                 <legend>Card images</legend>
+                {% if mode == "edit" %}
+                <label class="form-field" for="CardFrontAction">
+                    <span>Front artwork</span>
+                    <select id="CardFrontAction" name="front_action">
+                        <option value="keep">Keep current</option>
+                        <option value="replace">Replace</option>
+                    </select>
+                </label>
+                <label class="form-field" for="CardFoilAction">
+                    <span>Foil control</span>
+                    <select id="CardFoilAction" name="foil_action">
+                        <option value="keep">
+                            {% if has_foil %}
+                                Keep current
+                            {% else %}
+                                No foil
+                            {% endif %}
+                        </option>
+                        <option value="replace">
+                            {% if has_foil %}
+                                Replace
+                            {% else %}
+                                Add foil
+                            {% endif %}
+                        </option>
+                        {% if has_foil %}
+                        <option value="remove">Remove</option>
+                        {% endif %}
+                    </select>
+                </label>
+                {% endif %}
                 <div class="source-toggle">
                     <label>
                         <input type="radio" name="source_mode"
@@ -63,7 +99,7 @@
                         <span>Front artwork</span>
                         <input id="CardFrontFile" name="front" type="file"
                                accept="image/png,image/jpeg,image/webp,.avif"
-                               required>
+                               {% if mode == "create" %}required{% endif %}>
                         <small id="CardFrontFileMeta">No file selected</small>
                     </label>
                     <label class="form-field image-field"
@@ -99,17 +135,21 @@
                 <label class="form-field" for="CardShortDescription">
                     <span>Short description <em>Optional</em></span>
                     <textarea id="CardShortDescription"
-                              name="short_description" rows="3"></textarea>
+                              name="short_description"
+                              rows="3">{{ short_description }}</textarea>
                 </label>
                 <label class="form-field" for="CardLongDescription">
                     <span>Long description <em>Optional</em></span>
                     <textarea id="CardLongDescription"
-                              name="long_description" rows="7"></textarea>
+                              name="long_description"
+                              rows="7">{{ long_description }}</textarea>
                 </label>
                 <p class="form-hint">Descriptions support Markdown.</p>
             </section>
 
-            <button class="form-submit" type="submit">Create card</button>
+            <button class="form-submit" type="submit">
+                {{ submit_label }}
+            </button>
             <p id="CardFormStatus" class="form-status" role="status"
                aria-live="polite"></p>
         </form>
@@ -118,9 +158,9 @@
 
 <script id="CardPageData" type="application/json">
 {
-    "mode": "create",
+    "mode": "{{ mode }}",
     "front_url": "{{ front_url }}",
-    "foil_url": null,
+    "foil_url": {% if has_foil %}"{{ foil_url }}"{% else %}null{% endif %},
     "model_url": "{{ model_url }}",
     "vertex_shader_url": "{{ shader_vertex_url }}",
     "fragment_shader_url": "{{ shader_fragment_url }}",
diff --git a/templates/card_view.html b/templates/card_view.html
index 9088fde..64904a5 100644
--- a/templates/card_view.html
+++ b/templates/card_view.html
@@ -14,7 +14,10 @@
     </section>
 
     <aside class="card-info-panel" aria-labelledby="CardHeading">
-        <a class="back-link" href="{{ back_url }}">← All cards</a>
+        <nav class="card-actions" aria-label="Card actions">
+            <a class="back-link" href="{{ back_url }}">← All cards</a>
+            <a class="back-link" href="{{ edit_url }}">Edit card</a>
+        </nav>
         <p class="card-view-id">{{ display_id }}</p>
         <h1 id="CardHeading">{{ name }}</h1>
 
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index 425fe37..034f9e7 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -168,6 +168,40 @@ TEST(AppTest, RendersCardCreationForm)
               std::string::npos);
 }
 
+/// Verify the edit page reuses the card form with current values and actions.
+TEST(AppTest, RendersCardEditForm)
+{
+    Card card = makeCard(2, 35, "Moon card");
+    card.identity.game_short_name = std::nullopt;
+    card.short_description = "A quiet night.";
+    card.rarity = 4;
+    auto data_source = std::make_unique<DataSourceFake>(
+        std::vector<Card>{card});
+    App app(
+        makeConfig("https://example.test/collection/"),
+        std::move(data_source));
+    auto public_id = formatPublicId(card.identity);
+    ASSERT_TRUE(public_id);
+    App::Request request;
+    request.path_params.emplace("id", *public_id);
+    App::Response response;
+
+    app.handleCardEdit(request, response);
+
+    EXPECT_EQ(response.status, 200);
+    EXPECT_NE(response.body.find("Edit card"), std::string::npos);
+    EXPECT_NE(response.body.find("value=\"Moon card\""),
+              std::string::npos);
+    EXPECT_NE(response.body.find("name=\"revision\""),
+              std::string::npos);
+    EXPECT_NE(response.body.find("name=\"front_action\""),
+              std::string::npos);
+    EXPECT_NE(response.body.find("name=\"foil_action\""),
+              std::string::npos);
+    EXPECT_NE(response.body.find("\"mode\": \"edit\""),
+              std::string::npos);
+}
+
 /// Verify a card page renders fake metadata and read-only preview data.
 TEST(AppTest, RendersCardView)
 {
diff --git a/tests/card_service_test.cpp b/tests/card_service_test.cpp
index 0b3eb51..1b1c943 100644
--- a/tests/card_service_test.cpp
+++ b/tests/card_service_test.cpp
@@ -220,6 +220,127 @@ TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
     EXPECT_TRUE(std::filesystem::is_regular_file(published / "foil.jpg"));
 }
 
+/// Verify metadata and artwork edits increment revisions and replace assets.
+TEST(CardServiceTest, UpdatesLooseCard)
+{
+    initializeImageMagick();
+    TemporaryCardRoot temporary;
+    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
+    GameRegistry games;
+    auto data_source = prepareDataSource(database, games);
+    ASSERT_TRUE(data_source);
+
+    const std::filesystem::path create_staging =
+        temporary.path() / ".staging/upload";
+    const std::filesystem::path original_front =
+        create_staging / "upload_front";
+    writePng(original_front);
+    CardService service(
+        **data_source,
+        ImageProcessor(75, 256),
+        AssetStore(temporary.path()));
+    auto public_id = service.createLooseCard({
+        "Original",
+        std::nullopt,
+        std::nullopt,
+        0,
+        create_staging,
+        original_front,
+        std::nullopt,
+        std::nullopt,
+    });
+    ASSERT_TRUE(public_id);
+    auto cards = (*data_source)->getCards();
+    ASSERT_TRUE(cards);
+    ASSERT_EQ(cards->size(), 1);
+    const Card original_card = cards->front();
+
+    const std::filesystem::path metadata_staging =
+        temporary.path() / ".staging/metadata";
+    std::filesystem::create_directory(metadata_staging);
+    auto metadata_update = service.updateLooseCard({
+        original_card,
+        1,
+        "Edited metadata",
+        std::optional<std::string>("Summary"),
+        std::nullopt,
+        3,
+        metadata_staging,
+        FrontAssetAction::KEEP,
+        FoilAssetAction::KEEP,
+        std::nullopt,
+        std::nullopt,
+        std::nullopt,
+    });
+    ASSERT_TRUE(metadata_update) << metadata_update.error().msg();
+    EXPECT_EQ(*metadata_update, *public_id);
+    cards = (*data_source)->getCards();
+    ASSERT_TRUE(cards);
+    ASSERT_EQ(cards->size(), 1);
+    EXPECT_EQ(cards->front().name, "Edited metadata");
+    EXPECT_EQ(cards->front().revision, 2);
+    const std::filesystem::path published =
+        temporary.path() / "published" / *public_id;
+    EXPECT_TRUE(
+        std::filesystem::is_regular_file(published / "front-art.avif"));
+
+    const std::filesystem::path stale_staging =
+        temporary.path() / ".staging/stale";
+    std::filesystem::create_directory(stale_staging);
+    auto stale_update = service.updateLooseCard({
+        original_card,
+        1,
+        "Stale edit",
+        std::nullopt,
+        std::nullopt,
+        0,
+        stale_staging,
+        FrontAssetAction::KEEP,
+        FoilAssetAction::KEEP,
+        std::nullopt,
+        std::nullopt,
+        std::nullopt,
+    });
+    ASSERT_FALSE(stale_update);
+    const mw::HTTPError* stale_error =
+        stale_update.error().as<mw::HTTPError>();
+    ASSERT_NE(stale_error, nullptr);
+    EXPECT_EQ(stale_error->code, 409);
+
+    const std::filesystem::path artwork_staging =
+        temporary.path() / ".staging/artwork";
+    std::filesystem::create_directory(artwork_staging);
+    const std::filesystem::path replacement_front =
+        artwork_staging / "upload_front";
+    writeJpeg(replacement_front);
+    auto artwork_update = service.updateLooseCard({
+        cards->front(),
+        2,
+        "Edited artwork",
+        std::nullopt,
+        std::nullopt,
+        1,
+        artwork_staging,
+        FrontAssetAction::REPLACE,
+        FoilAssetAction::KEEP,
+        replacement_front,
+        std::nullopt,
+        std::nullopt,
+    });
+    ASSERT_TRUE(artwork_update) << artwork_update.error().msg();
+    cards = (*data_source)->getCards();
+    ASSERT_TRUE(cards);
+    ASSERT_EQ(cards->size(), 1);
+    EXPECT_EQ(cards->front().front_extension, "jpg");
+    EXPECT_EQ(cards->front().thumbnail_extension, "avif");
+    EXPECT_EQ(cards->front().revision, 3);
+    EXPECT_TRUE(
+        std::filesystem::is_regular_file(published / "front-art.jpg"));
+    EXPECT_FALSE(
+        std::filesystem::exists(published / "front-art.avif"));
+    EXPECT_TRUE(std::filesystem::is_regular_file(published / "thumb.avif"));
+}
+
 /// Verify multipart binaries use server paths and empty file controls vanish.
 TEST(MultipartReaderTest, StreamsExpectedFields)
 {
diff --git a/tests/data_sqlite_test.cpp b/tests/data_sqlite_test.cpp
index 00a9c9b..56fdde3 100644
--- a/tests/data_sqlite_test.cpp
+++ b/tests/data_sqlite_test.cpp
@@ -218,6 +218,44 @@ TEST(DataSourceSQLiteTest, InsertsLooseCard)
     EXPECT_EQ(cards->front().name, "New card");
 }
 
+/// Verify transactional reads and updates preserve a loose-card identity.
+TEST(DataSourceSQLiteTest, UpdatesLooseCard)
+{
+    TemporaryDatabase database;
+    GameRegistry games;
+    auto data_source = prepareDataSource(database.path(), games);
+    ASSERT_TRUE(data_source);
+    auto transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    auto card_id = (*transaction)->insertCard(
+        makeLooseCard(42, "Original"), nullptr, nullptr, {});
+    ASSERT_TRUE(card_id);
+    ASSERT_TRUE((*transaction)->commit());
+
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    auto card = (*transaction)->getCardForUpdate(*card_id);
+    ASSERT_TRUE(card);
+    ASSERT_TRUE(*card);
+    (**card).name = "Edited";
+    (**card).rarity = 5;
+    (**card).foil_extension = "webp";
+    (**card).revision = 2;
+    ASSERT_TRUE((*transaction)->updateCard(
+        **card, nullptr, nullptr, {}));
+    ASSERT_TRUE((*transaction)->commit());
+
+    auto stored = (*data_source)->getCard({std::nullopt, 42});
+    ASSERT_TRUE(stored);
+    ASSERT_TRUE(*stored);
+    EXPECT_EQ((**stored).id, *card_id);
+    EXPECT_EQ((**stored).identity.card_number, 42);
+    EXPECT_EQ((**stored).name, "Edited");
+    EXPECT_EQ((**stored).rarity, 5);
+    EXPECT_EQ((**stored).foil_extension, "webp");
+    EXPECT_EQ((**stored).revision, 2);
+}
+
 /// Verify destroying an uncommitted transaction rolls card insertion back.
 TEST(DataSourceSQLiteTest, RollsBackLooseCard)
 {