Skip to content
Hive

Task changes

done

screenote · add-image-attachments-to-screenote-260816-6a00

← Back to task

Available

Diff available. Open the public pull request for the complete change.

Committed changes

diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json
index 442b82d..13309c0 100644
--- a/.github/rulesets/main.json
+++ b/.github/rulesets/main.json
@@ -39,6 +39,7 @@
           { "context": "CI / container-s3", "integration_id": 15368 },
           { "context": "CI / backup-restore", "integration_id": 15368 },
           { "context": "CI / release-artifact", "integration_id": 15368 },
+          { "context": "concurrency-qualification / image-attachment-lifecycle", "integration_id": 15368 },
           { "context": "Secrets / GitGuardian repository incidents", "integration_id": 15368 },
           { "context": "GitGuardian Security Checks", "integration_id": 0 }
         ]
diff --git a/.github/workflows/concurrency-qualification.yml b/.github/workflows/concurrency-qualification.yml
new file mode 100644
index 0000000..89f047d
--- /dev/null
+++ b/.github/workflows/concurrency-qualification.yml
@@ -0,0 +1,52 @@
+name: concurrency-qualification
+
+# The application and its portable contracts stay database-agnostic, so this
+# adapter-specific job lives outside ci.yml. It re-runs the image attachment
+# lifecycle suites against a server database whose row locks match hosted
+# production, proving lock ordering, atomic claim, and the aggregate races that
+# SQLite can only assert by outcome.
+on:
+  pull_request:
+  push:
+    branches: [main]
+
+permissions:
+  contents: read
+
+jobs:
+  image-attachment-lifecycle:
+    name: image-attachment-lifecycle
+    runs-on: ubuntu-latest
+    timeout-minutes: 20
+    env:
+      RAILS_ENV: test
+      PARALLEL_WORKERS: "1"
+      # Fails the run if this job ever lands on SQLite instead of the server
+      # database whose locks it exists to exercise.
+      SCREENOTE_SERVER_DATABASE_QUALIFICATION: "1"
+      DATABASE_URL: postgresql://screenote@localhost:5432/screenote_test
+    services:
+      database:
+        image: postgres:17
+        env:
+          POSTGRES_USER: screenote
+          POSTGRES_DB: screenote_test
+          POSTGRES_HOST_AUTH_METHOD: trust
+        ports:
+          - 5432:5432
+        options: >-
+          --health-cmd "pg_isready -U screenote"
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 10
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+      - name: Install runtime packages
+        run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips libpq-dev
+      - name: Set up Ruby
+        uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 (2026-07-22)
+        with:
+          bundler-cache: true
+      - name: Exercise locks, constraints, and lifecycle races
+        run: script/release_test_matrix attachment-lifecycle
diff --git a/.tool-versions b/.tool-versions
new file mode 100644
index 0000000..83fc9c2
--- /dev/null
+++ b/.tool-versions
@@ -0,0 +1 @@
+go 1.26.4
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index b7255d4..1381d15 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -1488,6 +1488,7 @@ body.review-fullscreen-open {
 
 .annotation-form__actions {
   display: flex;
+  flex-wrap: wrap;
   gap: 0.375rem;
 }
 
diff --git a/app/assets/stylesheets/image_attachments.css b/app/assets/stylesheets/image_attachments.css
new file mode 100644
index 0000000..748cf3f
--- /dev/null
+++ b/app/assets/stylesheets/image_attachments.css
@@ -0,0 +1,356 @@
+/* Image attachments
+   The composer, gallery, and viewer are one component used in two very
+   different places: the clamped Annotorious overlay and the sticky sidebar.
+   Colors come from component-scoped custom properties so the same markup reads
+   correctly on a light panel or over a dark canvas without a global theme. */
+.image-attachment-composer,
+.image-attachment-gallery,
+.image-attachment-viewer {
+  --attachment-surface: var(--color-bg);
+  --attachment-surface-muted: var(--color-bg-secondary);
+  --attachment-text: var(--color-text);
+  --attachment-text-muted: var(--color-text-secondary);
+  --attachment-border: var(--color-border);
+  --attachment-danger: var(--color-danger);
+}
+
+.image-attachment-context--light {
+  --attachment-surface: #ffffff;
+  --attachment-surface-muted: #f9fafb;
+  --attachment-text: #1f2937;
+  --attachment-text-muted: #6b7280;
+  --attachment-border: #d1d5db;
+  --attachment-danger: #dc2626;
+}
+
+.image-attachment-context--dark {
+  --attachment-surface: #14161c;
+  --attachment-surface-muted: #1d2029;
+  --attachment-text: #e8e8ed;
+  --attachment-text-muted: #9aa0b0;
+  --attachment-border: #333846;
+  --attachment-danger: #f87171;
+}
+
+/* Button and flex rules in the application stylesheet must never override the
+   semantic hidden state used by retry/progress/viewer controls. */
+.image-attachment-composer [hidden],
+.image-attachment-viewer [hidden] {
+  display: none !important;
+}
+
+/* The viewer dialog lives inside its gallery, so it matches the base rule above
+   and declares its own variables — which shadows the context the gallery set,
+   because a declaration on the element always beats an inherited value. These
+   two rules re-apply the explicit context to the nested viewer, so an explicitly
+   dark gallery opens an explicitly dark viewer regardless of OS preference. */
+.image-attachment-context--light .image-attachment-viewer {
+  --attachment-surface: #ffffff;
+  --attachment-surface-muted: #f9fafb;
+  --attachment-text: #1f2937;
+  --attachment-text-muted: #6b7280;
+  --attachment-border: #d1d5db;
+  --attachment-danger: #dc2626;
+}
+
+.image-attachment-context--dark .image-attachment-viewer {
+  --attachment-surface: #14161c;
+  --attachment-surface-muted: #1d2029;
+  --attachment-text: #e8e8ed;
+  --attachment-text-muted: #9aa0b0;
+  --attachment-border: #333846;
+  --attachment-danger: #f87171;
+}
+
+/* Only a viewer with no explicit context follows the operating system. */
+@media (prefers-color-scheme: dark) {
+  .image-attachment-viewer {
+    --attachment-surface: #14161c;
+    --attachment-surface-muted: #1d2029;
+    --attachment-text: #e8e8ed;
+    --attachment-text-muted: #9aa0b0;
+    --attachment-border: #333846;
+  }
+}
+
+.image-attachment-composer {
+  color: var(--attachment-text);
+  margin-top: 0.5rem;
+}
+
+/* The rail dissolves into the overlay's action row. An empty composer is then
+   exactly one button wide and adds no height to the clamped form. */
+.image-attachment-composer--rail {
+  display: contents;
+}
+
+.image-attachment-composer--rail .image-attachment-composer__hint {
+  display: none;
+}
+
+.image-attachment-composer__items:empty,
+.image-attachment-composer__status:empty {
+  display: none;
+  margin: 0;
+}
+
+.image-attachment-composer--dropping {
+  outline: 2px dashed var(--color-primary);
+  outline-offset: 2px;
+}
+
+.image-attachment-composer__toolbar {
+  align-items: center;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.5rem;
+}
+
+.image-attachment-composer__hint {
+  color: var(--attachment-text-muted);
+  font-size: 0.75rem;
+}
+
+.image-attachment-composer__file-input {
+  height: 0;
+  opacity: 0;
+  position: absolute;
+  width: 0;
+}
+
+.image-attachment-composer__items {
+  display: flex;
+  gap: 0.5rem;
+  list-style: none;
+  margin: 0.5rem 0 0;
+  padding: 0;
+}
+
+/* Overlay variant: a compact horizontal rail that never grows tall enough to
+   cover the region the person just selected. */
+.image-attachment-composer--rail .image-attachment-composer__items {
+  flex-basis: 100%;
+  flex-direction: row;
+  overflow-x: auto;
+  padding-bottom: 0.25rem;
+}
+
+.image-attachment-composer--rail .image-attachment-composer__errors,
+.image-attachment-composer--rail .image-attachment-composer__status {
+  flex-basis: 100%;
+}
+
+/* One short row per image so the overlay grows by a single band, not a column
+   of stacked cards. */
+.image-attachment-composer--rail .image-attachment-item {
+  flex: 0 0 auto;
+  max-width: 12rem;
+}
+
+.image-attachment-composer--rail .image-attachment-item__preview {
+  height: 2.25rem;
+  width: 2.25rem;
+}
+
+.image-attachment-composer--rail .image-attachment-item__alt-input {
+  width: 6rem;
+}
+
+.image-attachment-composer--rail .image-attachment-item__alt-label {
+  display: none;
+}
+
+/* Sidebar variant: full-width stacked rows under the textarea. */
+.image-attachment-composer--stack .image-attachment-composer__items {
+  flex-direction: column;
+}
+
+.image-attachment-composer__errors {
+  color: var(--attachment-danger);
+  font-size: 0.8125rem;
+  margin-top: 0.5rem;
+}
+
+.image-attachment-composer__status {
+  color: var(--attachment-text-muted);
+  font-size: 0.75rem;
+  margin-top: 0.25rem;
+}
+
+.image-attachment-item {
+  align-items: center;
+  background: var(--attachment-surface-muted);
+  border: 1px solid var(--attachment-border);
+  border-radius: var(--radius);
+  display: flex;
+  gap: 0.5rem;
+  padding: 0.375rem;
+}
+
+.image-attachment-item--failed {
+  border-color: var(--attachment-danger);
+}
+
+.image-attachment-item__preview {
+  flex: 0 0 auto;
+  height: 3rem;
+  width: 3rem;
+}
+
+.image-attachment-item__thumb,
+.image-attachment-item__placeholder {
+  border-radius: 4px;
+  display: block;
+  height: 100%;
+  object-fit: cover;
+  width: 100%;
+}
+
+.image-attachment-item__placeholder {
+  background: repeating-linear-gradient(
+    45deg,
+    var(--attachment-border),
+    var(--attachment-border) 4px,
+    var(--attachment-surface) 4px,
+    var(--attachment-surface) 8px
+  );
+}
+
+.image-attachment-item__body {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  gap: 0.25rem;
+  min-width: 0;
+}
+
+.image-attachment-item__state {
+  color: var(--attachment-text-muted);
+  font-size: 0.75rem;
+}
+
+.image-attachment-item__progress {
+  height: 0.25rem;
+  width: 100%;
+}
+
+.image-attachment-item__alt-label {
+  color: var(--attachment-text-muted);
+  display: block;
+  font-size: 0.6875rem;
+}
+
+.image-attachment-item__alt-input {
+  background: var(--attachment-surface);
+  border: 1px solid var(--attachment-border);
+  border-radius: 4px;
+  color: var(--attachment-text);
+  font-size: 0.75rem;
+  padding: 0.25rem 0.375rem;
+  width: 100%;
+}
+
+.image-attachment-item__actions {
+  display: flex;
+  flex: 0 0 auto;
+  gap: 0.25rem;
+}
+
+.image-attachment-gallery {
+  margin-top: 0.5rem;
+}
+
+.image-attachment-gallery__items {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.375rem;
+  list-style: none;
+  margin: 0;
+  padding: 0;
+}
+
+.image-attachment-gallery__trigger {
+  background: none;
+  border: 1px solid var(--attachment-border);
+  border-radius: var(--radius);
+  cursor: pointer;
+  display: block;
+  height: 4.5rem;
+  overflow: hidden;
+  padding: 0;
+  width: 4.5rem;
+}
+
+.image-attachment-gallery__image {
+  display: block;
+  height: 100%;
+  object-fit: cover;
+  width: 100%;
+}
+
+/* Shown while the post-claim warming job has not produced a variant yet. The
+   layout never shifts when the real thumbnail arrives. */
+.image-attachment-gallery__placeholder {
+  align-items: center;
+  background: var(--attachment-surface-muted);
+  color: var(--attachment-text-muted);
+  display: flex;
+  font-size: 0.625rem;
+  height: 100%;
+  justify-content: center;
+  padding: 0.25rem;
+  text-align: center;
+  width: 100%;
+}
+
+.image-attachment-gallery__placeholder-label {
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 3;
+  overflow: hidden;
+}
+
+.image-attachment-viewer {
+  background: var(--attachment-surface);
+  border: 1px solid var(--attachment-border);
+  border-radius: var(--radius);
+  color: var(--attachment-text);
+  max-height: 90dvh;
+  max-width: 90vw;
+  padding: 1rem;
+}
+
+.image-attachment-viewer::backdrop {
+  background: rgba(0, 0, 0, 0.7);
+}
+
+.image-attachment-viewer__frame {
+  display: flex;
+  justify-content: center;
+  max-height: 70dvh;
+  overflow: auto;
+}
+
+.image-attachment-viewer__image {
+  max-width: 100%;
+  transform-origin: center center;
+}
+
+.image-attachment-viewer__bar {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.375rem;
+  justify-content: center;
+  margin-top: 0.75rem;
+}
+
+@media (max-width: 640px) {
+  .image-attachment-composer--rail .image-attachment-item {
+    max-width: 9rem;
+  }
+
+  .image-attachment-gallery__trigger {
+    height: 3.5rem;
+    width: 3.5rem;
+  }
+}
diff --git a/app/controllers/annotation_comments_controller.rb b/app/controllers/annotation_comments_controller.rb
index 024f390..18ea4ea 100644
--- a/app/controllers/annotation_comments_controller.rb
+++ b/app/controllers/annotation_comments_controller.rb
@@ -1,29 +1,102 @@
 # frozen_string_literal: true
 
 class AnnotationCommentsController < ApplicationController
+  include ImageAttachmentSubmission
+
+  # Raised by the claim's parent builder so that an already-open annotation is
+  # reported as an ordinary 422 conflict while still rolling the claim back.
+  class NotResolved < StandardError; end
+
   before_action :set_screenshot
   before_action :set_annotation
 
   def create
-    viewport = @annotation.viewport
-
-    if reopen_action?
-      @annotation.reopen!(user: Current.user, body: comment_body)
-      redirect_to page_workspace_path_for(@screenshot, viewport: viewport), notice: "Annotation unresolved."
-    else
-      @annotation.annotation_comments.create!(
-        user: Current.user,
-        body: comment_body,
-        action: :comment
-      )
-      redirect_to page_workspace_path_for(@screenshot, viewport: viewport), notice: "Comment added."
+    batch = submission_batch(@project)
+    # The resolved-state guard lives inside the parent builder rather than in
+    # front of the claim. A retry of a reopen whose response was lost arrives
+    # against an annotation the first attempt already opened, and it has to
+    # reach the claimed batch — which answers with the comment that reopen
+    # already created — instead of being rejected for the state it caused.
+    result = ImageAttachments::ClaimBatch.call(batch: batch, user: Current.user, project: @project,
+      parent_type: AnnotationComment, parent_matcher: method(:claimed_comment?)) do
+      raise NotResolved if reopen_action? && !@annotation.resolved?
+
+      reopen_action? ? reopen_annotation! : add_comment!
     end
-  rescue ActiveRecord::RecordInvalid
-    redirect_to page_workspace_path_for(@screenshot, viewport: viewport), alert: "Could not save comment."
+
+    respond_created(result.parent)
+  rescue NotResolved
+    respond_invalid_reopen
+  rescue ActiveRecord::RecordInvalid => error
+    respond_invalid(error.record)
   end
 
   private
 
+  # One batch belongs to one mounted disclosure. Class alone cannot tell them
+  # apart: every reply composer in the project, and the reopen composer on this
+  # same thread, all produce an AnnotationComment. A replayed public ID must
+  # therefore match this thread and this action or it is not owned here.
+  def claimed_comment?(comment)
+    comment.annotation_id == @annotation.id &&
+      comment.action == (reopen_action? ? "reopened" : "comment")
+  end
+
+  def reopen_annotation!
+    @annotation.reopen!(user: Current.user, body: comment_body)
+  end
+
+  def add_comment!
+    @annotation.annotation_comments.create!(user: Current.user, body: comment_body, action: :comment)
+  end
+
+  def respond_created(comment)
+    respond_to do |format|
+      format.json do
+        render json: {
+          status: "created",
+          annotation_id: @annotation.id,
+          # The submission contract identifies the parent the claim produced,
+          # which for a replayed post is the comment the first attempt created.
+          annotation_comment_id: comment.id,
+          redirect_url: page_workspace_path_for(@screenshot, viewport: @annotation.viewport)
+        }, status: :created
+      end
+      format.html do
+        redirect_to page_workspace_path_for(@screenshot, viewport: @annotation.viewport),
+          notice: reopen_action? ? "Annotation unresolved." : "Comment added."
+      end
+    end
+  end
+
+  def respond_invalid(record)
+    respond_to do |format|
+      format.json { render_submission_error(record.errors.full_messages, batch: submission_batch_for_state) }
+      format.html do
+        redirect_to page_workspace_path_for(@screenshot, viewport: @annotation.viewport),
+          alert: "Could not save comment."
+      end
+    end
+  end
+
+  # Unresolving an annotation that is already open is an ordinary conflict, not
+  # a missing record: the composer stays mounted and keeps the person's text.
+  def respond_invalid_reopen
+    respond_to do |format|
+      format.json do
+        render_submission_error([ "This annotation is not resolved" ],
+          code: "annotation_not_resolved", batch: submission_batch_for_state)
+      end
+      # The non-JavaScript fallback has no composer to preserve, so it keeps
+      # answering an impossible transition as a missing resource.
+      format.html { raise ActiveRecord::RecordNotFound, "Annotation is not resolved" }
+    end
+  end
+
+  def attachment_error_redirect_path
+    page_workspace_path_for(@screenshot, viewport: @annotation.viewport)
+  end
+
   def set_screenshot
     @screenshot = Screenshot.find(params[:screenshot_id])
     @project = Current.user.projects.find(@screenshot.page.project_id)
@@ -37,6 +110,16 @@ def comment_body
     params.require(:annotation_comment).permit(:body, :reopen)[:body]
   end
 
+  # Everything the still-mounted disclosure needs to put itself back exactly as
+  # the person left it.
+  def submitted_form_fields
+    {
+      annotation_id: @annotation.id,
+      body: params.dig(:annotation_comment, :body).to_s,
+      reopen: reopen_action?
+    }
+  end
+
   def reopen_action?
     params.dig(:annotation_comment, :reopen) == "1"
   end
diff --git a/app/controllers/annotations_controller.rb b/app/controllers/annotations_controller.rb
index 58bc157..749a288 100644
--- a/app/controllers/annotations_controller.rb
+++ b/app/controllers/annotations_controller.rb
@@ -1,21 +1,23 @@
 # frozen_string_literal: true
 
 class AnnotationsController < ApplicationController
+  include ImageAttachmentSubmission
+
   before_action :set_screenshot
   before_action :set_annotation, only: %i[update destroy]
 
   def create
-    return redirect_invalid_viewport unless submitted_viewport_valid?
-
-    @annotation = @screenshot.annotations.build(annotation_params)
-    @annotation.user = Current.user
+    return respond_invalid_viewport unless submitted_viewport_valid?
 
-    if @annotation.save
-      redirect_to page_workspace_path_for(@screenshot, viewport: @annotation.viewport), notice: "Annotation added."
-    else
-      redirect_to page_workspace_path_for(@screenshot, viewport: @annotation.viewport),
-        alert: "Could not save annotation."
+    batch = submission_batch(@project)
+    result = ImageAttachments::ClaimBatch.call(batch: batch, user: Current.user, project: @project,
+      parent_type: Annotation) do
+      build_annotation!
     end
+
+    respond_created(result.parent)
+  rescue ActiveRecord::RecordInvalid => error
+    respond_invalid(error.record)
   end
 
   def update
@@ -48,6 +50,52 @@ def destroy
 
   private
 
+  def build_annotation!
+    annotation = @screenshot.annotations.build(annotation_params)
+    annotation.user = Current.user
+    annotation.save!
+    annotation
+  end
+
+  def respond_created(annotation)
+    respond_to do |format|
+      format.json do
+        render json: {
+          status: "created",
+          annotation_id: annotation.id,
+          redirect_url: page_workspace_path_for(@screenshot, viewport: annotation.viewport)
+        }, status: :created
+      end
+      format.html do
+        redirect_to page_workspace_path_for(@screenshot, viewport: annotation.viewport), notice: "Annotation added."
+      end
+    end
+  end
+
+  def respond_invalid(record)
+    respond_to do |format|
+      format.json { render_submission_error(record.errors.full_messages, batch: submission_batch_for_state) }
+      format.html do
+        redirect_to page_workspace_path_for(@screenshot, viewport: record.viewport),
+          alert: "Could not save annotation."
+      end
+    end
+  end
+
+  def respond_invalid_viewport
+    respond_to do |format|
+      format.json do
+        render_submission_error([ "Viewport is not available for this screenshot" ],
+          code: "invalid_viewport", batch: submission_batch_for_state)
+      end
+      format.html { redirect_invalid_viewport }
+    end
+  end
+
+  def attachment_error_redirect_path
+    page_workspace_path_for(@screenshot)
+  end
+
   def set_screenshot
     @screenshot = Screenshot.find(params[:screenshot_id])
     @project = Current.user.projects.find(@screenshot.page.project_id)
@@ -57,6 +105,21 @@ def set_annotation
     @annotation = @screenshot.annotations.find(params[:id])
   end
 
+  # Everything the still-mounted overlay needs to put itself back exactly as the
+  # person left it: the message, the selected region, and the viewport it was
+  # drawn on.
+  def submitted_form_fields
+    submitted = params.fetch(:annotation, {})
+    {
+      body: submitted[:comment].to_s,
+      x_percent: submitted[:x_percent],
+      y_percent: submitted[:y_percent],
+      width_percent: submitted[:width_percent],
+      height_percent: submitted[:height_percent],
+      viewport: submitted[:viewport].presence
+    }
+  end
+
   def annotation_params
     permitted_params = params.require(:annotation).permit(
       :x_percent, :y_percent, :width_percent, :height_percent, :comment, :viewport
diff --git a/app/controllers/api/image_attachment_media_controller.rb b/app/controllers/api/image_attachment_media_controller.rb
new file mode 100644
index 0000000..4bcb286
--- /dev/null
+++ b/app/controllers/api/image_attachment_media_controller.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Api
+  # Bearer-authenticated attachment delivery for agents and the CLI.
+  #
+  # Three things must hold on every request: a valid bearer principal with the
+  # read scope, an unexpired purpose token minted for this exact attachment,
+  # and live access to the project that still owns the message. The token alone
+  # is never authority — revoking a credential or a membership takes effect on
+  # the next byte request.
+  class ImageAttachmentMediaController < Api::BaseController
+    include BlobStreaming
+
+    def show
+      return unless require_scope!(AuthenticatedPrincipal::READ_SCOPE)
+
+      attachment = authorized_attachment
+      return unless attachment
+
+      stream_blob(attachment.image.blob)
+    end
+
+    private
+
+    def authorized_attachment
+      attachment = ImageAttachment.find_by_token_for(
+        ImageAttachment::MEDIA_TOKEN_PURPOSE, params[:token].to_s
+      )
+      return deny unless attachment
+      return deny unless attachment.id.to_s == params[:id].to_s
+      return deny unless attachment.state_ready? && attachment.image.attached?
+      return deny unless attachment.submitted? && attachment.parent.present?
+      return deny unless current_principal.project_access?(attachment.project)
+
+      attachment
+    end
+
+    def deny
+      render_error("Attachment is not available", code: "not_found", status: :not_found)
+      nil
+    end
+  end
+end
diff --git a/app/controllers/api/v1/annotations_controller.rb b/app/controllers/api/v1/annotations_controller.rb
index 9920e98..38bface 100644
--- a/app/controllers/api/v1/annotations_controller.rb
+++ b/app/controllers/api/v1/annotations_controller.rb
@@ -29,7 +29,7 @@ def show
         project = require_current_project!(params[:project_id])
         return unless project
 
-        annotation = project_annotations(project).find(params[:id])
+        annotation = annotation_details(project).find(params[:id])
         cropped_base64 = begin
           annotation.crop
         rescue => e
@@ -41,7 +41,9 @@ def show
           nil
         end
 
-        render json: Api::V1::ContractSerializer.annotation_detail(annotation, cropped_base64: cropped_base64)
+        render json: Api::V1::ContractSerializer.annotation_detail(
+          annotation, cropped_base64: cropped_base64, url_options: serializer_url_options
+        )
       end
 
       private
@@ -50,6 +52,10 @@ def project_annotations(project)
         Api::V1::ProjectScope.annotations(project)
       end
 
+      def annotation_details(project)
+        Api::V1::ProjectScope.annotation_details(project)
+      end
+
       def serialize(annotation)
         Api::V1::ContractSerializer.annotation(annotation)
       end
diff --git a/app/controllers/concerns/blob_streaming.rb b/app/controllers/concerns/blob_streaming.rb
new file mode 100644
index 0000000..6f18cba
--- /dev/null
+++ b/app/controllers/concerns/blob_streaming.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+# One streaming contract for every protected blob response.
+#
+# Active Storage's public routes stay disabled: nothing here redirects to the
+# storage provider, and no durable or provider-signed URL ever leaves the
+# application. The caller authorizes the read first; this only decides how the
+# already-authorized bytes go out, and they are never cached anywhere.
+module BlobStreaming
+  extend ActiveSupport::Concern
+
+  included do
+    include ActiveStorage::Streaming
+  end
+
+  private
+
+  # Shared variant resolution for every protected media route. An
+  # authenticated GET never invokes libvips: a variant the warming job has not
+  # produced yet simply is not available, and `raw` names the variants that are
+  # served straight from the original blob.
+  def resolve_variant_blob(attached, variant_name, allowed:, raw: %w[original])
+    return nil unless attached.attached?
+    return attached.blob if raw.include?(variant_name)
+
+    variant_key = allowed[variant_name]
+    return nil unless variant_key
+
+    attached.variant(variant_key).image&.blob
+  end
+
+  def stream_blob(blob, disposition: :inline)
+    response.headers["Cache-Control"] = "private, no-store"
+    response.headers["X-Content-Type-Options"] = "nosniff"
+
+    if request.headers["Range"].present?
+      send_blob_byte_range_data(blob, request.headers["Range"], disposition: disposition)
+    else
+      response.headers["Accept-Ranges"] = "bytes"
+      response.headers["Content-Length"] = blob.byte_size.to_s
+      send_blob_stream(blob, disposition: disposition)
+    end
+  end
+end
diff --git a/app/controllers/concerns/image_attachment_submission.rb b/app/controllers/concerns/image_attachment_submission.rb
new file mode 100644
index 0000000..9f7ecf0
--- /dev/null
+++ b/app/controllers/concerns/image_attachment_submission.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+# Shared posting contract for the native composers.
+#
+# A form supplies exactly one draft batch public ID and nothing else about the
+# attachments: uploader, project, sizes, types, row IDs, and states all come
+# from the server. Ordinary validation failures answer with 422 plus enough
+# state for the still-mounted composer to restore itself, which is what keeps a
+# rejected message from losing the text and the images the person just added.
+module ImageAttachmentSubmission
+  extend ActiveSupport::Concern
+
+  included do
+    rescue_from ImageAttachments::Error, with: :render_attachment_error
+  end
+
+  private
+
+  def submission_batch(project)
+    public_id = params[:image_attachment_batch_id].presence
+    return nil if public_id.blank?
+
+    batch = ImageAttachmentBatch.find_by(public_id: public_id.to_s)
+    unless batch && batch.user_id == Current.user.id && batch.project_id == project.id
+      raise ImageAttachments::Error.new(code: "batch_not_owned")
+    end
+
+    batch
+  end
+
+  def submitted_batch_public_id
+    params[:image_attachment_batch_id].presence&.to_s
+  end
+
+  def ready_attachment_ids(batch)
+    return [] unless batch
+
+    batch.image_attachments.state_ready.ordered.pluck(:id)
+  end
+
+  def render_submission_error(messages, code: "validation_failed", batch: nil)
+    render json: {
+      error: { code: code, message: messages.first.to_s },
+      errors: messages,
+      form: composer_state(batch)
+    }, status: :unprocessable_entity
+  end
+
+  def render_attachment_error(error)
+    respond_to do |format|
+      format.json do
+        render json: {
+          error: { code: error.code, message: error.message, retryable: error.retryable? },
+          errors: [ error.message ],
+          form: composer_state(submission_batch_for_state)
+        }, status: error.status
+      end
+      format.html { redirect_to attachment_error_redirect_path, alert: error.message }
+    end
+  end
+
+  def submission_batch_for_state
+    ImageAttachmentBatch.find_by(public_id: submitted_batch_public_id.to_s, user_id: Current.user.id)
+  end
+
+  def composer_state(batch)
+    {
+      image_attachment_batch_id: submitted_batch_public_id,
+      ready_attachment_ids: ready_attachment_ids(batch)
+    }.merge(submitted_form_fields)
+  end
+end
diff --git a/app/controllers/concerns/page_workspace_navigation.rb b/app/controllers/concerns/page_workspace_navigation.rb
index 1e45f82..c7332f4 100644
--- a/app/controllers/concerns/page_workspace_navigation.rb
+++ b/app/controllers/concerns/page_workspace_navigation.rb
@@ -10,12 +10,18 @@ module PageWorkspaceNavigation
   private
 
   def page_workspace_path_for(screenshot, viewport: nil, **query)
+    page_path(screenshot.page_id, **page_workspace_options(screenshot, viewport: viewport, **query))
+  end
+
+  # The query a workspace deep link needs, without committing to a path or a
+  # URL helper. Mail builds an absolute URL from the same options, so the digest
+  # call to action and every in-app link cannot validate viewports differently.
+  def page_workspace_options(screenshot, viewport: nil, **query)
     options = { version_id: screenshot.id }
     validated_viewport = validated_workspace_viewport(screenshot, viewport)
     options[:viewport] = validated_viewport if validated_viewport
     options.merge!(query.compact)
-
-    page_path(screenshot.page_id, **options)
+    options
   end
 
   def page_workspace_viewport_for(screenshot, requested_viewport)
diff --git a/app/controllers/image_attachment_drafts/attachments_controller.rb b/app/controllers/image_attachment_drafts/attachments_controller.rb
new file mode 100644
index 0000000..18bb78a
--- /dev/null
+++ b/app/controllers/image_attachment_drafts/attachments_controller.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module ImageAttachmentDrafts
+  class AttachmentsController < BaseController
+    def create
+      batch = find_batch!
+      upload = params[:file]
+      raise ImageAttachments::Error.new(code: "missing_file") unless upload.respond_to?(:read)
+
+      result = ImageAttachments::Ingest.call(
+        batch: batch,
+        io: upload,
+        client_key: params[:client_key],
+        declared_content_type: upload.try(:content_type),
+        # The envelope carries multipart framing as well as the bytes, so the
+        # per-file ceiling is declared from the uploaded part's own size.
+        declared_length: upload.try(:size),
+        filename: upload.try(:original_filename)
+      )
+
+      render_attachment(result.attachment, status: :created)
+    end
+
+    # Alt text is authored while the image is still a draft. After the message
+    # is posted the description is part of the historical record.
+    def update
+      attachment = ImageAttachments::WriteAltText.call(
+        batch: find_batch!,
+        attachment_id: params[:id],
+        alt_text: params.require(:image_attachment).permit(:alt_text)[:alt_text]
+      )
+
+      render_attachment(attachment)
+    end
+
+    def destroy
+      batch = find_batch!
+      ImageAttachments::RemoveAttachment.call(batch: batch, attachment_id: params[:id])
+
+      render_batch(batch.reload)
+    end
+
+    # The browser always knows its client key, including before an in-flight
+    # POST has returned the numeric row ID. Reserving that key as removed makes
+    # an abort/delete race deterministic instead of letting a ghost row reach
+    # the next claim.
+    def discard
+      batch = find_batch!
+      ImageAttachments::RemoveAttachment.call(
+        batch: batch,
+        client_key: params.require(:client_key).to_s
+      )
+
+      render_batch(batch.reload)
+    end
+  end
+end
diff --git a/app/controllers/image_attachment_drafts/base_controller.rb b/app/controllers/image_attachment_drafts/base_controller.rb
new file mode 100644
index 0000000..aac32b0
--- /dev/null
+++ b/app/controllers/image_attachment_drafts/base_controller.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+module ImageAttachmentDrafts
+  # Browser-only draft APIs. Authoring attachments is a session capability: the
+  # standard forgery protection stays on, every request is scoped through the
+  # signed-in user's current membership, and a draft that belongs to somebody
+  # else is indistinguishable from one that does not exist.
+  class BaseController < ApplicationController
+    RATE_LIMIT = 120
+    RATE_LIMIT_WINDOW = 1.hour
+    DRAFT_RATE_LIMIT_STORE = Screenote::RateLimitStore.new(store: -> { cache_store })
+    # One account budget and one address budget for the whole draft surface.
+    # Without an explicit scope Rails keys each limit by controller path, which
+    # would give batches and attachments a separate allowance apiece and double
+    # what an account can actually spend.
+    RATE_LIMIT_SCOPE = "image_attachment_drafts"
+
+    # Authentication is inherited from ApplicationController and runs first, so
+    # the account bucket is always keyed by a real signed-in identity rather
+    # than by a shared anonymous one.
+    rate_limit to: RATE_LIMIT, within: RATE_LIMIT_WINDOW,
+      by: -> { "user:#{Current.user.id}" },
+      with: -> { render_error("Too many upload requests. Try again later.", code: "rate_limited", status: :too_many_requests) },
+      store: DRAFT_RATE_LIMIT_STORE,
+      scope: RATE_LIMIT_SCOPE
+    rate_limit to: RATE_LIMIT, within: RATE_LIMIT_WINDOW,
+      by: -> { "ip:#{request.remote_ip}" },
+      with: -> { render_error("Too many upload requests. Try again later.", code: "rate_limited", status: :too_many_requests) },
+      store: DRAFT_RATE_LIMIT_STORE,
+      scope: RATE_LIMIT_SCOPE
+
+    rescue_from ImageAttachments::Error, with: :render_service_error
+    rescue_from Screenote::RateLimitStore::Unavailable, with: :render_rate_limiter_unavailable
+
+    private
+
+    def find_batch!
+      public_id = params[:batch_public_id].presence || params[:public_id]
+      batch = ImageAttachmentBatch.find_by(public_id: public_id.to_s)
+
+      # A foreign or unknown draft is a 404, never a 403: sequential attachment
+      # IDs and guessed batch IDs must not confirm that anything exists.
+      raise ActiveRecord::RecordNotFound unless batch
+      raise ActiveRecord::RecordNotFound unless batch.user_id == Current.user.id
+      raise ActiveRecord::RecordNotFound unless member_of?(batch.project_id)
+
+      batch
+    end
+
+    def find_project!
+      project = Current.user.projects.find_by(id: params[:project_id])
+      raise ActiveRecord::RecordNotFound unless project
+
+      project
+    end
+
+    def member_of?(project_id)
+      Current.user.projects.exists?(id: project_id)
+    end
+
+    def render_batch(batch, status: :ok)
+      render json: ImageAttachments::DraftPresenter.batch(batch), status: status
+    end
+
+    def render_attachment(attachment, status: :ok)
+      render json: { attachment: ImageAttachments::DraftPresenter.attachment(attachment) }, status: status
+    end
+
+    def render_service_error(error)
+      render_error(error.message, code: error.code, status: error.status, retryable: error.retryable?)
+    end
+
+    def render_error(message, code:, status:, retryable: false)
+      render json: { error: { code: code, message: message, retryable: retryable } }, status: status
+    end
+
+    def render_rate_limiter_unavailable(_error)
+      render_error("Uploads are temporarily unavailable. Try again shortly.",
+        code: "rate_limiter_unavailable", status: :service_unavailable, retryable: true)
+    end
+
+    def not_found
+      render_error("Not found", code: "not_found", status: :not_found)
+    end
+  end
+end
diff --git a/app/controllers/image_attachment_drafts/batches_controller.rb b/app/controllers/image_attachment_drafts/batches_controller.rb
new file mode 100644
index 0000000..30dd460
--- /dev/null
+++ b/app/controllers/image_attachment_drafts/batches_controller.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module ImageAttachmentDrafts
+  class BatchesController < BaseController
+    # The composer supplies its own idempotency key, so a retry after a lost
+    # response resumes the batch that key already opened instead of spending
+    # another of the six open batches an account is allowed.
+    def create
+      batch = ImageAttachmentBatch.open_for!(
+        user: Current.user,
+        project: find_project!,
+        client_key: params[:client_key]
+      )
+      render_batch(batch, status: batch.previously_new_record? ? :created : :ok)
+    end
+
+    # Resume is what a reconnecting composer calls: it re-reads server state
+    # rather than trusting anything the page still holds locally. A batch that
+    # has expired or been claimed is refused here rather than restored as ready:
+    # media, upload, and claim all reject it, so answering 200 would only enable
+    # a submission that is certain to fail.
+    def show
+      batch = find_batch!
+      raise ImageAttachments::Error.new(code: "batch_unusable") unless batch.usable?
+
+      render_batch(batch)
+    end
+
+    # An explicit composer cancel discards its own unclaimed draft, which is
+    # what makes the open-batch ceiling's "finish or discard one first" true.
+    # Discarding an already claimed or already discarded batch is a no-op.
+    def destroy
+      ImageAttachments::DiscardBatch.call(batch: find_batch!)
+      head :no_content
+    end
+  end
+end
diff --git a/app/controllers/image_attachment_media_controller.rb b/app/controllers/image_attachment_media_controller.rb
new file mode 100644
index 0000000..6817c6d
--- /dev/null
+++ b/app/controllers/image_attachment_media_controller.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+# Streams attachment bytes after live authorization. Active Storage's public
+# routes stay disabled: nothing here redirects to the storage provider, and no
+# durable or provider-signed URL ever leaves the application.
+class ImageAttachmentMediaController < ApplicationController
+  include BlobStreaming
+
+  ALLOWED_VARIANTS = ImageAttachment::MEDIA_VARIANT_NAMES.to_h { |name| [ name.to_s, name ] }.freeze
+  DISPOSITIONS = { "download" => :attachment }.freeze
+  # A download is the same bytes as the original, only with an attachment
+  # disposition, so neither needs a variant.
+  RAW_VARIANTS = %w[original download].freeze
+
+  def show
+    attachment = authorized_attachment
+    blob = resolve_variant_blob(attachment.image, params[:variant],
+      allowed: ALLOWED_VARIANTS, raw: RAW_VARIANTS)
+    raise ActiveRecord::RecordNotFound unless blob
+
+    stream_blob(blob, disposition: DISPOSITIONS.fetch(params[:variant], :inline))
+  end
+
+  private
+
+  def authorized_attachment
+    attachment = ImageAttachment.with_media.find_by(id: params[:id])
+    raise ActiveRecord::RecordNotFound unless attachment
+    raise ActiveRecord::RecordNotFound unless attachment.state_ready? && attachment.image.attached?
+    raise ActiveRecord::RecordNotFound unless authorized?(attachment)
+
+    attachment
+  end
+
+  # Drafts have no message parent yet, so the only possible reader is the
+  # person who uploaded them into a live batch. Submitted rows are rechecked
+  # against the current project membership on every byte request, which is what
+  # makes revoking a collaborator take effect immediately.
+  def authorized?(attachment)
+    if attachment.draft?
+      attachment.user_id == Current.user.id &&
+        attachment.image_attachment_batch&.usable? &&
+        Current.user.projects.exists?(id: attachment.project_id)
+    else
+      attachment.parent.present? && Current.user.projects.exists?(id: attachment.project_id)
+    end
+  end
+end
diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb
index 7bc8ccd..b28f29a 100644
--- a/app/controllers/media_controller.rb
+++ b/app/controllers/media_controller.rb
@@ -1,24 +1,16 @@
 # frozen_string_literal: true
 
 class MediaController < ApplicationController
-  include ActiveStorage::Streaming
+  include BlobStreaming
 
   ALLOWED_VARIANTS = ScreenshotImage::THUMBNAIL_VARIANT_NAMES.to_h { |name| [ name.to_s, name ] }.freeze
 
   def show
     screenshot_image = accessible_images.find(params[:id])
-    blob = media_blob(screenshot_image, params[:variant])
+    blob = resolve_variant_blob(screenshot_image.image, params[:variant], allowed: ALLOWED_VARIANTS)
     raise ActiveRecord::RecordNotFound unless blob
 
-    response.headers["Cache-Control"] = "private, no-store"
-    response.headers["X-Content-Type-Options"] = "nosniff"
-    if request.headers["Range"].present?
-      send_blob_byte_range_data(blob, request.headers["Range"], disposition: :inline)
-    else
-      response.headers["Accept-Ranges"] = "bytes"
-      response.headers["Content-Length"] = blob.byte_size.to_s
-      send_blob_stream(blob, disposition: :inline)
-    end
+    stream_blob(blob)
   end
 
   private
@@ -29,16 +21,4 @@ def accessible_images
       .where(project_memberships: { user_id: Current.user.id })
       .includes(image_attachment: :blob)
   end
-
-  def media_blob(screenshot_image, variant_name)
-    return screenshot_image.image.blob if variant_name == "original" && screenshot_image.image.attached?
-
-    variant_key = ALLOWED_VARIANTS[variant_name]
-    return unless variant_key && screenshot_image.image.attached?
-
-    # Never make an authenticated GET trigger image decoding. The warming job
-    # creates a tracked record first; a missing record remains unavailable
-    # until reconciliation completes it.
-    screenshot_image.image.variant(variant_key).image&.blob
-  end
 end
diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb
index 8e37331..8aef278 100644
--- a/app/controllers/pages_controller.rb
+++ b/app/controllers/pages_controller.rb
@@ -68,7 +68,12 @@ def load_workspace
     @active_viewport = page_workspace_viewport_for(@screenshot, params[:viewport])
     @screenshot_image = @screenshot.image_for(@active_viewport) if @active_viewport
     @annotations = @screenshot.annotations
-      .includes(:user, :api_key, annotation_comments: [ :user, :api_key ])
+      .includes(
+        :user,
+        :api_key,
+        { image_attachments: ImageAttachment::RENDER_PRELOAD },
+        annotation_comments: [ :user, :api_key, { image_attachments: ImageAttachment::RENDER_PRELOAD } ]
+      )
       .order(:created_at)
     @annotations = @annotations.where(status: params[:status]) if Annotation.statuses.key?(params[:status])
     @annotations = @annotations.where(viewport: @active_viewport) if @active_viewport
diff --git a/app/javascript/controllers/annotorious_controller.js b/app/javascript/controllers/annotorious_controller.js
index 4d134a8..30c03e0 100644
--- a/app/javascript/controllers/annotorious_controller.js
+++ b/app/javascript/controllers/annotorious_controller.js
@@ -336,6 +336,9 @@ export default class extends Controller {
   cancelForm() {
     this.cancelPendingAnnotationSync()
     if (this.hasFormTarget) {
+      // Cancelling is a decision to throw this draft away, unlike an ordinary
+      // disconnect. The composer listens for it and discards its draft batch.
+      this.formTarget.dispatchEvent(new CustomEvent("annotorious:form-cancelled"))
       this.formTarget.remove()
     }
     if (this.pendingAnnotationId && this.anno && !this.pendingAnnotationLocal) {
@@ -499,6 +502,13 @@ export default class extends Controller {
     this.imageWrapperResizeObserver.observe(this.imageTarget.parentElement)
   }
 
+  // The overlay form changes height whenever the composer gains or loses an
+  // attachment row. Re-running the placement keeps the rail off the region the
+  // person just selected.
+  repositionForm() {
+    this.requestAnnotationFormPosition()
+  }
+
   requestAnnotationFormPosition() {
     if (!this.hasFormTarget || !this.pendingCoords || this.annotationFormPositionFrame != null) return
 
diff --git a/app/javascript/controllers/image_attachment_composer_controller.js b/app/javascript/controllers/image_attachment_composer_controller.js
new file mode 100644
index 0000000..fe4b9ac
--- /dev/null
+++ b/app/javascript/controllers/image_attachment_composer_controller.js
@@ -0,0 +1,894 @@
+import { Controller } from "@hotwired/stimulus"
+import { Turbo } from "@hotwired/turbo-rails"
+
+// One state machine for every native composer: the cloned Annotorious overlay
+// form and each reply/reopen disclosure. The controller owns a single
+// server-side draft batch, uploads each file immediately, and blocks the post
+// until every persisted row is ready.
+export default class extends Controller {
+  static targets = ["fileInput", "items", "itemTemplate", "status", "errors"]
+  static values = {
+    projectId: Number,
+    batchesUrl: String,
+    maxFiles: Number,
+    maxFileSize: Number,
+    maxTotalSize: Number,
+    acceptedTypes: Array,
+    unsupportedTypeMessage: String
+  }
+
+  // A hung upload must not block the post forever, and 20MB has to fit over a
+  // slow connection, so the ceiling is generous rather than tight.
+  static UPLOAD_TIMEOUT = 180000
+
+  // Every other draft call — batch create, alt text, remove, and the post
+  // itself — carries the same kind of ceiling. A hung claim must not leave the
+  // composer frozen with its submit button disabled.
+  static REQUEST_TIMEOUT = 60000
+  static RESUME_POLL_INTERVAL = 500
+  static RESUME_POLL_LIMIT = 20
+
+  connect() {
+    this.connectionToken = (this.connectionToken || 0) + 1
+    const connectionToken = this.connectionToken
+    this.disconnected = false
+    this.items = new Map()
+    this.pendingBatchRequest = null
+    // One key per mounted composer, stable across retries of the create call.
+    this.batchClientKey ||= `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`
+    this.submitting = false
+    this.navigating = false
+    this.resuming = false
+    this.resumeFailed = false
+    this.form = this.element.closest("form")
+    if (!this.form) return
+
+    // Bind drop and paste to the composer form only. The screenshot canvas
+    // keeps its Annotorious drag behavior, and a text-only paste stays text.
+    this.onSubmit = this.onSubmit.bind(this)
+    this.onDragOver = this.onDragOver.bind(this)
+    this.onDragLeave = this.onDragLeave.bind(this)
+    this.onDrop = this.onDrop.bind(this)
+    this.onPaste = this.onPaste.bind(this)
+    this.onCancelled = this.onCancelled.bind(this)
+
+    this.form.addEventListener("submit", this.onSubmit)
+    this.form.addEventListener("dragover", this.onDragOver)
+    this.form.addEventListener("dragleave", this.onDragLeave)
+    this.form.addEventListener("drop", this.onDrop)
+    this.form.addEventListener("paste", this.onPaste)
+    this.form.addEventListener("annotorious:form-cancelled", this.onCancelled)
+
+    this.batchField = this.form.querySelector("[data-image-attachment-batch-field]") ||
+      this.form.querySelector('input[name="image_attachment_batch_id"]')
+    if (!this.batchField) {
+      this.batchField = document.createElement("input")
+      this.batchField.type = "hidden"
+      this.batchField.name = "image_attachment_batch_id"
+      this.batchField.dataset.imageAttachmentBatchField = "true"
+      this.form.appendChild(this.batchField)
+    }
+
+    this.batchId = this.batchField.value || null
+    this.batchUrls = this.batchId ? this.urlsForBatch(this.batchId) : null
+    this.itemsTarget.replaceChildren()
+    if (this.batchId) {
+      this.resuming = true
+      this.resumeBatch(connectionToken)
+    }
+
+    this.refreshSubmitState()
+  }
+
+  disconnect() {
+    this.disconnected = true
+    if (!this.form) return
+
+    this.form.removeEventListener("submit", this.onSubmit)
+    this.form.removeEventListener("dragover", this.onDragOver)
+    this.form.removeEventListener("dragleave", this.onDragLeave)
+    this.form.removeEventListener("drop", this.onDrop)
+    this.form.removeEventListener("paste", this.onPaste)
+    this.form.removeEventListener("annotorious:form-cancelled", this.onCancelled)
+    this.items.forEach(item => {
+      item.request?.abort()
+      this.releasePreview(item)
+    })
+    clearTimeout(this.resumeTimer)
+    // Anything still in flight belongs to the connection being torn down.
+    this.pendingBatchRequest = null
+    this.connectionToken += 1
+    // A disconnect is only a client going away. Committed drafts stay on the
+    // server until their 24 hour expiry.
+    this.form = null
+  }
+
+  openPicker() {
+    this.fileInputTarget.click()
+  }
+
+  pickFiles(event) {
+    this.enqueue(Array.from(event.target.files || []))
+    event.target.value = ""
+  }
+
+  onDragOver(event) {
+    if (!this.transfersFiles(event.dataTransfer)) return
+
+    // A real OS drag exposes only `types` until the drop itself: `files` stays
+    // empty, so dragover can ask whether files are coming and nothing more.
+    // Without preventDefault here the browser handles the drop and navigates
+    // away to the file.
+    event.preventDefault()
+    this.element.classList.add("image-attachment-composer--dropping")
+  }
+
+  onDragLeave() {
+    this.element.classList.remove("image-attachment-composer--dropping")
+  }
+
+  onDrop(event) {
+    if (!this.transfersFiles(event.dataTransfer)) return
+
+    // The composer claimed this drag on dragover, so it owns the drop even
+    // when the dropped files turn out to be unusable.
+    event.preventDefault()
+    this.element.classList.remove("image-attachment-composer--dropping")
+
+    const images = this.imageFilesFrom(event.dataTransfer)
+    if (images.length === 0) {
+      this.showError(this.unsupportedTypeMessage)
+      return
+    }
+    if (images.length < Array.from(event.dataTransfer?.files || []).length) {
+      this.showError(`Some files were skipped. ${this.unsupportedTypeMessage}`)
+    }
+
+    this.enqueue(images)
+  }
+
+  onPaste(event) {
+    const images = this.imageFilesFrom(event.clipboardData)
+    const files = Array.from(event.clipboardData?.files || [])
+    if (images.length === 0) {
+      if (files.length > 0) this.showError(this.unsupportedTypeMessage)
+      return
+    }
+    if (images.length < files.length) {
+      this.showError(`Some files were skipped. ${this.unsupportedTypeMessage}`)
+    }
+
+    // Mixed clipboard content keeps its text: only the image half is consumed
+    // here, so the browser still inserts whatever text came with it.
+    const hasText = Array.from(event.clipboardData?.types || []).includes("text/plain")
+    if (!hasText) event.preventDefault()
+    this.enqueue(images)
+  }
+
+  transfersFiles(transfer) {
+    return Array.from(transfer?.types || []).includes("Files")
+  }
+
+  imageFilesFrom(transfer) {
+    return Array.from(transfer?.files || []).filter(file => this.acceptedFile(file))
+  }
+
+  // An extensionless PNG, JPEG, or WebP reaches the page with an empty
+  // `File.type`: the browser derives that string from the name, not the bytes.
+  // Refusing it here would reject a file the server accepts, so an undeclared
+  // type is passed through and the byte sniffing on the other end decides.
+  acceptedFile(file) {
+    return !file.type || this.acceptedTypesValue.includes(file.type)
+  }
+
+  async enqueue(files) {
+    if (files.length === 0) return
+    // A file added while the post is in flight would reach the server after
+    // the claim and leave the batch unready, so the composer is frozen until
+    // the submission settles.
+    if (this.submitting) {
+      this.showError("Wait for the message to finish posting.")
+      return
+    }
+    // Resume rebuilds the rail from the server on every poll tick, so a file
+    // added mid-settle would be dropped from `this.items` and orphaned.
+    if (this.resuming) {
+      this.showError("Wait for the interrupted upload to finish settling.")
+      return
+    }
+
+    const images = files.filter(file => this.acceptedFile(file))
+    if (images.length < files.length) {
+      this.showError(this.unsupportedTypeMessage)
+    }
+    if (images.length === 0) return
+
+    const room = this.maxFilesValue - this.items.size
+    if (room <= 0) {
+      this.showError(`You can attach up to ${this.maxFilesValue} images.`)
+      return
+    }
+
+    const accepted = images.slice(0, room)
+    if (accepted.length < images.length) {
+      this.showError(`You can attach up to ${this.maxFilesValue} images.`)
+    }
+
+    // Slots are reserved synchronously, before the first upload is awaited, so
+    // an overlapping paste, drop, or picker sees the rows this call already
+    // took and the five-file limit holds across all of them.
+    let total = this.totalBytes()
+    const queued = []
+    for (const file of accepted) {
+      if (file.size > this.maxFileSizeValue) {
+        this.showError(`Each image must be ${Math.round(this.maxFileSizeValue / 1048576)}MB or smaller.`)
+        continue
+      }
+
+      // The server enforces the same per-message total on commit; refusing the
+      // file here means nothing is uploaded only to be rejected afterwards.
+      if (total + file.size > this.maxTotalSizeValue) {
+        this.showError(
+          `Attachments for one message can total at most ${Math.round(this.maxTotalSizeValue / 1048576)}MB.`
+        )
+        break
+      }
+
+      total += file.size
+      queued.push(this.createItem(file))
+    }
+
+    for (const item of queued) await this.upload(item)
+  }
+
+  totalBytes() {
+    return Array.from(this.items.values()).reduce((sum, item) => sum + (item.size || 0), 0)
+  }
+
+  createItem(file) {
+    const clientKey = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
+    // The picked bytes are already in memory, so the rail thumbnail is painted
+    // from them. Asking the server for the original would pull the whole
+    // upload — up to 50MB per message — back through the app, uncached, only
+    // to draw a thumbnail, and drafts are never allowed to warm a variant.
+    const objectUrl = URL.createObjectURL(file)
+    return this.appendItem({
+      clientKey,
+      file,
+      size: file.size,
+      state: "uploading",
+      serverId: null,
+      objectUrl,
+      previewUrl: objectUrl,
+      savedAltText: "",
+      failure: null
+    })
+  }
+
+  restoreItem(row) {
+    return this.appendItem({
+      clientKey: row.client_key,
+      file: null,
+      size: row.size || 0,
+      state: row.state,
+      serverId: row.id,
+      // A resumed row has no local File, so its preview is the protected
+      // server path.
+      objectUrl: null,
+      previewUrl: row.preview_url,
+      savedAltText: row.alt_text || "",
+      failure: row.failure
+    })
+  }
+
+  appendItem(attributes) {
+    const element = this.itemTemplateTarget.content.firstElementChild.cloneNode(true)
+    const item = {
+      ...attributes,
+      element,
+      request: null,
+      removed: false,
+      altInput: element.querySelector("[data-attachment-alt]"),
+      altRequest: null,
+      removalFailed: false
+    }
+
+    element.dataset.clientKey = item.clientKey
+    element.querySelector("[data-attachment-remove]").addEventListener("click", () => this.remove(item))
+    element.querySelector("[data-attachment-retry]").addEventListener("click", () => this.upload(item))
+    item.altInput.addEventListener("change", event => {
+      this.saveAltText(item, event.target.value)
+    })
+
+    this.itemsTarget.appendChild(element)
+    item.altInput.value = item.savedAltText
+    this.items.set(item.clientKey, item)
+    this.renderItem(item)
+    this.refreshSubmitState()
+    this.notifyLayoutChanged()
+
+    return item
+  }
+
+  async resumeBatch(connectionToken, attempt = 0) {
+    try {
+      const response = await this.request(this.batchUrls.batchUrl)
+      const payload = await response.json().catch(() => ({}))
+      if (!this.connectedFor(connectionToken)) return
+      // The server refuses to resume a batch it has expired or already claimed.
+      // Those drafts are gone, not broken: the composer drops the stale handle
+      // and carries on with an empty rail rather than blocking the post.
+      if (response.status === 404 || payload.error?.code === "batch_unusable") {
+        this.discardStaleBatch()
+        return
+      }
+      if (!response.ok) throw new Error(payload.error?.message || "The upload session could not be restored.")
+
+      this.items.forEach(item => this.releasePreview(item))
+      this.items.clear()
+      this.itemsTarget.replaceChildren()
+      for (const row of payload.attachments || []) this.restoreItem(row)
+
+      const pending = Array.from(this.items.values()).some(item => item.state === "uploading")
+      if (pending && attempt < this.constructor.RESUME_POLL_LIMIT) {
+        this.resumeTimer = setTimeout(
+          () => this.resumeBatch(connectionToken, attempt + 1),
+          this.constructor.RESUME_POLL_INTERVAL
+        )
+        return
+      }
+
+      this.resuming = false
+      this.resumeFailed = pending
+      if (pending) this.showError("An interrupted upload is still settling. Reload and try again.")
+      this.items.forEach(item => this.renderItem(item))
+      this.refreshSubmitState()
+      this.notifyLayoutChanged()
+    } catch (error) {
+      if (!this.connectedFor(connectionToken)) return
+
+      this.resuming = false
+      this.resumeFailed = true
+      this.showError(error.message || "The upload session could not be restored.")
+      this.refreshSubmitState()
+    }
+  }
+
+  discardStaleBatch() {
+    this.items.forEach(item => this.releasePreview(item))
+    this.items.clear()
+    this.itemsTarget.replaceChildren()
+    this.batchId = null
+    this.batchUrls = null
+    if (this.batchField) this.batchField.value = ""
+    this.resuming = false
+    this.resumeFailed = false
+    this.refreshSubmitState()
+    this.notifyLayoutChanged()
+  }
+
+  connectedFor(connectionToken) {
+    return !this.disconnected && this.connectionToken === connectionToken
+  }
+
+  async upload(item) {
+    if (this.disconnected) return
+    if (this.submitting) {
+      this.showError("Wait for the message to finish posting.")
+      return
+    }
+
+    // Retrying supersedes whatever is still in flight for this row: the older
+    // attempt is aborted, and its rejection is then ignored so it cannot
+    // overwrite the state the new attempt is about to write.
+    item.request?.abort()
+    const attempt = (item.attempt || 0) + 1
+    item.attempt = attempt
+
+    item.state = "uploading"
+    item.failure = null
+    item.progress = 0
+    this.renderItem(item)
+    this.refreshSubmitState()
+
+    let batch
+    try {
+      batch = await this.ensureBatch()
+    } catch (error) {
+      if (this.superseded(item, attempt)) return
+
+      this.failItem(item, error.message, error.retryable !== false)
+      return
+    }
+    if (this.superseded(item, attempt)) return
+
+    const body = new FormData()
+    body.append("client_key", item.clientKey)
+    body.append("file", item.file)
+
+    try {
+      const payload = await this.uploadWithProgress(`${batch.attachmentsUrl}`, body, item)
+      if (this.superseded(item, attempt)) return
+
+      item.serverId = payload.attachment.id
+      item.state = payload.attachment.state
+      if (!item.objectUrl) item.previewUrl = payload.attachment.preview_url
+      item.failure = payload.attachment.failure
+      this.renderItem(item)
+      this.notifyLayoutChanged()
+      this.announce(`${this.itemLabel(item)} uploaded.`)
+    } catch (error) {
+      if (this.superseded(item, attempt)) return
+
+      this.failItem(item, error.message, error.retryable !== false)
+    } finally {
+      if (item.attempt === attempt) {
+        item.request = null
+        this.refreshSubmitState()
+      }
+    }
+  }
+
+  releasePreview(item) {
+    if (!item.objectUrl) return
+
+    URL.revokeObjectURL(item.objectUrl)
+    item.objectUrl = null
+  }
+
+  superseded(item, attempt) {
+    return this.disconnected || item.attempt !== attempt
+  }
+
+  uploadWithProgress(url, body, item) {
+    return new Promise((resolve, reject) => {
+      const request = new XMLHttpRequest()
+      item.request = request
+      request.open("POST", url)
+      request.timeout = this.constructor.UPLOAD_TIMEOUT
+      request.setRequestHeader("Accept", "application/json")
+      request.setRequestHeader("X-CSRF-Token", this.csrfToken)
+      request.upload.addEventListener("progress", event => {
+        if (!event.lengthComputable) return
+
+        item.progress = Math.round((event.loaded / event.total) * 100)
+        this.renderItem(item)
+      })
+      request.addEventListener("load", () => {
+        let payload = {}
+        try {
+          payload = JSON.parse(request.responseText || "{}")
+        } catch {
+          payload = {}
+        }
+
+        if (request.status >= 200 && request.status < 300) {
+          resolve(payload)
+        } else {
+          reject(this.requestError(
+            payload.error?.message || "The upload did not finish. Retry it.",
+            typeof payload.error?.retryable === "boolean" ? payload.error.retryable : request.status >= 500
+          ))
+        }
+      })
+      request.addEventListener("error", () => reject(this.requestError("The upload did not finish. Retry it.")))
+      request.addEventListener("timeout", () => reject(this.requestError("The upload timed out. Retry it.")))
+      request.addEventListener("abort", () => reject(this.requestError("Upload cancelled.")))
+      request.send(body)
+    })
+  }
+
+  async ensureBatch() {
+    if (this.batchUrls) return this.batchUrls
+    if (this.disconnected) throw new Error("Uploads are unavailable right now.")
+
+    this.pendingBatchRequest ||= this.requestBatch(this.connectionToken)
+    try {
+      return await this.pendingBatchRequest
+    } finally {
+      this.pendingBatchRequest = null
+    }
+  }
+
+  // The batch this controller opens belongs to this connection. A response that
+  // arrives after a disconnect and reconnect describes a controller that no
+  // longer exists, so it must not overwrite the reconnected one's batch and
+  // send its uploads somewhere the rail cannot show them.
+  async requestBatch(connectionToken) {
+    const response = await this.request(this.batchesUrlValue, {
+      method: "POST",
+      // The composer's own idempotency key. A create whose response is lost is
+      // retried with the same key and resumes the batch it already opened
+      // rather than spending another of the account's open-batch allowance.
+      body: JSON.stringify({ project_id: this.projectIdValue, client_key: this.batchClientKey })
+    })
+    if (!this.connectedFor(connectionToken)) throw new Error("Uploads are unavailable right now.")
+    const payload = await response.json().catch(() => ({}))
+    if (!response.ok) {
+      throw this.requestError(
+        payload.error?.message || "Uploads are unavailable right now.",
+        typeof payload.error?.retryable === "boolean" ? payload.error.retryable : response.status >= 500
+      )
+    }
+
+    if (!this.connectedFor(connectionToken)) throw new Error("Uploads are unavailable right now.")
+
+    this.batchId = payload.batch_id
+    this.batchField.value = payload.batch_id
+    this.batchUrls = this.urlsForBatch(payload.batch_id)
+
+    return this.batchUrls
+  }
+
+  async remove(item) {
+    if (this.submitting) {
+      this.showError("Wait for the message to finish posting.")
+      return
+    }
+    // The next resume tick would rebuild this row from the server anyway, so a
+    // removal accepted mid-settle could leave an untracked ghost behind.
+    if (this.resuming) {
+      this.showError("Wait for the interrupted upload to finish settling.")
+      return
+    }
+
+    item.removed = true
+    item.request?.abort()
+    item.attempt = (item.attempt || 0) + 1
+    item.state = "removing"
+    item.failure = null
+    item.removalFailed = false
+    this.renderItem(item)
+    this.refreshSubmitState()
+
+    try {
+      await this.ensureBatch()
+      await this.discardServerAttachment(item)
+
+      item.element.remove()
+      this.items.delete(item.clientKey)
+      this.releasePreview(item)
+      this.announce("Image removed.")
+      this.refreshSubmitState()
+      this.notifyLayoutChanged()
+    } catch {
+      item.removed = false
+      item.removalFailed = true
+      item.state = "failed"
+      item.failure = { message: "The image could not be removed. Try Remove again.", retryable: false }
+      this.renderItem(item)
+      this.showError(item.failure.message)
+      this.refreshSubmitState()
+    }
+  }
+
+  // The client key exists before the numeric row ID. DELETE reserves it as a
+  // tombstone, so it is safe whether the upload is pending, ready, failed, or
+  // has not created its row yet. The item stays visible if delivery fails; a
+  // silent local removal could otherwise let a ghost row reach claim.
+  async discardServerAttachment(item) {
+    if (!this.batchUrls) throw new Error("The upload session is unavailable.")
+
+    const response = await this.request(`${this.batchUrls.attachmentsUrl}/discard`, {
+      method: "DELETE",
+      body: JSON.stringify({ client_key: item.clientKey })
+    })
+    if (!response.ok) throw new Error("The image could not be removed.")
+  }
+
+  // The typed description is part of the message, so a write is a tracked
+  // promise the post waits on — and a rejected response is a failure, which a
+  // bare `fetch` would otherwise report as success.
+  saveAltText(item, value) {
+    if (!item.serverId || !this.batchUrls) return Promise.resolve(true)
+    if (value === item.savedAltText && !item.altRequest) return Promise.resolve(true)
+
+    // Chain writes per item. A blur PATCH for value A must not finish after the
+    // submit-time flush for value B and overwrite B on the server.
+    const prior = item.altRequest || Promise.resolve(true)
+    const write = prior.catch(() => false).then(async () => {
+      if (value === item.savedAltText) return true
+
+      try {
+        const response = await this.request(`${this.batchUrls.attachmentsUrl}/${item.serverId}`, {
+          method: "PATCH",
+          body: JSON.stringify({ image_attachment: { alt_text: value } })
+        })
+        if (!response.ok) throw new Error("the description was rejected")
+
+        item.savedAltText = value
+        return true
+      } catch {
+        if (!item.removed) this.showError("The description could not be saved. Try again.")
+        return false
+      }
+    })
+
+    const tracked = write.finally(() => {
+      if (item.altRequest === tracked) item.altRequest = null
+    })
+    item.altRequest = tracked
+    return tracked
+  }
+
+  // Saving without leaving the description field never fires `change`, and a
+  // write started on blur races the claim that moves the row off the batch. The
+  // post therefore flushes what is typed and waits for every write to land.
+  async flushAltText() {
+    const writes = Array.from(this.items.values()).map(item => this.saveAltText(item, item.altInput?.value ?? ""))
+    const results = await Promise.all(writes)
+
+    return results.every(Boolean)
+  }
+
+  async onSubmit(event) {
+    event.preventDefault()
+    if (this.submitting || this.navigating) return
+    if (!this.readyToSubmit()) {
+      this.showError("Wait for every image to finish uploading.")
+      return
+    }
+
+    this.submitting = true
+    this.refreshSubmitState()
+    this.clearError()
+
+    // Cancelling the overlay mid-post tears the form out from under us, so the
+    // navigation target is captured while it is still attached.
+    const frame = this.form.closest("turbo-frame")
+    const controller = new AbortController()
+    const timer = setTimeout(() => controller.abort(), this.constructor.REQUEST_TIMEOUT)
+
+    try {
+      // Descriptions are flushed before the claim, because a claim moves the
+      // rows off the batch and every later write is a 404 against them.
+      if (!(await this.flushAltText())) return
+
+      // The fields are read after that wait, not before it. A description write
+      // can take a while, the textarea and the coordinate inputs stay editable
+      // throughout, and a snapshot taken first would post an older message than
+      // the one still visible on screen.
+      const body = new FormData(this.form)
+      if (this.batchId) body.set("image_attachment_batch_id", this.batchId)
+
+      const response = await fetch(this.form.action, {
+        method: this.form.method || "POST",
+        body,
+        credentials: "same-origin",
+        signal: controller.signal,
+        headers: { Accept: "application/json", "X-CSRF-Token": this.csrfToken }
+      })
+      const payload = await response.json().catch(() => ({}))
+
+      if (response.ok) {
+        // Turbo navigation is asynchronous and this form is still mounted while
+        // it runs. Clearing the batch and re-enabling submit here would let a
+        // second click post the same message again — this time with no batch at
+        // all, so the duplicate would carry no images. The composer therefore
+        // stays locked until the page it is on has been replaced.
+        this.navigating = true
+        this.navigateAfterSubmit(payload.redirect_url, frame)
+        return
+      }
+
+      this.restoreFrom(payload)
+    } catch {
+      this.showError("The message could not be posted. Try again.")
+    } finally {
+      clearTimeout(timer)
+      if (!this.navigating) {
+        this.submitting = false
+        this.refreshSubmitState()
+      }
+    }
+  }
+
+  // An explicit Annotorious cancel throws the draft away so the per-account
+  // open-batch ceiling stays truthful. Ordinary disconnect, navigation, and
+  // reconnect still leave the batch for its 24 hour recovery window.
+  onCancelled() {
+    const batchUrl = this.batchUrls?.batchUrl
+    this.items.forEach(item => { item.removed = true })
+    this.batchId = null
+    this.batchUrls = null
+    if (this.batchField) this.batchField.value = ""
+    if (!batchUrl) return
+
+    // The composer is torn down in the same tick, so this outlives it.
+    fetch(batchUrl, {
+      method: "DELETE",
+      credentials: "same-origin",
+      keepalive: true,
+      headers: { Accept: "application/json", "X-CSRF-Token": this.csrfToken }
+    }).catch(() => {})
+  }
+
+  // Posting keeps the existing navigation contract. Both composers live inside
+  // the workspace Turbo frame, so reloading that frame is what a plain Turbo
+  // form submission used to do — and it leaves review state such as fullscreen
+  // mode untouched.
+  navigateAfterSubmit(url, frame) {
+    if (frame && url) {
+      frame.src = url
+      frame.reload()
+      return
+    }
+
+    Turbo.visit(url || window.location.href, { action: "replace" })
+  }
+
+  // A rejected post keeps the composer mounted: the body, the coordinates, the
+  // batch, and every ready row stay exactly where the person left them.
+  restoreFrom(payload) {
+    const form = payload.form || {}
+    const batchId = form.image_attachment_batch_id
+    if (batchId) {
+      this.batchId = batchId
+      this.batchUrls ||= this.urlsForBatch(batchId)
+      if (this.batchField) this.batchField.value = batchId
+    }
+
+    // The server echoes the editable half of what it rejected, so a composer
+    // that was remounted between the post and its answer still comes back with
+    // the message and, for the overlay, the region that was selected.
+    this.restoreField("textarea", form.body)
+    this.restoreField('[name="annotation[x_percent]"]', form.x_percent)
+    this.restoreField('[name="annotation[y_percent]"]', form.y_percent)
+    this.restoreField('[name="annotation[width_percent]"]', form.width_percent)
+    this.restoreField('[name="annotation[height_percent]"]', form.height_percent)
+    this.restoreField('[name="annotation[viewport]"]', form.viewport)
+
+    const readyIds = payload.form?.ready_attachment_ids || []
+    this.items.forEach(item => {
+      if (item.serverId && readyIds.includes(item.serverId)) {
+        item.state = "ready"
+        this.renderItem(item)
+      }
+    })
+
+    // A 500, an HTML error page, or a rejected CSRF token carries no usable
+    // message. Falling back keeps a rejected post from looking like nothing
+    // happened at all.
+    const message = (payload.errors || [payload.error?.message]).filter(Boolean).join(" ")
+    this.showError(message || "The message could not be posted. Try again.")
+  }
+
+  restoreField(selector, value) {
+    if (value === undefined || value === null) return
+
+    const field = this.form?.querySelector(selector)
+    if (field && !field.value) field.value = value
+  }
+
+  request(url, options = {}) {
+    const controller = new AbortController()
+    const timer = setTimeout(() => controller.abort(), this.constructor.REQUEST_TIMEOUT)
+
+    return fetch(url, {
+      credentials: "same-origin",
+      signal: controller.signal,
+      ...options,
+      headers: {
+        Accept: "application/json",
+        "Content-Type": "application/json",
+        "X-CSRF-Token": this.csrfToken,
+        ...(options.headers || {})
+      }
+    }).finally(() => clearTimeout(timer))
+  }
+
+  urlsForBatch(batchId) {
+    return {
+      batchUrl: `${this.batchesUrlValue}/${batchId}`,
+      attachmentsUrl: `${this.batchesUrlValue}/${batchId}/attachments`
+    }
+  }
+
+  requestError(message, retryable = true) {
+    const error = new Error(message)
+    error.retryable = retryable
+    return error
+  }
+
+  failItem(item, message, retryable = true) {
+    item.state = "failed"
+    item.failure = { message, retryable }
+    this.renderItem(item)
+    this.announce(`${this.itemLabel(item)} failed. ${message}`)
+    this.refreshSubmitState()
+  }
+
+  renderItem(item) {
+    const element = item.element
+    const state = element.querySelector("[data-attachment-state]")
+    const progress = element.querySelector("[data-attachment-progress]")
+    const retry = element.querySelector("[data-attachment-retry]")
+    const thumb = element.querySelector("[data-attachment-thumb]")
+    const placeholder = element.querySelector("[data-attachment-placeholder]")
+    const alt = element.querySelector("[data-attachment-alt]")
+
+    element.dataset.state = item.state
+    element.classList.toggle("image-attachment-item--failed", item.state === "failed")
+    progress.value = item.progress || 0
+    progress.hidden = item.state !== "uploading"
+    retry.hidden = item.state !== "failed" || !item.file || item.failure?.retryable !== true || item.removalFailed
+    alt.disabled = this.resuming || item.state !== "ready"
+
+    if (item.state === "uploading") {
+      state.textContent = `Uploading ${item.progress || 0}%`
+    } else if (item.state === "failed") {
+      state.textContent = item.failure?.message || "Upload failed."
+    } else if (item.state === "removing") {
+      state.textContent = "Removing…"
+    } else {
+      state.textContent = "Ready"
+    }
+
+    if (item.previewUrl) {
+      thumb.src = item.previewUrl
+      thumb.hidden = false
+      placeholder.hidden = true
+    } else {
+      thumb.hidden = true
+      placeholder.hidden = false
+    }
+  }
+
+  readyToSubmit() {
+    return !this.resuming && !this.resumeFailed &&
+      Array.from(this.items.values()).every(item => item.state === "ready")
+  }
+
+  refreshSubmitState() {
+    const blocked = this.submitting || this.navigating || !this.readyToSubmit()
+    this.form?.querySelectorAll("input[type=submit], button[type=submit]").forEach(button => {
+      button.disabled = blocked
+    })
+    this.items.forEach(item => {
+      item.altInput.disabled = this.submitting || this.resuming || item.state !== "ready"
+      item.element.querySelector("[data-attachment-remove]").disabled =
+        this.submitting || this.resuming || item.state === "removing"
+      item.element.querySelector("[data-attachment-retry]").disabled = this.submitting
+    })
+  }
+
+  itemLabel(item) {
+    const index = Array.from(this.items.keys()).indexOf(item.clientKey) + 1
+    return `Image ${index}`
+  }
+
+  // The clamped overlay places itself relative to the selected region, so any
+  // height change has to be announced to whoever owns that placement.
+  notifyLayoutChanged() {
+    this.dispatch("changed", { bubbles: true })
+  }
+
+  announce(message) {
+    if (this.hasStatusTarget) this.statusTarget.textContent = message
+  }
+
+  showError(message) {
+    if (!this.hasErrorsTarget || !message) return
+
+    this.errorsTarget.textContent = message
+    this.errorsTarget.hidden = false
+    this.announce(message)
+  }
+
+  clearError() {
+    if (!this.hasErrorsTarget) return
+
+    this.errorsTarget.textContent = ""
+    this.errorsTarget.hidden = true
+  }
+
+  // The server owns this copy: it is handed down from the same constant the
+  // upload endpoint raises with, so the two can never drift.
+  get unsupportedTypeMessage() {
+    return this.unsupportedTypeMessageValue || "That file type cannot be attached."
+  }
+
+  get csrfToken() {
+    return document.querySelector("meta[name='csrf-token']")?.content || ""
+  }
+}
diff --git a/app/javascript/controllers/image_attachment_viewer_controller.js b/app/javascript/controllers/image_attachment_viewer_controller.js
new file mode 100644
index 0000000..58f6667
--- /dev/null
+++ b/app/javascript/controllers/image_attachment_viewer_controller.js
@@ -0,0 +1,89 @@
+import { Controller } from "@hotwired/stimulus"
+
+// One labelled modal dialog per gallery. The native <dialog> element supplies
+// the contained tab sequence, the Escape dismissal, and focus restoration;
+// this controller adds arrow navigation, bounded zoom, and the download and
+// open-original links.
+export default class extends Controller {
+  static targets = ["dialog", "image", "previous", "next", "download", "openOriginal", "trigger"]
+
+  static MIN_ZOOM = 1
+  static MAX_ZOOM = 4
+  static ZOOM_STEP = 0.5
+
+  connect() {
+    this.index = 0
+    this.zoom = 1
+    this.onKeydown = this.onKeydown.bind(this)
+    this.dialogTarget.addEventListener("keydown", this.onKeydown)
+  }
+
+  disconnect() {
+    this.dialogTarget.removeEventListener("keydown", this.onKeydown)
+    if (this.dialogTarget.open) this.dialogTarget.close()
+  }
+
+  open(event) {
+    this.index = Number(event.currentTarget.dataset.index || 0)
+    this.zoom = 1
+    this.render()
+    this.dialogTarget.showModal()
+    this.imageTarget.focus({ preventScroll: true })
+  }
+
+  close() {
+    if (this.dialogTarget.open) this.dialogTarget.close()
+  }
+
+  next() {
+    this.index = (this.index + 1) % this.triggerTargets.length
+    this.zoom = 1
+    this.render()
+  }
+
+  previous() {
+    this.index = (this.index - 1 + this.triggerTargets.length) % this.triggerTargets.length
+    this.zoom = 1
+    this.render()
+  }
+
+  zoomIn() {
+    this.zoom = Math.min(this.constructor.MAX_ZOOM, this.zoom + this.constructor.ZOOM_STEP)
+    this.applyZoom()
+  }
+
+  zoomOut() {
+    this.zoom = Math.max(this.constructor.MIN_ZOOM, this.zoom - this.constructor.ZOOM_STEP)
+    this.applyZoom()
+  }
+
+  onKeydown(event) {
+    if (event.key === "ArrowRight") {
+      event.preventDefault()
+      this.next()
+    } else if (event.key === "ArrowLeft") {
+      event.preventDefault()
+      this.previous()
+    }
+  }
+
+  render() {
+    const trigger = this.triggerTargets[this.index]
+    if (!trigger) return
+
+    this.imageTarget.src = trigger.dataset.originalUrl
+    this.imageTarget.alt = trigger.dataset.alt
+    this.downloadTarget.href = trigger.dataset.downloadUrl
+    this.openOriginalTarget.href = trigger.dataset.originalUrl
+
+    // A one-image gallery has nowhere to navigate.
+    const single = this.triggerTargets.length < 2
+    this.previousTarget.hidden = single
+    this.nextTarget.hidden = single
+    this.applyZoom()
+  }
+
+  applyZoom() {
+    this.imageTarget.style.scale = String(this.zoom)
+  }
+}
diff --git a/app/jobs/image_attachment_draft_cleanup_job.rb b/app/jobs/image_attachment_draft_cleanup_job.rb
new file mode 100644
index 0000000..c5abe5a
--- /dev/null
+++ b/app/jobs/image_attachment_draft_cleanup_job.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+# Reclaims abandoned composer drafts. Candidate scans are bounded, and each
+# batch is locked and rechecked before anything is destroyed so a pass that
+# overlaps a live submission cannot take bytes away from a message.
+class ImageAttachmentDraftCleanupJob < ApplicationJob
+  BATCH_LIMIT = 500
+
+  queue_as :default
+
+  limits_concurrency key: -> { "image-attachment-draft-cleanup" },
+    duration: 30.minutes,
+    on_conflict: :discard
+
+  def perform(limit: BATCH_LIMIT)
+    now = Time.current
+    removed = 0
+
+    candidate_ids(now, limit).each do |id|
+      removed += 1 if reclaim(id, now)
+    rescue StandardError => error
+      Screenote::Monitoring.notify(
+        "Image attachment draft cleanup failed",
+        context: { image_attachment_batch_id: id, error_class: error.class.name }
+      )
+    end
+
+    removed
+  end
+
+  private
+
+  def candidate_ids(now, limit)
+    ImageAttachmentBatch.outstanding.expired(now).order(:id).limit(limit).pluck(:id)
+  end
+
+  def reclaim(id, now)
+    ImageAttachmentBatch.transaction do
+      batch = ImageAttachmentBatch.lock.find_by(id: id)
+      next false unless batch
+      next false unless batch.state_open? && batch.expired?(now)
+
+      # destroy cascades to the attachment rows, whose Active Storage lifecycle
+      # purges the primary blob together with every derivative.
+      batch.image_attachments.ordered.lock.to_a
+      batch.destroy!
+      true
+    end
+  end
+end
diff --git a/app/jobs/image_attachment_orphan_reconciliation_job.rb b/app/jobs/image_attachment_orphan_reconciliation_job.rb
new file mode 100644
index 0000000..f98b525
--- /dev/null
+++ b/app/jobs/image_attachment_orphan_reconciliation_job.rb
@@ -0,0 +1,150 @@
+# frozen_string_literal: true
+
+# Reconciles submitted attachments against what the storage service actually
+# holds, in both directions.
+#
+# Rows whose message no longer resolves — a database cascade, a bulk delete, or
+# a future code path that bypassed the Rails callbacks — are purged, so their
+# primary blobs and derivatives do not stay in the storage service forever.
+# Rows whose delivery variants were never produced are re-enqueued, so a claim
+# whose `perform_later` was lost does not leave a posted gallery on its stable
+# placeholder until the process restarts.
+class ImageAttachmentOrphanReconciliationJob < ApplicationJob
+  BATCH_LIMIT = 500
+  # An upload stages its blob before it attaches it, so a freshly created
+  # unattached blob may simply be mid-request.
+  UNATTACHED_GRACE = 6.hours
+
+  queue_as :default
+
+  limits_concurrency key: -> { "image-attachment-orphan-reconciliation" },
+    duration: 30.minutes,
+    on_conflict: :discard
+
+  def perform(limit: BATCH_LIMIT)
+    purged = purge_orphans(limit)
+    rewarmed = rewarm_variants(limit)
+    reclaimed = reclaim_unattached_blobs(limit)
+
+    if purged.positive? || rewarmed.positive? || reclaimed.positive?
+      Screenote::Monitoring.notify(
+        "Image attachments reconciled",
+        context: { purged: purged, rewarmed: rewarmed, reclaimed: reclaimed }
+      )
+    end
+
+    purged
+  end
+
+  private
+
+  def purge_orphans(limit)
+    purged = 0
+
+    orphan_ids(limit).each do |id|
+      purged += 1 if purge(id)
+    rescue StandardError => error
+      Screenote::Monitoring.notify(
+        "Image attachment orphan reconciliation failed",
+        context: { image_attachment_id: id, error_class: error.class.name }
+      )
+    end
+
+    purged
+  end
+
+  # Re-enqueueing is idempotent and generation aware: the job is keyed on the
+  # attachment together with the exact blob it was asked to warm, and it skips
+  # a row whose variants already exist or whose bytes have since been replaced.
+  # Warming state is read from preloaded variant records, so this never
+  # processes an image itself.
+  def rewarm_variants(limit)
+    enqueued = 0
+
+    unwarmed_candidates(limit).each do |attachment|
+      next if attachment.thumbnails_renderable?
+
+      ImageAttachmentThumbnailJob.perform_later(attachment, attachment.image.blob.id)
+      enqueued += 1
+    rescue StandardError => error
+      Screenote::Monitoring.notify(
+        "Image attachment variant reconciliation failed",
+        context: { image_attachment_id: attachment.id, error_class: error.class.name }
+      )
+    end
+
+    enqueued
+  end
+
+  # A JOIN plus GROUP BY has to build every group before the LIMIT can drop any
+  # of them, so the previous query still touched every submitted attachment ever
+  # posted on each hourly pass. `NOT EXISTS` per digest is a per-row predicate
+  # instead: the planner walks `image_attachments` in ID order, probes the
+  # variant-record index, and stops as soon as `limit` candidates are found.
+  def unwarmed_candidates(limit)
+    ids = ImageAttachment
+      .submitted
+      .state_ready
+      .joins(:image_blob)
+      .where(missing_any_variant)
+      .order(:id)
+      .limit(limit)
+      .pluck(:id)
+
+    ImageAttachment.where(id: ids).includes(ImageAttachment::RENDER_PRELOAD).order(:id)
+  end
+
+  def missing_any_variant
+    thumbnail_variant_digests
+      .map { |digest| ActiveStorage::VariantRecord.where(blob_id: ActiveStorage::Blob.arel_table[:id], variation_digest: digest).arel.exists.not }
+      .reduce(:or)
+  end
+
+  def thumbnail_variant_digests
+    ImageAttachment.attachment_reflections.fetch("image").named_variants
+      .values
+      .map { |variant| ActiveStorage::Variation.wrap(variant.transformations).digest }
+  end
+
+  # Where a provider delete failed, the blob row is deliberately left behind so
+  # the key stays durable. Retrying it is this pass's job. Only blobs this
+  # feature staged are considered — the server generates their names — and only
+  # once they are old enough that no in-flight upload can still be about to
+  # attach them.
+  def reclaim_unattached_blobs(limit)
+    reclaimed = 0
+
+    ActiveStorage::Blob
+      .unattached
+      .where("active_storage_blobs.filename LIKE ?", "image-attachment-%")
+      .where(created_at: ...UNATTACHED_GRACE.ago)
+      .order(:id)
+      .limit(limit)
+      .each { |blob| reclaimed += 1 if ImageAttachments::PurgeBlob.call(blob) }
+
+    reclaimed
+  end
+
+  def orphan_ids(limit)
+    ImageAttachment
+      .submitted
+      .left_outer_joins(:annotation, :annotation_comment)
+      .where(annotations: { id: nil }, annotation_comments: { id: nil })
+      .order(:id)
+      .limit(limit)
+      .pluck(:id)
+  end
+
+  def purge(id)
+    ImageAttachment.transaction do
+      attachment = ImageAttachment.lock.find_by(id: id)
+      next false unless attachment
+      # Re-resolve inside the lock: a row whose parent exists again, or which
+      # became a draft, must never be purged by a stale candidate scan.
+      next false unless attachment.submitted? && attachment.parent.nil?
+
+      attachment.destroy!
+      true
+    end
+  end
+end
diff --git a/app/jobs/image_attachment_thumbnail_job.rb b/app/jobs/image_attachment_thumbnail_job.rb
new file mode 100644
index 0000000..b94f9dc
--- /dev/null
+++ b/app/jobs/image_attachment_thumbnail_job.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+# Warms the delivery variants for a submitted attachment. Nothing enqueues this
+# for a draft, and an authenticated GET never invokes it: an unwarmed variant
+# stays unavailable until a run of this job produces it. Claiming a batch
+# enqueues one of these per attachment; reconciliation re-enqueues the ones
+# whose variants are still missing, which is why every pass re-resolves both
+# ownership and the blob generation it was asked to warm.
+class ImageAttachmentThumbnailJob < ApplicationJob
+  ThumbnailProcessingError = Class.new(StandardError)
+
+  queue_as :default
+
+  retry_on ThumbnailProcessingError, wait: 10.seconds, attempts: 3
+  discard_on ActiveJob::DeserializationError
+
+  limits_concurrency key: ->(attachment, blob_id) {
+    "#{attachment.to_global_id}/blob/#{blob_id}"
+  }, duration: 10.minutes, on_conflict: :discard
+
+  def perform(attachment, blob_id)
+    attachment.reload
+    return :skipped unless warmable?(attachment, blob_id)
+    return :skipped if attachment.thumbnail_variants_warmed?
+
+    ImageAttachment::THUMBNAIL_VARIANT_NAMES.each do |name|
+      attachment.reload
+      return :skipped unless warmable?(attachment, blob_id)
+      next if attachment.thumbnail_variant_ready?(name)
+
+      begin
+        ImageDecoding::Guard.synchronize { attachment.image.variant(name).processed }
+      rescue StandardError => error
+        raise ThumbnailProcessingError, "attachment variant processing failed", cause: error
+      end
+    end
+
+    :processed
+  rescue ActiveRecord::RecordNotFound
+    :skipped
+  end
+
+  private
+
+  # Re-resolves ownership on every pass so a job queued before a removal, a
+  # rollback, or a replacement can never warm bytes that no message owns.
+  def warmable?(attachment, blob_id)
+    attachment.submitted? &&
+      attachment.state_ready? &&
+      attachment.image.attached? &&
+      attachment.image_attachment.blob_id.to_s == blob_id.to_s &&
+      attachment.parent.present?
+  end
+end
diff --git a/app/jobs/send_digest_notifications_job.rb b/app/jobs/send_digest_notifications_job.rb
index 27d806c..a7f7104 100644
--- a/app/jobs/send_digest_notifications_job.rb
+++ b/app/jobs/send_digest_notifications_job.rb
@@ -34,7 +34,15 @@ def unnotified_resolutions
     AnnotationComment
       .where(action: :resolved, notified_at: nil)
       .joins(annotation: { screenshot: { page: :project } })
-      .includes(:user, annotation: [ :user, :annotation_comments, { screenshot: { page: :project } } ])
+      .includes(
+        :user,
+        annotation: [
+          :user,
+          :image_attachments,
+          { annotation_comments: :image_attachments },
+          { screenshot: [ :screenshot_images, { page: :project } ] }
+        ]
+      )
   end
 
   def mark_notified(comments)
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index 8dd8026..3d17f16 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -1,6 +1,10 @@
 # frozen_string_literal: true
 
 class NotificationMailer < ApplicationMailer
+  # The digest call to action is the same workspace deep link the app renders,
+  # so it validates its viewport through the same shared helper.
+  include PageWorkspaceNavigation
+
   def resolution_digest(recipient, comments)
     @recipient = recipient
     @grouped = prepare_grouped_comments(comments)
@@ -31,14 +35,46 @@ def prepare_grouped_comments(comments)
   end
 
   def build_item(resolution_comment)
-    reply = resolution_comment.annotation.annotation_comments
+    annotation = resolution_comment.annotation
+    reply = annotation.annotation_comments
       .select { |c| c.comment? && c.created_at <= resolution_comment.created_at }
       .max_by(&:created_at)
 
     {
-      annotation_text: resolution_comment.annotation.comment,
+      annotation_text: annotation.comment,
+      annotation_attachments: attachment_metadata(annotation.image_attachments),
       reply_text: reply&.body,
-      resolver: resolution_comment.user&.email || "API"
+      reply_attachments: attachment_metadata(reply&.image_attachments),
+      resolver: resolution_comment.user&.email || "API",
+      url: annotation_workspace_url(annotation)
     }
   end
+
+  # Digest mail carries descriptions and counts only. An attachment URL would
+  # outlive the five-minute window it is minted for and would not be authorized
+  # from a mail client anyway, so the call to action is a deep link into the
+  # workspace instead.
+  def attachment_metadata(attachments)
+    Array(attachments&.to_a).map do |attachment|
+      {
+        alt_text: attachment.display_alt_text,
+        media_type: attachment.media_type,
+        width: attachment.width,
+        height: attachment.height,
+        size: attachment.byte_size
+      }
+    end
+  end
+
+  def annotation_workspace_url(annotation)
+    screenshot = annotation.screenshot
+    options = page_workspace_options(screenshot, viewport: annotation.viewport)
+
+    "#{routes.page_url(screenshot.page_id, **options, **Screenote::Deployment.current.url_options)}" \
+      "##{ActionView::RecordIdentifier.dom_id(annotation)}"
+  end
+
+  def routes
+    Rails.application.routes.url_helpers
+  end
 end
diff --git a/app/models/annotation.rb b/app/models/annotation.rb
index c4502d2..cbed12b 100644
--- a/app/models/annotation.rb
+++ b/app/models/annotation.rb
@@ -7,6 +7,14 @@ class Annotation < ApplicationRecord
   belongs_to :resolved_by_user, class_name: "User", optional: true
   belongs_to :resolved_by_api_key, class_name: "ApiKey", optional: true
   has_many :annotation_comments, -> { order(:created_at) }, dependent: :destroy
+  has_many :image_attachments, -> { ordered }, dependent: :destroy, inverse_of: :annotation
+  # The claim receipt that made this annotation exists only to answer a
+  # replayed submission. It has no meaning once the message is gone.
+  has_many :claimed_image_attachment_batches,
+    class_name: "ImageAttachmentBatch",
+    foreign_key: :claimed_annotation_id,
+    inverse_of: :claimed_annotation,
+    dependent: :destroy
 
   enum :status, { open: 0, resolved: 1 }, default: :open
   enum :viewport, { desktop: 0, tablet: 1, mobile: 2 }, default: :desktop, prefix: :viewport
diff --git a/app/models/annotation_comment.rb b/app/models/annotation_comment.rb
index ac0ae26..f23196a 100644
--- a/app/models/annotation_comment.rb
+++ b/app/models/annotation_comment.rb
@@ -4,6 +4,12 @@ class AnnotationComment < ApplicationRecord
   belongs_to :annotation
   belongs_to :user, optional: true
   belongs_to :api_key, optional: true
+  has_many :image_attachments, -> { ordered }, dependent: :destroy, inverse_of: :annotation_comment
+  has_many :claimed_image_attachment_batches,
+    class_name: "ImageAttachmentBatch",
+    foreign_key: :claimed_annotation_comment_id,
+    inverse_of: :claimed_annotation_comment,
+    dependent: :destroy
 
   enum :action, { comment: 0, resolved: 1, reopened: 2 }, default: :comment
 
diff --git a/app/models/image_attachment.rb b/app/models/image_attachment.rb
new file mode 100644
index 0000000..1a8f9c9
--- /dev/null
+++ b/app/models/image_attachment.rb
@@ -0,0 +1,199 @@
+# frozen_string_literal: true
+
+# One uploaded image belonging to a native browser message. While a row is a
+# draft it belongs to an ImageAttachmentBatch and has no message parent. After
+# the composer posts, it belongs to exactly one Annotation or AnnotationComment
+# and is immutable.
+class ImageAttachment < ApplicationRecord
+  ALLOWED_CONTENT_TYPES = %w[image/png image/jpeg image/webp].freeze
+  ALLOWED_EXTENSIONS = {
+    "image/png" => %w[png].freeze,
+    "image/jpeg" => %w[jpg jpeg].freeze,
+    "image/webp" => %w[webp].freeze
+  }.freeze
+  MAX_FILE_SIZE = 20.megabytes
+  MAX_TOTAL_BYTES = 50.megabytes
+  MAX_FILES = 5
+  MAX_DIMENSION = 32_768
+  MAX_PIXELS = 50_000_000
+  MAX_ALT_TEXT = 1000
+  ALT_TEXT_FALLBACK = "Attached image"
+  REMOVAL_TOMBSTONE = "attachment_removed"
+  THUMBNAIL_VARIANT_NAMES = %i[attachment_thumb_1x attachment_thumb_2x].freeze
+  MEDIA_VARIANT_NAMES = (THUMBNAIL_VARIANT_NAMES + %i[original download]).freeze
+  # Rendering must never query for or process a variant. Preload this and read
+  # the loaded records; anything else is treated as not yet warmed.
+  RENDER_PRELOAD = { image_attachment: { blob: :variant_records } }.freeze
+  MEDIA_TOKEN_PURPOSE = :image_attachment_media
+  MEDIA_TOKEN_EXPIRY = 5.minutes
+  # Every attachment read is authorized live. The purpose token only bounds how
+  # long a minted URL stays usable; it never carries authority on its own.
+  generates_token_for MEDIA_TOKEN_PURPOSE, expires_in: MEDIA_TOKEN_EXPIRY
+
+  belongs_to :image_attachment_batch, optional: true
+  belongs_to :annotation, optional: true
+  belongs_to :annotation_comment, optional: true
+  belongs_to :user
+  belongs_to :project
+
+  # Active Storage's own `:purge_later` runs after the destroy has already
+  # committed and has no fallback: if the queue refuses the job, this row is
+  # gone, the blob is unattached, and nothing row-based can rediscover it. The
+  # blob is therefore released here, with an inline purge behind the enqueue.
+  has_one_attached :image, dependent: false do |attachable|
+    attachable.variant :attachment_thumb_1x, resize_to_limit: [ 480, 480 ]
+    attachable.variant :attachment_thumb_2x, resize_to_limit: [ 960, 960 ]
+  end
+
+  enum :state, { uploading: 0, ready: 1, failed: 2 }, default: :uploading, prefix: :state
+
+  normalizes :alt_text, with: ->(value) { value.to_s.strip.presence }
+
+  validates :client_key, presence: true, length: { maximum: 64 }
+  validates :alt_text, length: { maximum: MAX_ALT_TEXT }, allow_nil: true
+  validates :media_type, inclusion: { in: ALLOWED_CONTENT_TYPES }, allow_nil: true
+  validates :width, :height, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
+  validates :byte_size,
+    numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: MAX_FILE_SIZE },
+    allow_nil: true
+  validate :exactly_one_owner
+  validate :submitted_rows_are_complete
+  validate :identity_is_immutable
+  validate :submitted_metadata_is_immutable
+
+  before_destroy :remember_image_blob
+  after_destroy_commit :release_image_blob
+
+  scope :submitted, -> { where(image_attachment_batch_id: nil) }
+  scope :drafts, -> { where.not(image_attachment_batch_id: nil) }
+  scope :active_drafts, -> { where("failure_code IS NULL OR failure_code <> ?", REMOVAL_TOMBSTONE) }
+  scope :ordered, -> { order(:id) }
+  scope :with_media, -> { includes(image_attachment: :blob) }
+
+  def draft?
+    image_attachment_batch_id.present?
+  end
+
+  def submitted?
+    !draft?
+  end
+
+  # A removed client key stays reserved until the batch is claimed, discarded,
+  # or expires. That small tombstone is what makes a DELETE win even when the
+  # upload request has reached the server but has not reserved its row yet.
+  def removal_tombstone?
+    draft? && failure_code == REMOVAL_TOMBSTONE
+  end
+
+  def parent
+    annotation || annotation_comment
+  end
+
+  def display_alt_text
+    alt_text.presence || ALT_TEXT_FALLBACK
+  end
+
+  # Rendering never triggers image processing. A variant that the post-claim
+  # warming job has not produced yet is simply not available for delivery.
+  def thumbnail_variant_ready?(name)
+    return false unless image.attached?
+    return false unless THUMBNAIL_VARIANT_NAMES.include?(name.to_sym)
+
+    digest = image.variant(name.to_sym).variation.digest
+    image.blob.variant_records.exists?(variation_digest: digest)
+  end
+
+  def thumbnail_variants_warmed?
+    return false unless image.attached?
+
+    THUMBNAIL_VARIANT_NAMES.all? { |name| thumbnail_variant_ready?(name) }
+  end
+
+  # Render-time check that only consults preloaded variant records. An
+  # unwarmed gallery item shows a stable placeholder rather than a URL whose
+  # fetch would process the image inside a request.
+  def thumbnails_renderable?
+    return false unless image.attached?
+
+    records = image.blob.association(:variant_records)
+    return false unless records.loaded?
+
+    THUMBNAIL_VARIANT_NAMES.all? do |name|
+      digest = image.variant(name).variation.digest
+      records.target.any? { |record| record.variation_digest == digest }
+    end
+  end
+
+  def as_contract_json(url: nil, url_expires_at: nil)
+    {
+      id: id,
+      alt_text: alt_text,
+      media_type: media_type,
+      width: width,
+      height: height,
+      size: byte_size,
+      url: url,
+      url_expires_at: url_expires_at&.iso8601
+    }
+  end
+
+  private
+
+  # Read while the row still exists; the join record is gone by the time the
+  # destroy commits.
+  def remember_image_blob
+    @discarded_image_blob = image.attached? ? image.blob : nil
+  end
+
+  # Purging is background work, but an enqueue that fails must not be the end of
+  # it: the bytes are already unreachable through this row, so the fallback runs
+  # the purge inline, and a purge whose provider delete fails leaves the blob
+  # record behind for the bounded reconciliation pass to retry.
+  def release_image_blob
+    blob = @discarded_image_blob
+    @discarded_image_blob = nil
+    return unless blob
+
+    begin
+      blob.purge_later
+    rescue StandardError => error
+      Screenote::Monitoring.notify(error, context: { image_attachment_id: id, active_storage_blob_id: blob.id })
+      ImageAttachments::PurgeBlob.call(blob)
+    end
+  end
+
+  def exactly_one_owner
+    owners = [ image_attachment_batch_id, annotation_id, annotation_comment_id ].compact
+    return if owners.one?
+
+    errors.add(:base, "must belong to exactly one draft batch or message")
+  end
+
+  def submitted_rows_are_complete
+    return if draft?
+
+    errors.add(:state, "submitted attachment must be ready") unless state_ready?
+    return unless state_ready?
+
+    errors.add(:base, "submitted attachment is missing image metadata") if media_type.blank? ||
+      width.blank? || height.blank? || byte_size.blank?
+  end
+
+  def identity_is_immutable
+    return if new_record?
+
+    errors.add(:user_id, "cannot change") if will_save_change_to_user_id?
+    errors.add(:project_id, "cannot change") if will_save_change_to_project_id?
+  end
+
+  # Once a message owns the row, the stored bytes and their description are the
+  # historical record of what was posted.
+  def submitted_metadata_is_immutable
+    return if new_record?
+    return if image_attachment_batch_id_in_database.present?
+
+    %i[alt_text media_type width height byte_size state annotation_id annotation_comment_id].each do |attribute|
+      errors.add(attribute, "cannot change after submission") if will_save_change_to_attribute?(attribute)
+    end
+  end
+end
diff --git a/app/models/image_attachment_batch.rb b/app/models/image_attachment_batch.rb
new file mode 100644
index 0000000..511d2a8
--- /dev/null
+++ b/app/models/image_attachment_batch.rb
@@ -0,0 +1,154 @@
+# frozen_string_literal: true
+
+# A server-owned draft container for one mounted browser composer. The public
+# ID is the only identifier the browser ever sees: it addresses the draft and
+# doubles as the one-use submission idempotency key when the composer posts.
+class ImageAttachmentBatch < ApplicationRecord
+  EXPIRY = 24.hours
+  MAX_OPEN_PER_USER = 6
+  # Scheduler-independent backstop. Cleanup normally reclaims drafts long
+  # before this, but a stopped supervisor must not let one account park an
+  # unbounded number of bytes in the storage service.
+  MAX_OUTSTANDING_DRAFT_BYTES = 250.megabytes
+  PUBLIC_ID_FORMAT = /\A[A-Za-z0-9_-]{22,43}\z/
+  # The composer's own idempotency handle. It never identifies the batch to
+  # anybody else, so it only has to be well formed and scoped to the account.
+  CLIENT_KEY_FORMAT = /\A[A-Za-z0-9_-]{8,64}\z/
+
+  belongs_to :user
+  belongs_to :project
+  belongs_to :claimed_annotation, class_name: "Annotation", optional: true
+  belongs_to :claimed_annotation_comment, class_name: "AnnotationComment", optional: true
+  has_many :image_attachments, dependent: :destroy
+
+  enum :state, { open: 0, claimed: 1 }, default: :open, prefix: :state
+
+  before_validation :assign_public_id, on: :create
+  before_validation :assign_activity_window, on: :create
+
+  validates :public_id, presence: true, uniqueness: true, format: { with: PUBLIC_ID_FORMAT }
+  validates :client_key, format: { with: CLIENT_KEY_FORMAT }, allow_nil: true
+  validate :claim_target_matches_state
+
+  scope :expired, ->(now = Time.current) { where(expires_at: ...now) }
+  scope :outstanding, -> { where(state: :open) }
+
+  def self.generate_public_id
+    SecureRandom.urlsafe_base64(24)
+  end
+
+  # Opening a draft is the one place the per-account ceilings are applied, so
+  # they hold no matter which composer asked for the batch. The account row is
+  # locked for the check and the insert together: two composers mounting at the
+  # same moment must not both read room under the ceiling and each add a batch.
+  #
+  # A mounted composer supplies its own key so that a retry after a lost
+  # response resumes the batch it already opened instead of spending another of
+  # the six an account is allowed. An expired batch still holding the key is
+  # reclaimed here rather than handed back: the composer asked for something it
+  # can upload to.
+  def self.open_for!(user:, project:, client_key: nil)
+    key = normalize_client_key!(client_key)
+
+    transaction do
+      user.lock!
+      existing = key && resume_candidate(user, key)
+      next existing if existing&.usable? && existing.project_id == project.id
+
+      if existing
+        # Same global lock order every other reclaim uses: the batch, then its
+        # attachment rows by ID.
+        existing.image_attachments.ordered.lock.to_a
+        existing.destroy!
+      end
+      enforce_outstanding_caps!(user)
+      create!(user: user, project: project, client_key: key)
+    end
+  end
+
+  def self.resume_candidate(user, key)
+    outstanding_for(user.id).lock.find_by(client_key: key)
+  end
+
+  def self.normalize_client_key!(client_key)
+    key = client_key.to_s.strip.presence
+    return nil unless key
+    raise ImageAttachments::Error.new(code: "invalid_client_key") unless key.match?(CLIENT_KEY_FORMAT)
+
+    key
+  end
+
+  # Expired-but-unreclaimed batches still hold their rows and their blobs, so
+  # they keep counting: these caps have to survive a stopped cleanup supervisor.
+  def self.outstanding_for(user_id)
+    outstanding.where(user_id: user_id)
+  end
+
+  def self.outstanding_draft_bytes(user_id, excluding: nil)
+    # A removal tombstone keeps its byte_size until storage confirms purge, so
+    # it remains part of the scheduler-independent account ceiling during an
+    # outage even though claim and resume already treat it as removed.
+    scope = ImageAttachment.where(image_attachment_batch: outstanding_for(user_id))
+    scope = scope.where.not(id: excluding) if excluding
+    scope.sum(:byte_size)
+  end
+
+  def self.enforce_outstanding_caps!(user)
+    if outstanding_for(user.id).count >= MAX_OPEN_PER_USER
+      raise ImageAttachments::Error.new(code: "too_many_open_batches")
+    end
+    return if outstanding_draft_bytes(user.id) < MAX_OUTSTANDING_DRAFT_BYTES
+
+    raise ImageAttachments::Error.new(code: "draft_storage_exhausted")
+  end
+
+  def to_param
+    public_id
+  end
+
+  def expired?(now = Time.current)
+    expires_at.nil? || expires_at <= now
+  end
+
+  # A batch accepts uploads and can still be claimed only while it is open and
+  # inside its activity window.
+  def usable?(now = Time.current)
+    state_open? && !expired?(now)
+  end
+
+  def claimed_parent
+    claimed_annotation || claimed_annotation_comment
+  end
+
+  def touch_activity!(now = Time.current)
+    update_columns(last_activity_at: now, expires_at: now + EXPIRY, updated_at: now)
+  end
+
+  def total_byte_size(excluding: nil)
+    scope = image_attachments.active_drafts
+    scope = scope.where.not(id: excluding) if excluding
+    scope.sum(:byte_size)
+  end
+
+  private
+
+  def assign_public_id
+    self.public_id = self.class.generate_public_id if public_id.blank?
+  end
+
+  def assign_activity_window
+    now = Time.current
+    self.last_activity_at ||= now
+    self.expires_at ||= last_activity_at + EXPIRY
+  end
+
+  def claim_target_matches_state
+    targets = [ claimed_annotation_id, claimed_annotation_comment_id ].compact
+
+    if state_open?
+      errors.add(:base, "open batch cannot reference a claimed parent") if targets.any?
+    elsif !targets.one?
+      errors.add(:base, "claimed batch must reference exactly one parent")
+    end
+  end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 734b5b3..c5b915b 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -6,6 +6,7 @@ class Project < ApplicationRecord
   has_many :members, through: :project_memberships, source: :user
   has_many :project_invitations, dependent: :destroy
   has_many :pages, dependent: :destroy
+  has_many :image_attachment_batches, dependent: :destroy
   has_many :screenshots, through: :pages
   has_many :snapshots, dependent: :destroy
   has_many :api_keys, dependent: :destroy
diff --git a/app/models/user.rb b/app/models/user.rb
index 219157c..b52f694 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -26,6 +26,11 @@ def as_json(*)
   has_many :project_memberships, dependent: :destroy
   has_many :projects, through: :project_memberships
   has_many :annotations, dependent: :destroy
+  # Drafts are disposable, but a submitted attachment is part of another
+  # person's message history. Deleting the account must fail loudly rather
+  # than orphan the uploader identity those rows record.
+  has_many :image_attachment_batches, dependent: :destroy
+  has_many :image_attachments, dependent: :restrict_with_error
   has_one :subscription, dependent: :destroy
   has_many :authentication_tokens, dependent: :destroy
   has_many :installation_audit_events_as_actor,
@@ -39,6 +44,12 @@ def as_json(*)
     inverse_of: :target_user,
     dependent: :restrict_with_exception
 
+  # Attachment ingest locks the account row before the batch row, so account
+  # deletion has to take the same order: `dependent: :destroy` above would
+  # otherwise lock batches first and deadlock against a concurrent upload.
+  # Prepending puts this ahead of every dependent-destroy callback.
+  before_destroy :lock_account_before_dependent_attachments, prepend: true
+
   enum :access_status, { active: 0, suspended: 1 }, validate: true
 
   validates :oauth_provider, :oauth_uid, presence: true, if: :oauth_identity_present?
@@ -168,6 +179,10 @@ def oauth_value(hash, key)
 
   private
 
+  def lock_account_before_dependent_attachments
+    self.class.lock.find(id)
+  end
+
   def oauth_identity_present?
     oauth_provider.present? || oauth_uid.present?
   end
diff --git a/app/serializers/api/v1/contract_serializer.rb b/app/serializers/api/v1/contract_serializer.rb
index 77960a7..67c3710 100644
--- a/app/serializers/api/v1/contract_serializer.rb
+++ b/app/serializers/api/v1/contract_serializer.rb
@@ -100,20 +100,28 @@ def annotation(annotation)
           annotation.as_api_json
         end
 
-        def annotation_detail(annotation, cropped_base64:)
+        # Detail reads are the only place attachment metadata appears, and
+        # `attachments` is always present — an empty array where a message has
+        # none. Existing keys keep their names, nesting, and order so shipped
+        # MCP and CLI consumers are unaffected.
+        def annotation_detail(annotation, cropped_base64:, url_options: {})
           comments = annotation.annotation_comments.sort_by(&:created_at).map do |comment|
-            annotation_comment(comment, annotation: annotation, include_annotation_id: false)
+            annotation_comment(
+              comment, annotation: annotation, include_annotation_id: false, url_options: url_options
+            )
           end
 
           annotation(annotation).merge(
             screenshot_status: annotation.screenshot.status,
             cropped_image_base64: cropped_base64,
             mime_type: "image/png",
+            attachments: image_attachments(annotation, url_options: url_options),
             comments: comments
           )
         end
 
-        def annotation_comment(comment, annotation: comment.annotation, include_annotation_id: true)
+        def annotation_comment(comment, annotation: comment.annotation, include_annotation_id: true,
+          url_options: nil)
           payload = {
             id: comment.id,
             action: comment.action,
@@ -122,11 +130,37 @@ def annotation_comment(comment, annotation: comment.annotation, include_annotati
             created_at: comment.created_at.iso8601
           }
           payload[:annotation_id] = annotation.id if include_annotation_id
+          # Every comment carries the key. Write endpoints serialize without a
+          # URL context, and a comment they just created can never have
+          # attachments, so they emit the canonical empty array rather than
+          # omitting it and giving the same shape two forms.
+          payload[:attachments] = url_options ? image_attachments(comment, url_options: url_options) : []
           payload
         end
 
+        # URLs are minted at serialization time and never stored. Each one
+        # carries a five-minute purpose token that must still be presented
+        # alongside a bearer principal with live access to the project.
+        def image_attachments(parent, url_options: {})
+          expires_at = ImageAttachment::MEDIA_TOKEN_EXPIRY.from_now
+
+          parent.image_attachments.map do |attachment|
+            attachment.as_contract_json(
+              url: image_attachment_media_url(attachment, url_options),
+              url_expires_at: expires_at
+            )
+          end
+        end
+
         private
 
+        def image_attachment_media_url(attachment, url_options)
+          routes.api_image_attachment_media_url(
+            attachment,
+            (url_options || {}).merge(token: attachment.generate_token_for(ImageAttachment::MEDIA_TOKEN_PURPOSE))
+          )
+        end
+
         def screenshot_image(image)
           return nil unless image
 
diff --git a/app/services/api/v1/project_scope.rb b/app/services/api/v1/project_scope.rb
index 5185529..7ebf987 100644
--- a/app/services/api/v1/project_scope.rb
+++ b/app/services/api/v1/project_scope.rb
@@ -25,11 +25,22 @@ def self.screenshots(project)
         project.screenshots.includes(:page, :annotations, :screenshot_images).order(created_at: :desc)
       end
 
+      # List reads never serialize attachments, so they stay metadata-light.
       def self.annotations(project)
         Annotation.joins(screenshot: { page: :project })
           .where(projects: { id: project.id })
           .includes(:user, :api_key, :screenshot, annotation_comments: [ :user, :api_key ])
       end
+
+      # Detail reads serialize an attachment row per annotation and per
+      # comment, so they preload those rows. The contract payload is plain
+      # columns plus a signed record token, so no blob is ever touched.
+      def self.annotation_details(project)
+        annotations(project).includes(
+          :image_attachments,
+          annotation_comments: :image_attachments
+        )
+      end
     end
   end
 end
diff --git a/app/services/image_attachments/claim_batch.rb b/app/services/image_attachments/claim_batch.rb
new file mode 100644
index 0000000..397d90e
--- /dev/null
+++ b/app/services/image_attachments/claim_batch.rb
@@ -0,0 +1,133 @@
+# frozen_string_literal: true
+
+module ImageAttachments
+  # Binds a ready draft batch to a newly created message in one transaction.
+  #
+  # Lock order is fixed everywhere attachments are mutated: the batch first,
+  # then its attachment rows ordered by ID. The caller's block creates the
+  # parent inside that transaction, so an invalid message rolls the whole claim
+  # back and leaves the ready drafts untouched.
+  class ClaimBatch
+    Result = Data.define(:parent, :attachments, :replayed) do
+      def replayed?
+        replayed
+      end
+    end
+
+    class << self
+      def call(**kwargs, &block)
+        new(**kwargs).call(&block)
+      end
+    end
+
+    def initialize(batch:, user:, project:, parent_type:, parent_matcher: nil)
+      @batch = batch
+      @user = user
+      @project = project
+      @parent_type = parent_type
+      @parent_matcher = parent_matcher
+    end
+
+    def call(&parent_builder)
+      raise ArgumentError, "a parent builder is required" unless parent_builder
+      return Result.new(parent: parent_builder.call, attachments: [], replayed: false) if batch.nil?
+
+      result = claim(&parent_builder)
+      prewarm_variants(result) unless result.replayed?
+      result
+    end
+
+    private
+
+    attr_reader :batch, :user, :project, :parent_type, :parent_matcher
+
+    def claim(&parent_builder)
+      ImageAttachment.transaction do
+        batch.lock!
+
+        next replay if batch.state_claimed?
+
+        validate_ownership!
+        rows = locked_attachments
+        removed, attachments = rows.partition(&:removal_tombstone?)
+        removed.each(&:destroy!)
+        validate_attachments!(attachments)
+
+        parent = parent_builder.call
+        raise ArgumentError, "#{parent.class} cannot own image attachments" unless parent.is_a?(parent_type)
+
+        bind!(parent, attachments)
+        Result.new(parent: parent, attachments: attachments, replayed: false)
+      end
+    end
+
+    # A batch is claimed by exactly one composer. The parent class alone does
+    # not identify that composer: two reply disclosures on different threads,
+    # and the reply and reopen disclosures on one thread, all produce an
+    # AnnotationComment. Replay therefore also asks the caller whether this
+    # parent is the one its own composer created, so a public ID replayed from
+    # anywhere else is an ordinary rejection rather than somebody else's
+    # message handed back as if it had just been posted.
+    def replay
+      parent = batch.claimed_parent
+      invalid!("batch_not_owned") unless parent.is_a?(parent_type)
+      invalid!("batch_not_owned") if parent_matcher && !parent_matcher.call(parent)
+
+      Result.new(parent: parent, attachments: parent.image_attachments.ordered.to_a, replayed: true)
+    end
+
+    def locked_attachments
+      batch.image_attachments.ordered.lock.to_a
+    end
+
+    def validate_ownership!
+      invalid!("batch_unusable") unless batch.usable?
+      return if batch.user_id == user.id && batch.project_id == project.id
+
+      invalid!("batch_not_owned")
+    end
+
+    def validate_attachments!(attachments)
+      invalid!("attachments_not_ready") if attachments.any? { |attachment| !attachment.state_ready? }
+      invalid!("too_many_files") if attachments.size > ImageAttachment::MAX_FILES
+      return if attachments.sum { |attachment| attachment.byte_size.to_i } <= ImageAttachment::MAX_TOTAL_BYTES
+
+      invalid!("batch_too_large")
+    end
+
+    def bind!(parent, attachments)
+      column = parent_column(parent)
+      now = Time.current
+
+      attachments.each do |attachment|
+        attachment.update_columns(
+          column => parent.id,
+          :image_attachment_batch_id => nil,
+          :updated_at => now
+        )
+      end
+
+      batch.update!(state: :claimed, "claimed_#{column}" => parent.id)
+    end
+
+    def parent_column(parent)
+      case parent
+      when Annotation then :annotation_id
+      when AnnotationComment then :annotation_comment_id
+      else raise ArgumentError, "#{parent.class} cannot own image attachments"
+      end
+    end
+
+    # Variants are produced only for posted messages: drafts never warm, and no
+    # authenticated GET is allowed to trigger decoding.
+    def prewarm_variants(result)
+      result.attachments.each do |attachment|
+        ImageAttachmentThumbnailJob.perform_later(attachment, attachment.image.blob.id)
+      end
+    end
+
+    def invalid!(code)
+      raise Error.new(code: code)
+    end
+  end
+end
diff --git a/app/services/image_attachments/discard_batch.rb b/app/services/image_attachments/discard_batch.rb
new file mode 100644
index 0000000..6ee4470
--- /dev/null
+++ b/app/services/image_attachments/discard_batch.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module ImageAttachments
+  # Throws away an unclaimed draft batch and its attachments. Only an explicit
+  # composer cancel calls this: ordinary disconnect and navigation still leave a
+  # batch for its 24 hour recovery window.
+  #
+  # Uses the global lock order — the batch first, then its attachment rows —
+  # and rechecks the state inside the lock, so a cancel that races a successful
+  # claim resolves as "already claimed" and can never take attachments away
+  # from a posted message. Discarding twice is not an error.
+  class DiscardBatch
+    Result = Data.define(:discarded) do
+      def discarded?
+        discarded
+      end
+    end
+
+    class << self
+      def call(batch:)
+        new(batch: batch).call
+      end
+    end
+
+    def initialize(batch:)
+      @batch = batch
+    end
+
+    def call
+      discarded = ImageAttachmentBatch.transaction do
+        batch.lock!
+        next false unless batch.state_open?
+
+        # destroy cascades to the attachment rows, whose Active Storage
+        # lifecycle purges the primary blob together with every derivative.
+        batch.image_attachments.ordered.lock.to_a
+        batch.destroy!
+        true
+      end
+
+      Result.new(discarded: discarded)
+    rescue ActiveRecord::RecordNotFound
+      # Another cancel already reclaimed it.
+      Result.new(discarded: false)
+    end
+
+    private
+
+    attr_reader :batch
+  end
+end
diff --git a/app/services/image_attachments/draft_presenter.rb b/app/services/image_attachments/draft_presenter.rb
new file mode 100644
index 0000000..64058d8
--- /dev/null
+++ b/app/services/image_attachments/draft_presenter.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+module ImageAttachments
+  # The only attachment shape the browser ever sees. It carries stable IDs,
+  # state, immutable metadata, and a protected preview path — never a storage
+  # key, a provider URL, or the uploader's original filename.
+  class DraftPresenter
+    class << self
+      def batch(batch)
+        {
+          batch_id: batch.public_id,
+          project_id: batch.project_id,
+          state: batch.state,
+          expires_at: batch.expires_at.iso8601,
+          limits: {
+            max_files: ImageAttachment::MAX_FILES,
+            max_file_size: ImageAttachment::MAX_FILE_SIZE,
+            max_total_size: ImageAttachment::MAX_TOTAL_BYTES,
+            accepted_media_types: ImageAttachment::ALLOWED_CONTENT_TYPES
+          },
+          attachments: batch.image_attachments.active_drafts.ordered.map { |item| attachment(item) }
+        }
+      end
+
+      def attachment(attachment)
+        {
+          id: attachment.id,
+          client_key: attachment.client_key,
+          state: attachment.state,
+          alt_text: attachment.alt_text,
+          media_type: attachment.media_type,
+          width: attachment.width,
+          height: attachment.height,
+          size: attachment.byte_size,
+          preview_url: preview_url(attachment),
+          failure: failure(attachment)
+        }
+      end
+
+      private
+
+      def preview_url(attachment)
+        return nil unless attachment.state_ready? && attachment.image.attached?
+
+        routes.image_attachment_media_path(attachment, :original)
+      end
+
+      def failure(attachment)
+        return nil unless attachment.state_failed?
+
+        code = attachment.failure_code.presence || "upload_failed"
+        # The copy and the retry verdict both come from the same place the
+        # raise used, so a replayed failure reads exactly like the response the
+        # composer already saw and never offers Retry for bytes the server will
+        # keep rejecting.
+        { code: code, message: Error.message_for(code), retryable: Error::RETRYABLE_CODES.include?(code) }
+      end
+
+      def routes
+        Rails.application.routes.url_helpers
+      end
+    end
+  end
+end
diff --git a/app/services/image_attachments/error.rb b/app/services/image_attachments/error.rb
new file mode 100644
index 0000000..8378da4
--- /dev/null
+++ b/app/services/image_attachments/error.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module ImageAttachments
+  # Machine code plus actionable text. Controllers surface both; the code is
+  # what the composer branches on, and `retryable` tells it whether the same
+  # bytes are worth sending again.
+  #
+  # The copy for every code lives here so a raised failure and the same failure
+  # replayed from a stored row can never drift apart, and so every limit in the
+  # text is derived from the constant the server actually enforces.
+  class Error < StandardError
+    RETRYABLE_CODES = %w[decoder_busy storage_unavailable upload_failed].freeze
+
+    # Byte-sniffing rejects exactly the set the declared type check rejects, so
+    # both report it with the same words.
+    UNSUPPORTED_MEDIA_TYPE = "Attachments must be PNG, JPEG, or WebP images."
+
+    MESSAGES = {
+      "batch_unusable" => "This upload session expired. Reload the page and try again.",
+      "batch_not_owned" => "These attachments belong to a different composer.",
+      "missing_file" => "No file was uploaded.",
+      "missing_client_key" => "Upload is missing its client key",
+      "invalid_client_key" => "Upload client key is too long",
+      "invalid_content_type" => UNSUPPORTED_MEDIA_TYPE,
+      "invalid_image" => "That image could not be read.",
+      "content_type_mismatch" => "The file contents do not match its declared type.",
+      "extension_mismatch" => "The file contents do not match its extension.",
+      "empty_file" => "The upload was empty.",
+      "file_too_large" => "Each image must be #{ImageAttachment::MAX_FILE_SIZE / 1.megabyte}MB or smaller.",
+      "batch_too_large" =>
+        "Attachments for one message can total at most #{ImageAttachment::MAX_TOTAL_BYTES / 1.megabyte}MB.",
+      "too_many_files" => "You can attach up to #{ImageAttachment::MAX_FILES} images.",
+      "image_dimensions_too_large" => "Image dimensions exceed #{ImageAttachment::MAX_DIMENSION}px.",
+      "image_pixels_too_large" => "Image pixel count exceeds #{ImageAttachment::MAX_PIXELS}.",
+      "attachments_not_ready" => "Wait for every image to finish uploading.",
+      "attachment_removed" => "That image was removed.",
+      "alt_text_too_long" => "A description can be at most #{ImageAttachment::MAX_ALT_TEXT} characters.",
+      "too_many_open_batches" => "You have too many unfinished uploads. Finish or discard one first.",
+      "draft_storage_exhausted" => "You have too many unfinished uploads. Finish or discard one first.",
+      "decoder_busy" => "The image processor is busy. Retry this upload.",
+      "upload_failed" => "The upload did not finish. Retry it."
+    }.freeze
+
+    attr_reader :code, :status
+
+    def self.message_for(code)
+      MESSAGES.fetch(code.to_s) { MESSAGES.fetch("upload_failed") }
+    end
+
+    def initialize(message = nil, code:, status: :unprocessable_entity)
+      @code = code.to_s
+      @status = status
+      super(message.presence || self.class.message_for(@code))
+    end
+
+    def retryable?
+      RETRYABLE_CODES.include?(code)
+    end
+  end
+end
diff --git a/app/services/image_attachments/ingest.rb b/app/services/image_attachments/ingest.rb
new file mode 100644
index 0000000..9e9e36b
--- /dev/null
+++ b/app/services/image_attachments/ingest.rb
@@ -0,0 +1,441 @@
+# frozen_string_literal: true
+
+require "tempfile"
+require "vips"
+
+module ImageAttachments
+  # Streams one composer upload into a draft batch.
+  #
+  # The uploading row is persisted before any byte is read so that claim,
+  # cleanup, and the composer can observe every in-flight or failed item. The
+  # expensive work — streaming, byte-derived type detection, and a full decode
+  # through the global two-slot guard — happens with no row lock held. Only the
+  # aggregate recheck and the state transition run inside the batch lock.
+  class Ingest
+    CHUNK_SIZE = 64.kilobytes
+    PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b.freeze
+    JPEG_SIGNATURE = "\xFF\xD8".b.freeze
+    JPEG_START_OF_SCAN = 0xDA
+    JPEG_END_OF_IMAGE = 0xD9
+
+    Result = Data.define(:attachment)
+
+    class << self
+      def call(**kwargs)
+        new(**kwargs).call
+      end
+    end
+
+    def initialize(batch:, io:, client_key:, declared_content_type: nil, declared_length: nil, filename: nil)
+      @batch = batch
+      @io = io
+      @client_key = client_key.to_s
+      @declared_content_type = declared_content_type.to_s.downcase.presence
+      @declared_length = declared_length
+      @filename = filename.to_s.presence
+    end
+
+    def call
+      validate_request!
+      attachment = reserve_slot!
+      # A retry that lost only its response finds its slot already finished.
+      # Replaying it must not reopen the row or replace the stored blob.
+      return Result.new(attachment: attachment) if attachment.state_ready?
+
+      begin
+        Tempfile.create([ "screenote-attachment-#{Process.pid}-", ".image" ], Rails.root.join("tmp")) do |tempfile|
+          tempfile.binmode
+          byte_size = stream_to!(tempfile)
+          media_type = detect_media_type!(tempfile)
+          validate_declared_identity!(media_type)
+          validate_complete_stream!(tempfile, media_type)
+          width, height = decode!(tempfile)
+          attachment = commit!(attachment, tempfile, media_type:, byte_size:, width:, height:)
+        end
+
+        Result.new(attachment: attachment)
+      rescue Error => error
+        record_failure(attachment, error)
+        raise
+      rescue StandardError => error
+        # Storage and IO failures still answer the composer with the shared
+        # machine code and actionable text; the cause goes to monitoring.
+        Screenote::Monitoring.notify(error, context: { image_attachment_batch_id: batch.id })
+        failure = Error.new(code: "upload_failed", status: :internal_server_error)
+        record_failure(attachment, failure)
+        raise failure
+      end
+    end
+
+    private
+
+    attr_reader :batch, :io, :client_key, :declared_content_type, :declared_length, :filename
+
+    def validate_request!
+      invalid!("batch_unusable") unless batch.usable?
+      invalid!("missing_client_key") if client_key.blank?
+      invalid!("invalid_client_key") if client_key.length > 64
+
+      if declared_content_type.present? && !declared_content_type.in?(ImageAttachment::ALLOWED_CONTENT_TYPES)
+        invalid!("invalid_content_type")
+      end
+      return if declared_length.nil? || declared_length <= ImageAttachment::MAX_FILE_SIZE
+
+      invalid!("file_too_large")
+    end
+
+    # Reserving the slot up front means a client that dies mid-stream still
+    # leaves an observable row that cleanup can reclaim and that the composer
+    # counts against the five-file limit.
+    def reserve_slot!
+      batch.with_lock do
+        ensure_usable!
+        existing = batch.image_attachments.find_by(client_key: client_key)
+
+        if existing
+          raise Error.new(code: "attachment_removed", status: :not_found) if existing.removal_tombstone?
+
+          # Retry reuses the client idempotency key and therefore its slot. A
+          # slot that already finished is returned exactly as it stands.
+          existing.update!(state: :uploading, failure_code: nil) unless existing.state_ready?
+          next existing
+        end
+
+        invalid!("too_many_files") if batch.image_attachments.active_drafts.count >= ImageAttachment::MAX_FILES
+
+        batch.image_attachments.create!(
+          user_id: batch.user_id,
+          project_id: batch.project_id,
+          client_key: client_key,
+          state: :uploading
+        )
+      end
+    end
+
+    def stream_to!(tempfile)
+      total = 0
+
+      # IO#read(n) answers nil at EOF; a chunk of whitespace bytes is real
+      # data, so only nil may end the loop.
+      while (chunk = io.read(CHUNK_SIZE))
+        total += chunk.bytesize
+        invalid!("file_too_large") if total > ImageAttachment::MAX_FILE_SIZE
+        tempfile.write(chunk)
+      end
+      invalid!("empty_file") if total.zero?
+
+      tempfile.flush
+      total
+    end
+
+    def detect_media_type!(tempfile)
+      tempfile.rewind
+      detected = Marcel::MimeType.for(tempfile)
+      invalid!("invalid_image", Error::UNSUPPORTED_MEDIA_TYPE) unless
+        detected.in?(ImageAttachment::ALLOWED_CONTENT_TYPES)
+      detected
+    end
+
+    # A declaration is optional, but where the browser supplies one it must
+    # agree with the bytes and with the filename extension.
+    def validate_declared_identity!(media_type)
+      invalid!("content_type_mismatch") if declared_content_type.present? && declared_content_type != media_type
+
+      extension = File.extname(filename.to_s).delete_prefix(".").downcase
+      return if extension.blank?
+      return if ImageAttachment::ALLOWED_EXTENSIONS.fetch(media_type, []).include?(extension)
+
+      invalid!("extension_mismatch")
+    end
+
+    # A full decode proves the leading picture is real; it says nothing about
+    # what follows the picture's own end marker. libvips stops decoding there,
+    # so a valid PNG carrying an appended archive, script, or second file reads
+    # as a perfectly good image and would be stored and served back byte for
+    # byte. Each of the three accepted containers declares where it ends, so the
+    # container is walked and its declared end has to be the end of the file.
+    def validate_complete_stream!(tempfile, media_type)
+      complete = case media_type
+      when "image/png" then png_ends_at_eof?(tempfile)
+      when "image/jpeg" then jpeg_ends_at_eof?(tempfile)
+      when "image/webp" then webp_ends_at_eof?(tempfile)
+      else false
+      end
+      return if complete
+
+      invalid!("invalid_image")
+    end
+
+    # PNG is a chunk list: each chunk is a 4-byte length, a 4-byte type, the
+    # data, and a 4-byte CRC. Walking it lands exactly on IEND, and IEND has to
+    # be the last thing in the file.
+    def png_ends_at_eof?(file)
+      size = file.size
+      return false unless read_at(file, 0, 8) == PNG_SIGNATURE
+
+      offset = 8
+      while offset + 8 <= size
+        header = read_at(file, offset, 8)
+        return false unless header && header.bytesize == 8
+        return false unless header.byteslice(4, 4).match?(/\A[A-Za-z]{4}\z/)
+
+        offset += 12 + header.unpack1("N")
+        return offset == size if header.byteslice(4, 4) == "IEND"
+      end
+
+      false
+    end
+
+    # RIFF states its own payload length. Anything past it is not part of the
+    # image. The odd-length case allows the single pad byte RIFF appends to keep
+    # the container word aligned.
+    def webp_ends_at_eof?(file)
+      size = file.size
+      header = read_at(file, 0, 12)
+      return false unless header && header.bytesize == 12
+      return false unless header.byteslice(0, 4) == "RIFF" && header.byteslice(8, 4) == "WEBP"
+
+      declared = header.byteslice(4, 4).unpack1("V")
+      declared + 8 == size || (declared.odd? && declared + 9 == size)
+    end
+
+    # JPEG has no length field for its entropy-coded scans, so the segment list
+    # is walked and each scan is scanned for the next real marker: inside
+    # entropy data a literal 0xFF is stuffed as 0xFF00, and restart markers are
+    # part of the scan, so anything else terminates it. EOI has to be the last
+    # two bytes.
+    def jpeg_ends_at_eof?(file)
+      size = file.size
+      return false unless read_at(file, 0, 2) == JPEG_SIGNATURE
+
+      offset = 2
+      while offset + 2 <= size
+        marker = read_at(file, offset, 2)
+        return false unless marker && marker.getbyte(0) == 0xFF
+
+        code = marker.getbyte(1)
+        offset += 2
+        next if code == 0xFF || code == 0x01 || (0xD0..0xD7).cover?(code)
+        return offset == size if code == JPEG_END_OF_IMAGE
+
+        length = read_at(file, offset, 2)&.unpack1("n")
+        return false if length.nil? || length < 2
+
+        offset += length
+        return false if offset > size
+
+        if code == JPEG_START_OF_SCAN
+          offset = jpeg_entropy_end(file, offset, size)
+          return false unless offset
+        end
+      end
+
+      false
+    end
+
+    def jpeg_entropy_end(file, offset, size)
+      while offset < size
+        chunk = read_at(file, offset, CHUNK_SIZE)
+        return nil if chunk.nil? || chunk.empty?
+
+        index = 0
+        loop do
+          found = chunk.index("\xFF".b, index)
+          if found.nil?
+            offset += chunk.bytesize
+            break
+          end
+
+          following = chunk.getbyte(found + 1)
+          if following.nil?
+            # A 0xFF as the very last byte is truncated data; otherwise the
+            # marker straddles this chunk, so the walk resumes on it.
+            return nil if offset + found + 1 >= size
+
+            offset += found
+            break
+          end
+          return offset + found unless following.zero? || following == 0xFF ||
+            (0xD0..0xD7).cover?(following)
+
+          index = found + (following == 0xFF ? 1 : 2)
+        end
+      end
+
+      nil
+    end
+
+    def read_at(file, offset, length)
+      file.seek(offset)
+      file.read(length)
+    end
+
+    def decode!(tempfile)
+      # Keyed on the batch so overlapping uploads for one composer — a retry
+      # racing its aborted attempt, or a multi-file paste — decode one at a
+      # time instead of occupying every global slot.
+      ImageDecoding::Guard.synchronize(key: "image_attachment_batch:#{batch.id}") do
+        decoded = Vips::Image.new_from_file(tempfile.path, access: :sequential, fail_on: :warning)
+        width = decoded.width
+        height = decoded.height
+        validate_dimensions!(width, height)
+
+        # Vips loading is lazy. Reducing the whole image forces every scanline
+        # through the decoder while the global guard is held, so a truncated or
+        # polyglot file cannot reach storage.
+        decoded.avg
+        [ width, height ]
+      end
+    rescue ImageDecoding::Guard::Busy
+      raise Error.new(code: "decoder_busy", status: :service_unavailable)
+    rescue Vips::Error
+      invalid!("invalid_image")
+    end
+
+    def validate_dimensions!(width, height)
+      if width > ImageAttachment::MAX_DIMENSION || height > ImageAttachment::MAX_DIMENSION
+        invalid!("image_dimensions_too_large")
+      end
+      return if width * height <= ImageAttachment::MAX_PIXELS
+
+      invalid!("image_pixels_too_large")
+    end
+
+    def commit!(attachment, tempfile, media_type:, byte_size:, width:, height:)
+      blob = stage_blob!(attachment, tempfile, media_type)
+      attached = false
+
+      # The account row is the serialization point for the scheduler-
+      # independent byte ceiling across every open batch. Taking it before the
+      # batch also agrees with open_for! and account deletion, avoiding a
+      # user/batch lock inversion.
+      ImageAttachment.transaction do
+        User.lock.find(batch.user_id)
+        batch.lock!
+        ensure_usable!
+        # Remove and cleanup destroy the reserved row without this request
+        # knowing. Re-resolving it under the batch lock — the same lock both of
+        # those take first — is what stops a commit from binding bytes the
+        # composer already discarded onto a row a claim would then attach.
+        attachment = ensure_still_reserved!(attachment)
+        ensure_slot_available!(attachment)
+        ensure_total_within_limit!(attachment, byte_size)
+        ensure_outstanding_drafts_within_limit!(attachment, byte_size)
+
+        attachment.image.attach(blob)
+        attachment.update!(
+          state: :ready,
+          failure_code: nil,
+          media_type: media_type,
+          width: width,
+          height: height,
+          byte_size: byte_size
+        )
+        # The expiry bump belongs to the same locked transition. Renewing it
+        # after the lock is released leaves a window in which cleanup can take
+        # the batch lock, observe the old expiry, and destroy a draft that has
+        # just become ready.
+        batch.touch_activity!
+      end
+      # Only a committed transaction owns the blob. Setting this inside the
+      # block would skip the purge when COMMIT itself raises and the row rolls
+      # back to `uploading`, stranding staged bytes nothing reconciles.
+      attached = true
+
+      attachment
+    ensure
+      discard_blob(blob) if blob && !attached
+    end
+
+    def stage_blob!(attachment, tempfile, media_type)
+      tempfile.rewind
+      extension = ImageAttachment::ALLOWED_EXTENSIONS.fetch(media_type).first
+      blob = ActiveStorage::Blob.create_after_unfurling!(
+        io: tempfile,
+        # The stored name is generated server-side; the client filename never
+        # reaches storage or any rendered label.
+        filename: "image-attachment-#{attachment.id}.#{extension}",
+        content_type: media_type,
+        identify: false,
+        record: attachment
+      )
+      tempfile.rewind
+      blob.upload_without_unfurling(tempfile)
+      blob
+    rescue StandardError
+      discard_blob(blob) if blob
+      raise
+    end
+
+    def discard_blob(blob)
+      PurgeBlob.call(blob)
+    end
+
+    def ensure_usable!
+      return if batch.reload.usable?
+
+      invalid!("batch_unusable")
+    end
+
+    def ensure_still_reserved!(attachment)
+      current = batch.image_attachments.find_by(id: attachment.id)
+      return current if current && !current.removal_tombstone?
+
+      raise Error.new(code: "attachment_removed", status: :not_found)
+    end
+
+    # Counts the same rows the reservation side counts: a removal tombstone is
+    # no longer part of the composer, so remove-then-replace must not be
+    # rejected here after the replacement has already been decoded.
+    def ensure_slot_available!(attachment)
+      others = batch.image_attachments.active_drafts.where.not(id: attachment.id).count
+      return if others < ImageAttachment::MAX_FILES
+
+      invalid!("too_many_files")
+    end
+
+    def ensure_total_within_limit!(attachment, byte_size)
+      others = batch.total_byte_size(excluding: attachment.id)
+      return if others + byte_size <= ImageAttachment::MAX_TOTAL_BYTES
+
+      invalid!("batch_too_large")
+    end
+
+    # The per-account outstanding cap is enforced again here, not only when a
+    # batch is opened: six empty batches must not be fillable past the ceiling
+    # that keeps a stopped cleanup supervisor from parking unbounded bytes.
+    def ensure_outstanding_drafts_within_limit!(attachment, byte_size)
+      others = ImageAttachmentBatch.outstanding_draft_bytes(batch.user_id, excluding: attachment.id)
+      return if others + byte_size <= ImageAttachmentBatch::MAX_OUTSTANDING_DRAFT_BYTES
+
+      invalid!("draft_storage_exhausted")
+    end
+
+    # A timed-out or aborted attempt shares its slot with the retry that
+    # supersedes it, and with a removal that may have tombstoned it. Once the
+    # row is ready the upload it names has already succeeded, so a late failure
+    # from the disconnected attempt must not un-ready it and block the post with
+    # `attachments_not_ready`.
+    #
+    # Reading the state and then writing it are two statements, so the retry can
+    # commit `ready` between them. The write is therefore conditional on the row
+    # still being an uploading draft: the database decides, and a stale attempt
+    # that loses simply updates nothing.
+    def record_failure(attachment, error)
+      return unless attachment&.persisted?
+
+      ImageAttachment
+        .where(id: attachment.id, state: ImageAttachment.states[:uploading])
+        .where.not(image_attachment_batch_id: nil)
+        .update_all(
+          state: ImageAttachment.states[:failed],
+          failure_code: error.code,
+          updated_at: Time.current
+        )
+    end
+
+    def invalid!(code, message = nil)
+      raise Error.new(message, code: code)
+    end
+  end
+end
diff --git a/app/services/image_attachments/purge_blob.rb b/app/services/image_attachments/purge_blob.rb
new file mode 100644
index 0000000..721be16
--- /dev/null
+++ b/app/services/image_attachments/purge_blob.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module ImageAttachments
+  # Deletes stored bytes before the row that names them.
+  #
+  # `ActiveStorage::Blob#purge` destroys the tracking row first and only then
+  # asks the service to delete. A provider failure at that point has already
+  # thrown away the one durable record of the key, so nothing — not cleanup, not
+  # reconciliation, not an operator — can ever retry it. Deleting first inverts
+  # that: a failed delete leaves the row behind as an unattached blob the
+  # bounded reconciliation pass finds again on its next run.
+  module PurgeBlob
+    class << self
+      def call(blob)
+        return false unless blob
+
+        blob.delete
+        blob.destroy
+        true
+      rescue StandardError => error
+        Screenote::Monitoring.notify(
+          error,
+          context: { active_storage_blob_id: blob.id, active_storage_blob_key: blob.key }
+        )
+        false
+      end
+    end
+  end
+end
diff --git a/app/services/image_attachments/remove_attachment.rb b/app/services/image_attachments/remove_attachment.rb
new file mode 100644
index 0000000..2bff685
--- /dev/null
+++ b/app/services/image_attachments/remove_attachment.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+module ImageAttachments
+  # Idempotent draft removal. A tiny client-key tombstone remains in the batch
+  # until claim/discard/expiry, so removal also wins when it reaches the server
+  # before an in-flight upload has reserved its row. The tombstone is omitted
+  # from resume/claim and prevents that client key from being uploaded again.
+  class RemoveAttachment
+    Result = Data.define(:removed)
+
+    class << self
+      def call(batch:, attachment_id: nil, client_key: nil)
+        new(batch: batch, attachment_id: attachment_id, client_key: client_key).call
+      end
+    end
+
+    def initialize(batch:, attachment_id: nil, client_key: nil)
+      @batch = batch
+      @attachment_id = attachment_id
+      @client_key = client_key.to_s.presence
+    end
+
+    def call
+      attachment = nil
+
+      ImageAttachment.transaction do
+        batch.lock!
+        next unless batch.state_open?
+
+        attachment = find_or_reserve_tombstone
+        next unless attachment
+
+        attachment.update!(
+          state: :failed,
+          failure_code: ImageAttachment::REMOVAL_TOMBSTONE
+        ) unless attachment.removal_tombstone?
+      end
+
+      purge_removed_image(attachment)
+      batch.touch_activity! if batch.usable?
+      Result.new(removed: attachment.present?)
+    end
+
+    private
+
+    attr_reader :batch, :attachment_id, :client_key
+
+    def find_or_reserve_tombstone
+      attachment = if attachment_id.present?
+        batch.image_attachments.ordered.lock.find_by(id: attachment_id)
+      elsif client_key.present?
+        batch.image_attachments.ordered.lock.find_by(client_key: client_key)
+      end
+      return attachment if attachment
+      return unless client_key
+
+      validate_client_key!
+      created = batch.image_attachments.create!(
+        user_id: batch.user_id,
+        project_id: batch.project_id,
+        client_key: client_key,
+        state: :failed,
+        failure_code: ImageAttachment::REMOVAL_TOMBSTONE
+      )
+      prune_tombstones!(created)
+      created
+    end
+
+    # A tombstone exists to beat an upload that is already in flight for the
+    # same client key, so only the recent ones can still do any work. Without a
+    # bound, a client that invents a new key per DELETE could park unbounded
+    # rows on one open batch and make every later claim, removal, and cleanup
+    # pass lock more of them. The window is kept to the number of files a
+    # message may carry, and the oldest markers — the ones no live upload can
+    # still be racing — are dropped to make room.
+    def prune_tombstones!(created)
+      tombstones = batch.image_attachments
+        .where(failure_code: ImageAttachment::REMOVAL_TOMBSTONE)
+        .order(:id)
+        .lock
+        .to_a
+      surplus = tombstones.size - ImageAttachment::MAX_FILES
+      return unless surplus.positive?
+
+      tombstones.first(surplus).each { |marker| marker.destroy! unless marker.id == created.id }
+    end
+
+    # The marker is committed before storage is touched, so a failed purge can
+    # never let the upload come back. Keeping byte_size until purge succeeds
+    # also keeps the account cap fail-closed during a storage outage.
+    def purge_removed_image(attachment)
+      return unless attachment&.persisted?
+
+      if attachment.image.attached?
+        # Detach first, then delete the bytes before the row that names them:
+        # a provider failure leaves a discoverable unattached blob rather than
+        # an untracked key.
+        blob = attachment.image.blob
+        attachment.image.detach
+        PurgeBlob.call(blob)
+      end
+
+      ImageAttachment.transaction do
+        batch.lock!
+        marker = batch.image_attachments.find_by(id: attachment.id)
+        next unless marker&.removal_tombstone?
+
+        marker.update!(
+          alt_text: nil,
+          media_type: nil,
+          width: nil,
+          height: nil,
+          byte_size: nil
+        )
+      end
+    end
+
+    def validate_client_key!
+      raise Error.new(code: "missing_client_key") if client_key.blank?
+      raise Error.new(code: "invalid_client_key") if client_key.length > 64
+    end
+  end
+end
diff --git a/app/services/image_attachments/write_alt_text.rb b/app/services/image_attachments/write_alt_text.rb
new file mode 100644
index 0000000..11d3834
--- /dev/null
+++ b/app/services/image_attachments/write_alt_text.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module ImageAttachments
+  # Writes a draft description under the global lock order — the batch first,
+  # then the attachment row. Taking the batch lock is what makes a description
+  # racing a claim resolve as a plain not-found: once the claim has moved the
+  # row onto its message the row is no longer part of the batch, so the write
+  # can never land on submitted metadata or trip its immutability guard.
+  class WriteAltText
+    class << self
+      def call(batch:, attachment_id:, alt_text:)
+        new(batch: batch, attachment_id: attachment_id, alt_text: alt_text).call
+      end
+    end
+
+    def initialize(batch:, attachment_id:, alt_text:)
+      @batch = batch
+      @attachment_id = attachment_id
+      @alt_text = alt_text
+    end
+
+    def call
+      attachment = nil
+
+      ImageAttachment.transaction do
+        batch.lock!
+        attachment = batch.image_attachments.lock.find_by(id: attachment_id)
+        # A removal tombstone is a row the composer has already discarded, and
+        # removal nulls its metadata. Answering not-found keeps the description
+        # write on the same removal contract every other draft call follows.
+        raise ActiveRecord::RecordNotFound if attachment.nil? || attachment.removal_tombstone?
+
+        attachment.update!(alt_text: alt_text)
+      end
+
+      batch.touch_activity! if batch.usable?
+      attachment
+    rescue ActiveRecord::RecordInvalid
+      # Over-length descriptions are ordinary client input, so they answer with
+      # the same machine code envelope as every other draft failure rather than
+      # escaping as a 500.
+      raise Error.new(code: "alt_text_too_long")
+    end
+
+    private
+
+    attr_reader :batch, :attachment_id, :alt_text
+  end
+end
diff --git a/app/services/image_decoding/guard.rb b/app/services/image_decoding/guard.rb
index 71b15b3..f833eb5 100644
--- a/app/services/image_decoding/guard.rb
+++ b/app/services/image_decoding/guard.rb
@@ -13,11 +13,33 @@ class Guard
       end
     end
     SEMAPHORE = Concurrent::Semaphore.new(CONCURRENCY)
+    # Callers that pass a key decode one file at a time within that key before
+    # they compete for a global slot. One composer batch retrying while its
+    # earlier upload is still decoding must never hold every global slot and
+    # answer screenshot work with `decoder_busy`.
+    KEYED = {}
+    KEYED_MUTEX = Mutex.new
 
     class Busy < StandardError; end
 
     class << self
-      def synchronize(timeout: WAIT_SECONDS)
+      def synchronize(key: nil, timeout: WAIT_SECONDS, &block)
+        return acquire_global(timeout, &block) if key.nil?
+
+        # One deadline spans both stages. Passing the full timeout to each
+        # would let a keyed wait and a global wait stack, so a composer could
+        # block for twice the budget before it is told the decoder is busy.
+        deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
+        acquire_key(key, timeout) { acquire_global(remaining(deadline), &block) }
+      end
+
+      private
+
+      def remaining(deadline)
+        [ deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0 ].max
+      end
+
+      def acquire_global(timeout)
         acquired = SEMAPHORE.try_acquire(1, timeout)
         raise Busy, "image decoder is busy" unless acquired
 
@@ -25,6 +47,37 @@ def synchronize(timeout: WAIT_SECONDS)
       ensure
         SEMAPHORE.release if acquired
       end
+
+      def acquire_key(key, timeout)
+        semaphore = checkout(key)
+        acquired = semaphore.try_acquire(1, timeout)
+        raise Busy, "image decoder is busy" unless acquired
+
+        yield
+      ensure
+        semaphore.release if acquired
+        checkin(key)
+      end
+
+      # Entries are reference counted so a long-lived process does not
+      # accumulate one semaphore per draft batch it has ever served.
+      def checkout(key)
+        KEYED_MUTEX.synchronize do
+          entry = KEYED[key] ||= { semaphore: Concurrent::Semaphore.new(1), holders: 0 }
+          entry[:holders] += 1
+          entry[:semaphore]
+        end
+      end
+
+      def checkin(key)
+        KEYED_MUTEX.synchronize do
+          entry = KEYED[key]
+          next unless entry
+
+          entry[:holders] -= 1
+          KEYED.delete(key) if entry[:holders] <= 0
+        end
+      end
     end
   end
 end
diff --git a/app/services/screenote/readiness.rb b/app/services/screenote/readiness.rb
index 2036d2d..9d88ba9 100644
--- a/app/services/screenote/readiness.rb
+++ b/app/services/screenote/readiness.rb
@@ -29,7 +29,8 @@ def initialize(
     end
 
     def ready?
-      role_schemas_ready? && storage_volume_writable? && configured_storage_ready?
+      role_schemas_ready? && storage_volume_writable? && configured_storage_ready? &&
+        recurring_cleanup_registered?
     rescue StandardError
       false
     end
@@ -67,5 +68,11 @@ def configured_storage_ready?
       storage_services.fetch(deployment.active_storage_service)
       true
     end
+
+    # Bounded attachment cleanup only bounds anything when the recurring
+    # supervisor actually runs it.
+    def recurring_cleanup_registered?
+      Screenote::RecurringTasks.registered?(environment: Rails.env)
+    end
   end
 end
diff --git a/app/services/screenote/recurring_tasks.rb b/app/services/screenote/recurring_tasks.rb
new file mode 100644
index 0000000..40cd915
--- /dev/null
+++ b/app/services/screenote/recurring_tasks.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+module Screenote
+  # Reads the Solid Queue recurring schedule that a deployment will actually
+  # supervise. Deployment health depends on it: bounded cleanup that is never
+  # scheduled leaks draft bytes silently, so a missing task must fail visibly
+  # rather than degrade quietly.
+  class RecurringTasks
+    CONFIGURATION_PATH = "config/recurring.yml"
+    REQUIRED_TASKS = %w[
+      clear_expired_image_attachment_drafts
+      reconcile_image_attachments
+    ].freeze
+    # Environments that boot a Solid Queue supervisor. Development and test run
+    # jobs inline or on demand, so there is no schedule for them to be missing
+    # from.
+    SUPERVISED_ENVIRONMENTS = %w[production].freeze
+    # Solid Queue supervisors heartbeat once a minute by default. Three missed
+    # beats is a stopped supervisor, not a slow one.
+    HEARTBEAT_GRACE = 5.minutes
+    # A scheduler cannot have registered its tasks or written a heartbeat before
+    # the process that supervises it has finished booting. Readiness gates the
+    # deployment, so demanding live proof from the first second would stop the
+    # deployment from ever coming up. After this window the proof is required.
+    STARTUP_GRACE = 5.minutes
+
+    class << self
+      # Assigned once when the class loads, which under eager loading is process
+      # boot. Tests move it to exercise the post-boot requirement.
+      attr_accessor :booted_at
+
+      # The schedule file only records what a deployment intends to run.
+      # Readiness has to answer whether it is actually running: a supervisor
+      # that never booted, a scheduler that rejected the recurring entries, or
+      # one whose heartbeat has died all leave bounded cleanup unexecuted while
+      # the checked-in YAML still parses. Both halves are therefore required,
+      # and anything that cannot be established reports unhealthy.
+      def registered?(environment: Rails.env, required: REQUIRED_TASKS, now: Time.current)
+        return true unless supervised?(environment)
+        return false unless missing(environment: environment, required: required).empty?
+        return true if supervising?(required: required, now: now)
+
+        booting?
+      end
+
+      def booting?
+        Process.clock_gettime(Process::CLOCK_MONOTONIC) - booted_at < STARTUP_GRACE
+      end
+
+      # Reads the live supervisor state Solid Queue keeps in the queue database:
+      # each booted scheduler registers its static recurring entries and every
+      # running process heartbeats.
+      def supervising?(required: REQUIRED_TASKS, now: Time.current)
+        return false unless defined?(SolidQueue::Process) && defined?(SolidQueue::RecurringTask)
+
+        scheduler_alive?(now: now) && (required - registered_task_keys).empty?
+      rescue StandardError => error
+        Screenote::Monitoring.notify(error, context: { check: "recurring_task_supervisor" })
+        false
+      end
+
+      def scheduler_alive?(now: Time.current)
+        SolidQueue::Process
+          .where(kind: "Scheduler")
+          .where(last_heartbeat_at: (now - HEARTBEAT_GRACE)..)
+          .exists?
+      end
+
+      def registered_task_keys
+        SolidQueue::RecurringTask.where(static: true).pluck(:key).map(&:to_s)
+      end
+
+      def supervised?(environment)
+        SUPERVISED_ENVIRONMENTS.include?(environment.to_s)
+      end
+
+      def missing(environment: Rails.env, required: REQUIRED_TASKS)
+        configured = task_names(environment: environment)
+        required - configured
+      end
+
+      def task_names(environment: Rails.env)
+        schedule(environment: environment).keys.map(&:to_s)
+      end
+
+      # The schedule ships with the image and cannot change without a redeploy,
+      # so it is read once per environment rather than on every health probe.
+      def schedule(environment: Rails.env)
+        key = environment.to_s
+        cache = (@schedules ||= {})
+        cache.fetch(key) { cache[key] = load_schedule(key) }
+      end
+
+      def reset_schedule_cache!
+        @schedules = {}
+      end
+
+      private
+
+      def load_schedule(environment)
+        path = Rails.root.join(CONFIGURATION_PATH)
+        return {} unless path.exist?
+
+        loaded = YAML.safe_load(ERB.new(path.read).result, aliases: true) || {}
+        loaded.fetch(environment, nil) || {}
+      end
+    end
+
+    self.booted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+  end
+end
diff --git a/app/services/snapshots/attach_image.rb b/app/services/snapshots/attach_image.rb
index 8a9ff67..68a6fd9 100644
--- a/app/services/snapshots/attach_image.rb
+++ b/app/services/snapshots/attach_image.rb
@@ -210,8 +210,13 @@ def delete_staged_object_after_rollback(blob)
       end
     end
 
+    # `ActiveStorage::Blob#purge` destroys the tracking row before it asks the
+    # service to delete, so a provider failure there throws away the only
+    # durable record of the key. Deleting first keeps a failed delete
+    # retryable: the row survives as a discoverable unattached blob.
     def discard_staged_blob!(blob)
-      blob.purge
+      blob.delete
+      blob.destroy
     end
 
     def discard_staged_blob_after_error(blob)
diff --git a/app/tools/application_tool.rb b/app/tools/application_tool.rb
index 81d131b..93e5c80 100644
--- a/app/tools/application_tool.rb
+++ b/app/tools/application_tool.rb
@@ -81,6 +81,10 @@ def project_annotations(project)
     Api::V1::ProjectScope.annotations(project)
   end
 
+  def project_annotation_details(project)
+    Api::V1::ProjectScope.annotation_details(project)
+  end
+
   def serialize_annotation(annotation)
     Api::V1::ContractSerializer.annotation(annotation)
   end
diff --git a/app/tools/get_annotation_tool.rb b/app/tools/get_annotation_tool.rb
index 1ebce7c..4173bac 100644
--- a/app/tools/get_annotation_tool.rb
+++ b/app/tools/get_annotation_tool.rb
@@ -2,7 +2,9 @@
 
 class GetAnnotationTool < ApplicationTool
   tool_name "get_annotation"
-  description "Get annotation details with a cropped image of the annotated region (base64-encoded PNG)."
+  description "Get annotation details with a cropped image of the annotated region (base64-encoded PNG), " \
+    "plus the images attached to the annotation and to each of its comments: alt text, media type, " \
+    "dimensions, size, and a short-lived URL that must be fetched with the same bearer credential."
   mcp_action scope: :mcp_read, read_only: true, destructive: false, idempotent: true, open_world: false
 
   arguments do
@@ -15,7 +17,7 @@ def call(project_id:, annotation_id:)
     return error if error
 
     with_error_handling do
-      annotation = project_annotations(current_project).find(annotation_id)
+      annotation = project_annotation_details(current_project).find(annotation_id)
 
       screenshot = annotation.screenshot
       cropped_base64 = begin
@@ -29,21 +31,12 @@ def call(project_id:, annotation_id:)
         nil
       end
 
-      comments = annotation.annotation_comments.includes(:user, :api_key).order(:created_at).map do |ac|
-        {
-          id: ac.id,
-          action: ac.action,
-          body: ac.body,
-          author: ac.user&.email || ac.api_key&.name || "Unknown",
-          created_at: ac.created_at.iso8601
-        }
-      end
-
-      serialize_annotation(annotation).merge(
-        screenshot_status: screenshot.status,
-        cropped_image_base64: cropped_base64,
-        mime_type: "image/png",
-        comments: comments
+      # REST is canonical for the attachment object, but the shipped MCP
+      # envelope keeps every key it already had, in the same place.
+      Api::V1::ContractSerializer.annotation_detail(
+        annotation,
+        cropped_base64: cropped_base64,
+        url_options: Screenote::Deployment.current.url_options
       ).to_json
     end
   end
diff --git a/app/views/annotations/_annotation.html.erb b/app/views/annotations/_annotation.html.erb
index 3245714..f694b94 100644
--- a/app/views/annotations/_annotation.html.erb
+++ b/app/views/annotations/_annotation.html.erb
@@ -28,6 +28,8 @@
     <div class="annotation-item__comment" data-testid="annotation-comment"><%= annotation.comment %></div>
   <% end %>
 
+  <%= render "image_attachments/gallery", attachments: annotation.image_attachments %>
+
   <% if annotation.annotation_comments.any? %>
     <div class="annotation-thread">
       <% annotation.annotation_comments.each do |ac| %>
@@ -47,6 +49,7 @@
             <span class="annotation-thread__time"><%= time_ago_in_words(ac.created_at) %> ago</span>
           </div>
           <div class="annotation-thread__body"><%= ac.body %></div>
+          <%= render "image_attachments/gallery", attachments: ac.image_attachments %>
         </div>
       <% end %>
     </div>
@@ -103,6 +106,7 @@
             required: true,
             rows: 4,
             data: { annotation_controls_target: "replyField", testid: "reply-textarea" } %>
+        <%= render "image_attachments/composer", project: project, layout: "stack" %>
         <div class="annotation-thread__form-actions">
           <%= f.submit "Reply", class: "btn btn--small btn--primary", data: { testid: "reply-button" } %>
         </div>
@@ -125,6 +129,7 @@
               placeholder: "Explain why you're unresolving this...",
               rows: 4,
               data: { annotation_controls_target: "reopenField" } %>
+          <%= render "image_attachments/composer", project: project, layout: "stack" %>
           <div class="annotation-thread__form-actions">
             <%= f.submit "Unresolve", class: "btn btn--small btn--warning", data: { testid: "submit-unresolve-button" } %>
           </div>
diff --git a/app/views/annotations/_form.html.erb b/app/views/annotations/_form.html.erb
index 4aa7e9f..f3e9b69 100644
--- a/app/views/annotations/_form.html.erb
+++ b/app/views/annotations/_form.html.erb
@@ -23,10 +23,13 @@
         rows: 3,
         data: { annotorious_target: "comment" } %>
 
+    <%# The overlay is clamped inside the image, so the rail joins the existing
+        action row and only grows the form once an image is actually attached. %>
     <div class="annotation-form__actions">
       <%= f.submit "Save", class: "btn btn--primary btn--small", data: { testid: "save-annotation-button" } %>
       <button type="button" class="btn btn--secondary btn--small"
               data-action="click->annotorious#cancelForm" data-testid="cancel-button">Cancel</button>
+      <%= render "image_attachments/composer", project: project, layout: "rail" %>
     </div>
   </div>
 <% end %>
diff --git a/app/views/image_attachments/_composer.html.erb b/app/views/image_attachments/_composer.html.erb
new file mode 100644
index 0000000..6ad2f6a
--- /dev/null
+++ b/app/views/image_attachments/_composer.html.erb
@@ -0,0 +1,77 @@
+<%# Shared composer attachment surface.
+    layout: "rail" for the clamped Annotorious overlay (compact horizontal row
+    that never covers the selected region) or "stack" for the sticky sidebar
+    (full-width rows beneath the textarea). %>
+<%# The overlay sits over the screenshot canvas, the sidebar on a light panel.
+    Both contexts are explicit so the shared component never inherits a theme. %>
+<div class="image-attachment-composer image-attachment-composer--<%= layout %> image-attachment-context--<%= layout == "rail" ? "dark" : "light" %>"
+     data-controller="image-attachment-composer"
+     data-image-attachment-composer-project-id-value="<%= project.id %>"
+     data-image-attachment-composer-batches-url-value="<%= image_attachment_draft_batches_path %>"
+     data-image-attachment-composer-max-files-value="<%= ImageAttachment::MAX_FILES %>"
+     data-image-attachment-composer-max-file-size-value="<%= ImageAttachment::MAX_FILE_SIZE %>"
+     data-image-attachment-composer-max-total-size-value="<%= ImageAttachment::MAX_TOTAL_BYTES %>"
+     data-image-attachment-composer-accepted-types-value="<%= ImageAttachment::ALLOWED_CONTENT_TYPES.to_json %>"
+     <%# The rejection copy comes from the same constant the server raises with,
+         so the client and the API can never describe the accepted set
+         differently. %>
+     data-image-attachment-composer-unsupported-type-message-value="<%= ImageAttachments::Error::UNSUPPORTED_MEDIA_TYPE %>"
+     data-testid="image-attachment-composer">
+  <div class="image-attachment-composer__toolbar">
+    <button type="button"
+            class="btn btn--small btn--secondary image-attachment-composer__attach"
+            data-action="image-attachment-composer#openPicker"
+            data-testid="attach-image-button">Attach image</button>
+    <span class="image-attachment-composer__hint">PNG, JPEG, or WebP. Up to <%= ImageAttachment::MAX_FILES %>, <%= ImageAttachment::MAX_FILE_SIZE / 1.megabyte %>MB each, <%= ImageAttachment::MAX_TOTAL_BYTES / 1.megabyte %>MB per message.</span>
+  </div>
+
+  <input type="file"
+         class="image-attachment-composer__file-input"
+         multiple
+         accept="<%= ImageAttachment::ALLOWED_CONTENT_TYPES.join(",") %>"
+         aria-label="Attach images"
+         data-image-attachment-composer-target="fileInput"
+         data-action="change->image-attachment-composer#pickFiles">
+
+  <ul class="image-attachment-composer__items"
+      data-image-attachment-composer-target="items"
+      data-testid="attachment-items"></ul>
+
+  <p class="image-attachment-composer__errors"
+     data-image-attachment-composer-target="errors"
+     data-testid="composer-errors"
+     hidden></p>
+
+  <p class="image-attachment-composer__status"
+     role="status"
+     aria-live="polite"
+     data-image-attachment-composer-target="status"
+     data-testid="attachment-status"></p>
+
+  <template data-image-attachment-composer-target="itemTemplate">
+    <li class="image-attachment-item" data-testid="attachment-item">
+      <span class="image-attachment-item__preview">
+        <img class="image-attachment-item__thumb" alt="" data-attachment-thumb hidden>
+        <span class="image-attachment-item__placeholder" data-attachment-placeholder aria-hidden="true"></span>
+      </span>
+      <span class="image-attachment-item__body">
+        <span class="image-attachment-item__state" data-attachment-state data-testid="attachment-state"></span>
+        <progress class="image-attachment-item__progress" max="100" value="0" data-attachment-progress></progress>
+        <label class="image-attachment-item__alt">
+          <span class="image-attachment-item__alt-label">Describe this image (optional)</span>
+          <input type="text"
+                 class="image-attachment-item__alt-input"
+                 maxlength="<%= ImageAttachment::MAX_ALT_TEXT %>"
+                 data-attachment-alt
+                 data-testid="attachment-alt-input">
+        </label>
+      </span>
+      <span class="image-attachment-item__actions">
+        <button type="button" class="btn btn--small btn--secondary"
+                data-attachment-retry data-testid="attachment-retry" hidden>Retry</button>
+        <button type="button" class="btn btn--small btn--danger"
+                data-attachment-remove data-testid="attachment-remove">Remove</button>
+      </span>
+    </li>
+  </template>
+</div>
diff --git a/app/views/image_attachments/_gallery.html.erb b/app/views/image_attachments/_gallery.html.erb
new file mode 100644
index 0000000..9c5c7a8
--- /dev/null
+++ b/app/views/image_attachments/_gallery.html.erb
@@ -0,0 +1,77 @@
+<% attachments = attachments.to_a %>
+<% if attachments.any? %>
+  <% context = local_assigns.fetch(:context, "light") %>
+  <div class="image-attachment-gallery image-attachment-context--<%= context %>"
+       data-controller="image-attachment-viewer"
+       data-testid="image-attachment-gallery">
+    <ul class="image-attachment-gallery__items">
+      <% attachments.each_with_index do |attachment, index| %>
+        <li class="image-attachment-gallery__item">
+          <button type="button"
+                  class="image-attachment-gallery__trigger"
+                  data-action="image-attachment-viewer#open"
+                  data-image-attachment-viewer-target="trigger"
+                  data-index="<%= index %>"
+                  data-original-url="<%= image_attachment_media_path(attachment, :original) %>"
+                  data-download-url="<%= image_attachment_media_path(attachment, :download) %>"
+                  data-alt="<%= attachment.display_alt_text %>"
+                  data-testid="attachment-thumbnail">
+            <% if attachment.thumbnails_renderable? %>
+              <img class="image-attachment-gallery__image"
+                   src="<%= image_attachment_media_path(attachment, :attachment_thumb_1x) %>"
+                   srcset="<%= image_attachment_media_path(attachment, :attachment_thumb_1x) %> 1x, <%= image_attachment_media_path(attachment, :attachment_thumb_2x) %> 2x"
+                   alt="<%= attachment.display_alt_text %>"
+                   loading="lazy"
+                   decoding="async">
+            <% else %>
+              <span class="image-attachment-gallery__placeholder" data-testid="attachment-placeholder">
+                <span class="image-attachment-gallery__placeholder-label"><%= attachment.display_alt_text %></span>
+              </span>
+            <% end %>
+          </button>
+        </li>
+      <% end %>
+    </ul>
+
+    <dialog class="image-attachment-viewer"
+            aria-label="Attached image viewer"
+            data-image-attachment-viewer-target="dialog"
+            data-testid="attachment-viewer">
+      <div class="image-attachment-viewer__frame">
+        <%# The dialog opens focus onto its content, so the image itself has to
+            be programmatically focusable. %>
+        <img class="image-attachment-viewer__image"
+             alt=""
+             tabindex="-1"
+             data-image-attachment-viewer-target="image"
+             data-testid="attachment-viewer-image">
+      </div>
+      <div class="image-attachment-viewer__bar">
+        <button type="button" class="btn btn--small btn--secondary"
+                data-action="image-attachment-viewer#previous"
+                data-image-attachment-viewer-target="previous"
+                data-testid="attachment-viewer-previous">Previous</button>
+        <button type="button" class="btn btn--small btn--secondary"
+                data-action="image-attachment-viewer#next"
+                data-image-attachment-viewer-target="next"
+                data-testid="attachment-viewer-next">Next</button>
+        <button type="button" class="btn btn--small btn--secondary"
+                data-action="image-attachment-viewer#zoomOut">Zoom out</button>
+        <button type="button" class="btn btn--small btn--secondary"
+                data-action="image-attachment-viewer#zoomIn"
+                data-testid="attachment-viewer-zoom-in">Zoom in</button>
+        <a class="btn btn--small btn--secondary"
+           data-image-attachment-viewer-target="download"
+           data-testid="attachment-viewer-download"
+           download>Download</a>
+        <a class="btn btn--small btn--secondary"
+           target="_blank" rel="noopener"
+           data-image-attachment-viewer-target="openOriginal"
+           data-testid="attachment-viewer-open-original">Open original</a>
+        <button type="button" class="btn btn--small btn--secondary"
+                data-action="image-attachment-viewer#close"
+                data-testid="attachment-viewer-close">Close</button>
+      </div>
+    </dialog>
+  </div>
+<% end %>
diff --git a/app/views/notification_mailer/resolution_digest.html.erb b/app/views/notification_mailer/resolution_digest.html.erb
index 5bfe772..3bc440a 100644
--- a/app/views/notification_mailer/resolution_digest.html.erb
+++ b/app/views/notification_mailer/resolution_digest.html.erb
@@ -32,14 +32,29 @@
                 <p style="margin: 0 0 6px 0; font-size: 14px; color: #b0b0c0; line-height: 1.6;">
                   <strong style="color: #8888a0;">Your annotation:</strong> <%= truncate(item[:annotation_text], length: 200) %>
                 </p>
+                <% if item[:annotation_attachments].any? %>
+                  <p style="margin: 0 0 6px 0; font-size: 13px; color: #8888a0; line-height: 1.6;">
+                    <%= pluralize(item[:annotation_attachments].size, "image") %> attached:
+                    <%= item[:annotation_attachments].map { |a| a[:alt_text] }.join(", ") %>
+                  </p>
+                <% end %>
                 <% if item[:reply_text] %>
                   <p style="margin: 0 0 6px 0; font-size: 14px; color: #b0b0c0; line-height: 1.6;">
                     <strong style="color: #8888a0;">Reply:</strong> <%= truncate(item[:reply_text], length: 500) %>
                   </p>
                 <% end %>
-                <p style="margin: 0; font-size: 12px; color: #5a5a6e;">
+                <% if item[:reply_attachments].any? %>
+                  <p style="margin: 0 0 6px 0; font-size: 13px; color: #8888a0; line-height: 1.6;">
+                    Reply attached <%= pluralize(item[:reply_attachments].size, "image") %>:
+                    <%= item[:reply_attachments].map { |a| a[:alt_text] }.join(", ") %>
+                  </p>
+                <% end %>
+                <p style="margin: 0 0 6px 0; font-size: 12px; color: #5a5a6e;">
                   Resolved by <%= item[:resolver] %>
                 </p>
+                <p style="margin: 0; font-size: 13px;">
+                  <a href="<%= item[:url] %>" style="color: #e5a31e;">Open this annotation</a>
+                </p>
               </td>
             </tr>
           </table>
diff --git a/app/views/pages/show.html.erb b/app/views/pages/show.html.erb
index 8baeed2..7b4258e 100644
--- a/app/views/pages/show.html.erb
+++ b/app/views/pages/show.html.erb
@@ -1,5 +1,6 @@
 <% content_for :head do %>
   <%= stylesheet_link_tag "annotorious" if @screenshot %>
+  <%= stylesheet_link_tag "image_attachments" if @screenshot %>
 <% end %>
 <% content_for :main_class, "main--review" if @screenshot %>
 
diff --git a/app/views/screenshots/_workspace.html.erb b/app/views/screenshots/_workspace.html.erb
index 89be7a0..03860e6 100644
--- a/app/views/screenshots/_workspace.html.erb
+++ b/app/views/screenshots/_workspace.html.erb
@@ -15,7 +15,8 @@
 
   <div class="screenshot-workspace"
        data-controller="annotorious review-fullscreen"
-       data-action="annotorious:saved-marker-selected->review-fullscreen#showComments"
+       data-action="annotorious:saved-marker-selected->review-fullscreen#showComments
+                     image-attachment-composer:changed->annotorious#repositionForm"
        data-testid="screenshot-workspace">
     <div class="screenshot-canvas" data-annotorious-target="canvas">
       <button type="button"
@@ -93,11 +94,11 @@
 
       <div class="annotation-sidebar__list">
         <template data-annotorious-target="formTemplate">
-          <%= render "annotations/form", screenshot: @screenshot, active_viewport: @active_viewport %>
+          <%= render "annotations/form", screenshot: @screenshot, active_viewport: @active_viewport, project: @project %>
         </template>
 
         <% @annotations.each.with_index(1) do |annotation, index| %>
-          <%= render "annotations/annotation", annotation: annotation, index: index %>
+          <%= render "annotations/annotation", annotation: annotation, index: index, project: @project %>
         <% end %>
 
         <% if @annotations.empty? %>
diff --git a/bin/release-validate b/bin/release-validate
index 3483e75..3a666f1 100755
--- a/bin/release-validate
+++ b/bin/release-validate
@@ -38,6 +38,11 @@ class ReleaseValidation
     --auto-update=false
   ].freeze
   IMAGE_REFERENCE_PATTERN = %r{#{Regexp.escape(EXPECTED_IMAGE_REPOSITORY)}:[^\s"'`\\]+}
+  # A server-database job, deliberately outside the database-agnostic CI matrix.
+  # The lifecycle claims it proves — real row locks, aggregate races, lock
+  # ordering, atomic claim — cannot be established on SQLite, so it is required
+  # on the default branch in its own right.
+  CONCURRENCY_QUALIFICATION_CHECK = "concurrency-qualification / image-attachment-lifecycle"
   REQUIRED_CHECKS = %w[
     ruby-security
     lint
@@ -93,6 +98,7 @@ class ReleaseValidation
     .github/workflows/secrets.yml
     .github/workflows/release.yml
     .github/workflows/release-qualification.yml
+    .github/workflows/concurrency-qualification.yml
     .github/rulesets/main.json
     .github/rulesets/release-tags.json
     .github/rulesets/immutable-release-tags.json
@@ -454,6 +460,16 @@ class ReleaseValidation
     error("release workflow must isolate Syft from discovered configuration") unless
       release.include?('--config "$RUNNER_TEMP/syft-release.yaml"')
 
+    concurrency = read(".github/workflows/concurrency-qualification.yml")
+    error("concurrency workflow must gate pull requests and the default branch") unless
+      concurrency.include?("pull_request:") && concurrency.match?(/branches:\s*\[\s*main\s*\]/)
+    error("concurrency workflow must fail closed when it lands on SQLite") unless
+      concurrency.include?("SCREENOTE_SERVER_DATABASE_QUALIFICATION")
+    error("concurrency workflow must run against a PostgreSQL service") unless
+      concurrency.match?(/image:\s*postgres/) && concurrency.include?("DATABASE_URL: postgresql://")
+    error("concurrency workflow must run the attachment lifecycle matrix") unless
+      concurrency.include?("script/release_test_matrix attachment-lifecycle")
+
     qualification = read(".github/workflows/release-qualification.yml")
     error("qualification workflow must not infer release qualification from pull-request CI") if
       qualification.include?("pull_request:") || qualification.include?("workflow_run:")
@@ -501,6 +517,9 @@ class ReleaseValidation
         statuses.select { |check| expected_product_contexts.include?(check["context"]) }.all? do |check|
           check["integration_id"] == 15_368
         end
+    lifecycle = statuses.find { |check| check["context"] == CONCURRENCY_QUALIFICATION_CHECK }
+    error("main ruleset must require the server-database image attachment lifecycle job") unless
+      lifecycle&.fetch("integration_id", nil) == 15_368
     error("main ruleset must bind the GitGuardian App check to an integration placeholder") unless gitguardian&.fetch("integration_id", nil) == 0
     error("main ruleset must require the metadata-only incident check from GitHub Actions") unless incidents&.fetch("integration_id", nil) == 15_368
 
diff --git a/config/recurring.yml b/config/recurring.yml
index 4883c1b..24f93cb 100644
--- a/config/recurring.yml
+++ b/config/recurring.yml
@@ -26,6 +26,16 @@ production:
     class: ReconcileScreenshotProcessingJob
     schedule: every 5 minutes
 
+  # Abandoned composer drafts expire 24 hours after their last activity. A
+  # quarter-hour sweep keeps the reclaim window short without scanning hot.
+  clear_expired_image_attachment_drafts:
+    class: ImageAttachmentDraftCleanupJob
+    schedule: every 15 minutes
+
+  reconcile_image_attachments:
+    class: ImageAttachmentOrphanReconciliationJob
+    schedule: every hour at minute 41
+
 <% if Screenote::Deployment.current.mail? %>
   send_digest_notifications:
     class: SendDigestNotificationsJob
diff --git a/config/routes.rb b/config/routes.rb
index 962b7dc..b7d812e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -5,6 +5,18 @@
     as: :screenshot_image_media,
     constraints: { variant: /original|page_card_1x|page_card_2x|project_strip/ }
 
+  get "media/image_attachments/:id/:variant", to: "image_attachment_media#show",
+    as: :image_attachment_media,
+    constraints: { variant: /original|download|attachment_thumb_1x|attachment_thumb_2x/ }
+
+  scope module: :image_attachment_drafts, path: "image-attachment-drafts", as: :image_attachment_draft do
+    resources :batches, only: %i[create show destroy], param: :public_id do
+      resources :attachments, only: %i[create update destroy] do
+        delete :discard, on: :collection
+      end
+    end
+  end
+
   if Screenote::Deployment.current.self_hosted?
     resource :bootstrap, only: %i[show create], controller: "bootstrap"
   end
@@ -104,6 +116,7 @@
 
   namespace :api do
     put "screenshots/:id/upload", to: "screenshot_uploads#update", as: :screenshot_upload
+    get "media/image_attachments/:id", to: "image_attachment_media#show", as: :image_attachment_media
     namespace :v1 do
       resources :projects, only: [ :index, :create ] do
         resources :pages, only: [ :index ]
diff --git a/db/migrate/20260829120000_create_image_attachments.rb b/db/migrate/20260829120000_create_image_attachments.rb
new file mode 100644
index 0000000..e0db7ca
--- /dev/null
+++ b/db/migrate/20260829120000_create_image_attachments.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+class CreateImageAttachments < ActiveRecord::Migration[8.1]
+  BATCH_CLAIM_CHECK = <<~SQL.squish.freeze
+    (state = 0 AND claimed_annotation_id IS NULL AND claimed_annotation_comment_id IS NULL) OR
+    (state = 1 AND claimed_annotation_id IS NOT NULL AND claimed_annotation_comment_id IS NULL) OR
+    (state = 1 AND claimed_annotation_id IS NULL AND claimed_annotation_comment_id IS NOT NULL)
+  SQL
+
+  PARENT_CHECK = <<~SQL.squish.freeze
+    (image_attachment_batch_id IS NOT NULL AND annotation_id IS NULL AND annotation_comment_id IS NULL) OR
+    (image_attachment_batch_id IS NULL AND annotation_id IS NOT NULL AND annotation_comment_id IS NULL) OR
+    (image_attachment_batch_id IS NULL AND annotation_id IS NULL AND annotation_comment_id IS NOT NULL)
+  SQL
+
+  # A row that no longer belongs to a draft batch is part of a posted message.
+  # Submitted attachments are therefore always fully decoded and measured.
+  SUBMITTED_READY_CHECK = <<~SQL.squish.freeze
+    image_attachment_batch_id IS NOT NULL OR
+    (state = 1 AND media_type IS NOT NULL AND width IS NOT NULL AND height IS NOT NULL AND byte_size IS NOT NULL)
+  SQL
+
+  def change
+    create_table :image_attachment_batches do |table|
+      table.string :public_id, limit: 43, null: false
+      table.references :user, null: false, foreign_key: { on_delete: :restrict }
+      table.references :project, null: false, foreign_key: true
+      table.integer :state, null: false, default: 0
+      table.references :claimed_annotation, foreign_key: { to_table: :annotations }
+      table.references :claimed_annotation_comment, foreign_key: { to_table: :annotation_comments }
+      table.datetime :last_activity_at, null: false
+      table.datetime :expires_at, null: false
+
+      table.timestamps
+    end
+
+    add_index :image_attachment_batches, :public_id, unique: true
+    add_index :image_attachment_batches, %i[user_id state]
+    add_index :image_attachment_batches, %i[state expires_at]
+
+    add_check_constraint :image_attachment_batches, "state IN (0, 1)",
+      name: "image_attachment_batches_valid_state"
+    add_check_constraint :image_attachment_batches, "expires_at > last_activity_at",
+      name: "image_attachment_batches_future_expiry"
+    add_check_constraint :image_attachment_batches, BATCH_CLAIM_CHECK,
+      name: "image_attachment_batches_claim_state"
+
+    create_table :image_attachments do |table|
+      table.references :image_attachment_batch, foreign_key: true
+      table.references :annotation, foreign_key: true
+      table.references :annotation_comment, foreign_key: true
+      table.references :user, null: false, foreign_key: { on_delete: :restrict }
+      table.references :project, null: false, foreign_key: true
+      table.integer :state, null: false, default: 0
+      table.string :client_key, limit: 64, null: false
+      table.string :alt_text, limit: 1000
+      table.string :media_type, limit: 40
+      table.integer :width
+      table.integer :height
+      table.bigint :byte_size
+      table.string :failure_code, limit: 64
+
+      table.timestamps
+    end
+
+    add_index :image_attachments, %i[image_attachment_batch_id client_key],
+      unique: true,
+      where: "image_attachment_batch_id IS NOT NULL",
+      name: "index_image_attachments_on_batch_client_key"
+
+    add_check_constraint :image_attachments, "state IN (0, 1, 2)",
+      name: "image_attachments_valid_state"
+    add_check_constraint :image_attachments, PARENT_CHECK,
+      name: "image_attachments_exclusive_parent"
+    add_check_constraint :image_attachments, SUBMITTED_READY_CHECK,
+      name: "image_attachments_submitted_ready"
+    add_check_constraint :image_attachments, "byte_size IS NULL OR byte_size >= 0",
+      name: "image_attachments_nonnegative_bytes"
+  end
+end
diff --git a/db/migrate/20260831120000_add_client_key_to_image_attachment_batches.rb b/db/migrate/20260831120000_add_client_key_to_image_attachment_batches.rb
new file mode 100644
index 0000000..d8c1077
--- /dev/null
+++ b/db/migrate/20260831120000_add_client_key_to_image_attachment_batches.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# A composer that loses the response to its batch creation has no way to ask
+# whether the batch exists, so every retry used to consume one more of the six
+# open batches an account is allowed. The mounted composer now supplies its own
+# idempotency key and the server returns the batch that key already opened.
+class AddClientKeyToImageAttachmentBatches < ActiveRecord::Migration[8.1]
+  def change
+    add_column :image_attachment_batches, :client_key, :string, limit: 64
+
+    add_index :image_attachment_batches, %i[user_id client_key],
+      unique: true,
+      # Only an open batch reserves its key: once a batch is claimed the
+      # composer has finished with it, and the next mounted composer is free to
+      # present a fresh key without colliding with posted history.
+      where: "client_key IS NOT NULL AND state = 0",
+      name: "index_image_attachment_batches_on_user_and_client_key"
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 2d62e52..67af924 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema[8.1].define(version: 2026_08_09_120000) do
+ActiveRecord::Schema[8.1].define(version: 2026_08_31_120000) do
   create_table "active_storage_attachments", force: :cascade do |t|
     t.bigint "blob_id", null: false
     t.datetime "created_at", null: false
@@ -143,6 +143,59 @@
     t.check_constraint "state IN (0, 1, 2, 3)", name: "authentication_tokens_valid_state"
   end
 
+  create_table "image_attachment_batches", force: :cascade do |t|
+    t.integer "claimed_annotation_comment_id"
+    t.integer "claimed_annotation_id"
+    t.string "client_key", limit: 64
+    t.datetime "created_at", null: false
+    t.datetime "expires_at", null: false
+    t.datetime "last_activity_at", null: false
+    t.integer "project_id", null: false
+    t.string "public_id", limit: 43, null: false
+    t.integer "state", default: 0, null: false
+    t.datetime "updated_at", null: false
+    t.integer "user_id", null: false
+    t.index ["claimed_annotation_comment_id"], name: "idx_on_claimed_annotation_comment_id_9629c6e9ac"
+    t.index ["claimed_annotation_id"], name: "index_image_attachment_batches_on_claimed_annotation_id"
+    t.index ["project_id"], name: "index_image_attachment_batches_on_project_id"
+    t.index ["public_id"], name: "index_image_attachment_batches_on_public_id", unique: true
+    t.index ["state", "expires_at"], name: "index_image_attachment_batches_on_state_and_expires_at"
+    t.index ["user_id", "client_key"], name: "index_image_attachment_batches_on_user_and_client_key", unique: true, where: "client_key IS NOT NULL AND state = 0"
+    t.index ["user_id", "state"], name: "index_image_attachment_batches_on_user_id_and_state"
+    t.index ["user_id"], name: "index_image_attachment_batches_on_user_id"
+    t.check_constraint "(state = 0 AND claimed_annotation_id IS NULL AND claimed_annotation_comment_id IS NULL) OR (state = 1 AND claimed_annotation_id IS NOT NULL AND claimed_annotation_comment_id IS NULL) OR (state = 1 AND claimed_annotation_id IS NULL AND claimed_annotation_comment_id IS NOT NULL)", name: "image_attachment_batches_claim_state"
+    t.check_constraint "expires_at > last_activity_at", name: "image_attachment_batches_future_expiry"
+    t.check_constraint "state IN (0, 1)", name: "image_attachment_batches_valid_state"
+  end
+
+  create_table "image_attachments", force: :cascade do |t|
+    t.string "alt_text", limit: 1000
+    t.integer "annotation_comment_id"
+    t.integer "annotation_id"
+    t.bigint "byte_size"
+    t.string "client_key", limit: 64, null: false
+    t.datetime "created_at", null: false
+    t.string "failure_code", limit: 64
+    t.integer "height"
+    t.integer "image_attachment_batch_id"
+    t.string "media_type", limit: 40
+    t.integer "project_id", null: false
+    t.integer "state", default: 0, null: false
+    t.datetime "updated_at", null: false
+    t.integer "user_id", null: false
+    t.integer "width"
+    t.index ["annotation_comment_id"], name: "index_image_attachments_on_annotation_comment_id"
+    t.index ["annotation_id"], name: "index_image_attachments_on_annotation_id"
+    t.index ["image_attachment_batch_id", "client_key"], name: "index_image_attachments_on_batch_client_key", unique: true, where: "image_attachment_batch_id IS NOT NULL"
+    t.index ["image_attachment_batch_id"], name: "index_image_attachments_on_image_attachment_batch_id"
+    t.index ["project_id"], name: "index_image_attachments_on_project_id"
+    t.index ["user_id"], name: "index_image_attachments_on_user_id"
+    t.check_constraint "(image_attachment_batch_id IS NOT NULL AND annotation_id IS NULL AND annotation_comment_id IS NULL) OR (image_attachment_batch_id IS NULL AND annotation_id IS NOT NULL AND annotation_comment_id IS NULL) OR (image_attachment_batch_id IS NULL AND annotation_id IS NULL AND annotation_comment_id IS NOT NULL)", name: "image_attachments_exclusive_parent"
+    t.check_constraint "byte_size IS NULL OR byte_size >= 0", name: "image_attachments_nonnegative_bytes"
+    t.check_constraint "image_attachment_batch_id IS NOT NULL OR (state = 1 AND media_type IS NOT NULL AND width IS NOT NULL AND height IS NOT NULL AND byte_size IS NOT NULL)", name: "image_attachments_submitted_ready"
+    t.check_constraint "state IN (0, 1, 2)", name: "image_attachments_valid_state"
+  end
+
   create_table "installation_audit_events", force: :cascade do |t|
     t.integer "actor_user_id"
     t.datetime "created_at", null: false
@@ -410,6 +463,15 @@
   add_foreign_key "authentication_tokens", "project_invitations"
   add_foreign_key "authentication_tokens", "users"
   add_foreign_key "authentication_tokens", "users", column: "issued_by_user_id"
+  add_foreign_key "image_attachment_batches", "annotation_comments", column: "claimed_annotation_comment_id"
+  add_foreign_key "image_attachment_batches", "annotations", column: "claimed_annotation_id"
+  add_foreign_key "image_attachment_batches", "projects"
+  add_foreign_key "image_attachment_batches", "users", on_delete: :restrict
+  add_foreign_key "image_attachments", "annotation_comments"
+  add_foreign_key "image_attachments", "annotations"
+  add_foreign_key "image_attachments", "image_attachment_batches"
+  add_foreign_key "image_attachments", "projects"
+  add_foreign_key "image_attachments", "users", on_delete: :restrict
   add_foreign_key "installation_audit_events", "installations"
   add_foreign_key "installation_audit_events", "users", column: "actor_user_id"
   add_foreign_key "installation_audit_events", "users", column: "target_user_id"
diff --git a/internal/screenote/attachments_test.go b/internal/screenote/attachments_test.go
new file mode 100644
index 0000000..cdaf734
--- /dev/null
+++ b/internal/screenote/attachments_test.go
@@ -0,0 +1,243 @@
+package screenote
+
+import (
+	"encoding/json"
+	"reflect"
+	"testing"
+)
+
+// The service always sends `attachments` on the root annotation and on every
+// comment of a detail read. Decoding must keep the metadata and the expiring
+// URL intact without disturbing any previously shipped field.
+func TestAnnotationDetailDecodesAttachments(t *testing.T) {
+	payload := []byte(`{
+		"id": 7,
+		"screenshot_id": 3,
+		"viewport": "desktop",
+		"type": "region",
+		"coordinates": {"x_percent": 1.0, "y_percent": 2.0, "width_percent": 3.0, "height_percent": 4.0},
+		"comment": "Broken button",
+		"status": "open",
+		"author": "alice@example.com",
+		"comments_count": 1,
+		"created_at": "2026-08-29T00:00:00Z",
+		"screenshot_status": "ready",
+		"mime_type": "image/png",
+		"attachments": [
+			{
+				"id": 11,
+				"alt_text": "The disabled save button",
+				"media_type": "image/png",
+				"width": 800,
+				"height": 600,
+				"size": 12345,
+				"url": "https://screenote.test/api/media/image_attachments/11?token=abc",
+				"url_expires_at": "2026-08-29T00:05:00Z"
+			}
+		],
+		"comments": [
+			{
+				"id": 21,
+				"action": "comment",
+				"body": "Here is the crop",
+				"author": "bob@example.com",
+				"created_at": "2026-08-29T00:01:00Z",
+				"attachments": [
+					{
+						"id": 12,
+						"alt_text": null,
+						"media_type": "image/webp",
+						"width": 100,
+						"height": 50,
+						"size": 900,
+						"url": "https://screenote.test/api/media/image_attachments/12?token=def",
+						"url_expires_at": "2026-08-29T00:05:00Z"
+					}
+				]
+			}
+		]
+	}`)
+
+	var annotation Annotation
+	if err := json.Unmarshal(payload, &annotation); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+
+	if annotation.Comment != "Broken button" || annotation.MIMEType != "image/png" {
+		t.Fatalf("shipped annotation fields changed: %+v", annotation)
+	}
+	if annotation.Attachments == nil || len(*annotation.Attachments) != 1 {
+		t.Fatalf("expected one root attachment, got %+v", annotation.Attachments)
+	}
+
+	root := (*annotation.Attachments)[0]
+	if root.ID != 11 || root.MediaType != "image/png" || root.Width != 800 || root.Size != 12345 {
+		t.Fatalf("root attachment metadata lost: %+v", root)
+	}
+	if root.AltText == nil || *root.AltText != "The disabled save button" {
+		t.Fatalf("alt text lost: %+v", root.AltText)
+	}
+	if root.URLExpiresAt != "2026-08-29T00:05:00Z" {
+		t.Fatalf("url expiry lost: %q", root.URLExpiresAt)
+	}
+
+	if len(annotation.Comments) != 1 || annotation.Comments[0].Attachments == nil ||
+		len(*annotation.Comments[0].Attachments) != 1 {
+		t.Fatalf("comment attachments lost: %+v", annotation.Comments)
+	}
+	if (*annotation.Comments[0].Attachments)[0].AltText != nil {
+		t.Fatalf("absent alt text must decode as nil")
+	}
+	if annotation.Comments[0].Body != "Here is the crop" {
+		t.Fatalf("shipped comment fields changed: %+v", annotation.Comments[0])
+	}
+}
+
+// A detail read for a message with no images sends an empty array, and that is
+// an authoritative "no images" the round trip must preserve.
+func TestEmptyAttachmentsRemarshalAsAnArray(t *testing.T) {
+	var annotation Annotation
+	payload := `{"id": 1, "comment": "No images", "attachments": [], "comments": [{"id": 2, "attachments": []}]}`
+	if err := json.Unmarshal([]byte(payload), &annotation); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	if annotation.Attachments == nil || len(*annotation.Attachments) != 0 {
+		t.Fatalf("an empty array must decode as present and empty: %+v", annotation.Attachments)
+	}
+
+	remarshalled := remarshal(t, annotation)
+	attachments, ok := remarshalled["attachments"].([]any)
+	if !ok || len(attachments) != 0 {
+		t.Fatalf("root attachments must remarshal as an empty array: %+v", remarshalled)
+	}
+
+	comments, ok := remarshalled["comments"].([]any)
+	if !ok || len(comments) != 1 {
+		t.Fatalf("expected one comment: %+v", remarshalled)
+	}
+	commentAttachments, ok := comments[0].(map[string]any)["attachments"].([]any)
+	if !ok || len(commentAttachments) != 0 {
+		t.Fatalf("comment attachments must remarshal as an empty array: %+v", remarshalled)
+	}
+}
+
+// List rows carry no attachment metadata at all. Re-encoding one must not
+// invent `attachments: []`, which would tell an agent the message has no
+// images and stop it from ever asking for the detail read that reports them.
+func TestListRowsKeepAttachmentsAbsent(t *testing.T) {
+	var annotation Annotation
+	payload := `{"id": 1, "comment": "Only a list row", "comments_count": 2}`
+	if err := json.Unmarshal([]byte(payload), &annotation); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	if annotation.Attachments != nil {
+		t.Fatalf("an absent key must decode as nil: %+v", annotation.Attachments)
+	}
+
+	remarshalled := remarshal(t, annotation)
+	if _, present := remarshalled["attachments"]; present {
+		t.Fatalf("a list row must not gain an attachments key: %+v", remarshalled)
+	}
+
+	comment := remarshal(t, Comment{ID: 2})
+	if _, present := comment["attachments"]; present {
+		t.Fatalf("a comment without attachments must not gain the key: %+v", comment)
+	}
+}
+
+func remarshal(t *testing.T, value any) map[string]any {
+	t.Helper()
+
+	encoded, err := json.Marshal(value)
+	if err != nil {
+		t.Fatalf("marshal: %v", err)
+	}
+
+	var decoded map[string]any
+	if err := json.Unmarshal(encoded, &decoded); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+
+	return decoded
+}
+
+// The shipped detail contract, recorded whole. A key allowlist would not catch
+// a value that changed meaning or a field that quietly stopped round-tripping,
+// so this decodes the canonical payload and re-encodes it: every previously
+// shipped key, the canonical root attachment object, and the per-comment
+// attachment object all have to come back exactly as they went in.
+const goldenAnnotationDetail = `{
+	"id": 7,
+	"screenshot_id": 3,
+	"viewport": "desktop",
+	"type": "region",
+	"coordinates": {"x_percent": 50, "y_percent": 30, "width_percent": 12.5, "height_percent": 8.25},
+	"comment": "Button text too small",
+	"status": "open",
+	"author": "alice@example.com",
+	"comments_count": 2,
+	"created_at": "2026-08-29T00:00:00Z",
+	"screenshot_status": "ready",
+	"cropped_image_base64": "iVBORw0KGgo=",
+	"mime_type": "image/png",
+	"attachments": [
+		{
+			"id": 11,
+			"alt_text": "The disabled save button",
+			"media_type": "image/png",
+			"width": 800,
+			"height": 600,
+			"size": 12345,
+			"url": "https://screenote.test/api/media/image_attachments/11?token=abc",
+			"url_expires_at": "2026-08-29T00:05:00Z"
+		}
+	],
+	"comments": [
+		{
+			"id": 21,
+			"action": "comment",
+			"body": "And here",
+			"author": "bob@example.com",
+			"created_at": "2026-08-29T00:01:00Z",
+			"attachments": [
+				{
+					"id": 12,
+					"alt_text": null,
+					"media_type": "image/webp",
+					"width": 100,
+					"height": 50,
+					"size": 900,
+					"url": "https://screenote.test/api/media/image_attachments/12?token=def",
+					"url_expires_at": "2026-08-29T00:05:00Z"
+				}
+			]
+		},
+		{
+			"id": 22,
+			"action": "resolved",
+			"body": "Fixed",
+			"author": "alice@example.com",
+			"created_at": "2026-08-29T00:02:00Z",
+			"attachments": []
+		}
+	]
+}`
+
+func TestAnnotationDetailGoldenRoundTrip(t *testing.T) {
+	var annotation Annotation
+	if err := json.Unmarshal([]byte(goldenAnnotationDetail), &annotation); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+
+	var expected map[string]any
+	if err := json.Unmarshal([]byte(goldenAnnotationDetail), &expected); err != nil {
+		t.Fatalf("unmarshal golden: %v", err)
+	}
+
+	actual := remarshal(t, annotation)
+	if !reflect.DeepEqual(expected, actual) {
+		expectedJSON, _ := json.MarshalIndent(expected, "", "  ")
+		actualJSON, _ := json.MarshalIndent(actual, "", "  ")
+		t.Fatalf("the detail contract changed:\nwant %s\ngot  %s", expectedJSON, actualJSON)
+	}
+}
diff --git a/internal/screenote/types.go b/internal/screenote/types.go
index 2c39b9f..dd77d7c 100644
--- a/internal/screenote/types.go
+++ b/internal/screenote/types.go
@@ -1,5 +1,7 @@
 package screenote
 
+import "encoding/json"
+
 type Project struct {
 	ID              int    `json:"id"`
 	Name            string `json:"name"`
@@ -60,21 +62,53 @@ type Coordinates struct {
 	HeightPercent *float64 `json:"height_percent"`
 }
 
+// Attachment is one image posted with a native browser message. URL is minted
+// per read and expires; it is never durable, so callers must fetch it while
+// UrlExpiresAt is still in the future and always with their bearer credential.
+type Attachment struct {
+	ID           int     `json:"id"`
+	AltText      *string `json:"alt_text"`
+	MediaType    string  `json:"media_type"`
+	Width        int     `json:"width"`
+	Height       int     `json:"height"`
+	Size         int64   `json:"size"`
+	URL          string  `json:"url"`
+	URLExpiresAt string  `json:"url_expires_at"`
+}
+
+// Attachments marshals as an array whenever the key was present at all. The
+// service sends `attachments` on every detail read — empty rather than absent —
+// so a decode-then-encode round trip keeps that contract instead of emitting
+// null. List rows carry no attachment metadata and omit the key entirely; the
+// pointer fields below preserve that difference, because re-adding an empty
+// array there would assert "this message has no images" about a message whose
+// images are only reported on a detail read.
+type Attachments []Attachment
+
+func (a Attachments) MarshalJSON() ([]byte, error) {
+	if a == nil {
+		return []byte("[]"), nil
+	}
+
+	return json.Marshal([]Attachment(a))
+}
+
 type Annotation struct {
-	ID                 int         `json:"id"`
-	ScreenshotID       int         `json:"screenshot_id"`
-	Viewport           string      `json:"viewport"`
-	Type               string      `json:"type"`
-	Coordinates        Coordinates `json:"coordinates"`
-	Comment            string      `json:"comment"`
-	Status             string      `json:"status"`
-	Author             string      `json:"author"`
-	CommentsCount      int         `json:"comments_count"`
-	CreatedAt          string      `json:"created_at"`
-	ScreenshotStatus   string      `json:"screenshot_status,omitempty"`
-	CroppedImageBase64 *string     `json:"cropped_image_base64,omitempty"`
-	MIMEType           string      `json:"mime_type,omitempty"`
-	Comments           []Comment   `json:"comments,omitempty"`
+	ID                 int          `json:"id"`
+	ScreenshotID       int          `json:"screenshot_id"`
+	Viewport           string       `json:"viewport"`
+	Type               string       `json:"type"`
+	Coordinates        Coordinates  `json:"coordinates"`
+	Comment            string       `json:"comment"`
+	Status             string       `json:"status"`
+	Author             string       `json:"author"`
+	CommentsCount      int          `json:"comments_count"`
+	CreatedAt          string       `json:"created_at"`
+	ScreenshotStatus   string       `json:"screenshot_status,omitempty"`
+	CroppedImageBase64 *string      `json:"cropped_image_base64,omitempty"`
+	MIMEType           string       `json:"mime_type,omitempty"`
+	Attachments        *Attachments `json:"attachments,omitempty"`
+	Comments           []Comment    `json:"comments,omitempty"`
 }
 
 type AnnotationsResponse struct {
@@ -83,10 +117,11 @@ type AnnotationsResponse struct {
 }
 
 type Comment struct {
-	ID           int    `json:"id"`
-	AnnotationID int    `json:"annotation_id,omitempty"`
-	Action       string `json:"action"`
-	Body         string `json:"body"`
-	Author       string `json:"author"`
-	CreatedAt    string `json:"created_at"`
+	ID           int          `json:"id"`
+	AnnotationID int          `json:"annotation_id,omitempty"`
+	Action       string       `json:"action"`
+	Body         string       `json:"body"`
+	Author       string       `json:"author"`
+	CreatedAt    string       `json:"created_at"`
+	Attachments  *Attachments `json:"attachments,omitempty"`
 }
diff --git a/script/attachment_browser_evidence b/script/attachment_browser_evidence
new file mode 100755
index 0000000..9351aed
--- /dev/null
+++ b/script/attachment_browser_evidence
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+
+# Records the image attachment browser flow and leaves reviewable artifacts.
+#
+#   script/attachment_browser_evidence [OUTPUT_DIRECTORY]
+#
+# Produces, per test, a watchable WebM of the run, the ordered filmstrip it was
+# built from, the Playwright trace both came out of, and, at the points the plan
+# names, a captured frame with the page facts a picture cannot show on its own —
+# which URL each thumbnail was fetched from, which component context rendered
+# it, and the measured overlay geometry.
+#
+# The video is encoded from the trace's screencast rather than recorded by the
+# browser. Playwright's own `record_video_dir` cannot be used here: this driver
+# asks the page for its video path while the page is still open, and that path
+# resolves through a future the page-close event rejects, so a run that sets the
+# option blocks forever with an empty file. The screencast carries the same
+# picture — it runs at roughly 20fps for the whole length of a test — and every
+# frame keeps the timestamp it was captured at, so the encode below reproduces
+# the run's real timing instead of a nominal frame rate.
+
+set -Eeuo pipefail
+umask 077
+
+readonly REPOSITORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+readonly OUTPUT_DIRECTORY="$(cd "${REPOSITORY_ROOT}" && mkdir -p "${1:-tmp/evidence/attachments}" && cd "${1:-tmp/evidence/attachments}" && pwd)"
+readonly SUITE="test/system/image_attachments_test.rb"
+
+fail() {
+  printf 'attachment browser evidence: %s\n' "$*" >&2
+  exit 1
+}
+
+command -v unzip >/dev/null 2>&1 || fail "unzip is required to extract trace filmstrips"
+command -v ffmpeg >/dev/null 2>&1 || fail "ffmpeg is required to encode the filmstrips"
+
+cd "${REPOSITORY_ROOT}"
+[[ -f "${SUITE}" ]] || fail "required suite is missing: ${SUITE}"
+
+# The gate is only met by artifacts this run produced. Reusing a directory would
+# let a stale trace, or a partial run whose remaining tests never recorded,
+# satisfy it, so a non-empty target is refused rather than written over.
+if [[ -n "$(ls -A "${OUTPUT_DIRECTORY}" 2>/dev/null)" ]]; then
+  fail "${OUTPUT_DIRECTORY} already has contents; remove it so this run cannot inherit stale evidence"
+fi
+
+# One trace per declared test, counted from the suite itself so a test added
+# later cannot quietly go unrecorded.
+expected_traces="$(grep -cE '^[[:space:]]*test "' "${SUITE}")"
+(( expected_traces > 0 )) || fail "no tests were found in ${SUITE}"
+
+env RAILS_ENV=test CAPYBARA_RUN_SERVER=true PARALLEL_WORKERS=1 \
+  SCREENOTE_EVIDENCE_DIR="${OUTPUT_DIRECTORY}" \
+  bin/rails test "${SUITE}"
+
+shopt -s nullglob
+traces=("${OUTPUT_DIRECTORY}"/*.trace.zip)
+(( ${#traces[@]} == expected_traces )) ||
+  fail "expected ${expected_traces} traces, one per declared test, but the run wrote ${#traces[@]}"
+
+for trace in "${traces[@]}"; do
+  test_name="$(basename "${trace}" .trace.zip)"
+  filmstrip="${OUTPUT_DIRECTORY}/filmstrip/${test_name}"
+  rm -rf -- "${filmstrip}"
+  mkdir -p "${filmstrip}"
+  unzip -q -o -j "${trace}" 'resources/*.jpeg' -d "${filmstrip}" || true
+
+  # Each screencast frame is named `page@<page>-<capture-milliseconds>.jpeg`, so
+  # the numeric tail both orders the frames and dates them. Sorting has to run
+  # on the bare file name: the enclosing path is free to contain hyphens of its
+  # own, and sorting whole paths would key on one of those instead.
+  mapfile -t frames < <(
+    find "${filmstrip}" -maxdepth 1 -name 'page@*.jpeg' -printf '%f\n' |
+      awk -F- '{ print $NF, $0 }' | sort -n -k1,1 | cut -d' ' -f2-
+  )
+
+  if (( ${#frames[@]} == 0 )); then
+    fail "${test_name} recorded no screencast frames"
+  fi
+
+  timings=()
+  for index in "${!frames[@]}"; do
+    captured_at="${frames[index]##*-}"
+    timings+=("${captured_at%.jpeg}")
+    mv -- "${filmstrip}/${frames[index]}" "$(printf '%s/%04d.jpeg' "${filmstrip}" "$((index + 1))")"
+  done
+
+  # The concat demuxer holds each frame for the gap until the next one was
+  # captured, which is what makes the encode replay the run at its real pace.
+  # The final frame has no successor, so it gets a one-second tail, and the
+  # demuxer requires it listed once more for that duration to apply.
+  concat_list="${filmstrip}/frames.txt"
+  : >"${concat_list}"
+  for index in "${!timings[@]}"; do
+    frame="$(printf '%04d.jpeg' "$((index + 1))")"
+    if (( index + 1 < ${#timings[@]} )); then
+      duration="$(awk -v a="${timings[index]}" -v b="${timings[index + 1]}" 'BEGIN { printf "%.3f", (b - a) / 1000 }')"
+    else
+      duration="1.000"
+    fi
+    printf "file '%s'\nduration %s\n" "${frame}" "${duration}" >>"${concat_list}"
+  done
+  printf "file '%04d.jpeg'\n" "${#timings[@]}" >>"${concat_list}"
+
+  video="${OUTPUT_DIRECTORY}/${test_name}.webm"
+  ffmpeg -nostdin -loglevel error -y -f concat -safe 0 -i "${concat_list}" \
+    -r 30 -c:v libvpx-vp9 -b:v 0 -crf 34 -deadline realtime -cpu-used 5 \
+    -pix_fmt yuv420p "${video}" ||
+    fail "could not encode ${test_name}"
+
+  [[ -s "${video}" ]] || fail "${test_name} produced an empty video"
+
+  span="$(awk -v a="${timings[0]}" -v b="${timings[-1]}" 'BEGIN { printf "%.1f", (b - a) / 1000 }')"
+  printf '%s: %d frames over %ss, %d video bytes\n' \
+    "${test_name}" "${#frames[@]}" "${span}" "$(wc -c <"${video}")"
+done
+
+printf 'evidence written to %s\n' "${OUTPUT_DIRECTORY}"
diff --git a/script/attachment_object_store_qualification b/script/attachment_object_store_qualification
new file mode 100755
index 0000000..fdc5e70
--- /dev/null
+++ b/script/attachment_object_store_qualification
@@ -0,0 +1,97 @@
+#!/usr/bin/env bash
+
+# Runs the S3 delivery gate against a real object store.
+#
+# Disk storage has no presigned URL to leak and no remote host to redirect to,
+# so "the application streams the bytes itself" cannot be executed against it.
+# The container-s3 CI job supplies a real store as a service; this script
+# supplies the same thing on a developer or agent machine, so the object-store
+# half of the delivery claim can be executed anywhere rather than deferred to
+# an operator.
+#
+#   script/attachment_object_store_qualification
+#
+# Set SCREENOTE_S3_ENDPOINT and its companions to qualify against a store you
+# already run — the hosted Rabata endpoint, for instance; otherwise an ephemeral
+# MinIO container is started and removed.
+
+set -Eeuo pipefail
+umask 077
+
+readonly REPOSITORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+readonly CONTAINER_ENGINE="${SCREENOTE_CONTAINER_ENGINE:-docker}"
+# The same immutable image the container-s3 workflow qualifies against, so a
+# local run and a CI run cannot be measuring different S3 implementations.
+readonly OBJECT_STORE_IMAGE="${SCREENOTE_OBJECT_STORE_IMAGE:-minio/minio:RELEASE.2025-05-24T17-08-30Z@sha256:a616cd8f37758b0296db62cc9e6af05a074e844cc7b5c0a0e62176d73828d440}"
+readonly CONTAINER_NAME="screenote-attachment-object-store-$$"
+readonly ACCESS_KEY_ID="screenote-qualification"
+readonly SECRET_ACCESS_KEY="screenote-qualification-secret"
+
+started_container=false
+
+fail() {
+  printf 'attachment object store qualification: %s\n' "$*" >&2
+  exit 1
+}
+
+cleanup() {
+  local exit_status=$?
+
+  if [[ "${started_container}" == true ]]; then
+    if (( exit_status != 0 )); then
+      "${CONTAINER_ENGINE}" logs "${CONTAINER_NAME}" >&2 2>&1 || true
+    fi
+    "${CONTAINER_ENGINE}" rm --force "${CONTAINER_NAME}" >/dev/null 2>&1 || true
+  fi
+}
+trap cleanup EXIT
+
+start_object_store() {
+  command -v "${CONTAINER_ENGINE}" >/dev/null 2>&1 ||
+    fail "${CONTAINER_ENGINE} is required, or configure SCREENOTE_S3_ENDPOINT"
+
+  # An ephemeral host port keeps a qualification run from colliding with a
+  # store the machine already publishes.
+  "${CONTAINER_ENGINE}" run --detach --name "${CONTAINER_NAME}" \
+    --publish "127.0.0.1::9000" \
+    --env "MINIO_ROOT_USER=${ACCESS_KEY_ID}" \
+    --env "<redacted-credential> \
+    "${OBJECT_STORE_IMAGE}" server /data >/dev/null
+  started_container=true
+
+  local host_port
+  host_port="$("${CONTAINER_ENGINE}" port "${CONTAINER_NAME}" 9000/tcp | head -1 | sed 's/.*://')"
+  [[ -n "${host_port}" ]] || fail "the object store published no host port"
+
+  export SCREENOTE_S3_ENDPOINT="http://127.0.0.1:${host_port}"
+  export SCREENOTE_S3_REGION="us-east-1"
+  export SCREENOTE_S3_BUCKET="screenote-qualification"
+  export SCREENOTE_S3_PREFIX="attachment-delivery"
+  export SCREENOTE_S3_ACCESS_KEY_ID="${ACCESS_KEY_ID}"
+  export SCREENOTE_S3_SECRET_ACCESS_KEY="${SECRET_ACCESS_KEY}"
+
+  local attempt
+  for attempt in $(seq 1 60); do
+    if curl --fail --silent "${SCREENOTE_S3_ENDPOINT}/minio/health/ready" >/dev/null; then
+      return 0
+    fi
+    sleep 1
+  done
+
+  fail "the object store did not become ready"
+}
+
+cd "${REPOSITORY_ROOT}"
+
+if [[ -n "${SCREENOTE_S3_ENDPOINT-}" ]]; then
+  for name in SCREENOTE_S3_REGION SCREENOTE_S3_BUCKET SCREENOTE_S3_PREFIX \
+    SCREENOTE_S3_ACCESS_KEY_ID SCREENOTE_S3_SECRET_ACCESS_KEY; do
+    [[ -n "${!name-}" ]] || fail "${name} must be set alongside SCREENOTE_S3_ENDPOINT"
+  done
+else
+  start_object_store
+fi
+
+# The s3 gate exports SCREENOTE_REQUIRE_S3, which turns the suite's "no store
+# configured" skip into a failure, so this run cannot pass by not running.
+script/release_test_matrix s3
diff --git a/script/attachment_server_database_qualification b/script/attachment_server_database_qualification
new file mode 100755
index 0000000..667b330
--- /dev/null
+++ b/script/attachment_server_database_qualification
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+
+# Runs the image attachment lifecycle gate against a real server database.
+#
+# SQLite proves the constraint contracts and the deterministic interleavings,
+# but the aggregate races, the fixed lock order, and the atomic claim only have
+# production semantics under real row locks. The concurrency-qualification
+# workflow supplies that database as a CI service; this script supplies the same
+# thing on a developer or agent machine so the server-database half of the
+# lifecycle claim can be executed anywhere, not only in CI.
+#
+#   script/attachment_server_database_qualification
+#
+# Set SCREENOTE_SERVER_DATABASE_URL to qualify against a database you already
+# run; otherwise an ephemeral container is started and removed.
+
+set -Eeuo pipefail
+umask 077
+
+readonly REPOSITORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+readonly CONTAINER_ENGINE="${SCREENOTE_CONTAINER_ENGINE:-docker}"
+readonly SERVER_IMAGE="${SCREENOTE_SERVER_DATABASE_IMAGE:-postgres:17}"
+readonly CONTAINER_NAME="screenote-attachment-qualification-$$"
+readonly DATABASE_USER="screenote"
+readonly DATABASE_NAME="screenote_test"
+
+started_container=false
+host_port=""
+
+fail() {
+  printf 'attachment server database qualification: %s\n' "$*" >&2
+  exit 1
+}
+
+cleanup() {
+  local exit_status=$?
+
+  if [[ "${started_container}" == true ]]; then
+    if (( exit_status != 0 )); then
+      "${CONTAINER_ENGINE}" logs "${CONTAINER_NAME}" >&2 2>&1 || true
+    fi
+    "${CONTAINER_ENGINE}" rm --force "${CONTAINER_NAME}" >/dev/null 2>&1 || true
+  fi
+}
+trap cleanup EXIT
+
+start_server_database() {
+  command -v "${CONTAINER_ENGINE}" >/dev/null 2>&1 ||
+    fail "${CONTAINER_ENGINE} is required, or set SCREENOTE_SERVER_DATABASE_URL"
+
+  # An ephemeral host port keeps a qualification run from colliding with a
+  # database the machine already publishes.
+  "${CONTAINER_ENGINE}" run --detach --name "${CONTAINER_NAME}" \
+    --publish "127.0.0.1::5432" \
+    --env "POSTGRES_USER=${DATABASE_USER}" \
+    --env "POSTGRES_DB=${DATABASE_NAME}" \
+    --env POSTGRES_HOST_AUTH_METHOD=trust \
+    "${SERVER_IMAGE}" >/dev/null
+  started_container=true
+  host_port="$("${CONTAINER_ENGINE}" port "${CONTAINER_NAME}" 5432/tcp | head -1 | sed 's/.*://')"
+  [[ -n "${host_port}" ]] || fail "the server database published no host port"
+
+  local attempt
+  for attempt in $(seq 1 60); do
+    if "${CONTAINER_ENGINE}" exec "${CONTAINER_NAME}" \
+      pg_isready --username "${DATABASE_USER}" --dbname "${DATABASE_NAME}" >/dev/null 2>&1; then
+      return 0
+    fi
+    sleep 1
+  done
+
+  fail "the server database did not become ready"
+}
+
+cd "${REPOSITORY_ROOT}"
+
+if [[ -n "${SCREENOTE_SERVER_DATABASE_URL-}" ]]; then
+  database_url="${SCREENOTE_SERVER_DATABASE_URL}"
+else
+  start_server_database
+  database_url="postgresql://${DATABASE_USER}@127.0.0.1:${host_port}/${DATABASE_NAME}"
+fi
+
+# SCREENOTE_SERVER_DATABASE_QUALIFICATION makes the suite assert the adapter it
+# claims to exercise, so this run cannot silently pass on SQLite behavior.
+env DATABASE_URL="${database_url}" \
+  SCREENOTE_SERVER_DATABASE_QUALIFICATION=1 \
+  script/release_test_matrix attachment-lifecycle
diff --git a/script/release_test_matrix b/script/release_test_matrix
index 3aedc70..4181012 100755
--- a/script/release_test_matrix
+++ b/script/release_test_matrix
@@ -22,6 +22,21 @@ readonly CONCURRENCY_TESTS=(
   test/integration/instance_credential_issuance_serialization_test.rb
   test/integration/oauth_device_concurrency_test.rb
 )
+readonly ATTACHMENT_LIFECYCLE_TESTS=(
+  test/integration/image_attachment_concurrency_test.rb
+  test/integration/database_constraint_contract_test.rb
+  test/models/image_attachment_test.rb
+  test/models/image_attachment_batch_test.rb
+  test/services/image_attachments/ingest_test.rb
+  test/services/image_attachments/claim_batch_test.rb
+  test/services/screenote/recurring_tasks_test.rb
+  test/jobs/image_attachment_draft_cleanup_job_test.rb
+  test/jobs/image_attachment_orphan_reconciliation_job_test.rb
+  test/controllers/image_attachment_drafts/batches_controller_test.rb
+  test/controllers/image_attachment_drafts/attachments_controller_test.rb
+  test/controllers/image_attachment_submission_test.rb
+  test/controllers/image_attachment_media_controller_test.rb
+)
 readonly SECURITY_DATA_SELF_HOSTED_TESTS=(
   test/integration/authorized_media_delivery_test.rb
   test/integration/bearer_secret_upgrade_test.rb
@@ -229,6 +244,16 @@ case "${1-}" in
       bin/rails test test/integration/database_constraint_contract_test.rb \
       test/tools/mcp_security_contract_test.rb "${CONCURRENCY_TESTS[@]}"
     ;;
+  attachment-lifecycle)
+    # One list, two databases. Run with no DATABASE_URL for the local SQLite
+    # constraint and interleaving contracts; export a server DATABASE_URL (see
+    # script/attachment_server_database_qualification) to rerun the identical
+    # list against real row locks. Every case in the list executes on both, so
+    # neither database can be the only place a lifecycle claim was proven.
+    validate_manifest "${ATTACHMENT_LIFECYCLE_TESTS[@]}"
+    prepare_database
+    env RAILS_ENV=test PARALLEL_WORKERS=1 bin/rails test "${ATTACHMENT_LIFECYCLE_TESTS[@]}"
+    ;;
   security-data)
     validate_manifest "${SECURITY_DATA_SELF_HOSTED_TESTS[@]}" \
       "${SECURITY_DATA_SAAS_TESTS[@]}"
@@ -413,6 +438,15 @@ Usage: script/release_test_matrix GATE
 PR gates:
   self-hosted saas sqlite-production invariants
   security-data system-collaboration container-local s3 coverage
+  attachment-lifecycle
+
+attachment-lifecycle runs against the configured database. Export a server
+DATABASE_URL to rerun the same list against real row locks:
+  script/attachment_server_database_qualification
+
+s3 needs a real object store. To supply an ephemeral one and run the gate
+against it:
+  script/attachment_object_store_qualification
 
 Coverage requires an explicit comparison commit:
   SCREENOTE_COVERAGE_BASE_SHA=<full-ancestor-sha> script/release_test_matrix coverage
diff --git a/test/controllers/api/image_attachment_media_controller_test.rb b/test/controllers/api/image_attachment_media_controller_test.rb
new file mode 100644
index 0000000..10deec8
--- /dev/null
+++ b/test/controllers/api/image_attachment_media_controller_test.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+module Api
+  class ImageAttachmentMediaControllerTest < ActionDispatch::IntegrationTest
+    setup do
+      require_vips!
+      @user = users(:alice)
+      @project = projects(:alice_project)
+      @annotation = annotations(:point_annotation)
+      @api_key = api_keys(:alice_key)
+      @attachment = attach_to(@annotation)
+      @token = @attachment.generate_token_for(ImageAttachment::MEDIA_TOKEN_PURPOSE)
+    end
+
+    test "an authorized bearer principal with a live token streams the bytes" do
+      get media_path, headers: bearer_headers
+
+      assert_response :success
+      assert_equal "private, no-store", response.headers["Cache-Control"]
+      assert_equal "nosniff", response.headers["X-Content-Type-Options"]
+      assert_nil response.headers["Location"]
+    end
+
+    test "a token alone is never sufficient" do
+      get media_path
+
+      assert_response :unauthorized
+    end
+
+    test "a bearer principal without the token is refused" do
+      get api_image_attachment_media_path(@attachment), headers: bearer_headers
+
+      assert_response :not_found
+    end
+
+    test "a token for another attachment cannot be replayed against this one" do
+      other = attach_to(@annotation)
+
+      get api_image_attachment_media_path(@attachment, token: other.generate_token_for(ImageAttachment::MEDIA_TOKEN_PURPOSE)),
+        headers: bearer_headers
+
+      assert_response :not_found
+    end
+
+    test "the token expires after five minutes" do
+      travel ImageAttachment::MEDIA_TOKEN_EXPIRY + 1.second do
+        get media_path, headers: bearer_headers
+
+        assert_response :not_found
+      end
+    end
+
+    test "a refreshed token works after the first expires" do
+      travel ImageAttachment::MEDIA_TOKEN_EXPIRY + 1.second do
+        get api_image_attachment_media_path(
+          @attachment, token: @attachment.generate_token_for(ImageAttachment::MEDIA_TOKEN_PURPOSE)
+        ), headers: bearer_headers
+
+        assert_response :success
+      end
+    end
+
+    test "a revoked credential loses access immediately" do
+      @api_key.update!(revoked_at: Time.current)
+
+      get media_path, headers: bearer_headers
+
+      assert_response :unauthorized
+    end
+
+    test "a credential scoped to another project is refused" do
+      get media_path, headers: { "Authorization" => "Bearer #{bob_key_token}" }
+
+      assert_response :not_found
+    end
+
+    test "a write-only OAuth principal cannot read the bytes" do
+      write_only = create_oauth_token(
+        application: create_oauth_application, user: @user, scopes: AuthenticatedPrincipal::WRITE_SCOPE
+      )
+
+      get media_path, headers: { "Authorization" => "Bearer #{write_only.token}" }
+
+      assert_response :forbidden
+      assert_equal "insufficient_scope", response.parsed_body["code"]
+    end
+
+    test "an attachment whose bytes are gone is refused" do
+      @attachment.image.purge
+
+      get media_path, headers: bearer_headers
+
+      assert_response :not_found
+    end
+
+    test "an attachment whose message was deleted is refused" do
+      ImageAttachment.connection.disable_referential_integrity do
+        Annotation.where(id: @annotation.id).delete_all
+      end
+
+      get media_path, headers: bearer_headers
+
+      assert_response :not_found
+    end
+
+    private
+
+    def media_path
+      api_image_attachment_media_path(@attachment, token: @token)
+    end
+
+    def bearer_headers
+      { "Authorization" => "Bearer #{alice_key_token}" }
+    end
+
+    ALICE_TOKEN = "sk_proj_test_alice_key_000000000000000000000000"
+    BOB_TOKEN = "sk_proj_test_bob_key_0000000000000000000000000"
+
+    def alice_key_token
+      ALICE_TOKEN
+    end
+
+    def bob_key_token
+      BOB_TOKEN
+    end
+
+    def attach_to(parent)
+      batch = build_batch(user: @user, project: @project)
+      attachment = ingest_image(batch: batch)
+      ImageAttachments::ClaimBatch.call(batch: batch, user: @user, project: @project, parent_type: parent.class) { parent }
+      attachment.reload
+    end
+  end
+end
diff --git a/test/controllers/api/v1/annotation_attachments_contract_test.rb b/test/controllers/api/v1/annotation_attachments_contract_test.rb
new file mode 100644
index 0000000..12c671e
--- /dev/null
+++ b/test/controllers/api/v1/annotation_attachments_contract_test.rb
@@ -0,0 +1,146 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+module Api
+  module V1
+    class AnnotationAttachmentsContractTest < ActionDispatch::IntegrationTest
+      ALICE_TOKEN = "sk_proj_test_alice_key_000000000000000000000000"
+      BOB_TOKEN = "sk_proj_test_bob_key_0000000000000000000000000"
+
+      setup do
+        require_vips!
+        @user = users(:alice)
+        @project = projects(:alice_project)
+        @annotation = annotations(:point_annotation)
+      end
+
+      test "the detail read exposes attachments on the root and every comment" do
+        root = attach_to(@annotation, alt_text: "The disabled save button")
+        comment = @annotation.annotation_comments.create!(user: @user, body: "Here is the crop")
+        reply_attachment = attach_to(comment)
+
+        get_detail
+
+        assert_response :success
+        body = response.parsed_body
+        assert_equal [ root.id ], body.fetch("attachments").map { |item| item["id"] }
+        assert_equal "The disabled save button", body.dig("attachments", 0, "alt_text")
+        assert_equal(
+          [ reply_attachment.id ],
+          body.fetch("comments").find { |item| item["id"] == comment.id }.fetch("attachments").map { |i| i["id"] }
+        )
+      end
+
+      test "attachments are always present as an array" do
+        @annotation.annotation_comments.create!(user: @user, body: "No images here")
+
+        get_detail
+
+        assert_equal [], response.parsed_body.fetch("attachments")
+        assert(response.parsed_body.fetch("comments").all? { |item| item.fetch("attachments") == [] })
+      end
+
+      test "every attachment carries the canonical object" do
+        attach_to(@annotation)
+
+        get_detail
+
+        attachment = response.parsed_body.fetch("attachments").sole
+        assert_equal(
+          %w[alt_text height id media_type size url url_expires_at width],
+          attachment.keys.sort
+        )
+        assert_match %r{/api/media/image_attachments/\d+\?token=}, attachment.fetch("url")
+      end
+
+      test "url expiry matches the five minute purpose token" do
+        attach_to(@annotation)
+
+        freeze_time do
+          get_detail
+
+          assert_equal ImageAttachment::MEDIA_TOKEN_EXPIRY.from_now.iso8601,
+            response.parsed_body.dig("attachments", 0, "url_expires_at")
+        end
+      end
+
+      test "no storage key or filename is exposed" do
+        attachment = attach_to(@annotation)
+
+        get_detail
+
+        assert_not response.body.include?(attachment.image.blob.key)
+        assert_not response.body.include?("service_name")
+      end
+
+      # The comment object has one shape across every endpoint that returns it.
+      # A write endpoint mints no media URLs and its comment can never carry
+      # attachments, so it answers the canonical empty array rather than
+      # omitting the key.
+      test "write endpoints return a comment with an empty attachments array" do
+        post api_v1_annotation_comments_path(@annotation),
+          params: { body: "Taking a look", project_id: @project.id },
+          headers: bearer_headers
+
+        assert_response :created
+        assert_equal [], response.parsed_body.dig("comment", "attachments")
+      end
+
+      test "resolution endpoints return a comment with an empty attachments array" do
+        post api_v1_annotation_resolve_path(@annotation),
+          params: { project_id: @project.id },
+          headers: bearer_headers
+
+        assert_response :success
+        assert_equal [], response.parsed_body.dig("comment", "attachments")
+      end
+
+      test "the list read stays metadata light" do
+        attach_to(@annotation)
+
+        get api_v1_screenshot_annotations_path(@annotation.screenshot, project_id: @project.id),
+          headers: bearer_headers
+
+        assert_response :success
+        assert(response.parsed_body.fetch("annotations").none? { |item| item.key?("attachments") })
+      end
+
+      # A fixed ceiling would pass an N+1 that only shows up at scale, so the
+      # budget is measured against itself: adding attachments to the root and
+      # to a comment must not add a single query.
+      test "detail reads do not scale queries with attachment count" do
+        attach_to(@annotation)
+        comment = @annotation.annotation_comments.create!(user: @user, body: "Reply")
+        attach_to(comment)
+        get_detail
+        baseline = capture_app_queries { get_detail }.size
+
+        2.times { attach_to(@annotation) }
+        2.times { attach_to(comment) }
+        get_detail
+
+        assert_equal baseline, capture_app_queries { get_detail }.size,
+          "detail reads must not scale queries with attachment count"
+      end
+
+      private
+
+      def get_detail
+        get api_v1_annotation_path(@annotation, project_id: @project.id), headers: bearer_headers
+      end
+
+      def bearer_headers
+        { "Authorization" => "Bearer #{ALICE_TOKEN}" }
+      end
+
+      def attach_to(parent, alt_text: nil)
+        batch = build_batch(user: @user, project: @project)
+        attachment = ingest_image(batch: batch)
+        attachment.update!(alt_text: alt_text) if alt_text
+        ImageAttachments::ClaimBatch.call(batch: batch, user: @user, project: @project, parent_type: parent.class) { parent }
+        attachment.reload
+      end
+    end
+  end
+end
diff --git a/test/controllers/image_attachment_drafts/attachments_controller_test.rb b/test/controllers/image_attachment_drafts/attachments_controller_test.rb
new file mode 100644
index 0000000..b9ca9be
--- /dev/null
+++ b/test/controllers/image_attachment_drafts/attachments_controller_test.rb
@@ -0,0 +1,346 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+module ImageAttachmentDrafts
+  class AttachmentsControllerTest < ActionDispatch::IntegrationTest
+    setup do
+      require_vips!
+      @user = users(:alice)
+      @project = projects(:alice_project)
+      @batch = build_batch(user: @user, project: @project)
+      sign_in @user
+    end
+
+    test "uploads a file and returns stable metadata without provider details" do
+      post_upload("shot.png")
+
+      assert_response :created
+      attachment = response.parsed_body["attachment"]
+      assert_equal "ready", attachment["state"]
+      assert_equal "image/png", attachment["media_type"]
+      assert_equal 8, attachment["width"]
+      assert_equal "one", attachment["client_key"]
+      assert_match %r{\A/media/image_attachments/\d+/original\z}, attachment["preview_url"]
+      assert_nil attachment["failure"]
+      assert_not response.body.include?("service_name")
+      assert_not response.body.include?(ActiveStorage::Blob.last.key)
+    end
+
+    test "reports an actionable machine code for an unreadable file" do
+      post image_attachment_draft_batch_attachments_path(@batch.public_id),
+        params: { client_key: "bad", file: uploaded("junk.png", "not an image") },
+        headers: { "ACCEPT" => "application/json" }
+
+      assert_response :unprocessable_entity
+      error = response.parsed_body["error"]
+      assert_equal "invalid_image", error["code"]
+      assert_equal "Attachments must be PNG, JPEG, or WebP images.", error["message"]
+      assert_predicate @batch.image_attachments.sole, :state_failed?
+    end
+
+    test "a failed row is still reported so the composer can retry or remove it" do
+      post image_attachment_draft_batch_attachments_path(@batch.public_id),
+        params: { client_key: "bad", file: uploaded("junk.png", "not an image") },
+        headers: { "ACCEPT" => "application/json" }
+      raised = response.parsed_body.dig("error", "retryable")
+
+      get image_attachment_draft_batch_path(@batch.public_id), as: :json
+
+      attachment = response.parsed_body["attachments"].sole
+      assert_equal "failed", attachment["state"]
+      assert_equal "invalid_image", attachment.dig("failure", "code")
+      # Bytes the decoder will never accept are removable, not retryable, and a
+      # resumed row has to say exactly what the raise already said.
+      assert_equal raised, attachment.dig("failure", "retryable")
+      assert_not attachment.dig("failure", "retryable")
+      assert_nil attachment["preview_url"]
+    end
+
+    test "edits alt text on a draft" do
+      post_upload("shot.png")
+      id = response.parsed_body.dig("attachment", "id")
+
+      patch image_attachment_draft_batch_attachment_path(@batch.public_id, id),
+        params: { image_attachment: { alt_text: "The disabled save button" } }, as: :json
+
+      assert_response :success
+      assert_equal "The disabled save button", response.parsed_body.dig("attachment", "alt_text")
+    end
+
+    # The browser caps the field with maxlength, but the endpoint still has to
+    # answer a machine code rather than let the model validation escape as a
+    # 500.
+    test "alt text longer than the ceiling answers a machine code" do
+      post_upload("shot.png")
+      id = response.parsed_body.dig("attachment", "id")
+
+      patch image_attachment_draft_batch_attachment_path(@batch.public_id, id),
+        params: { image_attachment: { alt_text: "a" * (ImageAttachment::MAX_ALT_TEXT + 1) } }, as: :json
+
+      assert_response :unprocessable_entity
+      assert_equal "alt_text_too_long", response.parsed_body.dig("error", "code")
+      assert_nil ImageAttachment.find(id).alt_text
+    end
+
+    # Removal nulls the description, so a PATCH that lands after it must not
+    # repopulate a row the composer already discarded.
+    test "alt text on a removed draft is a private not-found" do
+      post_upload("shot.png")
+      id = response.parsed_body.dig("attachment", "id")
+      delete image_attachment_draft_batch_attachment_path(@batch.public_id, id), as: :json
+
+      patch image_attachment_draft_batch_attachment_path(@batch.public_id, id),
+        params: { image_attachment: { alt_text: "Ghost" } }, as: :json
+
+      assert_response :not_found
+      assert_nil ImageAttachment.find(id).alt_text
+    end
+
+    test "removal is idempotent" do
+      post_upload("shot.png")
+      id = response.parsed_body.dig("attachment", "id")
+
+      2.times do
+        delete image_attachment_draft_batch_attachment_path(@batch.public_id, id), as: :json
+
+        assert_response :success
+        assert_empty response.parsed_body["attachments"]
+      end
+      assert_predicate @batch.image_attachments.sole, :removal_tombstone?
+      assert_not @batch.image_attachments.sole.image.attached?
+    end
+
+    test "removal by client key wins before an upload reserves its row" do
+      delete discard_image_attachment_draft_batch_attachments_path(@batch.public_id),
+        params: { client_key: "not-reserved-yet" }, as: :json
+
+      assert_response :success
+      assert_empty response.parsed_body["attachments"]
+      assert_predicate @batch.image_attachments.sole, :removal_tombstone?
+
+      post_upload("shot.png", client_key: "not-reserved-yet")
+
+      assert_response :not_found
+      assert_equal "attachment_removed", response.parsed_body.dig("error", "code")
+      assert_predicate @batch.image_attachments.sole, :removal_tombstone?
+      assert_not @batch.image_attachments.sole.image.attached?
+    end
+
+    # A tombstone only has work to do while an upload for the same key might
+    # still be in flight, so the window is bounded. Without that bound, one open
+    # batch could accumulate rows forever and make every later claim, removal,
+    # and cleanup pass lock more of them.
+    test "retained removal tombstones stay bounded on one open batch" do
+      keys = 20.times.map { |index| "invented-#{index}" }
+      keys.each do |client_key|
+        delete discard_image_attachment_draft_batch_attachments_path(@batch.public_id),
+          params: { client_key: client_key }, as: :json
+        assert_response :success
+      end
+
+      tombstones = @batch.image_attachments.where(failure_code: ImageAttachment::REMOVAL_TOMBSTONE)
+
+      assert_equal ImageAttachment::MAX_FILES, tombstones.count
+      assert_equal keys.last(ImageAttachment::MAX_FILES), tombstones.order(:id).pluck(:client_key)
+    end
+
+    # Pruning may only drop markers; a ready draft is never a tombstone and must
+    # survive however many removals go past it.
+    test "bounding tombstones never touches a ready draft" do
+      post_upload("keeper.png", client_key: "keeper")
+      keeper = response.parsed_body.dig("attachment", "id")
+
+      10.times do |index|
+        delete discard_image_attachment_draft_batch_attachments_path(@batch.public_id),
+          params: { client_key: "churn-#{index}" }, as: :json
+      end
+
+      assert_predicate ImageAttachment.find(keeper), :state_ready?
+    end
+
+    test "a collaborator cannot upload into another member's draft" do
+      @project.project_memberships.find_or_create_by!(user: users(:bob)) { |m| m.role = :member }
+      delete session_path
+      sign_in users(:bob)
+
+      post_upload("shot.png")
+
+      assert_response :not_found
+    end
+
+    test "an expired batch refuses new uploads" do
+      @batch.update_columns(last_activity_at: 25.hours.ago, expires_at: 1.minute.ago)
+
+      post_upload("shot.png")
+
+      assert_response :unprocessable_entity
+      assert_equal "batch_unusable", response.parsed_body.dig("error", "code")
+    end
+
+    test "uploads require a CSRF token" do
+      with_forgery_protection do
+        post_upload("shot.png")
+
+        assert_response :unprocessable_entity
+      end
+    end
+
+    test "editing alt text requires a CSRF token" do
+      post_upload("shot.png")
+      id = response.parsed_body.dig("attachment", "id")
+
+      with_forgery_protection do
+        patch image_attachment_draft_batch_attachment_path(@batch.public_id, id),
+          params: { image_attachment: { alt_text: "Injected" } }, as: :json
+
+        assert_response :unprocessable_entity
+      end
+      assert_nil ImageAttachment.find(id).alt_text
+    end
+
+    test "an upload carrying the composer's token passes forgery protection" do
+      with_forgery_protection do
+        post image_attachment_draft_batch_attachments_path(@batch.public_id),
+          params: { client_key: "one", file: uploaded("shot.png", image_bytes) },
+          headers: { "ACCEPT" => "application/json", "X-CSRF-Token" => csrf_token }
+
+        assert_response :created
+        assert_equal "ready", response.parsed_body.dig("attachment", "state")
+      end
+    end
+
+    test "the per-file ceiling measures the uploaded part, not the multipart envelope" do
+      bytes = image_bytes
+
+      stub_const(ImageAttachment, :MAX_FILE_SIZE, bytes.bytesize + 1) do
+        post image_attachment_draft_batch_attachments_path(@batch.public_id),
+          params: { client_key: "one", file: uploaded("a-rather-long-original-filename.png", bytes) },
+          headers: { "ACCEPT" => "application/json" }
+
+        assert_operator request.content_length, :>, ImageAttachment::MAX_FILE_SIZE
+        assert_response :created
+      end
+    end
+
+    test "removal requires a CSRF token" do
+      post_upload("shot.png")
+      id = response.parsed_body.dig("attachment", "id")
+
+      with_forgery_protection do
+        delete image_attachment_draft_batch_attachment_path(@batch.public_id, id), as: :json
+
+        assert_response :unprocessable_entity
+      end
+    end
+
+    test "client-key removal requires a CSRF token" do
+      with_forgery_protection do
+        delete discard_image_attachment_draft_batch_attachments_path(@batch.public_id),
+          params: { client_key: "protected" }, as: :json
+
+        assert_response :unprocessable_entity
+      end
+      assert_empty @batch.image_attachments
+    end
+
+    test "an upload with no file is an actionable client error" do
+      post image_attachment_draft_batch_attachments_path(@batch.public_id),
+        params: { client_key: "one" }, headers: { "ACCEPT" => "application/json" }
+
+      assert_response :unprocessable_entity
+      assert_equal "missing_file", response.parsed_body.dig("error", "code")
+      assert_empty @batch.image_attachments
+    end
+
+    # Guessed draft IDs must not tell an attacker which ones exist, so an
+    # unknown draft answers exactly like somebody else's.
+    test "an unknown draft is a private not-found" do
+      post image_attachment_draft_batch_attachments_path("not-a-real-batch"),
+        params: { client_key: "one", file: uploaded("shot.png", image_bytes) },
+        headers: { "ACCEPT" => "application/json" }
+
+      assert_response :not_found
+      assert_equal "not_found", response.parsed_body.dig("error", "code")
+    end
+
+    test "an exhausted per-user upload budget is refused before any bytes are read" do
+      with_rate_limit_backend(SaturatedRateLimitBackend.new(matching: "user:")) do
+        post_upload("shot.png")
+      end
+
+      assert_response :too_many_requests
+      assert_equal "rate_limited", response.parsed_body.dig("error", "code")
+      assert_empty @batch.image_attachments
+    end
+
+    # The address bucket is independent of the account bucket: one host cannot
+    # spend everybody else's budget by signing in as somebody new.
+    test "an exhausted per-address upload budget is refused on its own" do
+      with_rate_limit_backend(SaturatedRateLimitBackend.new(matching: "ip:")) do
+        post_upload("shot.png")
+      end
+
+      assert_response :too_many_requests
+      assert_equal "rate_limited", response.parsed_body.dig("error", "code")
+      assert_empty @batch.image_attachments
+    end
+
+    test "an unavailable limiter backend fails closed instead of admitting the upload" do
+      with_rate_limit_backend(UnavailableRateLimitBackend.new) do
+        post_upload("shot.png")
+      end
+
+      assert_response :service_unavailable
+      body = response.parsed_body
+      assert_equal "rate_limiter_unavailable", body.dig("error", "code")
+      assert body.dig("error", "retryable")
+      assert_empty @batch.image_attachments
+    end
+
+    private
+
+    # Rails builds one cache key per limiter, so a double that saturates only
+    # the identity it is asked about proves each bucket is enforced separately.
+    class SaturatedRateLimitBackend
+      def initialize(matching:)
+        @matching = matching
+      end
+
+      def increment(key, _amount = 1, **)
+        key.to_s.include?(@matching) ? BaseController::RATE_LIMIT + 1 : 1
+      end
+    end
+
+    class UnavailableRateLimitBackend
+      def increment(*, **)
+        raise IOError, "backend unavailable"
+      end
+    end
+
+    def with_rate_limit_backend(backend)
+      original = BaseController.cache_store
+      BaseController.cache_store = backend
+      yield
+    ensure
+      BaseController.cache_store = original
+    end
+
+    def post_upload(name, client_key: "one")
+      post image_attachment_draft_batch_attachments_path(@batch.public_id),
+        params: { client_key: client_key, file: uploaded(name, image_bytes) },
+        headers: { "ACCEPT" => "application/json" }
+    end
+
+    def uploaded(name, bytes)
+      Rack::Test::UploadedFile.new(StringIO.new(bytes), "image/png", original_filename: name)
+    end
+
+    # The composer reads the same meta tag every rendered page carries.
+    def csrf_token
+      get root_path
+      follow_redirect! while response.redirect?
+      css_select("meta[name='csrf-token']").first["content"]
+    end
+  end
+end
diff --git a/test/controllers/image_attachment_drafts/batches_controller_test.rb b/test/controllers/image_attachment_drafts/batches_controller_test.rb
new file mode 100644
index 0000000..199e241
--- /dev/null
+++ b/test/controllers/image_attachment_drafts/batches_controller_test.rb
@@ -0,0 +1,249 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+module ImageAttachmentDrafts
+  class BatchesControllerTest < ActionDispatch::IntegrationTest
+    setup do
+      @user = users(:alice)
+      @project = projects(:alice_project)
+      sign_in @user
+    end
+
+    test "creates an isolated batch for a project the user belongs to" do
+      post image_attachment_draft_batches_path, params: { project_id: @project.id }, as: :json
+
+      assert_response :created
+      body = response.parsed_body
+      assert_equal @project.id, body["project_id"]
+      assert_equal "open", body["state"]
+      assert_empty body["attachments"]
+      assert_equal ImageAttachment::MAX_FILES, body.dig("limits", "max_files")
+      assert_equal ImageAttachment::ALLOWED_CONTENT_TYPES, body.dig("limits", "accepted_media_types")
+      assert_match ImageAttachmentBatch::PUBLIC_ID_FORMAT, body["batch_id"]
+    end
+
+    test "rejects a project the user does not belong to" do
+      post image_attachment_draft_batches_path, params: { project_id: projects(:bob_project).id }, as: :json
+
+      assert_response :not_found
+      assert_equal "not_found", response.parsed_body.dig("error", "code")
+    end
+
+    test "requires a signed in user" do
+      delete session_path
+
+      post image_attachment_draft_batches_path, params: { project_id: @project.id }, as: :json
+
+      assert_response :unauthorized
+    end
+
+    test "caps the number of simultaneously open batches" do
+      ImageAttachmentBatch::MAX_OPEN_PER_USER.times { build_batch(user: @user, project: @project) }
+
+      post image_attachment_draft_batches_path, params: { project_id: @project.id }, as: :json
+
+      assert_response :unprocessable_entity
+      assert_equal "too_many_open_batches", response.parsed_body.dig("error", "code")
+    end
+
+    test "caps total outstanding draft bytes independently of the scheduler" do
+      batch = build_batch(user: @user, project: @project)
+      per_file = ImageAttachment::MAX_FILE_SIZE
+      (ImageAttachmentBatch::MAX_OUTSTANDING_DRAFT_BYTES / per_file.to_f).ceil.times do |index|
+        batch.image_attachments.create!(
+          user: @user, project: @project, client_key: "big-#{index}", state: :ready,
+          media_type: "image/png", width: 1, height: 1, byte_size: per_file
+        )
+      end
+
+      post image_attachment_draft_batches_path, params: { project_id: @project.id }, as: :json
+
+      assert_response :unprocessable_entity
+      assert_equal "draft_storage_exhausted", response.parsed_body.dig("error", "code")
+    end
+
+    test "expired but unreclaimed batches still count toward the open cap" do
+      ImageAttachmentBatch::MAX_OPEN_PER_USER.times do
+        build_batch(user: @user, project: @project)
+          .update_columns(last_activity_at: 30.hours.ago, expires_at: 6.hours.ago)
+      end
+
+      post image_attachment_draft_batches_path, params: { project_id: @project.id }, as: :json
+
+      assert_response :unprocessable_entity
+      assert_equal "too_many_open_batches", response.parsed_body.dig("error", "code")
+    end
+
+    test "opening a draft requires a CSRF token" do
+      with_forgery_protection do
+        post image_attachment_draft_batches_path, params: { project_id: @project.id }, as: :json
+
+        assert_response :unprocessable_entity
+      end
+      assert_equal 0, @user.image_attachment_batches.count
+    end
+
+    test "resumes an existing batch" do
+      batch = build_batch(user: @user, project: @project)
+
+      get image_attachment_draft_batch_path(batch.public_id), as: :json
+
+      assert_response :success
+      assert_equal batch.public_id, response.parsed_body["batch_id"]
+    end
+
+    # A create whose response is lost has to be retryable. Without an
+    # idempotency key each retry spent one more of the six batches an account is
+    # allowed, so a flaky connection could lock a person out of uploading.
+    test "a repeated create with the same client key returns the same batch" do
+      post image_attachment_draft_batches_path,
+        params: { project_id: @project.id, client_key: "composer-abc123" }, as: :json
+      assert_response :created
+      first = response.parsed_body["batch_id"]
+
+      assert_no_difference -> { ImageAttachmentBatch.count } do
+        post image_attachment_draft_batches_path,
+          params: { project_id: @project.id, client_key: "composer-abc123" }, as: :json
+      end
+
+      assert_response :success
+      assert_equal first, response.parsed_body["batch_id"]
+    end
+
+    test "different client keys still open separate batches" do
+      post image_attachment_draft_batches_path,
+        params: { project_id: @project.id, client_key: "composer-aaaaaa" }, as: :json
+      first = response.parsed_body["batch_id"]
+
+      post image_attachment_draft_batches_path,
+        params: { project_id: @project.id, client_key: "composer-bbbbbb" }, as: :json
+
+      assert_response :created
+      assert_not_equal first, response.parsed_body["batch_id"]
+    end
+
+    test "a client key whose batch expired opens a usable replacement" do
+      post image_attachment_draft_batches_path,
+        params: { project_id: @project.id, client_key: "composer-expired" }, as: :json
+      stale = ImageAttachmentBatch.find_by!(public_id: response.parsed_body["batch_id"])
+      stale.update_columns(last_activity_at: 30.hours.ago, expires_at: 1.minute.ago)
+
+      post image_attachment_draft_batches_path,
+        params: { project_id: @project.id, client_key: "composer-expired" }, as: :json
+
+      assert_response :created
+      assert_not ImageAttachmentBatch.exists?(stale.id)
+      assert_predicate ImageAttachmentBatch.find_by!(public_id: response.parsed_body["batch_id"]), :usable?
+    end
+
+    test "a malformed client key is rejected" do
+      post image_attachment_draft_batches_path,
+        params: { project_id: @project.id, client_key: "no" }, as: :json
+
+      assert_response :unprocessable_entity
+      assert_equal "invalid_client_key", response.parsed_body.dig("error", "code")
+    end
+
+    # Media, upload, and claim all reject an expired batch, so answering resume
+    # with 200 and its ready rows would only re-enable a post that is certain to
+    # fail with `batch_unusable`.
+    test "an expired batch cannot be resumed" do
+      batch = build_batch(user: @user, project: @project)
+      ingest_image(batch: batch)
+      batch.update_columns(last_activity_at: 30.hours.ago, expires_at: 1.minute.ago)
+
+      get image_attachment_draft_batch_path(batch.public_id), as: :json
+
+      assert_response :unprocessable_entity
+      assert_equal "batch_unusable", response.parsed_body.dig("error", "code")
+    end
+
+    test "a claimed batch cannot be resumed" do
+      batch = build_batch(user: @user, project: @project)
+      batch.update!(state: :claimed, claimed_annotation: annotations(:point_annotation))
+
+      get image_attachment_draft_batch_path(batch.public_id), as: :json
+
+      assert_response :unprocessable_entity
+      assert_equal "batch_unusable", response.parsed_body.dig("error", "code")
+    end
+
+    test "another member cannot resume a guessed batch" do
+      batch = build_batch(user: @user, project: @project)
+      @project.project_memberships.find_or_create_by!(user: users(:bob)) { |m| m.role = :member }
+      delete session_path
+      sign_in users(:bob)
+
+      get image_attachment_draft_batch_path(batch.public_id), as: :json
+
+      assert_response :not_found
+    end
+
+    test "a removed member loses access to their own draft" do
+      batch = build_batch(user: @user, project: @project)
+      @project.project_memberships.where(user: @user).delete_all
+
+      get image_attachment_draft_batch_path(batch.public_id), as: :json
+
+      assert_response :not_found
+    end
+
+    test "cancelling discards the draft and frees its slot against the open cap" do
+      batch = build_batch(user: @user, project: @project)
+      attachment = ingest_image(batch: batch)
+      (ImageAttachmentBatch::MAX_OPEN_PER_USER - 1).times { build_batch(user: @user, project: @project) }
+
+      delete image_attachment_draft_batch_path(batch.public_id), as: :json
+
+      assert_response :no_content
+      assert_not ImageAttachmentBatch.exists?(batch.id)
+      assert_not ImageAttachment.exists?(attachment.id)
+
+      post image_attachment_draft_batches_path, params: { project_id: @project.id }, as: :json
+
+      assert_response :created
+    end
+
+    test "discarding is idempotent and never touches a claimed batch" do
+      batch = build_batch(user: @user, project: @project)
+      attachment = ingest_image(batch: batch)
+      ImageAttachments::ClaimBatch.call(batch: batch, user: @user, project: @project, parent_type: Annotation) do
+        annotations(:point_annotation)
+      end
+
+      delete image_attachment_draft_batch_path(batch.public_id), as: :json
+
+      assert_response :no_content
+      assert_predicate batch.reload, :state_claimed?
+      assert_equal annotations(:point_annotation).id, attachment.reload.annotation_id
+
+      delete image_attachment_draft_batch_path(batch.public_id), as: :json
+
+      assert_response :no_content
+    end
+
+    test "another member cannot discard a guessed batch" do
+      batch = build_batch(user: @user, project: @project)
+      @project.project_memberships.find_or_create_by!(user: users(:bob)) { |m| m.role = :member }
+      delete session_path
+      sign_in users(:bob)
+
+      delete image_attachment_draft_batch_path(batch.public_id), as: :json
+
+      assert_response :not_found
+      assert ImageAttachmentBatch.exists?(batch.id)
+    end
+
+    test "discarding a draft requires a CSRF token" do
+      batch = build_batch(user: @user, project: @project)
+
+      with_forgery_protection do
+        delete image_attachment_draft_batch_path(batch.public_id), as: :json
+
+        assert_response :unprocessable_entity
+      end
+      assert ImageAttachmentBatch.exists?(batch.id)
+    end
+  end
+end
diff --git a/test/controllers/image_attachment_media_controller_test.rb b/test/controllers/image_attachment_media_controller_test.rb
new file mode 100644
index 0000000..9fb0e2f
--- /dev/null
+++ b/test/controllers/image_attachment_media_controller_test.rb
@@ -0,0 +1,178 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class ImageAttachmentMediaControllerTest < ActionDispatch::IntegrationTest
+  include ActiveJob::TestHelper
+
+  setup do
+    require_vips!
+    @user = users(:alice)
+    @project = projects(:alice_project)
+    @batch = build_batch(user: @user, project: @project)
+    sign_in @user
+  end
+
+  test "the uploader can preview their own draft" do
+    attachment = ingest_image(batch: @batch)
+
+    get image_attachment_media_path(attachment, :original)
+
+    assert_response :success
+    assert_equal "private, no-store", response.headers["Cache-Control"]
+    assert_equal "nosniff", response.headers["X-Content-Type-Options"]
+    assert_equal "bytes", response.headers["Accept-Ranges"]
+    assert_nil response.headers["Location"]
+  end
+
+  test "a same-project collaborator cannot read a guessed draft ID" do
+    attachment = ingest_image(batch: @batch)
+    @project.project_memberships.find_or_create_by!(user: users(:bob)) { |m| m.role = :member }
+    delete session_path
+    sign_in users(:bob)
+
+    get image_attachment_media_path(attachment, :original)
+
+    assert_response :not_found
+  end
+
+  test "an expired draft is no longer readable" do
+    attachment = ingest_image(batch: @batch)
+    @batch.update_columns(last_activity_at: 25.hours.ago, expires_at: 1.minute.ago)
+
+    get image_attachment_media_path(attachment, :original)
+
+    assert_response :not_found
+  end
+
+  test "every project member can read a submitted attachment" do
+    attachment = submitted_attachment
+    @project.project_memberships.find_or_create_by!(user: users(:bob)) { |m| m.role = :member }
+    delete session_path
+    sign_in users(:bob)
+
+    get image_attachment_media_path(attachment, :original)
+
+    assert_response :success
+  end
+
+  test "revoking membership immediately revokes attachment reads" do
+    attachment = submitted_attachment
+    @project.project_memberships.find_or_create_by!(user: users(:bob)) { |m| m.role = :member }
+    delete session_path
+    sign_in users(:bob)
+    get image_attachment_media_path(attachment, :original)
+    assert_response :success
+
+    @project.project_memberships.where(user: users(:bob)).delete_all
+
+    get image_attachment_media_path(attachment, :original)
+    assert_response :not_found
+  end
+
+  test "a GET never triggers image decoding" do
+    attachment = submitted_attachment
+    calls = 0
+    original = ImageDecoding::Guard.method(:synchronize)
+    ImageDecoding::Guard.define_singleton_method(:synchronize) do |**options, &operation|
+      calls += 1
+      original.call(**options, &operation)
+    end
+
+    get image_attachment_media_path(attachment, :attachment_thumb_1x)
+
+    assert_response :not_found
+    assert_equal 0, calls
+  ensure
+    ImageDecoding::Guard.define_singleton_method(:synchronize, original) if original
+  end
+
+  test "a warmed variant is delivered without decoding" do
+    attachment = submitted_attachment
+    perform_enqueued_jobs(only: ImageAttachmentThumbnailJob)
+
+    get image_attachment_media_path(attachment, :attachment_thumb_1x)
+
+    assert_response :success
+  end
+
+  test "the download variant uses an attachment disposition" do
+    attachment = submitted_attachment
+
+    get image_attachment_media_path(attachment, :download)
+
+    assert_response :success
+    assert_match(/\Aattachment;/, response.headers["Content-Disposition"])
+  end
+
+  test "unknown variants are not routable" do
+    attachment = submitted_attachment
+
+    get "/media/image_attachments/#{attachment.id}/page_card_1x"
+
+    assert_response :not_found
+  end
+
+  test "a row that has not finished uploading has no bytes to serve" do
+    pending = @batch.image_attachments.create!(user: @user, project: @project, client_key: "pending")
+
+    get image_attachment_media_path(pending, :original)
+
+    assert_response :not_found
+  end
+
+  # The draft rule is "the uploader, while the batch is still live". A row whose
+  # batch is gone has no live batch to be read against, so it is unreadable
+  # rather than readable by default.
+  test "a draft whose batch row is gone is unreadable" do
+    attachment = ingest_image(batch: @batch)
+    ImageAttachment.connection.disable_referential_integrity do
+      ImageAttachmentBatch.where(id: @batch.id).delete_all
+    end
+
+    get image_attachment_media_path(attachment, :original)
+
+    assert_response :not_found
+  end
+
+  # The route constraint and the served variant list are declared separately,
+  # so the controller refuses a name it does not own rather than handing it to
+  # Active Storage.
+  test "a variant outside the media contract is never resolved" do
+    attachment = submitted_attachment
+
+    assert_nil ImageAttachmentMediaController.new.send(
+      :resolve_variant_blob, attachment.image, "page_card_1x",
+      allowed: ImageAttachmentMediaController::ALLOWED_VARIANTS,
+      raw: ImageAttachmentMediaController::RAW_VARIANTS
+    )
+  end
+
+  test "an unknown attachment ID is a private not-found" do
+    get image_attachment_media_path(999_999, :original)
+
+    assert_response :not_found
+  end
+
+  test "an attachment whose message was deleted is unreadable" do
+    attachment = submitted_attachment
+    ImageAttachment.connection.disable_referential_integrity do
+      Annotation.where(id: attachment.annotation_id).delete_all
+    end
+
+    get image_attachment_media_path(attachment, :original)
+
+    assert_response :not_found
+  end
+
+  private
+
+  def submitted_attachment
+    ingest_image(batch: @batch)
+    ImageAttachments::ClaimBatch.call(batch: @batch, user: @user, project: @project, parent_type: Annotation) do
+      screenshots(:alice_screenshot).annotations.create!(
+        user: @user, x_percent: 1, y_percent: 1, comment: "Posted"
+      )
+    end.attachments.sole
+  end
+end
diff --git a/test/controllers/image_attachment_submission_test.rb b/test/controllers/image_attachment_submission_test.rb
new file mode 100644
index 0000000..cf12396
--- /dev/null
+++ b/test/controllers/image_attachment_submission_test.rb
@@ -0,0 +1,463 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class ImageAttachmentSubmissionTest < ActionDispatch::IntegrationTest
+  include ActiveJob::TestHelper
+
+  setup do
+    require_vips!
+    @user = users(:alice)
+    @project = projects(:alice_project)
+    @screenshot = screenshots(:alice_screenshot)
+    @annotation = annotations(:point_annotation)
+    @batch = build_batch(user: @user, project: @project)
+    sign_in @user
+  end
+
+  test "posting a root annotation binds every ready attachment" do
+    first = ingest_image(batch: @batch, client_key: "a")
+    second = ingest_image(batch: @batch, client_key: "b")
+
+    post_annotation
+
+    assert_response :created
+    annotation = Annotation.find(response.parsed_body["annotation_id"])
+    assert_equal [ first.id, second.id ], annotation.image_attachments.pluck(:id)
+    assert_predicate @batch.reload, :state_claimed?
+  end
+
+  test "posting a reply binds attachments to the comment" do
+    attachment = ingest_image(batch: @batch)
+
+    post screenshot_annotation_annotation_comments_path(@screenshot, @annotation),
+      params: { annotation_comment: { body: "See the crop" }, image_attachment_batch_id: @batch.public_id },
+      as: :json
+
+    assert_response :created
+    comment = @annotation.annotation_comments.order(:id).last
+    assert_equal [ attachment.id ], comment.image_attachments.pluck(:id)
+  end
+
+  test "reopening binds attachments to the reopen comment" do
+    resolved = annotations(:resolved_annotation)
+    attachment = ingest_image(batch: @batch)
+
+    post screenshot_annotation_annotation_comments_path(@screenshot, resolved),
+      params: {
+        annotation_comment: { body: "Still broken", reopen: "1" },
+        image_attachment_batch_id: @batch.public_id
+      },
+      as: :json
+
+    assert_response :created
+    comment = resolved.annotation_comments.where(action: :reopened).sole
+    assert_equal [ attachment.id ], comment.image_attachments.pluck(:id)
+    assert_predicate resolved.reload, :open?
+  end
+
+  test "a response-loss retry with the same batch returns the same message" do
+    ingest_image(batch: @batch)
+    post_annotation
+    first_id = response.parsed_body["annotation_id"]
+
+    assert_no_difference -> { Annotation.count } do
+      post_annotation
+    end
+
+    assert_response :created
+    assert_equal first_id, response.parsed_body["annotation_id"]
+  end
+
+  test "invalid text answers 422 with the batch and ready IDs for rehydration" do
+    attachment = ingest_image(batch: @batch)
+
+    post_annotation(comment: "")
+
+    assert_response :unprocessable_entity
+    body = response.parsed_body
+    assert_equal @batch.public_id, body.dig("form", "image_attachment_batch_id")
+    assert_equal [ attachment.id ], body.dig("form", "ready_attachment_ids")
+    assert_predicate @batch.reload, :state_open?
+    assert_predicate attachment.reload, :draft?
+  end
+
+  test "an overlong reply body answers 422 and preserves the draft" do
+    attachment = ingest_image(batch: @batch)
+
+    post screenshot_annotation_annotation_comments_path(@screenshot, @annotation),
+      params: {
+        annotation_comment: { body: "x" * 5001 },
+        image_attachment_batch_id: @batch.public_id
+      },
+      as: :json
+
+    assert_response :unprocessable_entity
+    assert_equal [ attachment.id ], response.parsed_body.dig("form", "ready_attachment_ids")
+    assert_predicate attachment.reload, :draft?
+  end
+
+  test "reopening an open annotation answers 422 and keeps the composer state" do
+    ingest_image(batch: @batch)
+
+    post screenshot_annotation_annotation_comments_path(@screenshot, @annotation),
+      params: {
+        annotation_comment: { body: "Reopen please", reopen: "1" },
+        image_attachment_batch_id: @batch.public_id
+      },
+      as: :json
+
+    assert_response :unprocessable_entity
+    assert_equal "annotation_not_resolved", response.parsed_body.dig("error", "code")
+    assert_equal @batch.public_id, response.parsed_body.dig("form", "image_attachment_batch_id")
+  end
+
+  test "a pending attachment blocks the post" do
+    ingest_image(batch: @batch, client_key: "ready")
+    @batch.image_attachments.create!(user: @user, project: @project, client_key: "pending")
+
+    post_annotation
+
+    assert_response :unprocessable_entity
+    assert_equal "attachments_not_ready", response.parsed_body.dig("error", "code")
+    assert_equal 0, Annotation.where(comment: "Look here").count
+  end
+
+  test "a failed attachment blocks the post" do
+    ingest_image(batch: @batch, client_key: "ready")
+    @batch.image_attachments.create!(
+      user: @user, project: @project, client_key: "broken", state: :failed, failure_code: "invalid_image"
+    )
+
+    post_annotation
+
+    assert_response :unprocessable_entity
+    assert_equal "attachments_not_ready", response.parsed_body.dig("error", "code")
+  end
+
+  test "a batch belonging to another user is rejected" do
+    foreign = build_batch(user: users(:bob), project: projects(:bob_project))
+
+    post_annotation(batch_id: foreign.public_id)
+
+    assert_response :unprocessable_entity
+    assert_equal "batch_not_owned", response.parsed_body.dig("error", "code")
+  end
+
+  test "an unknown batch is rejected" do
+    post_annotation(batch_id: "not-a-real-batch")
+
+    assert_response :unprocessable_entity
+    assert_equal "batch_not_owned", response.parsed_body.dig("error", "code")
+  end
+
+  test "an expired batch is rejected" do
+    ingest_image(batch: @batch)
+    @batch.update_columns(last_activity_at: 25.hours.ago, expires_at: 1.minute.ago)
+
+    post_annotation
+
+    assert_response :unprocessable_entity
+    assert_equal "batch_unusable", response.parsed_body.dig("error", "code")
+  end
+
+  test "client supplied attachment fields are ignored" do
+    attachment = ingest_image(batch: @batch)
+
+    post screenshot_annotations_path(@screenshot),
+      params: {
+        annotation: {
+          x_percent: 10, y_percent: 10, comment: "Look here", viewport: "desktop"
+        },
+        image_attachment_batch_id: @batch.public_id,
+        image_attachment_ids: [ 999_999 ],
+        image_attachments: [ { user_id: users(:bob).id, byte_size: 1, state: "ready" } ]
+      },
+      as: :json
+
+    assert_response :created
+    annotation = Annotation.find(response.parsed_body["annotation_id"])
+    assert_equal [ attachment.id ], annotation.image_attachments.pluck(:id)
+    assert_equal @user.id, attachment.reload.user_id
+  end
+
+  test "posting without a batch still works" do
+    post screenshot_annotations_path(@screenshot),
+      params: { annotation: { x_percent: 10, y_percent: 10, comment: "Look here" } }, as: :json
+
+    assert_response :created
+    assert_empty Annotation.find(response.parsed_body["annotation_id"]).image_attachments
+  end
+
+  # A rejected post always answers with composer state, even when the person
+  # attached nothing: the still-mounted composer reads the same shape either
+  # way instead of branching on whether a draft exists.
+  test "a rejected post without a draft still answers with empty composer state" do
+    post screenshot_annotations_path(@screenshot),
+      params: { annotation: { x_percent: 10, y_percent: 10, comment: "Look here", viewport: "mobile" } },
+      as: :json
+
+    assert_response :unprocessable_entity
+    assert_equal "invalid_viewport", response.parsed_body.dig("error", "code")
+    assert_nil response.parsed_body.dig("form", "image_attachment_batch_id")
+    assert_empty response.parsed_body.dig("form", "ready_attachment_ids")
+  end
+
+  # The non-JavaScript fallback has no composer to restore, so an attachment
+  # failure has to land back on the workspace with the reason instead of
+  # rendering a JSON body the browser would download.
+  test "the HTML fallback returns an attachment failure to the workspace" do
+    foreign = build_batch(user: users(:bob), project: projects(:bob_project))
+
+    post screenshot_annotations_path(@screenshot),
+      params: {
+        annotation: { x_percent: 10, y_percent: 10, comment: "Look here" },
+        image_attachment_batch_id: foreign.public_id
+      }
+
+    assert_redirected_to page_path(@screenshot.page_id, version_id: @screenshot.id)
+    assert_equal 0, Annotation.where(comment: "Look here").count
+  end
+
+  test "the HTML reply fallback returns an attachment failure to the annotation" do
+    foreign = build_batch(user: users(:bob), project: projects(:bob_project))
+
+    post screenshot_annotation_annotation_comments_path(@screenshot, @annotation),
+      params: {
+        annotation_comment: { body: "See the crop" },
+        image_attachment_batch_id: foreign.public_id
+      }
+
+    assert_redirected_to page_path(
+      @screenshot.page_id, version_id: @screenshot.id, viewport: @annotation.viewport
+    )
+    assert_equal 0, @annotation.annotation_comments.where(body: "See the crop").count
+  end
+
+  test "the HTML fallback keeps redirecting" do
+    post screenshot_annotations_path(@screenshot),
+      params: { annotation: { x_percent: 10, y_percent: 10, comment: "Look here" } }
+
+    assert_response :redirect
+  end
+
+  # A batch belongs to exactly one composer. Replaying its public ID against
+  # the other endpoint is an ordinary rejection, not a parent of the wrong
+  # class handed to a responder that cannot describe it.
+  test "a reply batch replayed against the annotation endpoint is refused" do
+    ingest_image(batch: @batch)
+    post screenshot_annotation_annotation_comments_path(@screenshot, @annotation),
+      params: { annotation_comment: { body: "See the crop" }, image_attachment_batch_id: @batch.public_id },
+      as: :json
+    assert_response :created
+
+    assert_no_difference -> { Annotation.count } do
+      post_annotation
+    end
+
+    assert_response :unprocessable_entity
+    assert_equal "batch_not_owned", response.parsed_body.dig("error", "code")
+  end
+
+  test "a root batch replayed against the reply endpoint is refused" do
+    ingest_image(batch: @batch)
+    post_annotation
+    assert_response :created
+
+    assert_no_difference -> { AnnotationComment.count } do
+      post screenshot_annotation_annotation_comments_path(@screenshot, @annotation),
+        params: { annotation_comment: { body: "See the crop" }, image_attachment_batch_id: @batch.public_id },
+        as: :json
+    end
+
+    assert_response :unprocessable_entity
+    assert_equal "batch_not_owned", response.parsed_body.dig("error", "code")
+  end
+
+  # A reply batch is claimed by one disclosure. Parent class alone cannot tell
+  # two reply composers apart, so a public ID replayed from another thread must
+  # not answer with that thread's comment as if this post had succeeded.
+  test "a reply batch replayed from another thread is refused" do
+    ingest_image(batch: @batch)
+    post screenshot_annotation_annotation_comments_path(@screenshot, @annotation),
+      params: { annotation_comment: { body: "See the crop" }, image_attachment_batch_id: @batch.public_id },
+      as: :json
+    assert_response :created
+
+    other = annotations(:resolved_annotation)
+    assert_no_difference -> { AnnotationComment.count } do
+      post screenshot_annotation_annotation_comments_path(@screenshot, other),
+        params: { annotation_comment: { body: "See the crop" }, image_attachment_batch_id: @batch.public_id },
+        as: :json
+    end
+
+    assert_response :unprocessable_entity
+    assert_equal "batch_not_owned", response.parsed_body.dig("error", "code")
+  end
+
+  test "a reply batch replayed from the reopen composer is refused" do
+    resolved = annotations(:resolved_annotation)
+    ingest_image(batch: @batch)
+    post screenshot_annotation_annotation_comments_path(@screenshot, resolved),
+      params: { annotation_comment: { body: "See the crop" }, image_attachment_batch_id: @batch.public_id },
+      as: :json
+    assert_response :created
+
+    assert_no_difference -> { AnnotationComment.count } do
+      post screenshot_annotation_annotation_comments_path(@screenshot, resolved),
+        params: {
+          annotation_comment: { body: "See the crop", reopen: "1" },
+          image_attachment_batch_id: @batch.public_id
+        },
+        as: :json
+    end
+
+    assert_response :unprocessable_entity
+    assert_equal "batch_not_owned", response.parsed_body.dig("error", "code")
+  end
+
+  # The first reopen leaves the annotation open, so a retry of the identical
+  # request arrives against a state its own first attempt produced. It has to
+  # reach the claimed batch and get the comment back.
+  test "a response-loss retry of a reopen replays the comment it already created" do
+    resolved = annotations(:resolved_annotation)
+    ingest_image(batch: @batch)
+
+    post_reopen(resolved)
+    assert_response :created
+    comment_id = response.parsed_body["annotation_comment_id"]
+    assert_equal resolved.annotation_comments.where(action: :reopened).sole.id, comment_id
+
+    assert_no_difference -> { AnnotationComment.count } do
+      post_reopen(resolved)
+    end
+
+    assert_response :created
+    assert_equal comment_id, response.parsed_body["annotation_comment_id"]
+  end
+
+  test "a response-loss retry of a reply replays the comment it already created" do
+    ingest_image(batch: @batch)
+
+    post_reply
+    assert_response :created
+    comment_id = response.parsed_body["annotation_comment_id"]
+
+    assert_no_difference -> { AnnotationComment.count } do
+      post_reply
+    end
+
+    assert_response :created
+    assert_equal comment_id, response.parsed_body["annotation_comment_id"]
+  end
+
+  test "a reply response identifies the comment the claim produced" do
+    ingest_image(batch: @batch)
+
+    post screenshot_annotation_annotation_comments_path(@screenshot, @annotation),
+      params: { annotation_comment: { body: "See the crop" }, image_attachment_batch_id: @batch.public_id },
+      as: :json
+
+    assert_response :created
+    assert_equal @annotation.annotation_comments.order(:id).last.id,
+      response.parsed_body["annotation_comment_id"]
+  end
+
+  test "an invalid root post returns the body and the selected region" do
+    ingest_image(batch: @batch)
+
+    post screenshot_annotations_path(@screenshot),
+      params: {
+        annotation: {
+          x_percent: 12.5, y_percent: 30.25, width_percent: 8.0, height_percent: 4.5,
+          comment: "", viewport: "desktop"
+        },
+        image_attachment_batch_id: @batch.public_id
+      },
+      as: :json
+
+    assert_response :unprocessable_entity
+    form = response.parsed_body.fetch("form")
+    assert_equal "", form.fetch("body")
+    assert_equal 12.5, form.fetch("x_percent")
+    assert_equal 30.25, form.fetch("y_percent")
+    assert_equal 8.0, form.fetch("width_percent")
+    assert_equal 4.5, form.fetch("height_percent")
+    assert_equal "desktop", form.fetch("viewport")
+  end
+
+  test "an invalid reply returns the body and the composer it came from" do
+    ingest_image(batch: @batch)
+
+    post screenshot_annotation_annotation_comments_path(@screenshot, @annotation),
+      params: {
+        annotation_comment: { body: "x" * 5001 },
+        image_attachment_batch_id: @batch.public_id
+      },
+      as: :json
+
+    assert_response :unprocessable_entity
+    form = response.parsed_body.fetch("form")
+    assert_equal "x" * 5001, form.fetch("body")
+    assert_equal @annotation.id, form.fetch("annotation_id")
+    assert_equal false, form.fetch("reopen")
+  end
+
+  test "an invalid reopen returns the body and its reopen intent" do
+    ingest_image(batch: @batch)
+
+    post screenshot_annotation_annotation_comments_path(@screenshot, @annotation),
+      params: {
+        annotation_comment: { body: "Reopen please", reopen: "1" },
+        image_attachment_batch_id: @batch.public_id
+      },
+      as: :json
+
+    assert_response :unprocessable_entity
+    form = response.parsed_body.fetch("form")
+    assert_equal "Reopen please", form.fetch("body")
+    assert_equal true, form.fetch("reopen")
+  end
+
+  test "deleting the message removes its attachments and their bytes" do
+    attachment = ingest_image(batch: @batch)
+    post_annotation
+    annotation = Annotation.find(response.parsed_body["annotation_id"])
+    blob = attachment.reload.image.blob
+
+    perform_enqueued_jobs { delete screenshot_annotation_path(@screenshot, annotation) }
+
+    assert_not ImageAttachment.exists?(attachment.id)
+    assert_not ActiveStorage::Blob.exists?(blob.id)
+    assert_not ImageAttachmentBatch.exists?(@batch.id)
+  end
+
+  private
+
+  def post_annotation(comment: "Look here", batch_id: nil)
+    post screenshot_annotations_path(@screenshot),
+      params: {
+        annotation: { x_percent: 10, y_percent: 10, comment: comment, viewport: "desktop" },
+        image_attachment_batch_id: batch_id || @batch.public_id
+      },
+      as: :json
+  end
+
+  def post_reopen(annotation)
+    post screenshot_annotation_annotation_comments_path(@screenshot, annotation),
+      params: {
+        annotation_comment: { body: "Still broken", reopen: "1" },
+        image_attachment_batch_id: @batch.public_id
+      },
+      as: :json
+  end
+
+  def post_reply
+    post screenshot_annotation_annotation_comments_path(@screenshot, @annotation),
+      params: {
+        annotation_comment: { body: "See the crop" },
+        image_attachment_batch_id: @batch.public_id
+      },
+      as: :json
+  end
+end
diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb
index 2dc8abb..aa44646 100644
--- a/test/controllers/pages_controller_test.rb
+++ b/test/controllers/pages_controller_test.rb
@@ -254,6 +254,50 @@ class PagesControllerTest < ActionDispatch::IntegrationTest
     assert_select "form"
   end
 
+  # The workspace renders every annotation, every reply, and every attached
+  # image on one page, so an N+1 there is paid once per attachment. The budget
+  # is measured against itself rather than against a fixed ceiling.
+  test "the workspace does not scale queries with attachment count" do
+    require_vips!
+    sign_in(@user)
+    screenshot = screenshots(:alice_screenshot)
+    annotation = annotations(:point_annotation)
+    comment = annotation.annotation_comments.create!(user: @user, body: "Reply")
+    attach_to(annotation)
+    attach_to(comment)
+
+    get page_path(screenshot.page_id, version_id: screenshot.id)
+    baseline = capture_app_queries { get page_path(screenshot.page_id, version_id: screenshot.id) }
+
+    2.times { attach_to(annotation) }
+    2.times { attach_to(comment) }
+    get page_path(screenshot.page_id, version_id: screenshot.id)
+    queries = capture_app_queries { get page_path(screenshot.page_id, version_id: screenshot.id) }
+
+    assert_response :success
+    assert_equal baseline.size, queries.size,
+      "the workspace must not scale queries with attachment count"
+    # One bulk variant-record load for the root attachments and one for the
+    # comment attachments, no matter how many rows each holds.
+    assert_equal 2, queries.grep(/active_storage_variant_records/i).size
+  end
+
+  # The composer branches on the server's machine codes, so its rejection copy
+  # has to be the server's too rather than a second literal that can drift.
+  test "the composer takes its unsupported-type copy from the server" do
+    sign_in(@user)
+    screenshot = screenshots(:alice_screenshot)
+
+    get page_path(screenshot.page_id, version_id: screenshot.id)
+
+    assert_response :success
+    assert_select "[data-image-attachment-composer-unsupported-type-message-value=?]",
+      ImageAttachments::Error::UNSUPPORTED_MEDIA_TYPE
+    controller_source = Rails.root.join("app/javascript/controllers/image_attachment_composer_controller.js").read
+    assert_not controller_source.include?(ImageAttachments::Error::UNSUPPORTED_MEDIA_TYPE),
+      "the composer must not carry its own copy of the accepted-types message"
+  end
+
   # Update
   test "update with valid params" do
     sign_in(@user)
@@ -286,4 +330,15 @@ class PagesControllerTest < ActionDispatch::IntegrationTest
     end
     assert_response :not_found
   end
+
+  private
+
+  def attach_to(parent)
+    batch = build_batch(user: @user, project: @project)
+    attachment = ingest_image(batch: batch)
+    ImageAttachments::ClaimBatch.call(
+      batch: batch, user: @user, project: @project, parent_type: parent.class
+    ) { parent }
+    attachment.reload
+  end
 end
diff --git a/test/controllers/projects_controller_test.rb b/test/controllers/projects_controller_test.rb
index a4ea051..b06d57e 100644
--- a/test/controllers/projects_controller_test.rb
+++ b/test/controllers/projects_controller_test.rb
@@ -694,24 +694,6 @@ def create_thumbnail_project(name)
     project
   end
 
-  def capture_app_queries
-    Rails.cache.clear
-    ActiveRecord::Base.connection.clear_query_cache
-    queries = []
-    callback = lambda do |*, payload|
-      queries << payload[:sql] unless /SCHEMA|TRANSACTION/i.match?(payload[:name].to_s)
-    end
-
-    ActiveSupport::Notifications.subscribed(callback, "sql.active_record") { yield }
-    queries
-  end
-
-  def assert_variant_records_preloaded(queries)
-    variant_queries = queries.grep(/active_storage_variant_records/i)
-    assert_equal 1, variant_queries.size,
-      "Expected exactly one bulk variant-record preload, saw #{variant_queries.size}: #{variant_queries.inspect}"
-  end
-
   def assert_responsive_page_card_image(primary_image)
     assert_select ".page-card__thumbnail img", count: 1 do |images|
       image = images.first
diff --git a/test/fixtures/files/desktop_screenshot.png b/test/fixtures/files/desktop_screenshot.png
new file mode 100644
index 0000000..755c34c
Binary files /dev/null and b/test/fixtures/files/desktop_screenshot.png differ
diff --git a/test/fixtures/files/mcp_get_annotation_golden.json b/test/fixtures/files/mcp_get_annotation_golden.json
new file mode 100644
index 0000000..428e820
--- /dev/null
+++ b/test/fixtures/files/mcp_get_annotation_golden.json
@@ -0,0 +1,62 @@
+{
+  "_comment": "The complete MCP get_annotation response. Every shipped key, value, and nesting level is recorded here: only database identifiers, wall-clock stamps, the freshly encoded crop, and the per-read media URL are placeholders, and the test asserts those separately. Nothing in this file may be renamed, dropped, re-nested, or given a different value.",
+  "id": "<annotation_id>",
+  "screenshot_id": "<screenshot_id>",
+  "viewport": "desktop",
+  "type": "point",
+  "coordinates": {
+    "x_percent": 50.0,
+    "y_percent": 30.0,
+    "width_percent": null,
+    "height_percent": null
+  },
+  "comment": "Button text too small",
+  "status": "open",
+  "author": "alice@example.com",
+  "comments_count": 2,
+  "created_at": "<timestamp>",
+  "screenshot_status": "ready",
+  "cropped_image_base64": "<cropped-png-base64>",
+  "mime_type": "image/png",
+  "attachments": [
+    {
+      "id": "<root_attachment_id>",
+      "alt_text": "The disabled save button",
+      "media_type": "image/png",
+      "width": 8,
+      "height": 8,
+      "size": 287,
+      "url": "<media-url>",
+      "url_expires_at": "<url-expiry>"
+    }
+  ],
+  "comments": [
+    {
+      "id": "<reply_comment_id>",
+      "action": "comment",
+      "body": "And here",
+      "author": "alice@example.com",
+      "created_at": "<timestamp>",
+      "attachments": [
+        {
+          "id": "<reply_attachment_id>",
+          "alt_text": null,
+          "media_type": "image/png",
+          "width": 8,
+          "height": 8,
+          "size": 287,
+          "url": "<media-url>",
+          "url_expires_at": "<url-expiry>"
+        }
+      ]
+    },
+    {
+      "id": "<resolution_comment_id>",
+      "action": "resolved",
+      "body": "Fixed",
+      "author": "alice@example.com",
+      "created_at": "<timestamp>",
+      "attachments": []
+    }
+  ]
+}
diff --git a/test/fixtures/files/mcp_get_annotation_keys.json b/test/fixtures/files/mcp_get_annotation_keys.json
new file mode 100644
index 0000000..41434d0
--- /dev/null
+++ b/test/fixtures/files/mcp_get_annotation_keys.json
@@ -0,0 +1,21 @@
+{
+  "comment": "Keys and nesting of the shipped MCP get_annotation response, captured before image attachments were added. New keys may appear; nothing in this file may be renamed, dropped, or nested differently.",
+  "annotation": [
+    "id",
+    "screenshot_id",
+    "viewport",
+    "type",
+    "coordinates",
+    "comment",
+    "status",
+    "author",
+    "comments_count",
+    "created_at",
+    "screenshot_status",
+    "cropped_image_base64",
+    "mime_type",
+    "comments"
+  ],
+  "coordinates": ["x_percent", "y_percent", "width_percent", "height_percent"],
+  "comments": ["id", "action", "body", "author", "created_at"]
+}
diff --git a/test/fixtures/files/test_invalid.png b/test/fixtures/files/test_invalid.png
new file mode 100644
index 0000000..283e5e9
--- /dev/null
+++ b/test/fixtures/files/test_invalid.png
@@ -0,0 +1 @@
+not an image
diff --git a/test/integration/database_constraint_contract_test.rb b/test/integration/database_constraint_contract_test.rb
index 01c23e2..9213793 100644
--- a/test/integration/database_constraint_contract_test.rb
+++ b/test/integration/database_constraint_contract_test.rb
@@ -20,6 +20,14 @@ class DatabaseConstraintContractTest < ActiveSupport::TestCase
     authentication_tokens: %w[
       authentication_tokens_exact_subject authentication_tokens_recovery_issuer
       authentication_tokens_terminal_state
+    ],
+    image_attachment_batches: %w[
+      image_attachment_batches_valid_state image_attachment_batches_future_expiry
+      image_attachment_batches_claim_state
+    ],
+    image_attachments: %w[
+      image_attachments_valid_state image_attachments_exclusive_parent
+      image_attachments_submitted_ready
     ]
   }.freeze
 
@@ -157,6 +165,11 @@ class DatabaseConstraintContractTest < ActiveSupport::TestCase
     assert_not PrincipalActionContract.supported?(:create_point, :rest)
     assert_not PrincipalActionContract.supported?(:create_area, :public_cli)
     assert PrincipalActionContract.supported?(:reopen, :mcp)
+    assert PrincipalActionContract.supported?(:attach_image, :browser)
+    %i[rest public_cli mcp].each do |surface|
+      assert_not PrincipalActionContract.supported?(:attach_image, surface),
+        "attaching an image must stay a session-only capability"
+    end
   end
 
   private
diff --git a/test/integration/image_attachment_concurrency_test.rb b/test/integration/image_attachment_concurrency_test.rb
new file mode 100644
index 0000000..c0a5f43
--- /dev/null
+++ b/test/integration/image_attachment_concurrency_test.rb
@@ -0,0 +1,314 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require_relative "../support/deterministic_concurrency_test_helper"
+
+# SQLite proves the constraint contracts and the deterministic interleavings.
+# The PostgreSQL qualification workflow runs this same file against real row
+# locks so aggregate races, lock ordering, and atomic claim are exercised with
+# production semantics.
+class ImageAttachmentConcurrencyTest < ActiveSupport::TestCase
+  include DeterministicConcurrencyTestHelper
+  include ActiveJob::TestHelper
+
+  self.use_transactional_tests = false
+
+  setup do
+    require_vips!
+    @user = users(:alice)
+    @project = projects(:alice_project)
+    @screenshot = screenshots(:alice_screenshot)
+    @highest_blob_id = ActiveStorage::Blob.maximum(:id).to_i
+    @highest_annotation_id = Annotation.maximum(:id).to_i
+    @batch = build_batch
+  end
+
+  teardown do
+    # These tests deliberately race real transactions across threads, and a
+    # thread killed on timeout can leave its connection holding a write lock.
+    # Dropping the pool keeps one hung race from failing every later test in
+    # this worker.
+    ApplicationRecord.connection_pool.disconnect!
+
+    ImageAttachment.delete_all
+    ImageAttachmentBatch.delete_all
+    Annotation.where("id > ?", @highest_annotation_id).delete_all
+    ActiveStorage::Blob.where("id > ?", @highest_blob_id).find_each(&:purge)
+  end
+
+  # The qualification workflow exists to run this file against real row locks.
+  # Asserting the adapter is what stops that job from silently passing on the
+  # SQLite behavior it was added to cover for.
+  test "the qualification job runs against the server database it claims to" do
+    unless ENV["SCREENOTE_SERVER_DATABASE_QUALIFICATION"] == "1"
+      skip "server database qualification is opt-in"
+    end
+
+    assert_equal "PostgreSQL", ApplicationRecord.connection.adapter_name
+  end
+
+  test "concurrent submissions of one batch create exactly one message" do
+    ingest_image(batch: @batch)
+
+    outcomes = with_one_shot_instance_method_barrier(
+      ImageAttachment, :update_columns, predicate: ->(record, *_args) { record.is_a?(ImageAttachment) }
+    ) do |entered, release|
+      run_blocked_race(
+        entered: entered,
+        release: release,
+        first: -> { claim_result },
+        second: -> { claim_result }
+      )
+    end
+
+    assert_no_concurrency_exceptions(outcomes)
+    parent_ids = outcomes.map { |result| result.parent.id }.uniq
+    assert_equal 1, parent_ids.size
+    assert_equal 1, outcomes.count(&:replayed?)
+    assert_equal 1, Annotation.where(comment: "Look here").count
+    assert_predicate @batch.reload, :state_claimed?
+  end
+
+  test "concurrent uploads cannot exceed the file limit" do
+    (ImageAttachment::MAX_FILES - 1).times { |index| ingest_image(batch: @batch, client_key: "seed-#{index}") }
+    bytes = image_bytes
+
+    outcomes = with_one_shot_instance_method_barrier(
+      ImageAttachment, :update!, predicate: ->(record, attributes) {
+        record.is_a?(ImageAttachment) && attributes[:state] == :ready
+      }
+    ) do |entered, release|
+      run_blocked_race(
+        entered: entered,
+        release: release,
+        first: -> { safe_ingest(bytes, "race-a") },
+        second: -> { safe_ingest(bytes, "race-b") }
+      )
+    end
+
+    assert_equal ImageAttachment::MAX_FILES, ImageAttachment.where(image_attachment_batch: @batch).count
+    assert_equal 1, outcomes.grep(ImageAttachments::Error).size
+    assert_equal "too_many_files", outcomes.grep(ImageAttachments::Error).sole.code
+  end
+
+  # The 50 MB ceiling is the one every message is actually held to, so the race
+  # is run against that constant rather than a stubbed one. The batch is seeded
+  # up to exactly one more file's worth of room using recorded byte sizes, so
+  # two concurrent commits both see room and only one can be allowed to take it.
+  test "concurrent commits cannot exceed the fifty megabyte message total" do
+    bytes = image_bytes
+    headroom = ImageAttachment::MAX_TOTAL_BYTES - bytes.bytesize
+    seed_sizes = [
+      ImageAttachment::MAX_FILE_SIZE,
+      ImageAttachment::MAX_FILE_SIZE,
+      headroom - (2 * ImageAttachment::MAX_FILE_SIZE)
+    ]
+    seed_sizes.each_with_index do |size, index|
+      @batch.image_attachments.create!(
+        user: @user, project: @project, client_key: "seed-#{index}", state: :ready,
+        media_type: "image/png", width: 1, height: 1, byte_size: size
+      )
+    end
+
+    assert_equal headroom, @batch.reload.total_byte_size
+
+    outcomes = with_one_shot_instance_method_barrier(
+      ImageAttachment, :update!, predicate: ->(record, attributes) {
+        record.is_a?(ImageAttachment) && attributes[:state] == :ready
+      }
+    ) do |entered, release|
+      run_blocked_race(
+        entered: entered,
+        release: release,
+        first: -> { safe_ingest(bytes, "total-a") },
+        second: -> { safe_ingest(bytes, "total-b") }
+      )
+    end
+
+    errors = outcomes.grep(ImageAttachments::Error)
+    assert_equal 1, errors.size, -> { outcomes.map(&:inspect).inspect }
+    assert_equal "batch_too_large", errors.sole.code
+    assert_equal ImageAttachment::MAX_TOTAL_BYTES, @batch.reload.total_byte_size
+  end
+
+  test "removal loses to a concurrent claim rather than partially binding a message" do
+    attachment = ingest_image(batch: @batch)
+
+    outcomes = with_one_shot_instance_method_barrier(
+      ImageAttachment, :update_columns, predicate: ->(record, *_args) { record.is_a?(ImageAttachment) }
+    ) do |entered, release|
+      run_blocked_race(
+        entered: entered,
+        release: release,
+        first: -> { claim_result },
+        second: -> { ImageAttachments::RemoveAttachment.call(batch: @batch.reload, attachment_id: attachment.id) }
+      )
+    end
+
+    assert_no_concurrency_exceptions(outcomes)
+    claimed = outcomes.grep(ImageAttachments::ClaimBatch::Result).sole
+    assert_equal 1, claimed.parent.image_attachments.count
+    assert_equal claimed.parent.id, attachment.reload.annotation_id
+  end
+
+  # Removal reserves the client key as a tombstone, so the row an upload tries
+  # to create cannot be newer than the removal. The commit also rechecks that
+  # marker under the batch lock before it can bind bytes.
+  test "a removal during an upload stops that upload from binding its bytes" do
+    bytes = image_bytes
+
+    outcomes = with_one_shot_instance_method_barrier(
+      ActiveStorage::Blob, :upload_without_unfurling, predicate: ->(record, *_args) {
+        record.is_a?(ActiveStorage::Blob)
+      }
+    ) do |entered, release|
+      run_settled_race(
+        entered: entered,
+        release: release,
+        first: -> { safe_ingest(bytes, "removed-mid-upload") },
+        second: -> { remove_by_client_key("removed-mid-upload") }
+      )
+    end
+
+    errors = outcomes.grep(ImageAttachments::Error)
+    assert_equal 1, errors.size, -> { outcomes.map { |outcome| [ outcome.class.name, outcome.inspect ] }.inspect }
+    assert_equal "attachment_removed", errors.sole.code
+    assert_predicate ImageAttachment.where(image_attachment_batch: @batch).sole, :removal_tombstone?
+    assert_empty ActiveStorage::Blob.where("id > ?", @highest_blob_id)
+
+    result = ImageAttachments::ClaimBatch.call(
+      batch: ImageAttachmentBatch.find(@batch.id), user: @user, project: @project, parent_type: Annotation
+    ) do
+      @screenshot.annotations.create!(
+        user: @user, x_percent: 5, y_percent: 5, comment: "Nothing attached", viewport: :desktop
+      )
+    end
+
+    assert_empty result.attachments
+    assert_empty ImageAttachment.where(image_attachment_batch: @batch)
+  end
+
+  test "the account byte ceiling serializes commits across different batches" do
+    skip "row-lock qualification runs on PostgreSQL" unless ApplicationRecord.connection.adapter_name == "PostgreSQL"
+
+    bytes = image_bytes
+    other_batch = build_batch(user: @user, project: @project)
+    parked_batch = build_batch(user: @user, project: @project)
+    parked_batch.image_attachments.create!(
+      user: @user,
+      project: @project,
+      client_key: "parked",
+      state: :ready,
+      media_type: "image/png",
+      width: 1,
+      height: 1,
+      byte_size: bytes.bytesize
+    )
+
+    stub_const(ImageAttachmentBatch, :MAX_OUTSTANDING_DRAFT_BYTES, bytes.bytesize * 2) do
+      outcomes = with_one_shot_instance_method_barrier(
+        ImageAttachments::Ingest,
+        :ensure_outstanding_drafts_within_limit!,
+        predicate: ->(_record, *_args) { true }
+      ) do |entered, release|
+        run_blocked_race(
+          entered: entered,
+          release: release,
+          first: -> { safe_ingest(bytes, "cap-a", batch: @batch) },
+          second: -> { safe_ingest(bytes, "cap-b", batch: other_batch) }
+        )
+      end
+
+      assert_equal 1, outcomes.grep(ImageAttachments::Error).size
+      assert_equal "draft_storage_exhausted", outcomes.grep(ImageAttachments::Error).sole.code
+      assert_equal bytes.bytesize * 2,
+        ImageAttachmentBatch.outstanding_draft_bytes(@user.id)
+    end
+  end
+
+  test "a cleanup pass overlapping a submission cannot take bytes away from the message" do
+    attachment = ingest_image(batch: @batch)
+    @batch.update_columns(last_activity_at: 30.hours.ago, expires_at: 1.minute.ago)
+    job = ImageAttachmentDraftCleanupJob.new
+    listed_at = Time.current
+
+    assert_includes job.send(:candidate_ids, listed_at, 10), @batch.id
+
+    # The pass has already listed this draft as expired. Before it reaches the
+    # candidate, the composer resumes the draft and posts it.
+    outcomes = with_one_shot_instance_method_barrier(
+      ImageAttachment, :update_columns, predicate: ->(record, *_args) { record.is_a?(ImageAttachment) }
+    ) do |entered, release|
+      run_blocked_race(
+        entered: entered,
+        release: release,
+        first: -> { revive_and_claim },
+        second: -> { job.send(:reclaim, @batch.id, listed_at) }
+      )
+    end
+
+    assert_no_concurrency_exceptions(outcomes)
+    claimed = outcomes.grep(ImageAttachments::ClaimBatch::Result).sole
+    assert_includes outcomes, false
+    assert_predicate @batch.reload, :state_claimed?
+    assert_equal claimed.parent.id, attachment.reload.annotation_id
+    assert attachment.image.attached?
+  end
+
+  private
+
+  # SQLite cannot block a reader on another transaction's write, so a race that
+  # resolves through an aggregate recheck is asserted by outcome rather than by
+  # observed blocking. PostgreSQL runs the same interleaving with real row
+  # locks in the concurrency qualification workflow.
+  # An upload parked before its commit transaction holds no row lock, so the
+  # racing operation can be run all the way to completion before the upload is
+  # released. Waiting for it is what makes "the removal already happened" the
+  # interleaving under test rather than one the faster adapter wins by luck.
+  def run_settled_race(entered:, release:, first:, second:)
+    first_result = Queue.new
+    second_result = Queue.new
+    first_thread = concurrency_thread(first_result, Queue.new, first)
+    pop_with_timeout(entered)
+
+    second_thread = concurrency_thread(second_result, Queue.new, second)
+    join_with_timeout(second_thread)
+    release << true
+    join_with_timeout(first_thread)
+
+    [ pop_with_timeout(first_result), pop_with_timeout(second_result) ]
+  ensure
+    release << true if release
+    [ first_thread, second_thread ].compact.each { |thread| thread.kill if thread.alive? }
+  end
+
+  def claim_result
+    ImageAttachments::ClaimBatch.call(batch: ImageAttachmentBatch.find(@batch.id), user: @user, project: @project,
+      parent_type: Annotation) do
+      @screenshot.annotations.create!(
+        user: @user, x_percent: 10, y_percent: 10, comment: "Look here", viewport: :desktop
+      )
+    end
+  end
+
+  def revive_and_claim
+    ImageAttachmentBatch.find(@batch.id).touch_activity!
+    claim_result
+  end
+
+  def remove_by_client_key(client_key)
+    row = ImageAttachment.find_by!(image_attachment_batch_id: @batch.id, client_key: client_key)
+    ImageAttachments::RemoveAttachment.call(batch: ImageAttachmentBatch.find(@batch.id), attachment_id: row.id)
+  end
+
+  def safe_ingest(bytes, client_key, batch: @batch)
+    ImageAttachments::Ingest.call(
+      batch: ImageAttachmentBatch.find(batch.id),
+      io: StringIO.new(bytes),
+      client_key: client_key,
+      declared_content_type: "image/png"
+    )
+  rescue ImageAttachments::Error => error
+    error
+  end
+end
diff --git a/test/integration/image_attachment_s3_delivery_contract_test.rb b/test/integration/image_attachment_s3_delivery_contract_test.rb
new file mode 100644
index 0000000..bd9dc8b
--- /dev/null
+++ b/test/integration/image_attachment_s3_delivery_contract_test.rb
@@ -0,0 +1,152 @@
+# frozen_string_literal: true
+
+# screenote-edition: self_hosted
+
+require "test_helper"
+require_relative "../support/s3_contract_helper"
+
+# Disk-backed delivery tests prove the route, header, and token policy, but they
+# cannot prove the application streams provider bytes itself: a Disk service has
+# no presigned URL to leak and no remote host to redirect to. This runs the same
+# protected routes with the whole application pointed at a real object store, so
+# "the bytes come through the application, never through the provider" is
+# executed rather than inferred.
+class ImageAttachmentS3DeliveryContractTest < ActionDispatch::IntegrationTest
+  include S3ContractHelper
+
+  setup do
+    require_s3_contract!
+    require_vips!
+    @user = users(:alice)
+    @project = projects(:alice_project)
+    @annotation = annotations(:point_annotation)
+    @client = Aws::S3::Client.new(client_options)
+    @client.create_bucket(bucket: bucket)
+  rescue Aws::S3::Errors::BucketAlreadyOwnedByYou
+    nil
+  end
+
+  teardown do
+    next unless defined?(@client) && @client
+
+    @client.list_objects_v2(bucket: bucket, prefix: prefix).contents.each do |object|
+      @client.delete_object(bucket: bucket, key: object.key)
+    end
+  end
+
+  test "the session route streams provider bytes without ever naming the provider" do
+    with_s3_active_storage do
+      attachment = submitted_attachment
+      assert_stored_on_provider attachment
+
+      sign_in(@user)
+      get image_attachment_media_path(attachment, :original)
+
+      assert_response :ok
+      assert_equal attachment.media_type, response.media_type
+      assert_equal @bytes, response.body.b
+      assert_no_provider_location
+      assert_equal "private, no-store", response.headers["Cache-Control"]
+      assert_equal "nosniff", response.headers["X-Content-Type-Options"]
+    end
+  end
+
+  test "the session route streams a warmed variant from the provider" do
+    with_s3_active_storage do
+      attachment = submitted_attachment
+      ImageAttachment::THUMBNAIL_VARIANT_NAMES.each { |name| attachment.image.variant(name).processed }
+
+      sign_in(@user)
+      get image_attachment_media_path(attachment, :attachment_thumb_1x)
+
+      assert_response :ok
+      assert_no_provider_location
+      assert_predicate response.body.bytesize, :positive?
+      assert_equal "image/png", response.media_type
+    end
+  end
+
+  test "the application serves byte ranges itself instead of handing back a provider URL" do
+    with_s3_active_storage do
+      attachment = submitted_attachment
+
+      sign_in(@user)
+      get image_attachment_media_path(attachment, :original), headers: { "Range" => "bytes=0-15" }
+
+      assert_response :partial_content
+      assert_equal @bytes.byteslice(0, 16), response.body.b
+      assert_no_provider_location
+    end
+  end
+
+  test "the bearer route streams provider bytes and its purpose token still expires in five minutes" do
+    with_s3_active_storage do
+      attachment = submitted_attachment
+      token = attachment.generate_token_for(ImageAttachment::MEDIA_TOKEN_PURPOSE)
+
+      get api_image_attachment_media_path(attachment, token: token), headers: bearer_headers
+
+      assert_response :ok
+      assert_equal @bytes, response.body.b
+      assert_no_provider_location
+
+      travel ImageAttachment::MEDIA_TOKEN_EXPIRY + 1.second do
+        get api_image_attachment_media_path(attachment, token: token), headers: bearer_headers
+
+        assert_response :not_found
+        assert_no_provider_location
+      end
+    end
+  end
+
+  test "a revoked membership loses provider bytes on the next request" do
+    with_s3_active_storage do
+      attachment = submitted_attachment
+      @project.project_memberships.find_or_create_by!(user: users(:bob)) { |m| m.role = :member }
+      sign_in(users(:bob))
+      get image_attachment_media_path(attachment, :original)
+      assert_response :ok
+
+      @project.project_memberships.where(user: users(:bob)).delete_all
+
+      get image_attachment_media_path(attachment, :original)
+
+      assert_response :not_found
+      assert_no_provider_location
+    end
+  end
+
+  private
+
+  ALICE_TOKEN = "sk_proj_test_alice_key_000000000000000000000000"
+
+  def bearer_headers
+    { "Authorization" => "Bearer #{ALICE_TOKEN}" }
+  end
+
+  def submitted_attachment
+    @bytes = image_bytes(width: 64, height: 48)
+    batch = build_batch(user: @user, project: @project)
+    attachment = ingest_image(batch: batch, bytes: @bytes)
+    ImageAttachments::ClaimBatch.call(
+      batch: batch, user: @user, project: @project, parent_type: Annotation
+    ) { @annotation }
+    attachment.reload
+  end
+
+  # Without this the suite could pass against the Disk service it exists to
+  # replace, so the object has to be readable through the provider client.
+  def assert_stored_on_provider(attachment)
+    key = attachment.image.blob.key
+
+    assert_equal "s3_contract", attachment.image.blob.service_name
+    assert_equal @bytes, @client.get_object(bucket: bucket, key: "#{prefix}/#{key}").body.read.b
+  end
+
+  def assert_no_provider_location
+    assert_nil response.headers["Location"]
+    assert_not response.redirect?
+    refute_includes response.headers.to_h.values.map(&:to_s).join(" "), ENV.fetch("SCREENOTE_S3_ENDPOINT")
+    refute_includes response.body.to_s, "X-Amz-Signature"
+  end
+end
diff --git a/test/integration/image_decoder_limits_test.rb b/test/integration/image_decoder_limits_test.rb
index bc2dca9..6fb4ac0 100644
--- a/test/integration/image_decoder_limits_test.rb
+++ b/test/integration/image_decoder_limits_test.rb
@@ -11,6 +11,40 @@ class ImageDecoderLimitsTest < ActiveSupport::TestCase
     assert_operator Vips.cache_max_files, :<=, 20
   end
 
+  # One draft batch retrying while its earlier upload is still decoding must not
+  # be able to hold every global slot and answer screenshot work with
+  # `decoder_busy`.
+  test "a keyed decode runs one at a time and leaves the other global slot free" do
+    entered = Queue.new
+    release = Queue.new
+    key = "image_attachment_batch:test"
+
+    holder = Thread.new do
+      ImageDecoding::Guard.synchronize(key: key) do
+        entered << true
+        release.pop
+      end
+    end
+    entered.pop
+
+    assert_raises(ImageDecoding::Guard::Busy) do
+      ImageDecoding::Guard.synchronize(key: key, timeout: 0) { flunk "a second decode took the same key" }
+    end
+
+    # Another key still gets a slot: the serialization is per key, not global.
+    assert ImageDecoding::Guard.synchronize(key: "other", timeout: 0) { true }
+  ensure
+    release << true
+    holder&.join(5)
+  end
+
+  test "a keyed decode releases its key for the next caller" do
+    key = "image_attachment_batch:sequential"
+
+    2.times { assert ImageDecoding::Guard.synchronize(key: key, timeout: 0) { true } }
+    assert_empty ImageDecoding::Guard::KEYED
+  end
+
   test "automatic Active Storage analysis passes through the decoder guard" do
     blob = ActiveStorage::Blob.create_and_upload!(
       io: StringIO.new(file_fixture("test_image.png").binread),
diff --git a/test/integration/release_artifact_contract_test.rb b/test/integration/release_artifact_contract_test.rb
index a9253b7..47bfa00 100644
--- a/test/integration/release_artifact_contract_test.rb
+++ b/test/integration/release_artifact_contract_test.rb
@@ -1501,6 +1501,16 @@ class ReleaseArtifactContractTest < ActiveSupport::TestCase
 
     assert_equal ReleaseValidation::REQUIRED_CHECKS.map { |name| "CI / #{name}" }.sort,
       contexts.sort
+
+    # The PostgreSQL lifecycle job is not part of the database-agnostic CI
+    # matrix, so it is required on its own. Without it the branch could merge
+    # with the row-lock, aggregate-race, and atomic-claim evidence unproven.
+    lifecycle = checks.find do |check|
+      check.fetch("context") == ReleaseValidation::CONCURRENCY_QUALIFICATION_CHECK
+    end
+
+    assert_not_nil lifecycle, "the server-database lifecycle job must be a required check"
+    assert_equal 15_368, lifecycle.fetch("integration_id")
   end
 
   test "self-hosted coverage is an explicit positive manifest with fail-closed drift detection" do
diff --git a/test/integration/self_hosted_s3_contract_test.rb b/test/integration/self_hosted_s3_contract_test.rb
index 78865f6..1b7377a 100644
--- a/test/integration/self_hosted_s3_contract_test.rb
+++ b/test/integration/self_hosted_s3_contract_test.rb
@@ -3,11 +3,13 @@
 # screenote-edition: self_hosted
 
 require "test_helper"
-require "aws-sdk-s3"
+require_relative "../support/s3_contract_helper"
 require "base64"
 require "stringio"
 
 class SelfHostedS3ContractTest < ActiveSupport::TestCase
+  include S3ContractHelper
+
   setup do
     require_s3_contract!
     @client = Aws::S3::Client.new(client_options)
@@ -85,59 +87,4 @@ class SelfHostedS3ContractTest < ActiveSupport::TestCase
     end
     assert_operator Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, :<, 5
   end
-
-  private
-
-  def require_s3_contract!
-    required = ENV["SCREENOTE_REQUIRE_S3"] == "1"
-    missing = %w[
-      SCREENOTE_S3_ENDPOINT SCREENOTE_S3_REGION SCREENOTE_S3_BUCKET
-      SCREENOTE_S3_PREFIX SCREENOTE_S3_ACCESS_KEY_ID SCREENOTE_S3_SECRET_ACCESS_KEY
-    ].reject { |name| ENV[name].present? }
-    if missing.any?
-      flunk "S3 matrix missing #{missing.join(', ')}" if required
-      skip "run through script/release_test_matrix s3 against MinIO"
-    end
-  end
-
-  def storage_service
-    ActiveStorage::Service::PrefixedS3Service.new(
-      bucket: bucket,
-      prefix: prefix,
-      **client_options,
-      http_open_timeout: 2,
-      http_read_timeout: 2,
-      retry_limit: 1
-    )
-  end
-
-  def client_options
-    {
-      region: region,
-      access_key_id: access_key,
-      secret_access_key: secret_key,
-      endpoint: ENV.fetch("SCREENOTE_S3_ENDPOINT"),
-      force_path_style: true
-    }
-  end
-
-  def bucket
-    ENV.fetch("SCREENOTE_S3_BUCKET")
-  end
-
-  def prefix
-    ENV.fetch("SCREENOTE_S3_PREFIX")
-  end
-
-  def region
-    ENV.fetch("SCREENOTE_S3_REGION")
-  end
-
-  def access_key
-    ENV.fetch("SCREENOTE_S3_ACCESS_KEY_ID")
-  end
-
-  def secret_key
-    ENV.fetch("SCREENOTE_S3_SECRET_ACCESS_KEY")
-  end
 end
diff --git a/test/jobs/image_attachment_draft_cleanup_job_test.rb b/test/jobs/image_attachment_draft_cleanup_job_test.rb
new file mode 100644
index 0000000..724bc8c
--- /dev/null
+++ b/test/jobs/image_attachment_draft_cleanup_job_test.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class ImageAttachmentDraftCleanupJobTest < ActiveSupport::TestCase
+  include ActiveJob::TestHelper
+
+  setup do
+    require_vips!
+    @user = users(:alice)
+    @project = projects(:alice_project)
+  end
+
+  test "reclaims an abandoned draft and purges its bytes" do
+    batch = build_batch
+    attachment = ingest_image(batch: batch)
+    blob = attachment.image.blob
+    batch.update_columns(last_activity_at: 25.hours.ago, expires_at: 1.hour.ago)
+
+    assert_equal 1, perform_enqueued_jobs { ImageAttachmentDraftCleanupJob.perform_now }
+
+    assert_not ImageAttachmentBatch.exists?(batch.id)
+    assert_not ImageAttachment.exists?(attachment.id)
+    assert_not ActiveStorage::Blob.exists?(blob.id)
+  end
+
+  test "leaves live drafts alone" do
+    batch = build_batch
+    ingest_image(batch: batch)
+
+    assert_equal 0, ImageAttachmentDraftCleanupJob.perform_now
+    assert ImageAttachmentBatch.exists?(batch.id)
+  end
+
+  test "never takes bytes away from a posted message" do
+    batch = build_batch
+    attachment = ingest_image(batch: batch)
+    ImageAttachments::ClaimBatch.call(batch: batch, user: @user, project: @project, parent_type: Annotation) do
+      screenshots(:alice_screenshot).annotations.create!(
+        user: @user, x_percent: 1, y_percent: 1, comment: "Posted"
+      )
+    end
+    batch.update_columns(last_activity_at: 25.hours.ago, expires_at: 1.hour.ago)
+
+    assert_equal 0, ImageAttachmentDraftCleanupJob.perform_now
+
+    assert ImageAttachment.exists?(attachment.id)
+    assert attachment.reload.image.attached?
+  end
+
+  test "candidate scans are bounded" do
+    3.times do
+      build_batch.update_columns(last_activity_at: 25.hours.ago, expires_at: 1.hour.ago)
+    end
+
+    assert_equal 2, ImageAttachmentDraftCleanupJob.perform_now(limit: 2)
+    assert_equal 1, ImageAttachmentBatch.count
+  end
+end
diff --git a/test/jobs/image_attachment_orphan_reconciliation_job_test.rb b/test/jobs/image_attachment_orphan_reconciliation_job_test.rb
new file mode 100644
index 0000000..3a754be
--- /dev/null
+++ b/test/jobs/image_attachment_orphan_reconciliation_job_test.rb
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class ImageAttachmentOrphanReconciliationJobTest < ActiveSupport::TestCase
+  include ActiveJob::TestHelper
+
+  setup do
+    require_vips!
+    @user = users(:alice)
+    @project = projects(:alice_project)
+  end
+
+  test "purges a submitted row whose message was deleted without callbacks" do
+    attachment = submitted_attachment
+    blob = attachment.image.blob
+    delete_parent_bypassing_callbacks(attachment)
+
+    assert_equal 1, perform_enqueued_jobs { ImageAttachmentOrphanReconciliationJob.perform_now }
+
+    assert_not ImageAttachment.exists?(attachment.id)
+    assert_not ActiveStorage::Blob.exists?(blob.id)
+  end
+
+  test "never purges a row whose message still resolves" do
+    attachment = submitted_attachment
+
+    assert_equal 0, ImageAttachmentOrphanReconciliationJob.perform_now
+    assert ImageAttachment.exists?(attachment.id)
+  end
+
+  test "never purges a live draft" do
+    batch = build_batch
+    attachment = ingest_image(batch: batch)
+
+    assert_equal 0, ImageAttachmentOrphanReconciliationJob.perform_now
+    assert ImageAttachment.exists?(attachment.id)
+  end
+
+  test "reconciliation is idempotent" do
+    attachment = submitted_attachment
+    delete_parent_bypassing_callbacks(attachment)
+
+    assert_equal 1, ImageAttachmentOrphanReconciliationJob.perform_now
+    assert_equal 0, ImageAttachmentOrphanReconciliationJob.perform_now
+  end
+
+  # A lost `perform_later` at claim time would otherwise leave a posted gallery
+  # on its stable placeholder until the process restarted.
+  test "re-enqueues warming for a submitted attachment whose variants are missing" do
+    attachment = submitted_attachment
+    clear_enqueued_jobs
+
+    assert_not attachment.thumbnail_variants_warmed?
+    assert_enqueued_with(job: ImageAttachmentThumbnailJob) { ImageAttachmentOrphanReconciliationJob.perform_now }
+  end
+
+  test "re-enqueueing stops once the variants exist" do
+    attachment = submitted_attachment
+    perform_enqueued_jobs(only: ImageAttachmentThumbnailJob)
+
+    assert attachment.reload.thumbnail_variants_warmed?
+    assert_no_enqueued_jobs(only: ImageAttachmentThumbnailJob) do
+      ImageAttachmentOrphanReconciliationJob.perform_now
+    end
+  end
+
+  test "never warms a draft" do
+    batch = build_batch
+    ingest_image(batch: batch)
+    clear_enqueued_jobs
+
+    assert_no_enqueued_jobs(only: ImageAttachmentThumbnailJob) do
+      ImageAttachmentOrphanReconciliationJob.perform_now
+    end
+  end
+
+  test "variant reconciliation selects at most the requested missing rows" do
+    2.times { submitted_attachment }
+    clear_enqueued_jobs
+
+    ImageAttachmentOrphanReconciliationJob.perform_now(limit: 1)
+
+    assert_enqueued_jobs 1, only: ImageAttachmentThumbnailJob
+  end
+
+  # A provider delete that failed deliberately leaves the blob row behind so the
+  # key stays durable. Retrying it is this pass's job.
+  test "retries an unattached blob a failed provider delete left behind" do
+    attachment = submitted_attachment
+    blob = attachment.image.blob
+    attachment.image.detach
+    blob.update_columns(created_at: (ImageAttachmentOrphanReconciliationJob::UNATTACHED_GRACE + 1.hour).ago)
+
+    ImageAttachmentOrphanReconciliationJob.perform_now
+
+    assert_not ActiveStorage::Blob.exists?(blob.id)
+  end
+
+  test "never reclaims a blob an in-flight upload may still be about to attach" do
+    attachment = submitted_attachment
+    blob = attachment.image.blob
+    attachment.image.detach
+
+    ImageAttachmentOrphanReconciliationJob.perform_now
+
+    assert ActiveStorage::Blob.exists?(blob.id)
+  end
+
+  test "never reclaims an unattached blob that belongs to another subsystem" do
+    blob = ActiveStorage::Blob.create_and_upload!(
+      io: StringIO.new(image_bytes), filename: "screenshot.png", content_type: "image/png"
+    )
+    blob.update_columns(created_at: (ImageAttachmentOrphanReconciliationJob::UNATTACHED_GRACE + 1.hour).ago)
+
+    ImageAttachmentOrphanReconciliationJob.perform_now
+
+    assert ActiveStorage::Blob.exists?(blob.id)
+  end
+
+  private
+
+  # Foreign keys make this unreachable through the application, which is the
+  # point: reconciliation is the backstop for a future deletion path that skips
+  # the Rails callbacks entirely.
+  def delete_parent_bypassing_callbacks(attachment)
+    ImageAttachment.connection.disable_referential_integrity do
+      Annotation.where(id: attachment.annotation_id).delete_all
+    end
+  end
+
+  def submitted_attachment
+    batch = build_batch
+    ingest_image(batch: batch)
+    result = ImageAttachments::ClaimBatch.call(batch: batch, user: @user, project: @project, parent_type: Annotation) do
+      screenshots(:alice_screenshot).annotations.create!(
+        user: @user, x_percent: 1, y_percent: 1, comment: "Posted"
+      )
+    end
+    result.attachments.sole
+  end
+end
diff --git a/test/mailers/notification_mailer_attachments_test.rb b/test/mailers/notification_mailer_attachments_test.rb
new file mode 100644
index 0000000..a2e48b5
--- /dev/null
+++ b/test/mailers/notification_mailer_attachments_test.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class NotificationMailerAttachmentsTest < ActionMailer::TestCase
+  setup do
+    require_vips!
+    @user = users(:alice)
+    @project = projects(:alice_project)
+    @annotation = annotations(:point_annotation)
+  end
+
+  test "the digest describes root and latest pre-resolution reply attachments" do
+    attach_to(@annotation, alt_text: "The broken header")
+    old_reply = create_reply("Older reply", at: 3.hours.ago)
+    attach_to(old_reply, alt_text: "Stale evidence")
+    latest_reply = create_reply("Latest reply", at: 2.hours.ago)
+    attach_to(latest_reply, alt_text: "Current evidence")
+    resolution = create_resolution(at: 1.hour.ago)
+
+    body = deliver(resolution).body.to_s
+
+    assert_includes body, "The broken header"
+    assert_includes body, "Current evidence"
+    assert_not_includes body, "Stale evidence"
+  end
+
+  test "a reply posted after the resolution is not described" do
+    latest_reply = create_reply("Before", at: 2.hours.ago)
+    attach_to(latest_reply, alt_text: "Before evidence")
+    resolution = create_resolution(at: 1.hour.ago)
+    later = create_reply("After", at: 30.minutes.ago)
+    attach_to(later, alt_text: "After evidence")
+
+    body = deliver(resolution).body.to_s
+
+    assert_includes body, "Before evidence"
+    assert_not_includes body, "After evidence"
+  end
+
+  test "the call to action deep links to the annotation and embeds no blob url" do
+    attachment = attach_to(@annotation)
+    resolution = create_resolution(at: 1.hour.ago)
+
+    body = deliver(resolution).body.to_s
+
+    assert_includes body, "/pages/#{@annotation.screenshot.page_id}"
+    assert_includes body, "version_id=#{@annotation.screenshot_id}"
+    assert_includes body, "#annotation_#{@annotation.id}"
+    assert_not_includes body, attachment.image.blob.key
+    assert_not_includes body, "/media/image_attachments/"
+    assert_not_includes body, "/api/media/image_attachments/"
+  end
+
+  test "a message without attachments describes none" do
+    resolution = create_resolution(at: 1.hour.ago)
+
+    body = deliver(resolution).body.to_s
+
+    assert_not_includes body, "images attached"
+  end
+
+  private
+
+  def deliver(resolution)
+    NotificationMailer.resolution_digest(@user, [ resolution ])
+  end
+
+  def create_reply(text, at:)
+    @annotation.annotation_comments.create!(user: users(:bob), body: text, action: :comment).tap do |comment|
+      comment.update_columns(created_at: at)
+    end
+  end
+
+  def create_resolution(at:)
+    @annotation.update!(status: :resolved, resolved_by_user: users(:bob))
+    @annotation.annotation_comments.create!(user: users(:bob), body: "Fixed", action: :resolved).tap do |comment|
+      comment.update_columns(created_at: at)
+    end
+  end
+
+  def attach_to(parent, alt_text: nil)
+    batch = build_batch(user: @user, project: @project)
+    attachment = ingest_image(batch: batch)
+    attachment.update!(alt_text: alt_text) if alt_text
+    ImageAttachments::ClaimBatch.call(batch: batch, user: @user, project: @project, parent_type: parent.class) { parent }
+    attachment.reload
+  end
+end
diff --git a/test/manifests/release_security_coverage.yml b/test/manifests/release_security_coverage.yml
index 9435248..2231bab 100644
--- a/test/manifests/release_security_coverage.yml
+++ b/test/manifests/release_security_coverage.yml
@@ -47,6 +47,7 @@ domains:
     - app/controllers/annotation_comments_controller.rb
     - app/controllers/annotations_controller.rb
     - app/controllers/api/base_controller.rb
+    - app/controllers/api/image_attachment_media_controller.rb
     - app/controllers/api/screenshot_uploads_controller.rb
     - app/controllers/api/v1/annotation_comments_controller.rb
     - app/controllers/api/v1/annotation_resolutions_controller.rb
@@ -59,9 +60,15 @@ domains:
     - app/controllers/api_keys_controller.rb
     - app/controllers/application_controller.rb
     - app/controllers/collaborator_suggestions_controller.rb
+    - app/controllers/concerns/blob_streaming.rb
+    - app/controllers/concerns/image_attachment_submission.rb
     - app/controllers/concerns/page_workspace_navigation.rb
     - app/controllers/concerns/project_authorization.rb
     - app/controllers/concerns/screenote_session_management.rb
+    - app/controllers/image_attachment_drafts/attachments_controller.rb
+    - app/controllers/image_attachment_drafts/base_controller.rb
+    - app/controllers/image_attachment_drafts/batches_controller.rb
+    - app/controllers/image_attachment_media_controller.rb
     - app/controllers/media_controller.rb
     - app/controllers/oauth/authorizations_controller.rb
     - app/controllers/oauth/device_authorization_requests_controller.rb
diff --git a/test/manifests/self_hosted.yml b/test/manifests/self_hosted.yml
index 2dd452b..1b7ee3b 100644
--- a/test/manifests/self_hosted.yml
+++ b/test/manifests/self_hosted.yml
@@ -70,6 +70,7 @@ capabilities:
 
 specialized:
   s3:
+    - test/integration/image_attachment_s3_delivery_contract_test.rb
     - test/integration/self_hosted_s3_contract_test.rb
   system_claimed:
     - test/system/instance_administration_test.rb
diff --git a/test/models/image_attachment_batch_test.rb b/test/models/image_attachment_batch_test.rb
new file mode 100644
index 0000000..71e7a5a
--- /dev/null
+++ b/test/models/image_attachment_batch_test.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class ImageAttachmentBatchTest < ActiveSupport::TestCase
+  setup do
+    @user = users(:alice)
+    @project = projects(:alice_project)
+    @batch = ImageAttachmentBatch.open_for!(user: @user, project: @project)
+  end
+
+  test "a fresh batch is usable inside its activity window" do
+    assert_predicate @batch, :usable?
+    assert_not @batch.expired?
+    assert_equal @batch.last_activity_at + ImageAttachmentBatch::EXPIRY, @batch.expires_at
+  end
+
+  test "an expired batch is no longer usable" do
+    @batch.update_columns(last_activity_at: 25.hours.ago, expires_at: 1.minute.ago)
+
+    assert_predicate @batch.reload, :expired?
+    assert_not @batch.usable?
+  end
+
+  test "a claimed batch is no longer usable even inside its window" do
+    @batch.update!(state: :claimed, claimed_annotation: annotations(:point_annotation))
+
+    assert_not @batch.reload.usable?
+    assert_not @batch.expired?
+  end
+
+  test "touch_activity! slides the whole expiry window forward" do
+    @batch.update_columns(last_activity_at: 20.hours.ago, expires_at: 4.hours.from_now)
+    now = Time.current
+
+    @batch.touch_activity!(now)
+
+    assert_in_delta now.to_f, @batch.reload.last_activity_at.to_f, 1
+    assert_in_delta (now + ImageAttachmentBatch::EXPIRY).to_f, @batch.expires_at.to_f, 1
+  end
+
+  test "claimed_parent answers whichever message claimed the batch" do
+    assert_nil @batch.claimed_parent
+
+    @batch.update!(state: :claimed, claimed_annotation_comment: annotation_comments(:resolved_comment))
+
+    assert_equal annotation_comments(:resolved_comment), @batch.reload.claimed_parent
+  end
+
+  test "database rejects an unknown state" do
+    assert_raises ActiveRecord::StatementInvalid do
+      insert_batch(state: 2, claimed: "NULL, NULL")
+    end
+  end
+
+  test "database rejects an expiry that does not follow its activity stamp" do
+    assert_raises ActiveRecord::StatementInvalid do
+      insert_batch(expires_at: "2026-01-01 00:00:00", last_activity_at: "2026-01-02 00:00:00")
+    end
+  end
+
+  test "database rejects an open batch that already names a claimed parent" do
+    assert_raises ActiveRecord::StatementInvalid do
+      insert_batch(state: 0, claimed: "#{annotations(:point_annotation).id}, NULL")
+    end
+  end
+
+  test "database rejects a claimed batch that names both parents" do
+    assert_raises ActiveRecord::StatementInvalid do
+      insert_batch(
+        state: 1,
+        claimed: "#{annotations(:point_annotation).id}, #{annotation_comments(:resolved_comment).id}"
+      )
+    end
+  end
+
+  test "database rejects a claimed batch that names no parent" do
+    assert_raises ActiveRecord::StatementInvalid do
+      insert_batch(state: 1, claimed: "NULL, NULL")
+    end
+  end
+
+  private
+
+  def insert_batch(state: 0, claimed: "NULL, NULL",
+    last_activity_at: "2026-01-01 00:00:00", expires_at: "2026-01-02 00:00:00")
+    ImageAttachmentBatch.connection.execute(<<~SQL.squish)
+      INSERT INTO image_attachment_batches
+        (user_id, project_id, public_id, state, claimed_annotation_id, claimed_annotation_comment_id,
+         last_activity_at, expires_at, created_at, updated_at)
+      VALUES
+        (#{@user.id}, #{@project.id}, '#{SecureRandom.urlsafe_base64(24)}', #{state}, #{claimed},
+         '#{last_activity_at}', '#{expires_at}', '2026-01-01', '2026-01-01')
+    SQL
+  end
+end
diff --git a/test/models/image_attachment_test.rb b/test/models/image_attachment_test.rb
new file mode 100644
index 0000000..2f0403f
--- /dev/null
+++ b/test/models/image_attachment_test.rb
@@ -0,0 +1,218 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class ImageAttachmentTest < ActiveSupport::TestCase
+  include ActiveJob::TestHelper
+
+  setup do
+    @batch = build_batch
+  end
+
+  test "a draft belongs to a batch and no message" do
+    attachment = @batch.image_attachments.create!(
+      user: users(:alice), project: projects(:alice_project), client_key: "a"
+    )
+
+    assert_predicate attachment, :draft?
+    assert_not_predicate attachment, :submitted?
+  end
+
+  test "an attachment cannot belong to both a batch and a message" do
+    attachment = ImageAttachment.new(
+      image_attachment_batch: @batch,
+      annotation: annotations(:point_annotation),
+      user: users(:alice),
+      project: projects(:alice_project),
+      client_key: "a"
+    )
+
+    assert_not attachment.valid?
+    assert_includes attachment.errors[:base], "must belong to exactly one draft batch or message"
+  end
+
+  test "an attachment cannot belong to two messages" do
+    attachment = ImageAttachment.new(
+      annotation: annotations(:point_annotation),
+      annotation_comment: annotation_comments(:resolved_comment),
+      user: users(:alice),
+      project: projects(:alice_project),
+      client_key: "a"
+    )
+
+    assert_not attachment.valid?
+    assert_includes attachment.errors[:base], "must belong to exactly one draft batch or message"
+  end
+
+  test "a submitted attachment must be ready and fully measured" do
+    attachment = ImageAttachment.new(
+      annotation: annotations(:point_annotation),
+      user: users(:alice),
+      project: projects(:alice_project),
+      client_key: "a",
+      state: :uploading
+    )
+
+    assert_not attachment.valid?
+    assert_includes attachment.errors[:state], "submitted attachment must be ready"
+  end
+
+  test "submitted metadata and alt text are immutable" do
+    changes = {
+      alt_text: "changed",
+      media_type: "image/webp",
+      width: 99,
+      height: 99,
+      byte_size: 999,
+      state: :failed,
+      annotation_id: annotations(:region_annotation).id,
+      annotation_comment_id: annotation_comments(:resolved_comment).id
+    }
+
+    changes.each do |attribute, value|
+      attachment = submitted_attachment
+      attachment.public_send("#{attribute}=", value)
+
+      assert_not attachment.valid?, "#{attribute} must not be writable after submission"
+      assert_includes attachment.errors[attribute], "cannot change after submission"
+    end
+  end
+
+  test "uploader and project are immutable" do
+    attachment = @batch.image_attachments.create!(
+      user: users(:alice), project: projects(:alice_project), client_key: "a"
+    )
+
+    attachment.user = users(:bob)
+    assert_not attachment.valid?
+    assert_includes attachment.errors[:user_id], "cannot change"
+  end
+
+  test "alt text falls back to a neutral label" do
+    attachment = @batch.image_attachments.create!(
+      user: users(:alice), project: projects(:alice_project), client_key: "a"
+    )
+
+    assert_equal "Attached image", attachment.display_alt_text
+    attachment.update!(alt_text: "  A red button  ")
+    assert_equal "A red button", attachment.display_alt_text
+  end
+
+  test "database rejects a submitted row that is not ready" do
+    assert_raises ActiveRecord::StatementInvalid do
+      ImageAttachment.connection.execute(<<~SQL.squish)
+        INSERT INTO image_attachments
+          (annotation_id, user_id, project_id, state, client_key, created_at, updated_at)
+        VALUES
+          (#{annotations(:point_annotation).id}, #{users(:alice).id},
+           #{projects(:alice_project).id}, 0, 'sql', '2026-01-01', '2026-01-01')
+      SQL
+    end
+  end
+
+  test "database rejects an attachment with two message parents" do
+    assert_raises ActiveRecord::StatementInvalid do
+      ImageAttachment.connection.execute(<<~SQL.squish)
+        INSERT INTO image_attachments
+          (annotation_id, annotation_comment_id, user_id, project_id, state, client_key,
+           media_type, width, height, byte_size, created_at, updated_at)
+        VALUES
+          (#{annotations(:point_annotation).id}, #{annotation_comments(:resolved_comment).id},
+           #{users(:alice).id}, #{projects(:alice_project).id}, 1, 'sql',
+           'image/png', 1, 1, 1, '2026-01-01', '2026-01-01')
+      SQL
+    end
+  end
+
+  test "destroying a message destroys its attachments and purges the blob" do
+    require_vips!
+    annotation = annotations(:point_annotation)
+    attachment = ingest_image(batch: @batch)
+    ImageAttachments::ClaimBatch.call(batch: @batch, user: users(:alice), project: projects(:alice_project),
+      parent_type: Annotation) do
+      annotation
+    end
+    blob = attachment.reload.image.blob
+
+    assert_difference -> { ImageAttachment.count } => -1 do
+      perform_enqueued_jobs { annotation.destroy! }
+    end
+    assert_not ActiveStorage::Blob.exists?(blob.id)
+  end
+
+  # Active Storage's own `:purge_later` is the only release path and it runs
+  # after the destroy has committed. An enqueue failure there would leave an
+  # unattached blob that nothing row-based can rediscover, so the purge falls
+  # back to running inline.
+  test "a purge enqueue failure still releases the bytes" do
+    attachment = ingest_image(batch: @batch)
+    blob = attachment.image.blob
+    key = blob.key
+
+    ActiveStorage::PurgeJob.define_singleton_method(:perform_later) { |*| raise "the queue refused the job" }
+    begin
+      attachment.destroy!
+    ensure
+      ActiveStorage::PurgeJob.singleton_class.remove_method(:perform_later)
+    end
+
+    assert_not ActiveStorage::Blob.exists?(blob.id)
+    assert_not ActiveStorage::Blob.service.exist?(key)
+  end
+
+  # Deleting the bytes before the row that names them is what makes a provider
+  # failure retryable: the blob stays discoverable instead of becoming an
+  # untracked key.
+  test "a failed provider delete leaves the blob row behind to retry" do
+    attachment = ingest_image(batch: @batch)
+    blob = attachment.image.blob
+
+    blob.define_singleton_method(:delete) { raise "the storage service is unavailable" }
+
+    assert_not ImageAttachments::PurgeBlob.call(blob)
+
+    assert ActiveStorage::Blob.exists?(blob.id)
+  end
+
+  test "deleting a user with attachments on another member's message is rejected" do
+    require_vips!
+    projects(:alice_project).project_memberships.find_or_create_by!(user: users(:bob)) { |m| m.role = :member }
+    batch = build_batch(user: users(:bob))
+    ingest_image(batch: batch)
+    ImageAttachments::ClaimBatch.call(batch: batch, user: users(:bob), project: projects(:alice_project),
+      parent_type: AnnotationComment) do
+      annotations(:point_annotation).annotation_comments.create!(user: users(:bob), body: "See this")
+    end
+
+    assert_not users(:bob).destroy
+    assert_includes users(:bob).errors[:base].join, "image attachment"
+    assert User.exists?(users(:bob).id)
+  end
+
+  test "deleting a user removes their own drafts" do
+    require_vips!
+    batch = build_batch(user: users(:free_user), project: projects(:demo_project))
+    projects(:demo_project).project_memberships.find_or_create_by!(user: users(:free_user)) { |m| m.role = :member }
+    ingest_image(batch: batch)
+
+    assert_difference -> { ImageAttachmentBatch.count } => -1, -> { ImageAttachment.count } => -1 do
+      assert users(:free_user).destroy
+    end
+  end
+
+  private
+
+  def submitted_attachment
+    ImageAttachment.create!(
+      annotation: annotations(:point_annotation),
+      user: users(:alice),
+      project: projects(:alice_project),
+      client_key: "submitted-#{SecureRandom.hex(4)}",
+      state: :ready,
+      media_type: "image/png",
+      width: 8,
+      height: 8,
+      byte_size: 100
+    )
+  end
+end
diff --git a/test/services/image_attachments/claim_batch_test.rb b/test/services/image_attachments/claim_batch_test.rb
new file mode 100644
index 0000000..9720426
--- /dev/null
+++ b/test/services/image_attachments/claim_batch_test.rb
@@ -0,0 +1,150 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+module ImageAttachments
+  class ClaimBatchTest < ActiveSupport::TestCase
+    include ActiveJob::TestHelper
+
+    setup do
+      require_vips!
+      @user = users(:alice)
+      @project = projects(:alice_project)
+      @screenshot = screenshots(:alice_screenshot)
+      @batch = build_batch
+    end
+
+    test "binds every ready attachment to a new annotation" do
+      first = ingest_image(batch: @batch, client_key: "a")
+      second = ingest_image(batch: @batch, client_key: "b")
+
+      result = claim { build_annotation }
+
+      assert_not result.replayed?
+      assert_equal [ first.id, second.id ], result.parent.image_attachments.pluck(:id)
+      [ first, second ].each do |attachment|
+        attachment.reload
+        assert_predicate attachment, :submitted?
+        assert_equal result.parent.id, attachment.annotation_id
+        assert_nil attachment.annotation_comment_id
+      end
+      assert_predicate @batch.reload, :state_claimed?
+      assert_equal result.parent.id, @batch.claimed_annotation_id
+    end
+
+    test "binds attachments to a reply comment" do
+      ingest_image(batch: @batch)
+
+      result = claim(parent_type: AnnotationComment) do
+        annotations(:point_annotation).annotation_comments.create!(user: @user, body: "Reply")
+      end
+
+      attachment = result.parent.image_attachments.sole
+      assert_equal result.parent.id, attachment.annotation_comment_id
+      assert_nil attachment.annotation_id
+    end
+
+    test "replaying the same batch returns the already created parent" do
+      ingest_image(batch: @batch)
+      first = claim { build_annotation }
+
+      assert_no_difference -> { Annotation.count } do
+        replay = claim { raise "must not build a second parent" }
+
+        assert_predicate replay, :replayed?
+        assert_equal first.parent.id, replay.parent.id
+        assert_equal first.attachments.map(&:id), replay.attachments.map(&:id)
+      end
+    end
+
+    test "refuses to claim while any row is still uploading" do
+      ingest_image(batch: @batch, client_key: "ready")
+      @batch.image_attachments.create!(user: @user, project: @project, client_key: "pending")
+
+      error = assert_raises(ImageAttachments::Error) { claim { build_annotation } }
+
+      assert_equal "attachments_not_ready", error.code
+      assert_predicate @batch.reload, :state_open?
+    end
+
+    test "refuses to claim while any row failed" do
+      ingest_image(batch: @batch, client_key: "ready")
+      assert_raises(ImageAttachments::Error) do
+        ingest_image(batch: @batch, bytes: "junk", declared_content_type: nil, client_key: "broken")
+      end
+
+      error = assert_raises(ImageAttachments::Error) { claim { build_annotation } }
+
+      assert_equal "attachments_not_ready", error.code
+    end
+
+    test "refuses a batch owned by another user" do
+      ingest_image(batch: @batch)
+
+      error = assert_raises(ImageAttachments::Error) do
+        ImageAttachments::ClaimBatch.call(batch: @batch, user: users(:bob), project: @project,
+          parent_type: Annotation) { build_annotation }
+      end
+
+      assert_equal "batch_not_owned", error.code
+    end
+
+    test "refuses an expired batch" do
+      ingest_image(batch: @batch)
+      @batch.update_columns(last_activity_at: 25.hours.ago, expires_at: 1.minute.ago)
+
+      error = assert_raises(ImageAttachments::Error) { claim { build_annotation } }
+
+      assert_equal "batch_unusable", error.code
+    end
+
+    test "an invalid message rolls the claim back and preserves the ready drafts" do
+      attachment = ingest_image(batch: @batch)
+
+      assert_raises(ActiveRecord::RecordInvalid) do
+        claim { @screenshot.annotations.create!(user: @user, x_percent: 1, y_percent: 1, comment: "") }
+      end
+
+      assert_predicate @batch.reload, :state_open?
+      assert_predicate attachment.reload, :draft?
+      assert_predicate attachment, :state_ready?
+    end
+
+    test "claiming warms delivery variants only after the message exists" do
+      ingest_image(batch: @batch)
+
+      assert_enqueued_jobs 1, only: ImageAttachmentThumbnailJob do
+        claim { build_annotation }
+      end
+    end
+
+    test "a nil batch still builds the message and claims nothing" do
+      result = ImageAttachments::ClaimBatch.call(batch: nil, user: @user, project: @project,
+        parent_type: Annotation) { build_annotation }
+
+      assert_empty result.attachments
+      assert_not result.replayed?
+      assert_predicate result.parent, :persisted?
+    end
+
+    test "an unsupported parent type is rejected" do
+      ingest_image(batch: @batch)
+
+      assert_raises(ArgumentError) { claim { @screenshot } }
+    end
+
+    private
+
+    def claim(parent_type: Annotation, &block)
+      ImageAttachments::ClaimBatch.call(
+        batch: @batch, user: @user, project: @project, parent_type: parent_type, &block
+      )
+    end
+
+    def build_annotation
+      @screenshot.annotations.create!(
+        user: @user, x_percent: 10, y_percent: 10, comment: "Look here", viewport: :desktop
+      )
+    end
+  end
+end
diff --git a/test/services/image_attachments/ingest_test.rb b/test/services/image_attachments/ingest_test.rb
new file mode 100644
index 0000000..d2cadac
--- /dev/null
+++ b/test/services/image_attachments/ingest_test.rb
@@ -0,0 +1,342 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+module ImageAttachments
+  class IngestTest < ActiveSupport::TestCase
+    setup do
+      require_vips!
+      @batch = build_batch
+    end
+
+    test "accepts PNG, JPEG, and WebP" do
+      { "png" => "image/png", "jpg" => "image/jpeg", "webp" => "image/webp" }.each do |format, media_type|
+        attachment = ingest_image(
+          batch: @batch, format: format, declared_content_type: media_type, client_key: format
+        )
+
+        assert_predicate attachment, :state_ready?
+        assert_equal media_type, attachment.media_type
+        assert_equal 8, attachment.width
+        assert_predicate attachment.byte_size, :positive?
+        assert attachment.image.attached?
+      end
+    end
+
+    test "accepts an extensionless upload with no declared type" do
+      attachment = ingest_image(batch: @batch, declared_content_type: nil, filename: "clipboard")
+
+      assert_predicate attachment, :state_ready?
+      assert_equal "image/png", attachment.media_type
+    end
+
+    test "generates the stored filename server side" do
+      attachment = ingest_image(batch: @batch, filename: "../../etc/passwd.png")
+
+      assert_equal "image-attachment-#{attachment.id}.png", attachment.image.blob.filename.to_s
+    end
+
+    test "rejects a GIF" do
+      error = assert_raises(ImageAttachments::Error) do
+        ingest_image(batch: @batch, bytes: file_fixture("test_invalid.gif").binread, declared_content_type: nil)
+      end
+
+      assert_equal "invalid_image", error.code
+    end
+
+    test "rejects SVG bytes" do
+      error = assert_raises(ImageAttachments::Error) do
+        ingest_image(batch: @batch, bytes: "<svg xmlns='http://www.w3.org/2000/svg'></svg>", declared_content_type: nil)
+      end
+
+      assert_equal "invalid_image", error.code
+    end
+
+    test "rejects a declared type that does not match the bytes" do
+      error = assert_raises(ImageAttachments::Error) do
+        ingest_image(batch: @batch, format: "png", declared_content_type: "image/webp")
+      end
+
+      assert_equal "content_type_mismatch", error.code
+    end
+
+    test "rejects an extension that does not match the bytes" do
+      error = assert_raises(ImageAttachments::Error) do
+        ingest_image(batch: @batch, format: "png", declared_content_type: nil, filename: "spoof.webp")
+      end
+
+      assert_equal "extension_mismatch", error.code
+    end
+
+    test "rejects truncated image bytes" do
+      truncated = image_bytes(format: "png").byteslice(0, 40)
+
+      error = assert_raises(ImageAttachments::Error) do
+        ingest_image(batch: @batch, bytes: truncated, declared_content_type: "image/png")
+      end
+
+      assert_equal "invalid_image", error.code
+    end
+
+    test "rejects an empty upload" do
+      error = assert_raises(ImageAttachments::Error) do
+        ingest_image(batch: @batch, bytes: "", declared_content_type: "image/png")
+      end
+
+      assert_equal "empty_file", error.code
+    end
+
+    test "rejects a declared length over the per-file limit" do
+      error = assert_raises(ImageAttachments::Error) do
+        ingest_image(batch: @batch, declared_length: ImageAttachment::MAX_FILE_SIZE + 1)
+      end
+
+      assert_equal "file_too_large", error.code
+    end
+
+    test "rejects a stream that exceeds the per-file limit" do
+      stub_const(ImageAttachment, :MAX_FILE_SIZE, 32) do
+        error = assert_raises(ImageAttachments::Error) do
+          ingest_image(batch: @batch)
+        end
+
+        assert_equal "file_too_large", error.code
+      end
+    end
+
+    test "rejects a sixth file" do
+      ImageAttachment::MAX_FILES.times { |index| ingest_image(batch: @batch, client_key: "file-#{index}") }
+
+      error = assert_raises(ImageAttachments::Error) { ingest_image(batch: @batch, client_key: "file-5") }
+
+      assert_equal "too_many_files", error.code
+      assert_equal ImageAttachment::MAX_FILES, @batch.image_attachments.count
+    end
+
+    # Removal leaves a tombstone that keeps the client key reserved, but the
+    # composer no longer shows that row. Counting it against the ceiling would
+    # wedge the batch after the first replacement.
+    test "accepts a replacement after a removal at the file ceiling" do
+      ImageAttachment::MAX_FILES.times { |index| ingest_image(batch: @batch, client_key: "file-#{index}") }
+      RemoveAttachment.call(batch: @batch, client_key: "file-0")
+
+      replacement = ingest_image(batch: @batch, client_key: "replacement")
+
+      assert_predicate replacement, :state_ready?
+      assert_equal ImageAttachment::MAX_FILES, @batch.image_attachments.active_drafts.count
+    end
+
+    test "still rejects a sixth active file after a removal" do
+      ImageAttachment::MAX_FILES.times { |index| ingest_image(batch: @batch, client_key: "file-#{index}") }
+      RemoveAttachment.call(batch: @batch, client_key: "file-0")
+      ingest_image(batch: @batch, client_key: "replacement")
+
+      error = assert_raises(ImageAttachments::Error) { ingest_image(batch: @batch, client_key: "one-too-many") }
+
+      assert_equal "too_many_files", error.code
+    end
+
+    test "rejects a batch over the total byte budget" do
+      ingest_image(batch: @batch, client_key: "first")
+
+      stub_const(ImageAttachment, :MAX_TOTAL_BYTES, 1) do
+        error = assert_raises(ImageAttachments::Error) { ingest_image(batch: @batch, client_key: "second") }
+
+        assert_equal "batch_too_large", error.code
+      end
+      assert_predicate @batch.image_attachments.find_by(client_key: "second"), :state_failed?
+    end
+
+    test "rejects an expired batch" do
+      @batch.update_columns(last_activity_at: 25.hours.ago, expires_at: 1.minute.ago)
+
+      error = assert_raises(ImageAttachments::Error) { ingest_image(batch: @batch) }
+
+      assert_equal "batch_unusable", error.code
+    end
+
+    test "a failed upload keeps an observable row that occupies a slot" do
+      assert_raises(ImageAttachments::Error) do
+        ingest_image(batch: @batch, bytes: "not an image", declared_content_type: nil, client_key: "broken")
+      end
+
+      row = @batch.image_attachments.sole
+      assert_predicate row, :state_failed?
+      assert_equal "invalid_image", row.failure_code
+      assert_not row.image.attached?
+    end
+
+    test "retry with the same client key reuses the slot" do
+      assert_raises(ImageAttachments::Error) do
+        ingest_image(batch: @batch, bytes: "not an image", declared_content_type: nil, client_key: "retry")
+      end
+
+      attachment = ingest_image(batch: @batch, client_key: "retry")
+
+      assert_equal 1, @batch.image_attachments.count
+      assert_predicate attachment, :state_ready?
+      assert_nil attachment.failure_code
+    end
+
+    test "a busy decoder is retryable rather than a permanent failure" do
+      ImageDecoding::Guard::CONCURRENCY.times.map { ImageDecoding::Guard::SEMAPHORE.try_acquire(1, 0) }
+
+      error = assert_raises(ImageAttachments::Error) { ingest_image(batch: @batch) }
+
+      assert_equal "decoder_busy", error.code
+      assert_predicate error, :retryable?
+      assert_equal :service_unavailable, error.status
+    ensure
+      ImageDecoding::Guard::CONCURRENCY.times { ImageDecoding::Guard::SEMAPHORE.release }
+    end
+
+    test "a successful upload extends the batch activity window" do
+      @batch.update_columns(last_activity_at: 3.hours.ago, expires_at: 21.hours.from_now)
+
+      ingest_image(batch: @batch)
+
+      assert_operator @batch.reload.expires_at, :>, 23.hours.from_now
+    end
+
+    test "rejects images beyond the pixel budget" do
+      stub_const(ImageAttachment, :MAX_PIXELS, 4) do
+        error = assert_raises(ImageAttachments::Error) { ingest_image(batch: @batch) }
+
+        assert_equal "image_pixels_too_large", error.code
+      end
+    end
+
+    test "replaying a finished client key returns the same row and keeps its blob" do
+      attachment = ingest_image(batch: @batch, client_key: "lost-response")
+      blob_id = attachment.image.blob.id
+      blobs = ActiveStorage::Blob.count
+
+      replayed = ingest_image(batch: @batch, client_key: "lost-response")
+
+      assert_equal attachment.id, replayed.id
+      assert_predicate replayed, :state_ready?
+      assert_equal blob_id, replayed.reload.image.blob.id
+      assert_equal 1, @batch.image_attachments.count
+      assert_equal blobs, ActiveStorage::Blob.count
+    end
+
+    test "the outstanding draft byte ceiling is rechecked when an upload commits" do
+      parked = build_batch(user: @batch.user, project: @batch.project)
+      parked.image_attachments.create!(
+        user: @batch.user, project: @batch.project, client_key: "parked", state: :ready,
+        media_type: "image/png", width: 1, height: 1, byte_size: 4.kilobytes
+      )
+
+      stub_const(ImageAttachmentBatch, :MAX_OUTSTANDING_DRAFT_BYTES, 4.kilobytes) do
+        error = assert_raises(ImageAttachments::Error) { ingest_image(batch: @batch) }
+
+        assert_equal "draft_storage_exhausted", error.code
+      end
+
+      assert_predicate @batch.image_attachments.sole, :state_failed?
+      assert_not @batch.image_attachments.sole.image.attached?
+    end
+
+    # An aborted attempt shares its slot with the retry that supersedes it, so a
+    # late failure from the disconnected attempt must not un-ready the row the
+    # retry already finished and block the post with `attachments_not_ready`.
+    test "a late failure cannot un-ready a row another attempt finished" do
+      attachment = ingest_image(batch: @batch, client_key: "superseded")
+
+      ImageAttachments::Ingest
+        .new(batch: @batch, io: StringIO.new(""), client_key: "superseded")
+        .send(:record_failure, attachment, ImageAttachments::Error.new(code: "upload_failed"))
+
+      attachment.reload
+      assert_predicate attachment, :state_ready?
+      assert_nil attachment.failure_code
+    end
+
+    test "a commit refuses to bind bytes onto a row that was removed" do
+      attachment = ingest_image(batch: @batch, client_key: "removed")
+      ImageAttachments::RemoveAttachment.call(batch: @batch, attachment_id: attachment.id)
+
+      error = assert_raises(ImageAttachments::Error) do
+        ImageAttachments::Ingest.new(batch: @batch, io: StringIO.new(image_bytes), client_key: "removed")
+          .send(:commit!, attachment, Tempfile.new([ "gone", ".png" ]),
+            media_type: "image/png", byte_size: 8, width: 8, height: 8)
+      end
+
+      assert_equal "attachment_removed", error.code
+      assert_equal :not_found, error.status
+      assert_predicate @batch.image_attachments.reload.sole, :removal_tombstone?
+      assert_not @batch.image_attachments.sole.image.attached?
+    end
+
+    # A decoder stops at the end of the picture, so a valid image with a payload
+    # appended after its end marker decodes perfectly. Storing those bytes would
+    # serve the payload back verbatim from the original and download routes.
+    test "rejects a trailing-payload polyglot in every accepted format" do
+      { "png" => "image/png", "jpg" => "image/jpeg", "webp" => "image/webp" }.each do |format, media_type|
+        polyglot = image_bytes(format: format) + "<?php system($_GET[0]); ?>"
+
+        error = assert_raises(ImageAttachments::Error) do
+          ingest_image(batch: @batch, bytes: polyglot, declared_content_type: media_type, client_key: format)
+        end
+
+        assert_equal "invalid_image", error.code, "a #{media_type} polyglot was accepted"
+      end
+    end
+
+    test "rejects a second image appended to a valid one" do
+      polyglot = image_bytes(format: "png") + image_bytes(format: "png")
+
+      error = assert_raises(ImageAttachments::Error) do
+        ingest_image(batch: @batch, bytes: polyglot, declared_content_type: "image/png")
+      end
+
+      assert_equal "invalid_image", error.code
+    end
+
+    # The read and the write are separate statements, so the retry that
+    # supersedes an aborted attempt can commit between them. The write has to be
+    # the database's decision, not the stale attempt's.
+    test "a late failure that loses the race writes nothing at all" do
+      attachment = ingest_image(batch: @batch, client_key: "superseded")
+      ingest = ImageAttachments::Ingest.new(batch: @batch, io: StringIO.new(""), client_key: "superseded")
+      stale = ImageAttachment.find(attachment.id)
+      stale.update_columns(state: ImageAttachment.states[:uploading])
+      # The stale attempt holds an `uploading` snapshot; the retry finishes first.
+      ImageAttachment.find(attachment.id).update_columns(state: ImageAttachment.states[:ready])
+
+      ingest.send(:record_failure, stale, ImageAttachments::Error.new(code: "upload_failed"))
+
+      assert_predicate attachment.reload, :state_ready?
+      assert_nil attachment.failure_code
+    end
+
+    test "a late failure cannot overwrite a removal tombstone" do
+      attachment = ingest_image(batch: @batch, client_key: "removed-late")
+      ImageAttachments::RemoveAttachment.call(batch: @batch, attachment_id: attachment.id)
+
+      ImageAttachments::Ingest
+        .new(batch: @batch, io: StringIO.new(""), client_key: "removed-late")
+        .send(:record_failure, attachment, ImageAttachments::Error.new(code: "upload_failed"))
+
+      assert_predicate attachment.reload, :removal_tombstone?
+    end
+
+    test "an unexpected IO failure still answers with a machine code" do
+      failing_io = Object.new
+      def failing_io.read(*)
+        raise IOError, "the upload socket went away"
+      end
+
+      error = assert_raises(ImageAttachments::Error) do
+        ImageAttachments::Ingest.call(
+          batch: @batch, io: failing_io, client_key: "broken", declared_content_type: "image/png"
+        )
+      end
+
+      assert_equal "upload_failed", error.code
+      assert_equal :internal_server_error, error.status
+      assert_predicate error, :retryable?
+      assert_equal "upload_failed", @batch.image_attachments.sole.failure_code
+    end
+  end
+end
diff --git a/test/services/screenote/recurring_tasks_test.rb b/test/services/screenote/recurring_tasks_test.rb
new file mode 100644
index 0000000..e99ded9
--- /dev/null
+++ b/test/services/screenote/recurring_tasks_test.rb
@@ -0,0 +1,156 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+module Screenote
+  class RecurringTasksTest < ActiveSupport::TestCase
+    test "every environment that supervises jobs schedules bounded attachment cleanup" do
+      Screenote::RecurringTasks::SUPERVISED_ENVIRONMENTS.each do |environment|
+        assert_empty Screenote::RecurringTasks.missing(environment: environment),
+          "#{environment} does not schedule required recurring tasks"
+      end
+    end
+
+    test "a supervised environment missing the cleanup task is not registered" do
+      assert_not Screenote::RecurringTasks.registered?(
+        environment: "production", required: %w[definitely_not_scheduled]
+      )
+    end
+
+    # Checked-in YAML only records intent. If no supervisor booted, if the
+    # scheduler rejected the entries, or if its heartbeat died, cleanup never
+    # runs and drafts accumulate while health still reports green.
+    # Live proof is only required once the process has had time to produce it:
+    # a readiness probe that demanded it from the first second would stop the
+    # deployment it gates from ever coming up.
+    test "a booting process is registered before a supervisor could have appeared" do
+      during_startup_grace do
+        with_supervisor_tables do
+          assert Screenote::RecurringTasks.registered?(environment: "production")
+        end
+      end
+    end
+
+    test "a supervised environment with no live scheduler is not registered once booted" do
+      after_startup_grace do
+        with_supervisor_tables do
+          register_required_tasks
+
+          assert_not Screenote::RecurringTasks.registered?(environment: "production")
+        end
+      end
+    end
+
+    test "a supervised environment whose queue database cannot be read fails closed" do
+      assert_not Screenote::RecurringTasks.supervising?
+
+      after_startup_grace do
+        assert_not Screenote::RecurringTasks.registered?(environment: "production")
+      end
+    end
+
+    test "a live scheduler that never registered the tasks is not supervising" do
+      with_live_scheduler do
+        assert_not Screenote::RecurringTasks.supervising?
+      end
+    end
+
+    test "a scheduler whose heartbeat has died is not supervising" do
+      with_live_scheduler(heartbeat: 1.hour.ago) do
+        register_required_tasks
+
+        assert_not Screenote::RecurringTasks.supervising?
+      end
+    end
+
+    test "a live scheduler running the required tasks is supervising" do
+      after_startup_grace do
+        with_live_scheduler do
+          register_required_tasks
+
+          assert Screenote::RecurringTasks.supervising?
+          assert Screenote::RecurringTasks.registered?(environment: "production")
+        end
+      end
+    end
+
+    test "readiness fails closed when cleanup is unscheduled" do
+      original = Screenote::RecurringTasks.method(:registered?)
+      Screenote::RecurringTasks.define_singleton_method(:registered?) { |**| false }
+
+      assert_not Screenote::Readiness.new(storage_root: Dir.tmpdir).ready?
+    ensure
+      Screenote::RecurringTasks.define_singleton_method(:registered?, original)
+    end
+
+    private
+
+    def during_startup_grace(&)
+      with_booted_at(Process.clock_gettime(Process::CLOCK_MONOTONIC), &)
+    end
+
+    def after_startup_grace
+      booted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) - Screenote::RecurringTasks::STARTUP_GRACE - 60
+      with_booted_at(booted_at) { yield }
+    end
+
+    def with_booted_at(booted_at)
+      original = Screenote::RecurringTasks.booted_at
+      Screenote::RecurringTasks.booted_at = booted_at
+      yield
+    ensure
+      Screenote::RecurringTasks.booted_at = original
+    end
+
+    # The test environment runs one database and never loads the queue schema,
+    # so the two Solid Queue tables this check reads are created here. Creating
+    # them is what lets the real queries run rather than a stub of them.
+    def with_supervisor_tables
+      connection = SolidQueue::Process.connection
+      connection.create_table(:solid_queue_processes, if_not_exists: true) do |t|
+        t.string :kind, null: false
+        t.datetime :last_heartbeat_at, null: false
+        t.bigint :supervisor_id
+        t.integer :pid, null: false
+        t.string :hostname
+        t.text :metadata
+        t.datetime :created_at, null: false
+        t.string :name, null: false
+      end
+      connection.create_table(:solid_queue_recurring_tasks, if_not_exists: true) do |t|
+        t.string :key, null: false
+        t.string :schedule, null: false
+        t.string :command, limit: 2048
+        t.string :class_name
+        t.text :arguments
+        t.string :queue_name
+        t.integer :priority, default: 0
+        t.boolean :static, default: true, null: false
+        t.text :description
+        t.timestamps
+      end
+      yield
+    ensure
+      connection.drop_table(:solid_queue_processes, if_exists: true)
+      connection.drop_table(:solid_queue_recurring_tasks, if_exists: true)
+    end
+
+    def with_live_scheduler(heartbeat: Time.current)
+      with_supervisor_tables do
+        SolidQueue::Process.create!(
+          kind: "Scheduler", last_heartbeat_at: heartbeat, pid: Process.pid,
+          name: "scheduler-test-#{SecureRandom.hex(4)}"
+        )
+        yield
+      end
+    end
+
+    def register_required_tasks
+      Screenote::RecurringTasks::REQUIRED_TASKS.each do |key|
+        SolidQueue::RecurringTask.create!(
+          key: key, schedule: "every hour", class_name: "ImageAttachmentDraftCleanupJob", static: true
+        )
+      end
+    end
+  end
+end
diff --git a/test/support/deterministic_concurrency_test_helper.rb b/test/support/deterministic_concurrency_test_helper.rb
index e899b7a..6cd4c60 100644
--- a/test/support/deterministic_concurrency_test_helper.rb
+++ b/test/support/deterministic_concurrency_test_helper.rb
@@ -10,7 +10,7 @@ module DeterministicConcurrencyTestHelper
 
   def with_one_shot_instance_method_barrier(model, method_name, predicate:)
     original = model.instance_method(method_name)
-    own_method = model.instance_method(method_name) if model.instance_methods(false).include?(method_name)
+    own_method = model.instance_method(method_name) if own_method?(model, method_name)
     visibility = method_visibility(model, method_name)
     mutex = Mutex.new
     armed = true
@@ -105,6 +105,16 @@ def join_with_timeout(thread)
     Timeout.timeout(THREAD_TIMEOUT) { thread.join }
   end
 
+  # `instance_methods(false)` lists only public and protected methods, so asking
+  # it about a private method reports the class as not owning one and the
+  # restore below deletes the real implementation instead of the barrier.
+  def own_method?(model, method_name)
+    owned = model.instance_methods(false) +
+      model.protected_instance_methods(false) +
+      model.private_instance_methods(false)
+    owned.include?(method_name)
+  end
+
   def method_visibility(model, method_name)
     return :private if model.private_method_defined?(method_name)
     return :protected if model.protected_method_defined?(method_name)
diff --git a/test/support/image_attachment_test_helper.rb b/test/support/image_attachment_test_helper.rb
new file mode 100644
index 0000000..747b99a
--- /dev/null
+++ b/test/support/image_attachment_test_helper.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require "tempfile"
+
+module ImageAttachmentTestHelper
+  private
+
+  def image_bytes(format: "png", width: 8, height: 8)
+    require "vips"
+    Tempfile.create([ "screenote-test-", ".#{format}" ]) do |file|
+      file.binmode
+      Vips::Image.black(width, height).add(64).cast(:uchar).bandjoin([ 128, 200 ]).write_to_file(file.path)
+      file.rewind
+      file.read
+    end
+  end
+
+  # Limits are constants so that nothing can widen them at runtime; tests that
+  # need a narrower ceiling swap it back afterwards.
+  def stub_const(owner, name, value)
+    original = owner.const_get(name)
+    owner.send(:remove_const, name)
+    owner.const_set(name, value)
+    yield
+  ensure
+    owner.send(:remove_const, name)
+    owner.const_set(name, original)
+  end
+
+  def with_forgery_protection
+    original = ActionController::Base.allow_forgery_protection
+    ActionController::Base.allow_forgery_protection = true
+    yield
+  ensure
+    ActionController::Base.allow_forgery_protection = original
+  end
+
+  def build_batch(user: users(:alice), project: projects(:alice_project))
+    ImageAttachmentBatch.create!(user: user, project: project)
+  end
+
+  def ingest_image(batch:, client_key: "key-#{SecureRandom.hex(4)}", bytes: nil, format: "png",
+    declared_content_type: "image/png", filename: nil, declared_length: nil)
+    bytes ||= image_bytes(format: format)
+    ImageAttachments::Ingest.call(
+      batch: batch,
+      io: StringIO.new(bytes),
+      client_key: client_key,
+      declared_content_type: declared_content_type,
+      declared_length: declared_length,
+      filename: filename
+    ).attachment
+  end
+end
diff --git a/test/support/principal_action_contract.rb b/test/support/principal_action_contract.rb
index c61cdf4..50e9ec9 100644
--- a/test/support/principal_action_contract.rb
+++ b/test/support/principal_action_contract.rb
@@ -15,7 +15,12 @@ module PrincipalActionContract
     reply: { browser: true, rest: true, public_cli: true, mcp: true },
     resolve: { browser: true, rest: true, public_cli: true, mcp: true },
     reopen: { browser: true, rest: false, public_cli: false, mcp: true },
-    invite_collaborator: { browser: true, rest: false, public_cli: false, mcp: true }
+    invite_collaborator: { browser: true, rest: false, public_cli: false, mcp: true },
+    # Attaching an image is a session-only capability. Every other surface can
+    # read the resulting bytes, but only over an authenticated media request —
+    # MCP hands an agent the URL, it does not stream the image itself.
+    attach_image: { browser: true, rest: false, public_cli: false, mcp: false },
+    read_attachment_media: { browser: true, rest: true, public_cli: true, mcp: false }
   }.transform_values(&:freeze).freeze
 
   MCP_TOOLS = {
@@ -35,7 +40,8 @@ module PrincipalActionContract
     list_annotations: [ "GET", %r{\A/api/v1/screenshots/:screenshot_id/annotations(?:\(.:format\))?\z} ],
     get_annotation: [ "GET", %r{\A/api/v1/annotations/:id(?:\(.:format\))?\z} ],
     reply: [ "POST", %r{\A/api/v1/annotations/:annotation_id/comments(?:\(.:format\))?\z} ],
-    resolve: [ "POST", %r{\A/api/v1/annotations/:annotation_id/resolve(?:\(.:format\))?\z} ]
+    resolve: [ "POST", %r{\A/api/v1/annotations/:annotation_id/resolve(?:\(.:format\))?\z} ],
+    read_attachment_media: [ "GET", %r{\A/api/media/image_attachments/:id(?:\(.:format\))?\z} ]
   }.freeze
 
   def self.supported?(action, surface)
diff --git a/test/support/query_budget_helper.rb b/test/support/query_budget_helper.rb
new file mode 100644
index 0000000..5fb8f27
--- /dev/null
+++ b/test/support/query_budget_helper.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+# Shared SQL budget instrumentation. Reads that preload media are only correct
+# if their query count stays flat as records are added, so every such path
+# measures the same way instead of reinventing a subscriber with its own
+# tolerance.
+module QueryBudgetHelper
+  private
+
+  def capture_app_queries
+    Rails.cache.clear
+    ActiveRecord::Base.connection.clear_query_cache
+    queries = []
+    callback = lambda do |*, payload|
+      queries << payload[:sql] unless /SCHEMA|TRANSACTION/i.match?(payload[:name].to_s)
+    end
+
+    ActiveSupport::Notifications.subscribed(callback, "sql.active_record") { yield }
+    queries
+  end
+
+  def assert_variant_records_preloaded(queries)
+    variant_queries = queries.grep(/active_storage_variant_records/i)
+    assert_equal 1, variant_queries.size,
+      "Expected exactly one bulk variant-record preload, saw #{variant_queries.size}: #{variant_queries.inspect}"
+  end
+end
diff --git a/test/support/s3_contract_helper.rb b/test/support/s3_contract_helper.rb
new file mode 100644
index 0000000..b0357f1
--- /dev/null
+++ b/test/support/s3_contract_helper.rb
@@ -0,0 +1,95 @@
+# frozen_string_literal: true
+
+require "aws-sdk-s3"
+
+# Shared wiring for the S3 compatibility gate. Everything here reads the same
+# operator-supplied configuration that `script/release_test_matrix s3` passes to
+# a real object store, so a suite either runs against that store or says so.
+module S3ContractHelper
+  private
+
+  # Skips when nobody configured an object store and fails closed when the s3
+  # gate did, so this coverage can never be quietly reported as passing.
+  def require_s3_contract!
+    required = ENV["SCREENOTE_REQUIRE_S3"] == "1"
+    missing = %w[
+      SCREENOTE_S3_ENDPOINT SCREENOTE_S3_REGION SCREENOTE_S3_BUCKET
+      SCREENOTE_S3_PREFIX SCREENOTE_S3_ACCESS_KEY_ID SCREENOTE_S3_SECRET_ACCESS_KEY
+    ].reject { |name| ENV[name].present? }
+    return if missing.empty?
+
+    flunk "S3 matrix missing #{missing.join(', ')}" if required
+    skip "run through script/release_test_matrix s3 against MinIO"
+  end
+
+  def storage_service
+    ActiveStorage::Service::PrefixedS3Service.new(
+      bucket: bucket,
+      prefix: prefix,
+      **client_options,
+      http_open_timeout: 2,
+      http_read_timeout: 2,
+      retry_limit: 1
+    )
+  end
+
+  # Points the whole application at the object store for the duration of the
+  # block. Registering a named service is what makes new blobs record it, so
+  # every later read resolves back through the same provider.
+  def with_s3_active_storage(name: :s3_contract)
+    original_services = ActiveStorage::Blob.services
+    original_service = ActiveStorage::Blob.service
+    configurations = Rails.application.config.active_storage.service_configurations.merge(
+      name.to_s => {
+        "service" => "PrefixedS3",
+        "bucket" => bucket,
+        "prefix" => prefix,
+        "region" => region,
+        "access_key_id" => access_key,
+        "secret_access_key" => secret_key,
+        "endpoint" => ENV.fetch("SCREENOTE_S3_ENDPOINT"),
+        "force_path_style" => true,
+        "http_open_timeout" => 5,
+        "http_read_timeout" => 5,
+        "retry_limit" => 1
+      }
+    )
+    registry = ActiveStorage::Service::Registry.new(configurations)
+    ActiveStorage::Blob.services = registry
+    ActiveStorage::Blob.service = registry.fetch(name)
+    yield ActiveStorage::Blob.service
+  ensure
+    ActiveStorage::Blob.services = original_services
+    ActiveStorage::Blob.service = original_service
+  end
+
+  def client_options
+    {
+      region: region,
+      access_key_id: access_key,
+      secret_access_key: secret_key,
+      endpoint: ENV.fetch("SCREENOTE_S3_ENDPOINT"),
+      force_path_style: true
+    }
+  end
+
+  def bucket
+    ENV.fetch("SCREENOTE_S3_BUCKET")
+  end
+
+  def prefix
+    ENV.fetch("SCREENOTE_S3_PREFIX")
+  end
+
+  def region
+    ENV.fetch("SCREENOTE_S3_REGION")
+  end
+
+  def access_key
+    ENV.fetch("SCREENOTE_S3_ACCESS_KEY_ID")
+  end
+
+  def secret_key
+    ENV.fetch("SCREENOTE_S3_SECRET_ACCESS_KEY")
+  end
+end
diff --git a/test/system/application_system_test_case.rb b/test/system/application_system_test_case.rb
index ba2d86b..c879451 100644
--- a/test/system/application_system_test_case.rb
+++ b/test/system/application_system_test_case.rb
@@ -2,6 +2,8 @@
 
 require "test_helper"
 require "capybara-playwright-driver"
+require "fileutils"
+require "json"
 require "uri"
 
 # System tests run against the dev server on port 3005 (must be running: bin/dev).
@@ -21,14 +23,26 @@
 
 class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
   TEST_IMAGE_PATH = Rails.root.join("test/fixtures/files/test_image.png").to_s
-
-  driven_by :playwright, screen_size: [ 1280, 720 ], options: {
+  # A realistically sized page capture. Geometry claims about the annotation
+  # overlay only mean something against a screenshot the size of a real one.
+  DESKTOP_SCREENSHOT_PATH = Rails.root.join("test/fixtures/files/desktop_screenshot.png").to_s
+  # Set SCREENOTE_EVIDENCE_DIR to have a run write named frames, the page facts
+  # behind each frame, and a per-test trace into that directory. The trace
+  # carries the screencast `script/attachment_browser_evidence` encodes into a
+  # video. Nothing is recorded otherwise, so an ordinary run pays nothing for it.
+  EVIDENCE_DIR = ENV["SCREENOTE_EVIDENCE_DIR"].presence
+  # The width every desktop layout claim is measured at, and the width a test
+  # returns to after narrowing the viewport.
+  SCREEN_SIZE = [ 1280, 720 ].freeze
+
+  driven_by :playwright, screen_size: SCREEN_SIZE, options: {
     browser_type: ENV.fetch("PLAYWRIGHT_BROWSER", "chromium").to_sym,
     headless: ENV["HEADED"] != "true",
     deviceScaleFactor: Float(ENV.fetch("DEVICE_SCALE_FACTOR", "1"))
   }
 
   setup do
+    start_evidence_trace
     # The test environment's NullStore intentionally makes fail-closed rate
     # limiters unavailable. Give each system test a functional isolated store
     # so in-process browser/API requests exercise limits without sharing state.
@@ -46,8 +60,75 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
     Rails.cache = @original_system_test_cache
   end
 
+  # Capybara resets the session in `after_teardown`, which closes the browser
+  # context and takes the recording with it. Anything read from the live browser
+  # has to happen here, which is the same reason Rails saves its failure
+  # screenshot from `before_teardown`.
+  def before_teardown
+    save_evidence_trace
+  ensure
+    super
+  end
+
   private
 
+  # --- Evidence helpers ---
+
+  def evidence_capture?
+    EVIDENCE_DIR.present?
+  end
+
+  def evidence_directory
+    @evidence_directory ||= Pathname.new(EVIDENCE_DIR).tap { |dir| FileUtils.mkdir_p(dir) }
+  end
+
+  # Records the run itself, not just its outcome: a Playwright trace carries a
+  # timestamped screencast and a DOM snapshot per action, so the whole authoring
+  # flow can be replayed with `npx playwright show-trace <file>` and encoded
+  # into a watchable video from the screencast alone.
+  #
+  # Playwright's own `record_video_dir` is deliberately not set. `reset!` in this
+  # driver asks the page for its video path while the page is still open, and
+  # `Playwright::Video#path` blocks on a future that the page-close event
+  # rejects, so a run that sets the option hangs and leaves a zero-byte file.
+  # An evidence run exists to produce the trace, so a trace that cannot be
+  # started or cannot be saved fails the run. Swallowing either would let a
+  # green suite stand in for a recording nobody can watch.
+  def start_evidence_trace
+    @evidence_frame_number = 0
+    return unless evidence_capture?
+
+    page.driver.start_tracing(name: name, screenshots: true, snapshots: true)
+  end
+
+  def save_evidence_trace
+    return unless evidence_capture?
+
+    path = evidence_directory.join("#{name.parameterize}.trace.zip")
+    page.driver.stop_tracing(path: path.to_s)
+    raise "the trace for #{name} was not written to #{path}" unless path.exist? && path.size.positive?
+  end
+
+  # Writes one named frame plus the page facts behind it. A screenshot alone
+  # cannot show which URL an image was fetched from or which component context
+  # rendered it, so the reviewable claim lives beside the picture.
+  def capture_evidence(label, facts: {})
+    return unless evidence_capture?
+
+    @evidence_frame_number += 1
+    slug = format("%02d-%s-%s", @evidence_frame_number, name.parameterize, label.parameterize)
+    with_playwright_page { |pw_page| pw_page.screenshot(path: evidence_directory.join("#{slug}.png").to_s) }
+    evidence_directory.join("#{slug}.json").write(JSON.pretty_generate(
+      { test: name, label: label, viewport: evidence_viewport }.merge(facts)
+    ))
+  end
+
+  def evidence_viewport
+    with_playwright_page do |pw_page|
+      pw_page.evaluate("({ width: window.innerWidth, height: window.innerHeight })")
+    end
+  end
+
   # --- Auth helpers ---
 
   def login_as(email, password)
diff --git a/test/system/image_attachments_test.rb b/test/system/image_attachments_test.rb
new file mode 100644
index 0000000..f7c4bca
--- /dev/null
+++ b/test/system/image_attachments_test.rb
@@ -0,0 +1,915 @@
+# frozen_string_literal: true
+
+require "timeout"
+require_relative "application_system_test_case"
+require_relative "pages/auth_page"
+require_relative "pages/projects_page"
+require_relative "pages/pages_page"
+require_relative "pages/screenshots_page"
+require_relative "pages/annotations_page"
+
+class ImageAttachmentsTest < ApplicationSystemTestCase
+  include Pages::AuthPage
+  include Pages::ProjectsPage
+  include Pages::PagesPage
+  include Pages::ScreenshotsPage
+  include Pages::AnnotationsPage
+
+  ANNOTORIOUS_OVERLAY = ".a9s-annotationlayer"
+  COMPOSER = '[data-testid="image-attachment-composer"]'
+  FILE_INPUT = "#{COMPOSER} input[type=file]"
+  ATTACH_BUTTON = '[data-testid="attach-image-button"]'
+  ATTACHMENT_ITEM = '[data-testid="attachment-item"]'
+  ATTACHMENT_STATE = '[data-testid="attachment-state"]'
+  ATTACHMENT_REMOVE = '[data-testid="attachment-remove"]'
+  ATTACHMENT_RETRY = '[data-testid="attachment-retry"]'
+  ATTACHMENT_ALT = '[data-testid="attachment-alt-input"]'
+  ATTACHMENT_STATUS = '[data-testid="attachment-status"]'
+  COMPOSER_ERRORS = '[data-testid="composer-errors"]'
+  GALLERY = '[data-testid="image-attachment-gallery"]'
+  THUMBNAIL = '[data-testid="attachment-thumbnail"]'
+  VIEWER = '[data-testid="attachment-viewer"]'
+  VIEWER_IMAGE = '[data-testid="attachment-viewer-image"]'
+  VIEWER_CLOSE = '[data-testid="attachment-viewer-close"]'
+  VIEWER_NEXT = '[data-testid="attachment-viewer-next"]'
+  SECOND_IMAGE_PATH = Rails.root.join("test/fixtures/files/test_image.png").to_s
+  # Phone-sized, so a layout claim is made where the space actually runs out.
+  NARROW_VIEWPORT = [ 480, 800 ].freeze
+
+  # Drag and clipboard payloads have to be synthesized inside the page, and a
+  # canvas is the only PNG a page script can produce without a file picker.
+  IMAGE_FILE_HELPER = <<~JS
+    async function screenoteTestImageFile(name) {
+      const canvas = document.createElement("canvas")
+      canvas.width = 8
+      canvas.height = 8
+      const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/png"))
+      return new File([blob], name, { type: "image/png" })
+    }
+  JS
+
+  setup do
+    login_as_test_user
+    create_screenshot_for_annotation
+  end
+
+  test "posts a root annotation with a picked image" do
+    open_root_composer
+    attach_file_to_composer(TEST_IMAGE_PATH)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 15
+
+    fill_annotation_comment("Broken button")
+    submit_annotation
+    wait_for_turbo
+
+    assert_annotation_visible("Broken button")
+    assert_selector "#{GALLERY} #{THUMBNAIL}", count: 1, wait: 15
+  end
+
+  # Plan Unit 3 requires the clamped overlay rail to stay off the region the
+  # person just selected. That is a claim about real geometry, so it is measured
+  # against a full-size page capture rather than a thumbnail-sized fixture: on a
+  # 1440x900 screenshot the placement has somewhere to go, and "somewhere" has
+  # to be provably clear of the selection.
+  test "the overlay composer never covers the selected region on a full-size screenshot" do
+    create_screenshot_for_annotation(image_path: DESKTOP_SCREENSHOT_PATH)
+
+    open_root_composer
+    attach_file_to_composer(TEST_IMAGE_PATH)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 15
+
+    geometry = overlay_geometry
+
+    assert_operator geometry["imageWidth"], :>=, 600,
+      "the overlap claim is only meaningful against a real screenshot"
+    assert geometry["withinImage"], "the clamped overlay must stay inside the image"
+    assert_equal 0, geometry["overlapArea"],
+      "the rail must not cover the selected region: #{geometry.inspect}"
+    assert_equal "row", geometry["direction"]
+    assert_equal 1, geometry["rowCount"]
+    assert_operator geometry["railHeight"], :<=, 80
+    capture_evidence("overlay-rail-clear-of-selection", facts: { geometry: geometry })
+  end
+
+  test "the overlay composer stays a compact rail inside the image" do
+    open_root_composer
+    attach_file_to_composer(TEST_IMAGE_PATH)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 15
+
+    geometry = overlay_geometry
+
+    assert geometry["withinImage"], "the clamped overlay must stay inside the image"
+    assert_equal "row", geometry["direction"]
+    assert_equal 1, geometry["rowCount"]
+    assert_operator geometry["railHeight"], :<=, 80
+  end
+
+  # A synthetic ClipboardEvent never performs the browser's own text insertion,
+  # so this pastes for real: the clipboard carries both halves and the keystroke
+  # produces a trusted event. Only that can show the text actually lands.
+  test "pasting an image into the composer attaches it and pasted text stays text" do
+    open_root_composer
+
+    with_playwright_page do |pw_page|
+      pw_page.locator(ATTACH_BUTTON).wait_for(state: "visible", timeout: 10_000)
+      pw_page.context.grant_permissions(%w[clipboard-read clipboard-write])
+      pw_page.evaluate(<<~JS)
+        (async () => {
+          #{IMAGE_FILE_HELPER}
+          const file = await screenoteTestImageFile("pasted.png")
+          await navigator.clipboard.write([
+            new ClipboardItem({
+              "image/png": file,
+              "text/plain": new Blob(["pasted words"], { type: "text/plain" })
+            })
+          ])
+        })()
+      JS
+      pw_page.locator("#annotation-form textarea").click
+      pw_page.keyboard.press("ControlOrMeta+V")
+    end
+
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 15
+    assert_equal "pasted words", composer_body, "the text half of a mixed paste must reach the textarea"
+  end
+
+  test "dropping an image on the composer attaches it and dropping on the canvas does not" do
+    open_root_composer
+
+    # The screenshot keeps its Annotorious drag behavior: the composer only
+    # claims drops that land on the form itself.
+    drop_image_on("[data-testid='screenshot-image']")
+    assert_no_selector ATTACHMENT_ITEM, wait: 3
+
+    drop_image_on("#annotation-form")
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 15
+  end
+
+  # A browser leaves `dataTransfer.files` empty until the drop itself, so this
+  # is the only shape that proves a real OS drag is accepted: dragover must be
+  # cancelled from `types` alone, or the browser navigates to the file instead.
+  test "a system drag that exposes no files until the drop still attaches" do
+    open_root_composer
+
+    accepted = with_playwright_page do |pw_page|
+      pw_page.evaluate(<<~JS)
+        (async () => {
+          #{IMAGE_FILE_HELPER}
+          const form = document.querySelector("#annotation-form")
+
+          // An OS drag in progress: `types` announces files, `files` is empty.
+          const hovering = new DataTransfer()
+          hovering.items.add(new File([], "placeholder.png", { type: "image/png" }))
+          hovering.items.clear()
+          Object.defineProperty(hovering, "types", { value: ["Files"] })
+          const dragover = new DragEvent("dragover", {
+            dataTransfer: hovering, bubbles: true, cancelable: true
+          })
+          const claimed = !form.dispatchEvent(dragover)
+
+          const dropped = new DataTransfer()
+          dropped.items.add(await screenoteTestImageFile("dragged.png"))
+          form.dispatchEvent(new DragEvent("drop", { dataTransfer: dropped, bubbles: true, cancelable: true }))
+
+          return claimed
+        })()
+      JS
+    end
+
+    assert_equal true, accepted, "dragover must be cancelled from types alone, before files are readable"
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 15
+  end
+
+  # Cancelling is the one client action that throws a draft away, which is what
+  # makes "finish or discard one first" true when the open-batch ceiling is hit.
+  # Ordinary disconnect and navigation still leave the batch for its 24 hour
+  # recovery window, so nothing else may issue this request.
+  test "cancelling the overlay discards its draft batch" do
+    open_root_composer
+    attach_file_to_composer(TEST_IMAGE_PATH)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 15
+
+    discards = record_batch_discards
+    cancel_annotation
+
+    assert_no_selector "#annotation-form", wait: 10
+    Timeout.timeout(10) { sleep 0.05 until discards.any? }
+    assert_equal 1, discards.size
+  end
+
+  # Plan Unit 3 requires every input path to reach a posted message, not merely
+  # to attach. Picker, composer-scoped drop, and a real clipboard paste each add
+  # one image to the same root composer, and the post has to carry all three.
+  test "a root annotation posts images added by picker, drop, and paste together" do
+    open_root_composer
+    attach_file_to_composer(TEST_IMAGE_PATH)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 1, wait: 15
+
+    drop_image_on("#annotation-form")
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 2, wait: 15
+
+    paste_image_into("#annotation-form textarea")
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 3, wait: 15
+
+    fill_annotation_comment("Three ways in")
+    submit_annotation
+    wait_for_turbo
+
+    assert_annotation_visible("Three ways in")
+    assert_selector "#{GALLERY} #{THUMBNAIL}", count: 3, wait: 15
+  end
+
+  test "a reply posts several images added by picker, drop, and paste" do
+    create_annotation("Needs several crops")
+
+    within find(ANNOTATION_ITEM, text: "Needs several crops") do
+      find(REPLY_TOGGLE).click
+      find(REPLY_TEXTAREA).set("All three crops")
+      attach_file_to_composer(TEST_IMAGE_PATH)
+      assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 1, wait: 15
+    end
+
+    drop_image_on("form:has([data-testid='reply-textarea'])")
+    within find(ANNOTATION_ITEM, text: "Needs several crops") do
+      assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 2, wait: 15
+    end
+
+    paste_image_into(REPLY_TEXTAREA)
+    within find(ANNOTATION_ITEM, text: "Needs several crops") do
+      assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 3, wait: 15
+      find(REPLY_BUTTON).click
+    end
+    wait_for_turbo
+
+    assert_selector "#{THREAD_ENTRY} #{GALLERY} #{THUMBNAIL}", count: 3, wait: 15
+  end
+
+  test "unresolving posts several images added by picker and drop" do
+    create_annotation("Fix the footer")
+    resolve_annotation("Fix the footer")
+    wait_for_turbo
+
+    within find(ANNOTATION_ITEM, text: "Fix the footer") do
+      find(UNRESOLVE_BUTTON).click
+      within find('[data-testid="unresolve-form"]') do
+        find(UNRESOLVE_TEXTAREA).set("Still wrong, twice over")
+        attach_file_to_composer(TEST_IMAGE_PATH)
+        assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 1, wait: 15
+      end
+    end
+
+    # The listener is on the composer's own form, and the disclosure wraps it in
+    # a div, so the drop is dispatched at the form itself.
+    drop_image_on("[data-testid='unresolve-form'] form")
+    within find('[data-testid="unresolve-form"]') do
+      assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 2, wait: 15
+      find(SUBMIT_UNRESOLVE_BUTTON).click
+    end
+    wait_for_turbo
+
+    assert_selector THREAD_BADGE_REOPENED, wait: 15
+    assert_selector "#{THREAD_ENTRY} #{GALLERY} #{THUMBNAIL}", count: 2, wait: 15
+  end
+
+  # A message must never be posted while one of its images is missing, so the
+  # submit control is the assertion: blocked while a row is not ready, released
+  # only once the retry succeeds, and the post then carries both images.
+  test "a failed upload blocks the post until a retry finishes it" do
+    open_root_composer
+    attach_file_to_composer(TEST_IMAGE_PATH)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 1, wait: 15
+
+    with_playwright_page do |pw_page|
+      pw_page.route("**/attachments", ->(route, _request) { route.abort })
+    end
+    attach_file_to_composer(SECOND_IMAGE_PATH)
+
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=failed]", wait: 15
+    assert submit_disabled?(SAVE_BUTTON), "a failed image must block the post"
+
+    with_playwright_page { |pw_page| pw_page.unroute("**/attachments") }
+    find(ATTACHMENT_RETRY, match: :first).click
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 2, wait: 15
+    assert_not submit_disabled?(SAVE_BUTTON), "a settled composer must release the post"
+
+    fill_annotation_comment("Retried into place")
+    submit_annotation
+    wait_for_turbo
+
+    assert_annotation_visible("Retried into place")
+    assert_selector "#{GALLERY} #{THUMBNAIL}", count: 2, wait: 15
+  end
+
+  # One image has nothing to navigate between, so the viewer must not offer it —
+  # while every other control the plan names still has to work.
+  test "a one-image viewer hides navigation and still zooms, downloads, and opens the original" do
+    post_annotation_with_attachment("Only one image")
+
+    assert_selector "#{GALLERY} #{THUMBNAIL}", count: 1, wait: 15
+    find(THUMBNAIL, match: :first).click
+    assert_selector "#{VIEWER}[open]", wait: 10
+
+    assert_selector "#{VIEWER_NEXT}[hidden]", visible: :all
+    assert_selector '[data-testid="attachment-viewer-previous"][hidden]', visible: :all
+
+    before = viewer_image_width
+    find('[data-testid="attachment-viewer-zoom-in"]').click
+    assert_operator viewer_image_width, :>, before, "zooming in must enlarge the image"
+
+    download = find("[data-testid='attachment-viewer-download']")
+    original = find("[data-testid='attachment-viewer-open-original']")
+    assert_match %r{/media/image_attachments/\d+/download\z}, download["href"]
+    assert_match %r{/media/image_attachments/\d+/original\z}, original["href"]
+    assert download["download"]
+    assert_equal "_blank", original["target"]
+    capture_evidence("single-image-viewer", facts: gallery_facts)
+
+    find(VIEWER_CLOSE).click
+    assert_no_selector "#{VIEWER}[open]", wait: 10
+  end
+
+  test "an unreadable file reports a specific error and can be removed" do
+    open_root_composer
+    attach_file_to_composer(Rails.root.join("test/fixtures/files/test_invalid.png").to_s)
+
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=failed]", wait: 15
+    assert_selector ATTACHMENT_STATE, text: /PNG, JPEG, or WebP|could not be read/, wait: 10
+    assert_no_selector ATTACHMENT_RETRY, visible: :visible
+
+    find(ATTACHMENT_REMOVE).click
+    assert_no_selector ATTACHMENT_ITEM, wait: 10
+  end
+
+  test "a failed remove stays visible and can be retried safely" do
+    open_root_composer
+    attach_file_to_composer(TEST_IMAGE_PATH)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 15
+
+    with_playwright_page do |pw_page|
+      pw_page.route("**/attachments/discard", ->(route, _request) { route.abort })
+    end
+    find(ATTACHMENT_REMOVE).click
+
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=failed]", wait: 10
+    assert_selector ATTACHMENT_STATE, text: /could not be removed/i
+
+    with_playwright_page { |pw_page| pw_page.unroute("**/attachments/discard") }
+    find(ATTACHMENT_REMOVE).click
+    assert_no_selector ATTACHMENT_ITEM, wait: 10
+  end
+
+  test "a Stimulus reconnect restores one ready preview without duplicate listeners" do
+    open_root_composer
+    attach_file_to_composer(TEST_IMAGE_PATH)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 1, wait: 15
+
+    with_playwright_page do |pw_page|
+      pw_page.evaluate(<<~JS)
+        (async () => {
+          const composer = document.querySelector('#annotation-form #{COMPOSER}')
+          const parent = composer.parentElement
+          const next = composer.nextSibling
+          composer.remove()
+          await new Promise(resolve => setTimeout(resolve, 50))
+          parent.insertBefore(composer, next)
+        })()
+      JS
+    end
+
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 1, wait: 15
+    attach_file_to_composer(SECOND_IMAGE_PATH)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 2, wait: 15
+
+    fill_annotation_comment("Restored once")
+    submit_annotation
+    wait_for_turbo
+    assert_selector "#{GALLERY} #{THUMBNAIL}", count: 2, wait: 15
+  end
+
+  test "an invalid post keeps the composer, the body, and the ready image" do
+    open_root_composer
+    attach_file_to_composer(TEST_IMAGE_PATH)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 15
+
+    body = "x" * 5001
+    with_playwright_page do |pw_page|
+      pw_page.evaluate("document.querySelector('textarea[name=\"annotation[comment]\"]').value = 'x'.repeat(5001)")
+    end
+    submit_annotation
+
+    assert_selector COMPOSER_ERRORS, wait: 15
+    assert_selector "#annotation-form", wait: 5
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 5
+    assert_equal body, composer_body, "a rejected post must keep the text that was typed"
+  end
+
+  test "replies carry their own attachments" do
+    create_annotation("Needs a screenshot")
+
+    within find(ANNOTATION_ITEM, text: "Needs a screenshot") do
+      find(REPLY_TOGGLE).click
+      find(REPLY_TEXTAREA).set("Here is the crop")
+      attach_file_to_composer(TEST_IMAGE_PATH)
+      assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 15
+      find(REPLY_BUTTON).click
+    end
+    wait_for_turbo
+
+    assert_selector "#{THREAD_ENTRY} #{GALLERY} #{THUMBNAIL}", count: 1, wait: 15
+  end
+
+  # Plan Unit 3 requires batch isolation across composers that are mounted at
+  # the same time: a reply composer open while the root overlay is open must
+  # take its own batch and keep its own images.
+  test "a root composer and a reply composer mounted together stay isolated" do
+    create_annotation("Needs a screenshot")
+
+    # A second draw has to land clear of the pin the first annotation left, or
+    # Annotorious selects that shape instead of starting a new one.
+    open_root_composer(x_offset: 180, y_offset: 140)
+    root_form = find("#annotation-form")
+    attach_file_to_composer(SECOND_IMAGE_PATH, within: root_form)
+    within root_form do
+      assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 1, wait: 15
+    end
+
+    within find(ANNOTATION_ITEM, text: "Needs a screenshot") do
+      find(REPLY_TOGGLE).click
+      find(REPLY_TEXTAREA).set("Here is the crop")
+      attach_file_to_composer(TEST_IMAGE_PATH)
+      assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 1, wait: 15
+    end
+
+    batch_ids = with_playwright_page do |pw_page|
+      pw_page.evaluate(<<~JS)
+        Array.from(document.querySelectorAll("input[name='image_attachment_batch_id']"))
+          .map(field => field.value)
+          .filter(value => value)
+      JS
+    end
+
+    assert_equal 2, batch_ids.size, "each mounted composer must own a batch"
+    assert_equal batch_ids.uniq, batch_ids, "mounted composers must not share one batch"
+
+    # Each composer still shows exactly the one image it was given.
+    within find(ANNOTATION_ITEM, text: "Needs a screenshot") do
+      assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 1
+      find(REPLY_BUTTON).click
+    end
+    wait_for_turbo
+
+    assert_selector "#{THREAD_ENTRY} #{GALLERY} #{THUMBNAIL}", count: 1, wait: 15
+  end
+
+  test "unresolving carries its own attachments" do
+    create_annotation("Fix the header")
+    resolve_annotation("Fix the header")
+    wait_for_turbo
+
+    within find(ANNOTATION_ITEM, text: "Fix the header") do
+      find(UNRESOLVE_BUTTON).click
+      within find('[data-testid="unresolve-form"]') do
+        find(UNRESOLVE_TEXTAREA).set("Still wrong")
+        attach_file_to_composer(TEST_IMAGE_PATH)
+        assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 15
+        find(SUBMIT_UNRESOLVE_BUTTON).click
+      end
+    end
+    wait_for_turbo
+
+    assert_selector THREAD_BADGE_REOPENED, wait: 15
+    assert_selector "#{THREAD_ENTRY} #{GALLERY} #{THUMBNAIL}", minimum: 1, wait: 15
+  end
+
+  test "alt text is optional and falls back to a neutral label" do
+    open_root_composer
+    attach_file_to_composer(TEST_IMAGE_PATH)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 15
+    assert_selector ATTACHMENT_ALT, wait: 5
+
+    fill_annotation_comment("No description")
+    submit_annotation
+    wait_for_turbo
+
+    assert_selector "#{THUMBNAIL}[data-alt='Attached image']", wait: 15
+  end
+
+  test "saving while the alt field is focused waits for its final description" do
+    open_root_composer
+    attach_file_to_composer(TEST_IMAGE_PATH)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 15
+
+    fill_annotation_comment("Described image")
+    find(ATTACHMENT_ALT).set("A dialog showing the failed save")
+    submit_annotation
+    wait_for_turbo
+
+    assert_selector "#{THUMBNAIL}[data-alt='A dialog showing the failed save']", wait: 15
+  end
+
+  # A screen reader only learns about an upload from the composer's live region,
+  # so the region and the exact text it carries are asserted rather than left to
+  # a manual listen.
+  test "the composer announces every upload transition in a polite live region" do
+    open_root_composer
+
+    assert_selector "#{COMPOSER} [role=status][aria-live=polite]", visible: :all
+
+    attach_file_to_composer(TEST_IMAGE_PATH)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 15
+    assert_selector ATTACHMENT_STATUS, text: "Image 1 uploaded.", wait: 10
+
+    find(ATTACHMENT_REMOVE).click
+    assert_no_selector ATTACHMENT_ITEM, wait: 10
+    assert_selector ATTACHMENT_STATUS, text: "Image removed.", wait: 10
+
+    attach_file_to_composer(Rails.root.join("test/fixtures/files/test_invalid.png").to_s)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=failed]", wait: 15
+    assert_selector ATTACHMENT_STATUS, text: /Image 1 failed\./, wait: 10
+  end
+
+  test "the viewer opens on two images, navigates with the keyboard, and restores focus" do
+    open_root_composer
+    attach_file_to_composer(TEST_IMAGE_PATH)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 1, wait: 15
+    attach_file_to_composer(SECOND_IMAGE_PATH)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", count: 2, wait: 15
+
+    fill_annotation_comment("Look at both")
+    submit_annotation
+    wait_for_turbo
+
+    assert_annotation_visible("Look at both")
+    assert_selector "#{GALLERY} #{THUMBNAIL}", count: 2, wait: 15
+    first_url, second_url = all(THUMBNAIL).first(2).map { |trigger| trigger["data-original-url"] }
+    assert_not_equal first_url, second_url
+
+    all(THUMBNAIL).first.click
+    assert_selector "#{VIEWER}[open]", wait: 10
+    assert_selector "#{VIEWER_NEXT}:not([hidden])"
+    assert_viewer_shows first_url
+    assert_equal "attachment-viewer-image", focused_testid, "the dialog must open focus onto its content"
+
+    # The viewer is the only place an attachment can be read at full size, so
+    # every control the plan names has to be on it, pointed at the application.
+    assert_selector "#{VIEWER}[aria-label='Attached image viewer']"
+    assert_selector '[data-testid="attachment-viewer-previous"]'
+    assert_selector '[data-testid="attachment-viewer-zoom-in"]'
+    assert_selector "[data-testid='attachment-viewer-download'][href^='/media/image_attachments/']"
+    assert_selector "[data-testid='attachment-viewer-open-original'][href^='/media/image_attachments/']"
+    capture_evidence("viewer-open", facts: gallery_facts)
+
+    press_key("ArrowRight")
+    assert_viewer_shows second_url
+    press_key("ArrowLeft")
+    assert_viewer_shows first_url
+
+    press_key("Escape")
+    assert_no_selector "#{VIEWER}[open]", wait: 10
+    assert_equal "attachment-thumbnail", focused_testid, "closing must restore focus to the trigger"
+  end
+
+  test "the gallery and its viewer render at a narrow width" do
+    post_annotation_with_attachment("Narrow layout")
+
+    resize_viewport(NARROW_VIEWPORT)
+    with_playwright_page do |pw_page|
+      box = pw_page.locator(THUMBNAIL).first.bounding_box
+
+      assert_operator box["width"], :>, 0
+      assert_operator box["x"] + box["width"], :<=, NARROW_VIEWPORT.first
+    end
+    capture_evidence("gallery-narrow-width", facts: gallery_facts)
+
+    # A viewer that overflows the viewport cannot be read on a phone, so the
+    # narrow claim covers the full-size view as well as the thumbnails.
+    find(THUMBNAIL, match: :first).click
+    assert_selector "#{VIEWER}[open]", wait: 10
+    with_playwright_page do |pw_page|
+      box = pw_page.locator(VIEWER_IMAGE).bounding_box
+
+      assert_operator box["x"], :>=, -1
+      assert_operator box["x"] + box["width"], :<=, NARROW_VIEWPORT.first + 1
+    end
+    capture_evidence("viewer-narrow-width", facts: gallery_facts)
+  end
+
+  test "the composer exposes explicit light and dark component contexts" do
+    open_root_composer
+
+    assert_selector "#{COMPOSER}.image-attachment-context--dark", visible: :all, wait: 10
+    capture_evidence("composer-dark-overlay-context", facts: { context: "dark" })
+    resize_viewport(NARROW_VIEWPORT)
+    assert_selector "#{COMPOSER}.image-attachment-context--dark", visible: :all, wait: 10
+    capture_evidence("composer-dark-overlay-context-narrow", facts: { context: "dark" })
+    resize_viewport(SCREEN_SIZE)
+
+    create_annotation_after_cancel("Sidebar context")
+    within find(ANNOTATION_ITEM, text: "Sidebar context") do
+      find(REPLY_TOGGLE).click
+      assert_selector "#{COMPOSER}.image-attachment-context--light", wait: 10
+    end
+    capture_evidence("composer-light-sidebar-context", facts: { context: "light" })
+    resize_viewport(NARROW_VIEWPORT)
+    assert_selector "#{COMPOSER}.image-attachment-context--light", wait: 10
+    capture_evidence("composer-light-sidebar-context-narrow", facts: { context: "light" })
+  end
+
+  # The viewer dialog lives inside its gallery and matches a rule that declares
+  # the same variables, and a declaration on an element always beats an
+  # inherited value. Without the stylesheet re-applying the context to the
+  # nested viewer, an explicitly dark gallery would open a light viewer unless
+  # the operating system happened to prefer dark.
+  test "an explicitly dark gallery opens an explicitly dark viewer" do
+    post_annotation_with_attachment("Dark context viewer")
+    assert_selector "#{GALLERY}.image-attachment-context--light", wait: 15
+
+    light_surface = viewer_surface_variable
+    assert_equal "#ffffff", light_surface
+
+    with_playwright_page do |pw_page|
+      pw_page.evaluate(<<~JS)
+        document.querySelector(`#{GALLERY}`).classList.replace(
+          "image-attachment-context--light", "image-attachment-context--dark"
+        )
+      JS
+    end
+
+    assert_equal "#14161c", viewer_surface_variable,
+      "an explicit dark context must reach the nested viewer without an OS preference"
+    find(THUMBNAIL, match: :first).click
+    assert_selector "#{VIEWER}[open]", wait: 10
+    capture_evidence("viewer-dark-context", facts: { context: "dark", surface: viewer_surface_variable })
+  end
+
+  # Rendering never processes an image, so a posted gallery shows a neutral
+  # placeholder until the post-claim warming job has run. Both halves matter:
+  # the placeholder is what a reader sees first, and the responsive thumbnail is
+  # what the job is for.
+  test "a posted gallery shows a neutral placeholder and warms into responsive thumbnails" do
+    create_screenshot_for_annotation(image_path: DESKTOP_SCREENSHOT_PATH)
+    post_annotation_with_attachment("Warmed thumbnails")
+
+    assert_selector "#{GALLERY} [data-testid='attachment-placeholder']", count: 1, wait: 15
+    assert_selector "#{THUMBNAIL}[data-alt='Attached image']"
+    capture_evidence("gallery-pending-placeholder", facts: gallery_facts)
+
+    within find(ANNOTATION_ITEM, text: "Warmed thumbnails") do
+      find(REPLY_TOGGLE).click
+      find(REPLY_TEXTAREA).set("And here is mine")
+      attach_file_to_composer(SECOND_IMAGE_PATH)
+      assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 15
+      find(REPLY_BUTTON).click
+    end
+    wait_for_turbo
+    assert_selector "#{THREAD_ENTRY} #{GALLERY} #{THUMBNAIL}", count: 1, wait: 15
+
+    warm_attachment_thumbnails
+    page.refresh
+    assert_selector "#{GALLERY} img.image-attachment-gallery__image", count: 2, wait: 15
+    assert_no_selector "[data-testid='attachment-placeholder']"
+
+    facts = gallery_facts
+    assert facts["thumbnailSources"].all? { |src| src.start_with?("/media/image_attachments/") },
+      "thumbnails must be fetched from the application, not the storage provider: #{facts.inspect}"
+    assert facts["thumbnailSrcsets"].all? { |set| set.include?("2x") },
+      "each thumbnail must offer a 2x source: #{facts.inspect}"
+    capture_evidence("gallery-warmed-thumbnails-desktop", facts: facts)
+  end
+
+  private
+
+  # The DOM facts a gallery screenshot cannot show on its own: which URL each
+  # image came from, whether a 2x source was offered, and how many items are
+  # still waiting on the warming job.
+  def gallery_facts
+    with_playwright_page do |pw_page|
+      pw_page.evaluate(<<~JS)
+        (() => {
+          const triggers = Array.from(document.querySelectorAll("[data-testid='attachment-thumbnail']"))
+          const images = Array.from(document.querySelectorAll(".image-attachment-gallery__image"))
+          return {
+            thumbnailCount: triggers.length,
+            placeholderCount: document.querySelectorAll("[data-testid='attachment-placeholder']").length,
+            thumbnailSources: images.map(image => new URL(image.src).pathname),
+            thumbnailSrcsets: images.map(image => image.getAttribute("srcset") || ""),
+            originalUrls: triggers.map(trigger => trigger.dataset.originalUrl),
+            downloadUrls: triggers.map(trigger => trigger.dataset.downloadUrl),
+            altText: triggers.map(trigger => trigger.dataset.alt)
+          }
+        })()
+      JS
+    end
+  end
+
+  # The warming job is what turns a placeholder into a thumbnail. Test jobs are
+  # enqueued and never run, so evidence for the warmed state has to run it.
+  def warm_attachment_thumbnails
+    ImageAttachment.submitted.includes(image_attachment: :blob).find_each do |attachment|
+      ImageAttachmentThumbnailJob.perform_now(attachment, attachment.image.blob.id)
+    end
+  end
+
+  # Reads the same numbers the placement algorithm uses: the selected region
+  # comes from the form's own percentage fields against the image box, so the
+  # measurement cannot drift from what was actually selected.
+  def overlay_geometry
+    with_playwright_page do |pw_page|
+      pw_page.evaluate(<<~JS)
+        (() => {
+          const form = document.querySelector("#annotation-form")
+          const image = document.querySelector("[data-testid='screenshot-image']")
+          const items = document.querySelector(".image-attachment-composer__items")
+          const formRect = form.getBoundingClientRect()
+          const imageRect = image.getBoundingClientRect()
+          const itemsRect = items.getBoundingClientRect()
+          const percent = name => Number(form.querySelector(`[name='annotation[${name}]']`)?.value) || 0
+          const region = {
+            left: imageRect.left + (percent("x_percent") / 100) * imageRect.width,
+            top: imageRect.top + (percent("y_percent") / 100) * imageRect.height
+          }
+          region.right = region.left + (percent("width_percent") / 100) * imageRect.width
+          region.bottom = region.top + (percent("height_percent") / 100) * imageRect.height
+          const overlapWidth = Math.max(
+            Math.min(formRect.right, region.right) - Math.max(formRect.left, region.left), 0
+          )
+          const overlapHeight = Math.max(
+            Math.min(formRect.bottom, region.bottom) - Math.max(formRect.top, region.top), 0
+          )
+
+          return {
+            imageWidth: Math.round(imageRect.width),
+            imageHeight: Math.round(imageRect.height),
+            formWidth: Math.round(formRect.width),
+            formHeight: Math.round(formRect.height),
+            regionWidth: Math.round(region.right - region.left),
+            regionHeight: Math.round(region.bottom - region.top),
+            overlapArea: Math.round(overlapWidth * overlapHeight),
+            withinImage: formRect.left >= imageRect.left - 1 && formRect.top >= imageRect.top - 1 &&
+                         formRect.right <= imageRect.right + 1 && formRect.bottom <= imageRect.bottom + 1,
+            direction: getComputedStyle(items).flexDirection,
+            railHeight: Math.round(itemsRect.height),
+            rowCount: items.children.length
+          }
+        })()
+      JS
+    end
+  end
+
+  def open_root_composer(x_offset: 0, y_offset: 0)
+    click_on_image_to_annotate(x_offset: x_offset, y_offset: y_offset)
+    assert_annotation_form_visible
+    assert_selector ATTACH_BUTTON, wait: 10
+  end
+
+  def attach_file_to_composer(path, within: nil)
+    scope = within || page
+    scope.find(FILE_INPUT, visible: :all, match: :first).set(path)
+  end
+
+  # Puts one synthesized PNG on the real clipboard and pastes it into the
+  # element the composer owns, so the browser produces a trusted paste rather
+  # than a synthetic event the page dispatched at itself.
+  def paste_image_into(selector, name: "pasted.png")
+    with_playwright_page do |pw_page|
+      pw_page.context.grant_permissions(%w[clipboard-read clipboard-write])
+      pw_page.evaluate(<<~JS)
+        (async () => {
+          #{IMAGE_FILE_HELPER}
+          const file = await screenoteTestImageFile("#{name}")
+          await navigator.clipboard.write([new ClipboardItem({ "image/png": file })])
+        })()
+      JS
+      pw_page.locator(selector).click
+      pw_page.keyboard.press("ControlOrMeta+V")
+    end
+  end
+
+  # Reads the component variable the whole viewer is coloured from, which is
+  # the value the cascade actually resolved rather than a class name.
+  def viewer_surface_variable
+    with_playwright_page do |pw_page|
+      pw_page.evaluate(<<~JS)
+        getComputedStyle(document.querySelector(`#{VIEWER}`))
+          .getPropertyValue("--attachment-surface").trim()
+      JS
+    end
+  end
+
+  def viewer_image_width
+    with_playwright_page { |pw_page| pw_page.locator(VIEWER_IMAGE).bounding_box["width"] }
+  end
+
+  def submit_disabled?(selector)
+    with_playwright_page { |pw_page| pw_page.locator(selector).evaluate("button => button.disabled") }
+  end
+
+  # Files can only be synthesized in the page, so drag payloads are built there
+  # and dispatched at the exact element under test.
+  def drop_image_on(selector)
+    with_playwright_page do |pw_page|
+      pw_page.evaluate(<<~JS)
+        (async () => {
+          #{IMAGE_FILE_HELPER}
+          const file = await screenoteTestImageFile("dropped.png")
+          const transfer = new DataTransfer()
+          transfer.items.add(file)
+          document.querySelector("#{selector}").dispatchEvent(
+            new DragEvent("drop", { dataTransfer: transfer, bubbles: true, cancelable: true })
+          )
+        })()
+      JS
+    end
+  end
+
+  # The overlay and the sidebar composers use different textareas, so the body
+  # is read from whichever one the current form owns.
+  def composer_body
+    with_playwright_page do |pw_page|
+      pw_page.evaluate("document.querySelector('#annotation-form textarea')?.value")
+    end
+  end
+
+  # Only an explicit cancel may discard a draft, so the assertion is on the
+  # request itself rather than on any state the page still shows.
+  def record_batch_discards
+    discards = []
+    with_playwright_page do |pw_page|
+      pw_page.route("**/image-attachment-drafts/batches/*", lambda do |route, request|
+        discards << request.url if request.method == "DELETE"
+        route.continue
+      end)
+    end
+    discards
+  end
+
+  def resize_viewport(size)
+    width, height = size
+    with_playwright_page { |pw_page| pw_page.set_viewport_size(width: width, height: height) }
+  end
+
+  def press_key(key)
+    with_playwright_page { |pw_page| pw_page.keyboard.press(key) }
+  end
+
+  def focused_testid
+    with_playwright_page { |pw_page| pw_page.evaluate("document.activeElement?.dataset?.testid") }
+  end
+
+  def assert_viewer_shows(url)
+    assert_selector "#{VIEWER_IMAGE}[src='#{url}']", wait: 10
+  end
+
+  def post_annotation_with_attachment(comment)
+    open_root_composer
+    attach_file_to_composer(TEST_IMAGE_PATH)
+    assert_selector "#{ATTACHMENT_ITEM}[data-state=ready]", wait: 15
+    fill_annotation_comment(comment)
+    submit_annotation
+    wait_for_turbo
+    assert_annotation_visible(comment)
+  end
+
+  def create_annotation_after_cancel(comment)
+    cancel_annotation if page.has_selector?("#annotation-form", wait: 1)
+    create_annotation(comment)
+  end
+
+  def create_screenshot_for_annotation(image_path: TEST_IMAGE_PATH)
+    navigate_to_demo_project
+    navigate_to_first_page
+
+    click_link "Upload version"
+    fill_screenshot_form(title: "Attachment Test #{Time.now.to_i}-#{rand(1000)}", image_path: image_path)
+    submit_screenshot_form
+    assert_on_screenshot_show
+    assert_screenshot_image_loaded
+  end
+
+  def create_annotation(comment_text)
+    click_on_image_to_annotate
+    assert_annotation_form_visible
+    fill_annotation_comment(comment_text)
+    submit_annotation
+    wait_for_turbo
+    assert_annotation_visible(comment_text)
+  end
+
+  def click_on_image_to_annotate(x_offset: 0, y_offset: 0)
+    assert_selector ANNOTORIOUS_OVERLAY, wait: 15
+
+    with_playwright_page do |pw_page|
+      svg = pw_page.locator(ANNOTORIOUS_OVERLAY).first
+      svg.wait_for(state: "visible", timeout: 10_000)
+
+      box = svg.bounding_box
+      start_x = box["x"] + (box["width"] * 0.3) + x_offset
+      start_y = box["y"] + (box["height"] * 0.3) + y_offset
+
+      pw_page.mouse.move(start_x, start_y)
+      pw_page.mouse.down
+      pw_page.mouse.move(start_x + 80, start_y + 60)
+      pw_page.mouse.up
+    end
+  end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 528b43a..1c41d38 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -6,8 +6,10 @@
 require "rails/test_help"
 require_relative "support/device_authorization_test_helper"
 require_relative "support/deployment_mode_helper"
+require_relative "support/image_attachment_test_helper"
 require_relative "support/oauth_test_helper"
 require_relative "support/principal_action_contract"
+require_relative "support/query_budget_helper"
 require_relative "support/snapshot_manifest_contract_helper"
 
 module ActiveSupport
@@ -26,6 +28,8 @@ class TestCase
     include DeviceAuthorizationTestHelper
     include DeploymentModeHelper
     include SnapshotManifestContractHelper
+    include ImageAttachmentTestHelper
+    include QueryBudgetHelper
   end
 end
 
diff --git a/test/tools/get_annotation_tool_test.rb b/test/tools/get_annotation_tool_test.rb
new file mode 100644
index 0000000..cc72052
--- /dev/null
+++ b/test/tools/get_annotation_tool_test.rb
@@ -0,0 +1,166 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class GetAnnotationToolTest < ActiveSupport::TestCase
+  setup do
+    require_vips!
+    @user = users(:alice)
+    @project = projects(:alice_project)
+    @annotation = annotations(:point_annotation)
+    Current.authenticated_principal = AuthenticatedPrincipal.for_user(@user)
+  end
+
+  teardown do
+    Current.authenticated_principal = nil
+  end
+
+  test "the shipped response envelope is preserved and attachments are added" do
+    golden = JSON.parse(file_fixture("mcp_get_annotation_keys.json").read)
+    payload = call_tool
+
+    golden.fetch("annotation").each do |key|
+      assert payload.key?(key), "get_annotation dropped the shipped key #{key}"
+    end
+    assert_equal golden.fetch("coordinates").sort, payload.fetch("coordinates").keys.sort
+    assert_includes payload.keys, "attachments"
+
+    payload.fetch("comments").each do |comment|
+      golden.fetch("comments").each do |key|
+        assert comment.key?(key), "get_annotation dropped the shipped comment key #{key}"
+      end
+      assert comment.key?("attachments")
+    end
+  end
+
+  # A key allowlist proves that nothing was renamed; it proves nothing about
+  # the values a shipped agent already reads. This compares the whole response
+  # — every key, every literal value, and the exact nesting — against a
+  # recorded golden. Only genuinely per-read values are replaced with named
+  # placeholders, and each of those is asserted for shape on its own.
+  test "the whole get_annotation payload matches the recorded golden" do
+    scenario = golden_scenario
+    payload = call_tool
+
+    golden = JSON.parse(file_fixture("mcp_get_annotation_golden.json").read).except("_comment")
+
+    assert_equal golden, normalize_golden(payload, scenario)
+
+    assert_match(/\AiVBORw0KGgo/, payload.fetch("cropped_image_base64"))
+    (payload.fetch("attachments") + payload.fetch("comments").flat_map { |c| c.fetch("attachments") }).each do |item|
+      assert_match %r{\Ahttps?://[^?]+/api/media/image_attachments/\d+\?token=.+\z}, item.fetch("url")
+      assert_in_delta ImageAttachment::MEDIA_TOKEN_EXPIRY.from_now,
+        Time.zone.parse(item.fetch("url_expires_at")), 5
+    end
+  end
+
+  test "attachments are an empty array when a message has none" do
+    payload = call_tool
+
+    assert_equal [], payload.fetch("attachments")
+    assert(payload.fetch("comments").all? { |comment| comment.fetch("attachments") == [] })
+  end
+
+  test "root and comment attachments carry metadata and an expiring url" do
+    attach_to(@annotation, alt_text: "The broken button")
+    comment = @annotation.annotation_comments.create!(user: @user, body: "And here")
+    attach_to(comment)
+
+    payload = call_tool
+
+    root = payload.fetch("attachments").sole
+    assert_equal "The broken button", root.fetch("alt_text")
+    assert_equal "image/png", root.fetch("media_type")
+    assert_equal 8, root.fetch("width")
+    assert_operator root.fetch("size"), :>, 0
+    assert_match %r{/api/media/image_attachments/\d+\?token=}, root.fetch("url")
+    assert_in_delta ImageAttachment::MEDIA_TOKEN_EXPIRY.from_now,
+      Time.zone.parse(root.fetch("url_expires_at")), 5
+
+    reply = payload.fetch("comments").find { |item| item["body"] == "And here" }
+    assert_equal 1, reply.fetch("attachments").size
+  end
+
+  test "no storage key or semantic filename is exposed" do
+    attachment = attach_to(@annotation)
+
+    payload = call_tool.to_json
+
+    assert_not payload.include?(attachment.image.blob.key)
+    assert_not payload.include?("filename")
+    assert_not payload.include?("service_name")
+  end
+
+  private
+
+  # One annotation carrying every part of the envelope at once: a real cropped
+  # region, a root image with author alt text, and two comments — an ordinary
+  # reply that also carries an image, and a resolution that carries none.
+  def golden_scenario
+    attach_screenshot_image
+    root = attach_to(@annotation, alt_text: "The disabled save button")
+    reply = @annotation.annotation_comments.create!(user: @user, body: "And here", action: :comment)
+    reply_attachment = attach_to(reply)
+    resolution = @annotation.annotation_comments.create!(user: @user, body: "Fixed", action: :resolved)
+
+    {
+      "<annotation_id>" => @annotation.id,
+      "<screenshot_id>" => @annotation.screenshot_id,
+      "<reply_comment_id>" => reply.id,
+      "<resolution_comment_id>" => resolution.id,
+      "<root_attachment_id>" => root.id,
+      "<reply_attachment_id>" => reply_attachment.id
+    }
+  end
+
+  def attach_screenshot_image
+    image = @annotation.screenshot.image_for(@annotation.viewport)
+    image.image.attach(
+      io: File.open(Rails.root.join("test/fixtures/files/desktop_screenshot.png")),
+      filename: "screenshot.png",
+      content_type: "image/png"
+    )
+    image.update!(status: :ready, width: 1440, height: 900)
+  end
+
+  # Replaces exactly what cannot be recorded: primary keys, wall-clock stamps,
+  # the freshly encoded crop, and the media URL minted for this read. Everything
+  # else has to match the golden byte for byte.
+  #
+  # Substitution is keyed on the field, never on the value. Keying on the value
+  # would rewrite any number that happened to equal a row ID — `comments_count`,
+  # a width, a byte size — and quietly stop asserting it.
+  IDENTIFIER_KEYS = %w[id screenshot_id annotation_id].freeze
+
+  def normalize_golden(value, scenario)
+    identifiers = scenario.to_h { |placeholder, id| [ id, placeholder ] }
+
+    case value
+    when Hash
+      value.to_h do |key, nested|
+        case key
+        when "created_at" then [ key, "<timestamp>" ]
+        when "cropped_image_base64" then [ key, "<cropped-png-base64>" ]
+        when "url" then [ key, "<media-url>" ]
+        when "url_expires_at" then [ key, "<url-expiry>" ]
+        when *IDENTIFIER_KEYS then [ key, identifiers.fetch(nested, nested) ]
+        else [ key, normalize_golden(nested, scenario) ]
+        end
+      end
+    when Array then value.map { |item| normalize_golden(item, scenario) }
+    else value
+    end
+  end
+
+  def call_tool
+    JSON.parse(GetAnnotationTool.new.call(project_id: @project.id, annotation_id: @annotation.id))
+  end
+
+  def attach_to(parent, alt_text: nil)
+    batch = build_batch(user: @user, project: @project)
+    attachment = ingest_image(batch: batch)
+    attachment.update!(alt_text: alt_text) if alt_text
+    ImageAttachments::ClaimBatch.call(batch: batch, user: @user, project: @project, parent_type: parent.class) { parent }
+    attachment.reload
+  end
+end
diff --git a/wiki/api-cli.md b/wiki/api-cli.md
index 67aaf10..6458c92 100644
--- a/wiki/api-cli.md
+++ b/wiki/api-cli.md
@@ -3,7 +3,7 @@ title: API CLI
 type: architecture
 source: README.md, cmd/screenote, internal/cli, internal/screenote, app/controllers/api/v1
 created: 2026-07-08
-updated: 2026-08-13
+updated: 2026-08-29
 tags: [cli, api, rest, agents, self-hosting, once]
 ---
 
@@ -142,6 +142,31 @@ The REST service exposes write contracts that current or future CLI workflows ca
 
 Project-scoped OAuth tokens are bound to the token's `project_id` throughout REST authorization: project listing returns only that bound member project, and passing a different project id cannot widen the token even when its user belongs to both projects. Deleting the bound project also deletes its scoped grants and access tokens instead of converting those bearer credentials into user-scoped access.
 
+## Image Attachments On Detail Reads
+
+`GET /api/v1/annotations/:id` — the payload behind `screenote annotation get` —
+adds an always-present `attachments` array to the root annotation and to every
+`comments[]` item. Nothing already shipped was renamed, dropped, or nested
+differently. Go's `screenote.Annotation` and `screenote.Comment` carry the new
+`Attachment` struct; `annotation get` still passes the raw service JSON
+through, so an older binary keeps working against a newer service.
+
+Each entry is `id`, `alt_text`, `media_type`, `width`, `height`, `size`, `url`,
+and `url_expires_at`. `url` is minted per read against
+`/api/media/image_attachments/:id?token=…` and is valid for five minutes;
+fetching it requires the caller's own bearer credential in addition to the
+token, and the service rechecks live project access before streaming. Store
+the metadata, not the URL.
+
+List reads stay metadata-light and omit `attachments` entirely rather than
+sending an empty array. The Go structs hold it as a pointer, so a list row
+decoded and re-encoded by `annotation list` keeps the key absent: an empty
+array there would assert "this message has no images" about a message whose
+images are only reported on a detail read.
+
+The CLI cannot author attachments. Uploading an image is a browser session
+capability; agents read what people attached and reply with text.
+
 ## Deferred
 
 Public CLI wiring for `annotation reopen`, snapshot-scoped feedback retrieval, daemon/watch mode, member management, and required multi-viewport upload remains future work. The exact CLI tag named by a Screenote release is authoritative for supported agent workflows.
diff --git a/wiki/controllers/web-controllers.md b/wiki/controllers/web-controllers.md
index 5d35e0e..9c7677e 100644
--- a/wiki/controllers/web-controllers.md
+++ b/wiki/controllers/web-controllers.md
@@ -3,7 +3,7 @@ title: Web Controllers
 type: controller
 source: app/controllers/
 created: 2026-04-10
-updated: 2026-08-13
+updated: 2026-08-31
 tags: [controller, web, ui, auth]
 ---
 
@@ -160,6 +160,20 @@ Source: `app/controllers/annotation_comments_controller.rb`
 - Handles two paths: (1) reopen annotation if `reopen=1`, (2) regular comment
 - Scoped via screenshot -> annotation -> project membership check
 - Permits: `body`, `reopen`
+- The reopen state guard runs inside the claim's parent builder, not in front of
+  it. A retry of a reopen whose response was lost arrives against an annotation
+  its own first attempt already opened, so it has to reach the claimed batch and
+  replay the comment that reopen created rather than be rejected for the state
+  it caused.
+- The claim is given a matcher for this exact disclosure — same thread, same
+  action — because parent class alone cannot tell two reply composers apart.
+- Success answers with `annotation_comment_id`, which for a replayed post is the
+  comment the first attempt created.
+
+Both create endpoints share `ImageAttachmentSubmission`. Every ordinary 422
+answers with the editable form state the still-mounted composer needs to restore
+itself — the message body, and for the root overlay the selected coordinates and
+viewport — alongside the batch public ID and the ready attachment IDs.
 
 ---
 
diff --git a/wiki/data-model.md b/wiki/data-model.md
index fe34aa6..3c19839 100644
--- a/wiki/data-model.md
+++ b/wiki/data-model.md
@@ -3,7 +3,7 @@ title: Data Model
 type: architecture
 source: db/schema.rb
 created: 2026-04-10
-updated: 2026-08-10
+updated: 2026-08-31
 tags: [database, schema, models, relationships]
 ---
 
@@ -84,6 +84,8 @@ erDiagram
 | `screenshot_images` | Per-viewport image variant | screenshot_id, viewport (enum), status (enum), content_sha256, expected_content_type, width, height |
 | `annotations` | Feedback pinned to screenshot regions | x_percent, y_percent, width_percent, height_percent, viewport, comment, status (enum), screenshot_id, user_id |
 | `annotation_comments` | Threaded comments on annotations | body, action (enum), annotation_id, user_id, api_key_id, notified_at |
+| `image_attachment_batches` | Server-owned composer draft container and one-use submission key | public_id, client_key, user_id, project_id, state (enum), claimed_annotation_id, claimed_annotation_comment_id, last_activity_at, expires_at |
+| `image_attachments` | One image on a native browser message | image_attachment_batch_id, annotation_id, annotation_comment_id, user_id, project_id, state (enum), client_key, alt_text, media_type, width, height, byte_size, failure_code |
 
 ### Collaboration
 
@@ -157,16 +159,24 @@ erDiagram
 - `annotations.(screenshot_id, viewport)` -- composite for per-viewport annotation views
 - `annotation_comments.(annotation_id, created_at)` -- composite for ordered threads
 - `annotation_comments.(action, notified_at)` -- for digest notification queries
+- `image_attachment_batches.public_id` -- unique unguessable browser-facing handle and one-use submission key
+- `image_attachment_batches.(state, expires_at)` -- bounded candidate scan for draft cleanup
+- `image_attachments.(image_attachment_batch_id, client_key)` -- partial unique index making an upload retry reuse its slot rather than open a new one
+- `image_attachment_batches.(user_id, client_key)` -- partial unique index over open batches, making a batch-create retry after a lost response resume the batch that key already opened
 
 ## Foreign Key Cascade Rules
 
 - `annotation_comments -> annotations`: ON DELETE CASCADE
+- `image_attachments -> users`: ON DELETE RESTRICT. Account deletion locks the account row ahead of its dependent batch destruction, which is the order ingest also takes, so the two paths cannot deadlock.
 - Annotation and comment actor foreign keys are restrictive because their database checks require exactly one durable user or API-key actor.
 - `oauth_access_grants/tokens -> oauth_applications`: ON DELETE CASCADE
 - `oauth_access_grants/tokens/device_grants -> projects`: ON DELETE CASCADE so project credentials are revoked rather than widened to account authority
 - `oauth_device_grants -> oauth_applications/users`: ON DELETE CASCADE so ephemeral grants cannot outlive their client or resource owner
 - `screenshots -> snapshots`: ON DELETE SET NULL
 - `screenshot_images -> screenshots`: no database cascade; Rails `dependent: :destroy` preserves Active Storage purge callbacks
+- `image_attachments -> annotations/annotation_comments`: no database cascade; Rails `dependent: :destroy` preserves Active Storage purge callbacks, and `ImageAttachmentOrphanReconciliationJob` is the backstop for any delete that bypasses them
+- `image_attachments/image_attachment_batches -> users`: ON DELETE RESTRICT. Deleting an account destroys its own annotations and drafts first; a submitted attachment still living on another member's message rejects the deletion rather than orphaning the uploader identity
+- A CHECK keeps an attachment on exactly one owner: a draft batch, an annotation, or an annotation comment. There is no polymorphic parent, so no future message type can silently acquire attachments
 - Duplicate snapshots for the same `(project_id, git_commit)` are allowed because repeated `/snapshot` captures of one commit can be useful at different times; `taken_at` distinguishes the runs.
 
-See also: [[schema-evolution]], [[models/user]], [[models/project]], [[models/snapshot]], [[models/screenshot]], [[models/screenshot-image]], [[models/annotation]]
+See also: [[schema-evolution]], [[models/user]], [[models/project]], [[models/snapshot]], [[models/screenshot]], [[models/screenshot-image]], [[models/annotation]], [[models/image-attachment]], [[models/image-attachment-batch]]
diff --git a/wiki/gaps.md b/wiki/gaps.md
index 5c703a4..64885b8 100644
--- a/wiki/gaps.md
+++ b/wiki/gaps.md
@@ -3,7 +3,7 @@ title: Gaps
 type: gap
 source: wiki analysis, plans/, todos/
 created: 2026-04-10
-updated: 2026-08-10
+updated: 2026-08-31
 tags: [gaps, documentation, todo, deployment, once, release]
 ---
 
@@ -143,6 +143,50 @@ The following gaps from the original bootstrap have been partially or fully addr
 - ~~Where do MCP tool classes live?~~ Confirmed: `app/tools/` with `ApplicationTool` base class.
 - Plans and todos now documented in [[plans-and-initiatives]], [[technical-debt]], and [[roadmap]].
 
+## Image attachments
+
+- Attachment delivery now has an object-store contract of its own.
+  `test/integration/image_attachment_s3_delivery_contract_test.rb` points the
+  whole application at a real S3-compatible service and drives the protected
+  session and bearer routes through it, asserting provider-stored bytes, no
+  `Location`, no provider host in any header, application-served byte ranges,
+  the five-minute purpose token expiring, and revoked membership losing access.
+  It runs in the existing `script/release_test_matrix s3` gate against MinIO,
+  and `script/attachment_object_store_qualification` supplies that store on any
+  machine with a container engine, so the run no longer waits on an operator.
+  Running it against the production Rabata endpoint specifically still needs
+  operator-supplied credentials, which the same script accepts through
+  `SCREENOTE_S3_ENDPOINT` and its companions.
+- The clamped Annotorious overlay is now verified to leave the selected region
+  completely uncovered against a full-size 1440x900 page capture
+  (`test/fixtures/files/desktop_screenshot.png`), which is the geometry the
+  claim is about. On a screenshot small enough that no placement can avoid the
+  form — a 300px-tall image with a thumbnail row present — the algorithm still
+  only minimizes overlap, and the older compact-rail assertion covers that case.
+- Browser evidence is a watchable video per test plus named frames.
+  `SCREENOTE_EVIDENCE_DIR` writes `<frame>.png`, `<frame>.json`, and
+  `<test>.trace.zip`, and `script/attachment_browser_evidence` encodes each
+  trace's screencast into `<test>.webm` at the run's own pace. Playwright's own
+  `record_video_dir` is not used: `capybara-playwright-driver` asks the page for
+  its video path while the page is open, and that path blocks on a future the
+  page-close event rejects, so a run that sets the option hangs and leaves a
+  zero-byte file.
+- Readiness now requires live supervisor proof — a Solid Queue scheduler
+  heartbeat plus the recurring tasks registered in the queue database — but only
+  after a bounded startup grace. Inside that window a supervised environment
+  still reports ready on the checked-in schedule alone, because a probe that
+  demanded proof from the first second would stop the deployment it gates from
+  coming up. The window's length is a judgement, not a measurement.
+- Polyglot rejection walks each accepted container to its declared end. That
+  covers appended payloads, the case the OWASP guidance names, and it is not a
+  general malformed-input proof: a file whose declared structure is internally
+  consistent but semantically hostile is still only caught by the full libvips
+  decode that runs alongside it.
+- Live-region announcements are asserted from the DOM: the composer's
+  `role="status"` region, its polite politeness, and the exact uploaded,
+  removed, and failed text it carries. A listen-through with a real screen
+  reader is still a manual step nothing in the suite can stand in for.
+
 ## Questions to Resolve
 
 1. What is the intended long-term removal plan for the legacy `Screenshot#image`, `width`, `height`, and `status` path after `ScreenshotImage` stabilizes?
diff --git a/wiki/index.md b/wiki/index.md
index be79c03..bd54d93 100644
--- a/wiki/index.md
+++ b/wiki/index.md
@@ -3,15 +3,15 @@ title: screenote Wiki
 type: index
 source: wiki/**/*.md
 created: 2026-05-14
-updated: 2026-08-06
+updated: 2026-08-29
 tags: [index, wiki]
 ---
 
 
 **TLDR**: Catalog of the LLM-maintained wiki for `screenote`.
 
-Page count: 44
-Updated: 2026-08-06
+Page count: 46
+Updated: 2026-08-29
 
 ## Core Architecture
 
@@ -48,6 +48,8 @@ Updated: 2026-08-06
 - [[models/screenshot-image]] — Per-viewport image variants.
 - [[models/annotation]] — Viewport-scoped visual feedback.
 - [[models/annotation-comment]] — Threaded comments and resolution audit log.
+- [[models/image-attachment]] — Images posted with a native browser message.
+- [[models/image-attachment-batch]] — Server-owned composer draft container and submission key.
 - [[models/api-key]] — Project-scoped bearer tokens.
 - [[models/project-membership]] — Project membership roles.
 - [[models/project-invitation]] — Email invitations.
diff --git a/wiki/log.d/20260829T033000Z-image-attachments.md b/wiki/log.d/20260829T033000Z-image-attachments.md
new file mode 100644
index 0000000..d4c4c27
--- /dev/null
+++ b/wiki/log.d/20260829T033000Z-image-attachments.md
@@ -0,0 +1,32 @@
+## [2026-08-29] Image attachments for native browser messages
+
+**Action:** Added images to the annotation root composer and the reply and
+reopen composers. Uploads stream into a server-owned draft batch whose
+unguessable public ID is also the one-use submission key; posting binds every
+ready row to exactly one Annotation or AnnotationComment inside the same
+transaction that creates the message. Streaming, byte-derived type detection,
+and a full decode through the global two-slot guard run with no row lock held,
+and only the aggregate recheck and the state transition are serialized. A
+rejected post answers 422 with the batch and its ready row IDs so the mounted
+composer keeps its text and images. Bytes are delivered by the application
+after live authorization on two routes — a browser session route and a bearer
+route requiring a five-minute purpose token in addition to the credential —
+and Active Storage's public routes stay disabled. REST, MCP, and CLI detail
+reads gained an always-present `attachments` array on the root annotation and
+on every comment without renaming, dropping, or nesting any shipped key.
+Expiry cleanup and orphan reconciliation are scheduled, and readiness fails
+closed when a supervised environment does not register them.
+
+**Pages updated:** wiki/models/image-attachment.md,
+wiki/models/image-attachment-batch.md, wiki/models/annotation.md,
+wiki/models/annotation-comment.md, wiki/data-model.md, wiki/routes.md,
+wiki/mcp-tools.md, wiki/api-cli.md, wiki/testing-and-ci.md, wiki/gaps.md,
+wiki/index.md, wiki/log.d/20260829T033000Z-image-attachments.md
+
+**Source:** migration `20260829120000`, `app/models/image_attachment*.rb`,
+`app/services/image_attachments/`, `app/controllers/image_attachment_drafts/`,
+`app/controllers/image_attachment_media_controller.rb`,
+`app/controllers/api/image_attachment_media_controller.rb`,
+`app/javascript/controllers/image_attachment_*_controller.js`,
+`app/jobs/image_attachment_*_job.rb`, `internal/screenote/types.go`, and the
+focused model, request, job, contract, concurrency, and browser regressions
diff --git a/wiki/log.d/20260829T034017Z-postgres-ci-auth.md b/wiki/log.d/20260829T034017Z-postgres-ci-auth.md
new file mode 100644
index 0000000..6f402aa
--- /dev/null
+++ b/wiki/log.d/20260829T034017Z-postgres-ci-auth.md
@@ -0,0 +1,11 @@
+## [2026-08-29] Remove the PostgreSQL qualification fixture password
+
+**Action:** Changed the runner-local PostgreSQL service used by the attachment
+concurrency qualification workflow to trust authentication and a
+credential-free loopback URL. This preserves the isolated CI database while
+keeping password-shaped fixture bytes out of the published diff.
+
+**Pages updated:** wiki/testing-and-ci.md,
+wiki/log.d/20260829T034017Z-postgres-ci-auth.md
+
+**Source:** `.github/workflows/concurrency-qualification.yml`
diff --git a/wiki/log.d/20260829T060000Z-image-attachment-review-fixes.md b/wiki/log.d/20260829T060000Z-image-attachment-review-fixes.md
new file mode 100644
index 0000000..24b621c
--- /dev/null
+++ b/wiki/log.d/20260829T060000Z-image-attachment-review-fixes.md
@@ -0,0 +1,21 @@
+## [2026-08-29] Harden image attachment draft caps, retries, and read scopes
+
+**Action:** Moved the per-account draft caps onto `ImageAttachmentBatch`, made
+them count expired-but-unreclaimed batches, and rechecked the outstanding byte
+ceiling inside the ingest commit lock. Made a replayed `client_key` return an
+already-finished row untouched, measured the per-file ceiling from the uploaded
+part rather than the multipart envelope, answered storage and IO failures with
+the shared machine code instead of a raw 500, and moved every failure string
+into one table derived from the enforced constants. Alt-text writes now take
+the batch lock in the global order. Split the REST/MCP annotation scope so list
+reads stay metadata-light and only detail reads preload attachment blobs.
+
+**Pages updated:** wiki/models/image-attachment-batch.md,
+wiki/log.d/20260829T060000Z-image-attachment-review-fixes.md
+
+**Source:** `app/models/image_attachment_batch.rb`,
+`app/services/image_attachments/`, `app/services/api/v1/project_scope.rb`,
+`app/controllers/image_attachment_drafts/`,
+`app/javascript/controllers/image_attachment_composer_controller.js`,
+`internal/screenote/types.go`, and the attachment draft, concurrency, and
+browser regressions
diff --git a/wiki/log.d/20260829T065600Z-image-attachment-security-coverage.md b/wiki/log.d/20260829T065600Z-image-attachment-security-coverage.md
new file mode 100644
index 0000000..01c4e01
--- /dev/null
+++ b/wiki/log.d/20260829T065600Z-image-attachment-security-coverage.md
@@ -0,0 +1,15 @@
+## [2026-08-29] Declare image attachment security coverage owners
+
+**Action:** Assigned every image-attachment session and API controller, plus
+both shared authorization and streaming concerns, to the `principal` domain in
+the release security coverage manifest. This keeps controller discovery fail
+closed as the feature adds protected draft and media routes.
+
+**Pages updated:** wiki/testing-and-ci.md,
+wiki/log.d/20260829T065600Z-image-attachment-security-coverage.md
+
+**Source:** `test/manifests/release_security_coverage.yml`,
+`app/controllers/image_attachment_drafts/`,
+`app/controllers/image_attachment_media_controller.rb`,
+`app/controllers/api/image_attachment_media_controller.rb`, and
+`app/controllers/concerns/`
diff --git a/wiki/log.d/20260829T070000Z-image-attachment-draft-discard-and-races.md b/wiki/log.d/20260829T070000Z-image-attachment-draft-discard-and-races.md
new file mode 100644
index 0000000..2737123
--- /dev/null
+++ b/wiki/log.d/20260829T070000Z-image-attachment-draft-discard-and-races.md
@@ -0,0 +1,33 @@
+## [2026-08-29] Close image attachment draft races and add an explicit discard
+
+**Action:** Added `DELETE /image-attachment-drafts/batches/:public_id` and
+`ImageAttachments::DiscardBatch`, called only on an explicit Annotorious cancel
+through a new `annotorious:form-cancelled` event, so the open-batch ceiling's
+"finish or discard one first" copy is true; ordinary disconnect and navigation
+still leave a batch for its 24 hour window. Made the ingest commit re-resolve
+its own row under the batch lock, stopped a superseded attempt from un-readying
+a finished row, serialized decoding per batch inside `ImageDecoding::Guard`,
+and locked the account row across the per-account cap check and insert.
+Reconciliation now also re-enqueues missing delivery variants. `DraftPresenter`
+derives `retryable` from `Error::RETRYABLE_CODES` so a stored failure and a
+live raise agree. In the composer, dragover is gated on `dataTransfer.types`
+alone (a real OS drop exposes no files until the drop), item slots are reserved
+synchronously, typed alt text is flushed and awaited before the claim, batch
+and post requests carry the upload timeout, and a rejected post with no usable
+error text still says something. CLI list rows keep `attachments` absent
+instead of remarshalling an empty array. Extracted the shared blob-streaming
+contract into one concern and moved the attachment CSS into a sibling
+stylesheet.
+
+**Pages updated:** wiki/routes.md, wiki/api-cli.md,
+wiki/models/image-attachment-batch.md, wiki/models/image-attachment.md,
+wiki/log.d/20260829T070000Z-image-attachment-draft-discard-and-races.md
+
+**Source:** `app/services/image_attachments/`, `app/services/image_decoding/`,
+`app/models/image_attachment_batch.rb`,
+`app/jobs/image_attachment_orphan_reconciliation_job.rb`,
+`app/controllers/concerns/blob_streaming.rb`,
+`app/controllers/image_attachment_drafts/batches_controller.rb`,
+`app/javascript/controllers/`, `app/assets/stylesheets/image_attachments.css`,
+`internal/screenote/types.go`, and the draft, decoder, concurrency,
+reconciliation, and browser regressions
diff --git a/wiki/log.d/20260829T070000Z-image-attachment-removal-and-cap-serialization.md b/wiki/log.d/20260829T070000Z-image-attachment-removal-and-cap-serialization.md
new file mode 100644
index 0000000..a20c143
--- /dev/null
+++ b/wiki/log.d/20260829T070000Z-image-attachment-removal-and-cap-serialization.md
@@ -0,0 +1,21 @@
+## [2026-08-29] Make image attachment removal and account caps race-safe
+
+**Action:** Draft removal now reserves the browser's client key as a bounded
+tombstone, so an aborted upload cannot create a ready row after the remove
+request looked for it. Claim omits and destroys tombstones, while resume hides
+them. Upload commit takes the account lock before the batch lock when
+rechecking the outstanding-draft byte ceiling, which serializes the cap across
+different open composers. Variant reconciliation selects a bounded SQL set of
+rows that are actually missing a named thumbnail digest instead of scanning
+every submitted attachment in Ruby.
+
+**Pages updated:** wiki/models/image-attachment.md,
+wiki/models/image-attachment-batch.md,
+wiki/log.d/20260829T070000Z-image-attachment-removal-and-cap-serialization.md
+
+**Source:** `app/services/image_attachments/remove_attachment.rb`,
+`app/services/image_attachments/ingest.rb`,
+`app/services/image_attachments/claim_batch.rb`,
+`app/models/image_attachment_batch.rb`,
+`app/jobs/image_attachment_orphan_reconciliation_job.rb`, and the attachment
+draft and concurrency regressions
diff --git a/wiki/log.d/20260829T071000Z-image-attachment-composer-recovery.md b/wiki/log.d/20260829T071000Z-image-attachment-composer-recovery.md
new file mode 100644
index 0000000..ed5df04
--- /dev/null
+++ b/wiki/log.d/20260829T071000Z-image-attachment-composer-recovery.md
@@ -0,0 +1,20 @@
+## [2026-08-29] Restore composer state without ghosts
+
+**Action:** The shared attachment composer keeps its hidden batch identity
+across a Stimulus disconnect, reloads the authoritative rows through the batch
+resume endpoint, and rebuilds previews and listeners once. Interrupted uploads
+are polled through a bounded settling window while submission stays disabled.
+Draft removal is addressed by client key and remains visible when the DELETE
+cannot be confirmed, so a network failure cannot hide a row that claim could
+still attach. Alt-text writes are serialized per image and submission waits for
+the final typed value. Permanent validation errors no longer offer Retry;
+transport and server upload failures still do.
+
+**Pages updated:** wiki/models/image-attachment-batch.md, wiki/routes.md,
+wiki/log.d/20260829T071000Z-image-attachment-composer-recovery.md
+
+**Source:**
+`app/javascript/controllers/image_attachment_composer_controller.js`,
+`app/controllers/image_attachment_drafts/`,
+`app/services/image_attachments/draft_presenter.rb`, and the composer browser
+regressions
diff --git a/wiki/log.d/20260829T234500Z-image-attachment-review-pass-03.md b/wiki/log.d/20260829T234500Z-image-attachment-review-pass-03.md
new file mode 100644
index 0000000..9281694
--- /dev/null
+++ b/wiki/log.d/20260829T234500Z-image-attachment-review-pass-03.md
@@ -0,0 +1,33 @@
+## [2026-08-29] Close the last image attachment draft and replay gaps
+
+**Action:** The commit-side slot check now counts `active_drafts` like the
+reservation side, so removing an image and attaching a replacement no longer
+wedges a composer at the five-file ceiling. `ClaimBatch` takes the endpoint's
+`parent_type` and refuses a replayed batch whose claimed parent belongs to the
+other composer, which turns a cross-composer replay into an ordinary 422
+instead of a 500 or a silent no-op. A failing COMMIT now purges its staged
+blob, upload streaming ends on EOF rather than on a blank chunk, and the
+decoder guard shares one deadline across its keyed and global stages. Alt-text
+writes answer a machine code when too long and not-found on a removal
+tombstone. The composer blocks attach and remove while an interrupted upload
+is settling, paints thumbnails from the picked bytes instead of re-downloading
+the original, and takes its unsupported-type copy from the server constant.
+Detail reads stop preloading blobs no serializer touches, the recurring
+schedule is read once per environment, write-endpoint comments carry a
+canonical empty `attachments` array, the draft rate limits share one explicit
+scope, variant resolution and the workspace deep link each live in one place,
+and the unwired draft-cleanup startup enqueue is gone.
+
+**Pages updated:** wiki/models/image-attachment.md,
+wiki/models/image-attachment-batch.md, wiki/testing-and-ci.md,
+wiki/log.d/20260829T234500Z-image-attachment-review-pass-03.md
+
+**Source:** `app/services/image_attachments/`, `app/services/image_decoding/`,
+`app/services/api/v1/project_scope.rb`,
+`app/services/screenote/recurring_tasks.rb`,
+`app/controllers/annotations_controller.rb`,
+`app/controllers/annotation_comments_controller.rb`,
+`app/controllers/concerns/`, `app/controllers/image_attachment_drafts/`,
+`app/mailers/notification_mailer.rb`,
+`app/javascript/controllers/image_attachment_composer_controller.js`, and the
+draft, submission, batch model, query budget, and browser regressions
diff --git a/wiki/log.d/20260830T170000Z-image-attachment-executed-evidence.md b/wiki/log.d/20260830T170000Z-image-attachment-executed-evidence.md
new file mode 100644
index 0000000..85651fa
--- /dev/null
+++ b/wiki/log.d/20260830T170000Z-image-attachment-executed-evidence.md
@@ -0,0 +1,38 @@
+## [2026-08-30] Make the image attachment release claims executable
+
+**Action:** Four release claims for image attachments could only be verified by
+reading a diff, so each one gained a way to run. The lifecycle suites moved from
+a list embedded in one CI workflow into
+`script/release_test_matrix attachment-lifecycle`, which runs against whatever
+database is configured, and `script/attachment_server_database_qualification`
+boots an ephemeral server database and reruns the identical list under real row
+locks, so the account byte-ceiling race and the adapter assertion are no longer
+reachable only inside GitHub Actions. Attachment delivery gained an object-store
+contract that points the whole application at a real S3-compatible service and
+drives the protected session and bearer routes through it — provider-stored
+bytes, no `Location`, no provider host in any header, application-served byte
+ranges, the five-minute purpose token expiring, and revoked membership losing
+access — and it runs in the existing `s3` gate. The clamped overlay's
+"never covers the selected region" claim is now measured against a full-size
+1440x900 page capture instead of a 300px fixture, where zero overlap is a real
+guarantee rather than an impossibility. Browser runs can record themselves:
+`SCREENOTE_EVIDENCE_DIR` writes a Playwright trace per test plus named frames
+carrying the resolved media paths, srcset candidates, component context, and
+measured geometry, and `script/attachment_browser_evidence` extracts each trace
+into an ordered filmstrip. The gallery's neutral placeholder and its warmed
+responsive thumbnails are now both asserted, as is the composer's polite live
+region and the viewer's full control set. Pinning the Go toolchain lets the CLI
+contract tests run outside CI.
+
+**Pages updated:** wiki/testing-and-ci.md, wiki/gaps.md,
+wiki/log.d/20260830T170000Z-image-attachment-executed-evidence.md
+
+**Source:** `script/release_test_matrix`,
+`script/attachment_server_database_qualification`,
+`script/attachment_browser_evidence`,
+`.github/workflows/concurrency-qualification.yml`,
+`test/integration/image_attachment_s3_delivery_contract_test.rb`,
+`test/support/s3_contract_helper.rb`,
+`test/system/application_system_test_case.rb`,
+`test/system/image_attachments_test.rb`, and
+`test/fixtures/files/desktop_screenshot.png`
diff --git a/wiki/log.d/20260830T183000Z-browser-evidence-video.md b/wiki/log.d/20260830T183000Z-browser-evidence-video.md
new file mode 100644
index 0000000..d09afa7
--- /dev/null
+++ b/wiki/log.d/20260830T183000Z-browser-evidence-video.md
@@ -0,0 +1,26 @@
+## [2026-08-30] Encode the browser evidence run as watchable video
+
+**Action:** A recorded system-test run now leaves a WebM per test beside its
+trace and named frames, so the image attachment authoring flow can be watched
+rather than reconstructed frame by frame.
+`script/attachment_browser_evidence` encodes the video from the Playwright
+trace's screencast: it runs at roughly 20 frames a second for the whole length
+of a test, and every frame keeps the millisecond it was captured at in its
+name, so a concat encode holds each frame for its real gap and replays the run
+at its own pace. Playwright's own `record_video_dir` remains unusable —
+`capybara-playwright-driver` asks the page for its video path while the page is
+still open, and `Playwright::Video#path` blocks on a future the page-close
+event rejects, so a run that sets the option hangs and leaves a zero-byte file.
+Frame ordering now reads the numeric tail from the bare file name; sorting whole
+paths keyed on a hyphen in the enclosing directory instead.
+
+The gallery's narrow-width claim grew to the viewer that opens from it, and the
+explicit light and dark component contexts are now captured at a phone-sized
+viewport as well as at desktop, so every component context the plan names has
+evidence at both widths.
+
+**Pages updated:** wiki/testing-and-ci.md, wiki/gaps.md,
+wiki/log.d/20260830T183000Z-browser-evidence-video.md
+
+**Source:** `script/attachment_browser_evidence` and
+`test/system/image_attachments_test.rb`
diff --git a/wiki/log.d/20260831T133000Z-image-attachment-review-pass-04.md b/wiki/log.d/20260831T133000Z-image-attachment-review-pass-04.md
new file mode 100644
index 0000000..6571a5f
--- /dev/null
+++ b/wiki/log.d/20260831T133000Z-image-attachment-review-pass-04.md
@@ -0,0 +1,61 @@
+## [2026-08-31] Close the image attachment replay, lifecycle, and evidence gaps
+
+**Action:** A claimed batch now identifies the composer that claimed it, not
+just the parent class: `ClaimBatch` takes a caller-supplied matcher, so a reply
+batch replayed from another thread or from the reopen disclosure is refused
+instead of answering with somebody else's comment. The reopen state guard moved
+inside the claim's parent builder, so a retry of a reopen whose response was
+lost reaches the claimed batch and replays the comment it already created; both
+create endpoints now return the parent identity, and every 422 carries the
+editable form state — message body plus the root composer's coordinates and
+viewport — alongside the batch and ready IDs.
+
+Batch creation became idempotent: a composer supplies its own `client_key`, a
+retry after a lost response resumes the batch that key already opened rather
+than spending one of the six an account is allowed, and an expired batch holding
+the key is reclaimed instead of returned. Resume refuses an expired or claimed
+batch rather than restoring rows that every later request would reject.
+
+Ingest now rejects trailing-payload polyglots by walking each accepted
+container — PNG chunks to IEND, the RIFF declared length, and the JPEG marker
+and entropy stream to EOI — and requiring the declared end to be the end of the
+file. The ready transition renews batch activity inside the same locked
+transaction, and a late failure from a superseded attempt is a conditional
+update the database decides, so it can no longer revert a successful retry or a
+removal tombstone. Removal tombstones are bounded to the five-file window.
+Account deletion locks the user row ahead of dependent batch destruction, which
+is the same order ingest takes. Blob release deletes stored bytes before the row
+that names them, so a failed provider delete leaves a discoverable unattached
+blob; `purge_later` has an inline fallback when the queue refuses the job, and
+the reconciliation pass retries those blobs and bounds its unwarmed-variant scan
+with per-row `NOT EXISTS` predicates instead of a full join and group.
+
+Readiness stopped believing checked-in YAML on its own: a supervised environment
+must also show a live Solid Queue scheduler heartbeat and the required recurring
+tasks registered in the queue database. Evidence gates fail closed — a trace
+that cannot start or save fails its test, and
+`script/attachment_browser_evidence` refuses a non-empty output directory and
+requires one trace per declared test. The PostgreSQL lifecycle workflow is a
+required check on the default branch and is validated by `bin/release-validate`.
+The concurrency suite proves real overlap before releasing a parked
+transaction and exercises the actual 50 MB message ceiling. MCP and CLI reads
+are pinned to full golden payloads rather than key allowlists, and the browser
+suite posts multi-image root, reply, and reopen messages through picker, drop,
+and paste, proves a failed upload blocks the post until a retry finishes it, and
+covers the one-image viewer plus an explicitly dark gallery and viewer.
+
+**Pages updated:** wiki/models/image-attachment-batch.md,
+wiki/models/image-attachment.md, wiki/controllers/web-controllers.md,
+wiki/testing-and-ci.md, wiki/data-model.md,
+wiki/log.d/20260831T133000Z-image-attachment-review-pass-04.md
+
+**Source:** `app/services/image_attachments/`, `app/models/image_attachment*.rb`,
+`app/models/user.rb`, `app/controllers/annotation_comments_controller.rb`,
+`app/controllers/concerns/image_attachment_submission.rb`,
+`app/controllers/image_attachment_drafts/batches_controller.rb`,
+`app/jobs/image_attachment_orphan_reconciliation_job.rb`,
+`app/services/screenote/recurring_tasks.rb`,
+`app/javascript/controllers/image_attachment_composer_controller.js`,
+`app/assets/stylesheets/image_attachments.css`, `bin/release-validate`,
+`.github/rulesets/main.json`, `script/attachment_browser_evidence`, and the
+attachment lifecycle, submission, concurrency, contract, and browser suites
diff --git a/wiki/mcp-tools.md b/wiki/mcp-tools.md
index 553fea6..93dd877 100644
--- a/wiki/mcp-tools.md
+++ b/wiki/mcp-tools.md
@@ -3,7 +3,7 @@ title: MCP Tools
 type: architecture
 source: app/tools/**/*.rb, config/initializers/fast_mcp.rb
 created: 2026-05-14
-updated: 2026-08-05
+updated: 2026-08-29
 tags: [mcp, tools, api, agent]
 ---
 
@@ -44,7 +44,7 @@ The Go CLI in [[api-cli]] does not call MCP. It uses REST `api/v1` so shell and
 | `create_screenshot_upload` | `app/tools/create_screenshot_upload_tool.rb` | Create one desktop `ScreenshotImage` and return a credential-free URL plus one-time upload bearer |
 | `create_multi_viewport_screenshot` | `app/tools/create_multi_viewport_screenshot_tool.rb` | Create one screenshot with 1-3 viewport variants and per-variant URL/bearer pairs |
 | `list_annotations` | `app/tools/list_annotations_tool.rb` | List annotations with status, screenshot, viewport, limit, and offset filters |
-| `get_annotation` | `app/tools/get_annotation_tool.rb` | Return annotation details, comments, and cropped image data |
+| `get_annotation` | `app/tools/get_annotation_tool.rb` | Return annotation details, comments, cropped image data, and attachment metadata |
 | `create_annotation` | `app/tools/create_annotation_tool.rb` | Create point or region annotation; viewport is required for multi-variant screenshots |
 | `resolve_annotation` | `app/tools/resolve_annotation_tool.rb` | Mark an annotation resolved and create audit comment |
 | `reopen_annotation` | `app/tools/reopen_annotation_tool.rb` | Reopen a resolved annotation with a reason |
@@ -54,6 +54,21 @@ The Go CLI in [[api-cli]] does not call MCP. It uses REST `api/v1` so shell and
 | `cancel_invitation` | `app/tools/cancel_invitation_tool.rb` | Cancel a pending invitation |
 | `remove_project_member` | `app/tools/remove_project_member_tool.rb` | Remove a member from a project |
 
+## Attachment Metadata
+
+`get_annotation` keeps every key it already shipped — `screenshot_status`,
+`cropped_image_base64`, `mime_type`, and each comment's `id`, `action`,
+`body`, `author`, and `created_at` — and adds an always-present `attachments`
+array to the root annotation and to every `comments[]` item. Each entry is
+`id`, `alt_text`, `media_type`, `width`, `height`, `size`, `url`, and
+`url_expires_at`; storage keys and semantic filenames are never exposed.
+
+`url` points at `/api/media/image_attachments/:id?token=…` and expires five
+minutes after the read. Fetching it needs the agent's own bearer credential
+plus the purpose token plus live access to the project — the token by itself
+is not authority, and nothing durable is stored. MCP has no attachment write
+tool: authoring is a browser session capability.
+
 ## Multi-Viewport Semantics
 
 `create_multi_viewport_screenshot` creates a parent [[screenshot]] plus one [[models/screenshot-image]] per requested viewport inside a transaction. Upload URLs are per `ScreenshotImage`, but the endpoint remains `/api/screenshots/:id/upload` for URL shape stability.
diff --git a/wiki/models/annotation-comment.md b/wiki/models/annotation-comment.md
index fc787c3..2651f37 100644
--- a/wiki/models/annotation-comment.md
+++ b/wiki/models/annotation-comment.md
@@ -32,6 +32,7 @@ Source: `app/models/annotation_comment.rb`
 | Association | Type | Target |
 |-------------|------|--------|
 | annotation | belongs_to | [[annotation]] |
+| image_attachments | has_many (dependent: destroy) | [[image-attachment]] — images posted with this reply or reopen |
 | user | belongs_to | [[user]] (optional) |
 | api_key | belongs_to | [[api-key]] (optional) |
 
diff --git a/wiki/models/annotation.md b/wiki/models/annotation.md
index 7984bbb..5942654 100644
--- a/wiki/models/annotation.md
+++ b/wiki/models/annotation.md
@@ -3,7 +3,7 @@ title: Annotation
 type: model
 source: app/models/annotation.rb
 created: 2026-04-10
-updated: 2026-08-05
+updated: 2026-08-29
 tags: [model, annotation, feedback, core]
 ---
 
@@ -38,6 +38,7 @@ Source: `app/models/annotation.rb`
 | Association | Type | Target |
 |-------------|------|--------|
 | screenshot | belongs_to | [[screenshot]] |
+| image_attachments | has_many (dependent: destroy) | [[image-attachment]] — images posted with the root comment |
 | user | belongs_to | [[user]] (optional creator) |
 | api_key | belongs_to | [[api-key]] (optional automation creator) |
 | resolved_by_user | belongs_to | [[user]] (optional) |
diff --git a/wiki/models/image-attachment-batch.md b/wiki/models/image-attachment-batch.md
new file mode 100644
index 0000000..b924094
--- /dev/null
+++ b/wiki/models/image-attachment-batch.md
@@ -0,0 +1,136 @@
+---
+title: ImageAttachmentBatch
+type: model
+source: app/models/image_attachment_batch.rb
+created: 2026-08-29
+updated: 2026-08-31
+tags: [model, attachment, draft, lifecycle]
+---
+
+# ImageAttachmentBatch
+
+TLDR: A server-owned draft container for one mounted browser composer. Its
+unguessable `public_id` is the only identifier the browser sees, and it doubles
+as the one-use submission idempotency key.
+
+Source: `app/models/image_attachment_batch.rb`
+
+## Columns
+
+| Column | Type | Notes |
+|--------|------|-------|
+| id | integer | PK |
+| public_id | string(43) | NOT NULL, unique. URL-safe random, the browser-facing handle |
+| client_key | string(64) | Nullable. The mounted composer's own idempotency handle, unique per user while the batch is open |
+| user_id | integer | NOT NULL, FK to users with `ON DELETE RESTRICT` |
+| project_id | integer | NOT NULL, FK to projects |
+| state | integer | Enum: open(0), claimed(1). Prefix `state` |
+| claimed_annotation_id | integer | Nullable FK. Set when a root annotation claimed the batch |
+| claimed_annotation_comment_id | integer | Nullable FK. Set when a reply or reopen claimed it |
+| last_activity_at | datetime | NOT NULL. Bumped by each successful upload |
+| expires_at | datetime | NOT NULL. 24 hours after the last activity |
+
+## Constraints
+
+| Name | Meaning |
+|------|---------|
+| image_attachment_batches_valid_state | state is 0 or 1 |
+| image_attachment_batches_future_expiry | expiry is after the last activity |
+| image_attachment_batches_claim_state | open batches reference no parent; claimed ones reference exactly one |
+
+## Caps
+
+`MAX_OPEN_PER_USER` is 6 — abuse headroom for three mounted composers plus
+retries, not a per-message limit. `MAX_OUTSTANDING_DRAFT_BYTES` is a
+scheduler-independent backstop so a stopped cleanup supervisor cannot let one
+account park unbounded bytes.
+
+Both caps live on the model. `ImageAttachmentBatch.open_for!` applies them to
+every draft that is opened, locking the account row so the check and the insert
+are one step and two composers cannot each read the same room. It also accepts
+the composer's `client_key`: a create whose response was lost is retried with
+the same key and resumes the batch it already opened rather than spending
+another of the six, and a key whose batch has expired is reclaimed under the
+same lock so the composer is handed something it can actually upload to. `Ingest` takes
+that same account lock before its batch lock and rechecks the byte ceiling at
+commit, so uploads into different open batches cannot each observe the same
+remaining room. Expired batches that cleanup has not yet reclaimed still hold
+their rows and blobs, so they keep counting toward both caps.
+
+## Claim
+
+`ImageAttachments::ClaimBatch` locks the batch, then its attachment rows by ID,
+revalidates ownership, expiry, the 5-file and 50 MB limits, creates the message
+inside the same transaction, moves each row onto exactly one parent FK, and
+records the claimed parent. Replaying the same public ID returns the message
+that was already created instead of posting twice. The caller declares the
+parent class it can accept plus a matcher for its own composer, so a batch
+claimed by the root composer replayed against the reply endpoint — or a reply
+batch replayed from another thread or from the reopen disclosure — is refused
+with `batch_not_owned` rather than handed back a parent the responder cannot
+describe. Class alone is not enough: every reply composer in a project produces
+an `AnnotationComment`.
+
+The 5-file limit counts active drafts on both sides. A removal tombstone is not
+an active draft, so removing an image and attaching a replacement stays inside
+the ceiling.
+
+## Drafts
+
+`ImageAttachments::Ingest` reserves the row for a `client_key` before it reads
+a byte, so a retry reuses that slot. A key whose upload already finished is
+returned exactly as it stands: replaying a lost 201 never reopens the row or
+replaces its blob. `ImageAttachments::WriteAltText` and
+`ImageAttachments::RemoveAttachment` both take the batch lock first, so a
+description or a removal racing a claim resolves as not-found rather than
+touching submitted metadata. Removal retains a bounded client-key tombstone
+inside the draft, capped at the five-file window and pruned oldest-first so a
+client inventing keys cannot park unbounded rows on one open batch. It is hidden from resume and discarded at claim, but it stops
+an in-flight POST that had not created its row yet from bringing the removed
+image back. The commit re-resolves its own row under that same lock and answers
+`attachment_removed` for a tombstone. A failure recorded by a superseded attempt never un-readies a row another
+attempt finished or overwrites the tombstone: the write is conditional on the
+row still being an uploading draft, so the database decides the race rather
+than a stale in-memory snapshot. The ready transition renews the batch expiry
+inside the same locked transaction, so cleanup cannot destroy a draft in the
+window between the two. A tombstone keeps its byte count in the account ceiling until its
+blob purge succeeds, then clears the stored metadata; storage failure therefore
+cannot make the scheduler-independent cap optimistic.
+
+Decoding is serialized per batch as well as globally: `ImageDecoding::Guard`
+takes a per-key slot before a global one, so one batch's overlapping uploads
+cannot occupy every decoder slot and answer screenshot work with
+`decoder_busy`. Both stages share one deadline, so a caller waits the guard's
+timeout once rather than twice.
+
+An alt-text write longer than `MAX_ALT_TEXT` answers the `alt_text_too_long`
+machine code, and a write aimed at a removal tombstone answers not-found.
+
+## Discard
+
+`DELETE /image-attachment-drafts/batches/:public_id` throws an unclaimed batch
+away, and `ImageAttachments::DiscardBatch` locks and rechecks the state first,
+so a cancel racing a successful claim resolves as "already claimed" and can
+never take attachments away from a posted message. Only an explicit Annotorious
+cancel calls it — the composer listens for `annotorious:form-cancelled`.
+Ordinary Turbo disconnect, navigation, and reconnect still leave the batch for
+its 24 hour recovery window. This is what makes the open-batch ceiling's
+"finish or discard one first" copy true.
+
+## Cleanup
+
+`ImageAttachmentDraftCleanupJob` runs every 15 minutes in production, scans a
+bounded set of expired open batches, locks and rechecks each one, and destroys
+it together with its blobs. `Screenote::RecurringTasks` fails readiness closed
+when a supervised environment does not schedule it — and also when it is
+scheduled only on paper: readiness reads the queue database for a live Solid
+Queue scheduler heartbeat and for the required recurring tasks registered
+there, so a stopped supervisor, a rejected schedule, or a dead heartbeat all
+report unhealthy.
+
+## Resume
+
+`GET /image-attachment-drafts/batches/:public_id` re-reads server state for a
+reconnecting composer, and it answers `batch_unusable` for a batch that has
+expired or already been claimed. Restoring those rows as ready would only
+re-enable a post that upload, media, and claim are all certain to reject.
diff --git a/wiki/models/image-attachment.md b/wiki/models/image-attachment.md
new file mode 100644
index 0000000..56e0520
--- /dev/null
+++ b/wiki/models/image-attachment.md
@@ -0,0 +1,112 @@
+---
+title: ImageAttachment
+type: model
+source: app/models/image_attachment.rb
+created: 2026-08-29
+updated: 2026-08-31
+tags: [model, attachment, image, browser, active-storage]
+---
+
+# ImageAttachment
+
+TLDR: One image posted with a native browser message. While it is a draft it
+belongs to an [[image-attachment-batch]] and has no message parent; after the
+composer posts it belongs to exactly one [[annotation]] or
+[[annotation-comment]] and becomes immutable. Bytes are never public — every
+read is authorized live and streamed by the application.
+
+Source: `app/models/image_attachment.rb`
+
+## Columns
+
+| Column | Type | Notes |
+|--------|------|-------|
+| id | integer | PK |
+| image_attachment_batch_id | integer | Nullable FK. Present iff the row is still a draft |
+| annotation_id | integer | Nullable FK. Exactly one message parent after submission |
+| annotation_comment_id | integer | Nullable FK. Exactly one message parent after submission |
+| user_id | integer | NOT NULL, FK to users with `ON DELETE RESTRICT`. Immutable |
+| project_id | integer | NOT NULL, FK to projects. Immutable |
+| state | integer | Enum: uploading(0), ready(1), failed(2). Prefix `state` |
+| client_key | string(64) | NOT NULL. Client idempotency key; unique per batch |
+| alt_text | string(1000) | Nullable. Editable only while a draft |
+| media_type | string(40) | `image/png`, `image/jpeg`, or `image/webp`, derived from the bytes |
+| width / height | integer | Decoded dimensions |
+| byte_size | bigint | Stored size, at most 20 MB |
+| failure_code | string(64) | Machine code for a failed upload |
+
+## Constraints
+
+| Name | Meaning |
+|------|---------|
+| image_attachments_valid_state | state is 0, 1, or 2 |
+| image_attachments_exclusive_parent | exactly one of batch / annotation / annotation_comment |
+| image_attachments_submitted_ready | a row with no batch is ready and fully measured |
+| image_attachments_nonnegative_bytes | byte_size is never negative |
+
+## Limits
+
+`MAX_FILES` 5 per message, `MAX_FILE_SIZE` 20 MB per image, `MAX_TOTAL_BYTES`
+50 MB per message, `MAX_DIMENSION` and `MAX_PIXELS` shared with screenshot
+policy. `ALT_TEXT_FALLBACK` is the exact string `Attached image`.
+
+## Accepted bytes
+
+`ImageAttachments::Ingest` derives the media type from the bytes, requires any
+declared type and filename extension to agree with them, and fully decodes the
+file through the bounded `ImageDecoding::Guard`.
+
+A decode alone is not enough. libvips stops at the end of the picture, so a
+valid PNG, JPEG, or WebP carrying an appended archive, script, or second file
+decodes cleanly, and the original and download routes would then serve those
+bytes back verbatim. Ingest therefore walks the container and requires its
+declared end to be the end of the file: PNG chunks to `IEND`, the RIFF declared
+length, and the JPEG segment and entropy stream — respecting byte stuffing and
+restart markers — to `EOI`. Anything trailing is `invalid_image`.
+
+## Delivery
+
+`has_one_attached :image` with `attachment_thumb_1x` and `attachment_thumb_2x`
+variants. Variants are warmed by `ImageAttachmentThumbnailJob` only after the
+batch is claimed. An authenticated GET never invokes libvips: an unwarmed
+variant is simply unavailable and the gallery renders a placeholder.
+
+Two routes read the bytes, both application-streamed with
+`private, no-store` and `nosniff` and never a provider redirect:
+
+- `GET /media/image_attachments/:id/:variant` — browser session. Drafts are
+  readable only by their uploader inside a live batch; submitted rows recheck
+  project membership on every request.
+- `GET /api/media/image_attachments/:id?token=…` — bearer principal plus a
+  five-minute `:image_attachment_media` purpose token plus live project access.
+  The token alone is never authority.
+
+## Lifecycle
+
+Destroying an [[annotation]] or [[annotation-comment]] destroys its
+attachments and purges the primary blob with every derivative. The model owns
+that release rather than Active Storage's `:purge_later` default: that callback
+runs after the destroy has committed and has no fallback, so an enqueue the
+queue refuses would leave an unattached blob no row-based pass could find. The
+row therefore remembers its blob before the destroy and, if `purge_later`
+raises, purges inline. Every purge path goes through
+`ImageAttachments::PurgeBlob`, which deletes the stored bytes before the row
+that names them: a provider failure then leaves a discoverable unattached blob
+rather than an untracked key, and the reconciliation pass retries it.
+`ImageAttachmentOrphanReconciliationJob` is the backstop in both directions: it
+purges rows whose message no longer resolves after a delete that bypassed the
+callbacks, and it re-enqueues `ImageAttachmentThumbnailJob` for submitted rows
+whose variants were never produced, so a claim whose `perform_later` was lost
+does not leave a posted gallery on its placeholder until the process restarts.
+The reconciliation query selects only rows missing one of the two named
+variant digests, expressed as a `NOT EXISTS` predicate per digest so the LIMIT
+can stop the scan early instead of grouping every submitted attachment ever
+posted. It also runs a bounded pass over unattached blobs whose server-generated
+name marks them as this feature's, once they are old enough that no in-flight
+upload could still be about to attach them. Re-enqueueing is
+idempotent and generation aware: the job is keyed on the attachment together
+with the exact blob it was asked to warm. Deleting a user whose submitted
+attachments live on another member's message is rejected with a domain error
+rather than orphaning the uploader identity. Account deletion locks the account
+row before its dependent batches are destroyed, which is the order ingest also
+takes, so the two cannot deadlock against each other.
diff --git a/wiki/routes.md b/wiki/routes.md
index 9aad89b..94bfc83 100644
--- a/wiki/routes.md
+++ b/wiki/routes.md
@@ -3,7 +3,7 @@ title: Routes
 type: architecture
 source: config/routes.rb
 created: 2026-04-10
-updated: 2026-08-13
+updated: 2026-08-29
 tags: [routes, api, endpoints, auth]
 ---
 
@@ -84,7 +84,7 @@ rendering a second workspace.
 
 | Method | Path | Action | Auth |
 |--------|------|--------|------|
-| POST | `/screenshots/:screenshot_id/annotations` | create | Member |
+| POST | `/screenshots/:screenshot_id/annotations` | create (accepts one `image_attachment_batch_id`) | Member |
 | PATCH | `/screenshots/:screenshot_id/annotations/:id` | update | Member |
 | DELETE | `/screenshots/:screenshot_id/annotations/:id` | destroy | Member |
 
@@ -92,7 +92,38 @@ rendering a second workspace.
 
 | Method | Path | Action | Auth |
 |--------|------|--------|------|
-| POST | `/screenshots/:screenshot_id/annotations/:annotation_id/annotation_comments` | create | Member |
+| POST | `/screenshots/:screenshot_id/annotations/:annotation_id/annotation_comments` | create reply or reopen (accepts one `image_attachment_batch_id`) | Member |
+
+### Image attachment drafts (browser session only)
+
+| Method | Path | Action | Auth |
+|--------|------|--------|------|
+| POST | `/image-attachment-drafts/batches` | Open a draft batch for one mounted composer | Session member of `project_id` |
+| GET | `/image-attachment-drafts/batches/:public_id` | Resume a draft batch | Session uploader of the batch |
+| DELETE | `/image-attachment-drafts/batches/:public_id` | Discard an unclaimed draft batch on an explicit composer cancel | Session uploader of the batch |
+| POST | `/image-attachment-drafts/batches/:batch_public_id/attachments` | Stream one image into the batch | Session uploader of the batch |
+| DELETE | `/image-attachment-drafts/batches/:batch_public_id/attachments/discard` | Reserve a browser client key as removed, including before its upload returns an ID | Session uploader of the batch |
+| PATCH | `/image-attachment-drafts/batches/:batch_public_id/attachments/:id` | Edit optional alt text while still a draft | Session uploader of the batch |
+| DELETE | `/image-attachment-drafts/batches/:batch_public_id/attachments/:id` | Legacy ID-addressed idempotent removal | Session uploader of the batch |
+
+Forgery protection stays on: the composer sends `X-CSRF-Token` with every
+multipart and JSON request. A foreign or unknown batch answers `404`, never
+`403`, so sequential attachment IDs and guessed batch IDs confirm nothing.
+Attaching an image is a session-only capability — REST, the public CLI, and
+MCP read attachments but cannot author them.
+
+### Attachment media
+
+| Method | Path | Action | Auth |
+|--------|------|--------|------|
+| GET | `/media/image_attachments/:id/:variant` | Stream a draft preview or a submitted image | Session: draft uploader inside a live batch, or current project member |
+| GET | `/api/media/image_attachments/:id?token=…` | Stream a submitted image for an agent | Bearer principal **and** unexpired five-minute purpose token **and** live project access |
+
+Variants are limited to `original`, `download`, `attachment_thumb_1x`, and
+`attachment_thumb_2x`. Both routes stream from the application with
+`private, no-store` and `nosniff` and never redirect to the storage provider.
+A GET never invokes libvips: an unwarmed variant is unavailable until the
+post-claim warming job produces it.
 
 ### API Keys (nested under projects)
 
@@ -159,7 +190,7 @@ SaaS only; this hosted analytics authority is unrelated to self-hosted instance
 | PUT | `/api/v1/projects/:project_id/screenshot_images/:id` | Stream content-bound bytes into a prepared viewport image | API key or OAuth `mcp_write` |
 | POST | `/api/v1/screenshots` | Direct multipart screenshot upload | API key or OAuth `mcp_write` |
 | GET | `/api/v1/screenshots/:screenshot_id/annotations` | List annotations with `status`, `viewport`, `limit`, and `offset` filters | API key or OAuth `mcp_read` |
-| GET | `/api/v1/annotations/:id` | Get annotation details, comments, and best-effort crop data | API key or OAuth `mcp_read` |
+| GET | `/api/v1/annotations/:id` | Get annotation details, comments, best-effort crop data, and attachment metadata | API key or OAuth `mcp_read` |
 | POST | `/api/v1/annotations/:annotation_id/comments` | Add an API-key-authored or OAuth-user-authored annotation comment | API key or OAuth `mcp_write` |
 | POST | `/api/v1/annotations/:annotation_id/resolve` | Idempotently resolve an annotation and create its audit comment | API key or OAuth `mcp_write` |
 
diff --git a/wiki/testing-and-ci.md b/wiki/testing-and-ci.md
index 6e5f71c..fab4041 100644
--- a/wiki/testing-and-ci.md
+++ b/wiki/testing-and-ci.md
@@ -3,7 +3,7 @@ title: Testing and CI
 type: operations
 source: test/, bin/ci, config/ci.rb, .github/workflows/ci.yml, .github/workflows/release-qualification.yml
 created: 2026-07-28
-updated: 2026-08-08
+updated: 2026-08-31
 tags: [testing, ci, minitest, capybara, playwright]
 ---
 
@@ -28,6 +28,11 @@ Image-processing tests require libvips. The helper `require_vips!` skips those
 tests explicitly when the system dependency is absent instead of hiding a
 processing failure.
 
+Stale parallel test databases cause misleading `SQLite3::BusyException`
+failures spread across unrelated tests. If a run reports many lock errors in
+fixture loading, remove `storage/test.sqlite3*` and re-run
+`bin/rails db:test:prepare` before investigating the code.
+
 Every CI job that boots Rails must install libvips before `ruby/setup-ruby`
 hands control to the test command. The application loads the Vips initializer
 at boot even when a focused contract does not transform an image; a focused
@@ -74,6 +79,48 @@ invalidates an existing browser session, restoration requires a fresh sign-in,
 and a private recovery link resets credentials once in a separate session while
 rejecting replay and the former password.
 
+`test/system/image_attachments_test.rb` is the browser contract for image
+attachments. Every input path is proved all the way to a posted message rather
+than only to an attachment: root, reply, and unresolve each post several images
+collected through the picker, composer-scoped drop, and a real clipboard paste.
+It also covers mixed clipboard input, per-file progress, alt text, removal, a
+failed upload holding the submit control closed until a retry finishes it, 422
+rehydration, the gallery placeholder warming into responsive thumbnails, the
+modal viewer for one image and for several — navigation hidden when there is
+nothing to navigate to, zoom, download, open-original, focus handling — narrow
+layout, the explicit light and dark component contexts for both the composer
+and the nested viewer, and the composer's polite live region. Two geometry contracts sit side by side: against the full-size
+`desktop_screenshot.png` fixture the clamped overlay must leave the selected
+region completely uncovered, and against a thumbnail-sized capture it must at
+least stay one compact rail inside the image.
+
+`SCREENOTE_EVIDENCE_DIR` turns any browser run into a recorded one. Each test
+writes a Playwright trace (`<test>.trace.zip`), and `capture_evidence` writes a
+named frame plus the page facts a picture cannot show — resolved media paths,
+srcset candidates, component context, measured geometry.
+`script/attachment_browser_evidence` runs the attachment suite that way,
+extracts each trace into an ordered filmstrip, and encodes that filmstrip into
+a watchable `<test>.webm`.
+
+Both halves fail closed. A trace that cannot be started or cannot be written
+fails its test rather than printing a note, and the script refuses a non-empty
+output directory and requires one trace per declared test in the suite, so a
+stale directory or a run that stopped early cannot satisfy the gate.
+
+The video is encoded from the trace screencast, not recorded by the browser.
+Playwright's own `record_video_dir` cannot be used here: `reset!` in
+`capybara-playwright-driver` asks the page for its video path while the page is
+still open, and `Playwright::Video#path` blocks on a future that the page-close
+event rejects, so a run that sets the option hangs and leaves a zero-byte file.
+The trace screencast carries the same picture — roughly 20 frames a second for
+the whole length of a test — and each frame keeps the millisecond it was
+captured at in its name, so the concat encode reproduces the run's real timing
+rather than a nominal frame rate. Frames must be ordered by that numeric tail
+read from the bare file name; the enclosing path contains hyphens of its own.
+Anything read from the live browser must happen in `before_teardown`, because
+Capybara closes the context in `after_teardown`, ahead of ordinary teardown
+callbacks.
+
 `DEVICE_SCALE_FACTOR` configures the Playwright context for responsive-image
 proof. Run `test/system/pages_test.rb` at both `1` and `2`; its responsive card
 test verifies `currentSrc` selects the 480w and 960w candidates respectively
@@ -99,12 +146,58 @@ model, and Rake task tests.
 ## Full gate
 
 `bin/ci` installs missing dependencies and runs formatting/whitespace checks,
-security scans, Rails tests, seed validation, and any configured Go tests. Set
+security scans, Rails tests, seed validation, and the Go tests as
+`env GOFLAGS=-mod=mod go test ./...` — the module flag is required because the
+repository's top-level `vendor/` directory belongs to Ruby, and a bare
+`go test` reads it as an inconsistent Go vendor tree and refuses to run. Set
 `REQUIRE_COVERAGE=true` to enforce the SimpleCov line and branch thresholds;
 coverage mode forces one Rails worker for stable accounting. System tests are
 currently commented out as optional in `config/ci.rb`, so run the Playwright
 command above separately when browser behavior changes.
 
+One adapter-specific workflow sits outside that boundary. `concurrency-qualification.yml`
+runs `script/release_test_matrix attachment-lifecycle` against a PostgreSQL
+server database so real row locks exercise lock ordering, atomic claim,
+aggregate races, and the cleanup/remove/submit races that SQLite can only assert
+by outcome. It is a required status check on the default branch, and
+`bin/release-validate` asserts both that requirement and the workflow's own
+shape, so the branch cannot merge with that evidence unproven. It is a separate workflow precisely so `ci.yml` stays free of
+adapter-specific content and the portability contract keeps passing. The
+ephemeral, runner-local PostgreSQL service uses trust authentication and a
+credential-free loopback URL, so the workflow does not carry a reusable test
+password in its published source.
+
+Every race in that suite proves real overlap before it asserts an outcome: the
+helper starts the second operation, confirms it is blocked on the transaction
+the first one is parked inside, and only then releases. Serial execution cannot
+pass. The aggregate race runs against the real 50 MB message ceiling — the
+batch is seeded with recorded byte sizes up to exactly one more file's worth of
+room — rather than a stubbed limit.
+
+The gate names one list of suites and runs it against whatever database is
+configured, so the SQLite and server-database halves cannot drift apart. Two
+cases in that list only mean something on a server database — the account
+byte-ceiling serialization race and the adapter assertion that keeps the
+qualification honest — and they skip on SQLite.
+`script/attachment_server_database_qualification` boots an ephemeral server
+database, exports `SCREENOTE_SERVER_DATABASE_QUALIFICATION=1`, and reruns the
+gate, so that half is reproducible outside CI rather than only inside it.
+
+The `s3` gate carries the delivery half of the same contract.
+`test/integration/image_attachment_s3_delivery_contract_test.rb` points the
+whole application at the configured object store and drives the protected
+session and bearer routes through it, which is the only way to prove the
+application streams the bytes itself: a Disk service has no presigned URL to
+leak and no remote host to redirect to.
+`script/attachment_object_store_qualification` supplies that store the same way
+the database script supplies a database — an ephemeral MinIO container on the
+same immutable image `container-s3` uses — and reruns the gate, so the delivery
+half is reproducible outside CI too. It accepts an operator's own endpoint
+through `SCREENOTE_S3_ENDPOINT` and its companions for a run against the hosted
+provider. The gate exports `SCREENOTE_REQUIRE_S3=1`, which turns the suite's
+"no store configured" skip into a failure, so a qualification run cannot pass
+by not running.
+
 The source workflow has one adapter-neutral `test` job for the Rails suite and
 the self-hosted-only smoke tests. It replaces separate SQLite and PostgreSQL
 application-test jobs and exercises the configured test database through

Next: Open the public pull request for the complete change.

HiveDev, when it’s ready.

HiveDev is the hosted product being built around open-source Hive. It isn’t available yet; join the waitlist to hear when it is. We’ll use your email for HiveDev availability updates.

Read our privacy notice.