diff --git a/CHANGELOG.md b/CHANGELOG.md
index 526bfad45..0a61d357d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,14 @@ All notable changes to this project are documented in this file. Format follows
For narrative release notes written for operators and product owners, see [RELEASE_NOTES.md](RELEASE_NOTES.md).
+## [1.11.1] - 2026-07-24
+
+Patch release fixing Markdown artifact downloads that saved the HTML render wrapper instead of the authored `.md` source. Backend-only, no dependency changes, no CDK deploy — ships via `backend.yml` (artifact-render Lambda). Existing artifacts are fixed on download with no re-storage.
+
+### 🐛 Fixed
+
+- Downloading a Markdown artifact now saves the authored `.md` source instead of a `.html` file of the render scaffolding — Markdown records keep `content_type=text/markdown` but S3 holds the writer's HTML wrapper (raw Markdown base64-embedded in a `"
+)
+
+
+def _extract_markdown_source(wrapper: str) -> str | None:
+ """Recover the raw Markdown source the writer base64-embedded in its
+ HTML render wrapper. Returns None when the marker is absent or the
+ payload can't be decoded, so the caller can fall back to the wrapper
+ bytes rather than fail the download."""
+ match = _MARKDOWN_SOURCE_RE.search(wrapper)
+ if not match:
+ return None
+ try:
+ return base64.b64decode(match.group("b64").strip()).decode("utf-8")
+ except (ValueError, UnicodeDecodeError):
+ return None
+
+
def _security_headers(content_type: str) -> dict[str, str]:
return {
"content-type": content_type,
@@ -154,12 +191,13 @@ def _security_headers(content_type: str) -> dict[str, str]:
# File extension to suggest when saving an artifact. Keyed by the bare
# (parameter-stripped, lowercased) authored content type. Markdown rows
-# hold the writer's HTML render wrapper in S3 (see module docstring), so
-# the saved bytes are HTML — extension follows the bytes, not the label.
+# hold the writer's HTML render wrapper in S3, but a download recovers the
+# embedded raw Markdown source (see `_download_payload`) so the saved file
+# is `.md`, matching what the user authored.
_DOWNLOAD_EXTENSIONS = {
"text/html": "html",
- "text/markdown": "html",
- "text/x-markdown": "html",
+ "text/markdown": "md",
+ "text/x-markdown": "md",
"image/svg+xml": "svg",
"application/json": "json",
"text/css": "css",
@@ -175,8 +213,28 @@ def _security_headers(content_type: str) -> dict[str, str]:
def _download_extension(stored_content_type: str) -> str:
- bare = (stored_content_type or "").split(";")[0].strip().lower()
- return _DOWNLOAD_EXTENSIONS.get(bare, "bin")
+ return _DOWNLOAD_EXTENSIONS.get(_bare_type(stored_content_type), "bin")
+
+
+def _download_payload(
+ stored_content_type: str, serve_content_type: str, body: str
+) -> tuple[str, str, str]:
+ """Choose the bytes, HTTP content type, and file extension for a save.
+
+ Markdown records serve an HTML render wrapper but download as the raw
+ Markdown source the writer embedded in it, so the user saves the `.md`
+ they authored. If the source can't be recovered (marker absent), fall
+ back to the wrapper bytes as `.html`. Every other type downloads
+ exactly as served."""
+ if _bare_type(stored_content_type) in _MARKDOWN_MIME_TYPES:
+ source = _extract_markdown_source(body)
+ if source is not None:
+ return source, _MARKDOWN_CONTENT_TYPE, "md"
+ logger.warning(
+ "markdown download source marker missing; serving wrapper as html"
+ )
+ return body, serve_content_type, "html"
+ return body, serve_content_type, _download_extension(stored_content_type)
def _content_disposition(title: str, ext: str) -> str:
@@ -486,14 +544,16 @@ def handler(event: dict[str, Any], _context: Any) -> dict[str, Any]:
return _error_response(500, "The artifact service is misconfigured.")
if _wants_download(event):
- ext = _download_extension(stored_content_type)
+ download_body, download_type, ext = _download_payload(
+ stored_content_type, content_type, body
+ )
headers = _download_headers(
- content_type, _content_disposition(title, ext)
+ download_type, _content_disposition(title, ext)
)
return {
"statusCode": 200,
"headers": headers,
- "body": "" if method == "HEAD" else body,
+ "body": "" if method == "HEAD" else download_body,
}
if method == "HEAD":
diff --git a/backend/tests/lambdas/test_artifact_render.py b/backend/tests/lambdas/test_artifact_render.py
index 5817c760e..48fe3de15 100644
--- a/backend/tests/lambdas/test_artifact_render.py
+++ b/backend/tests/lambdas/test_artifact_render.py
@@ -432,9 +432,9 @@ def test_oversized_content_is_500(aws_env, monkeypatch: pytest.MonkeyPatch) -> N
"stored,ext",
[
("text/html; charset=utf-8", "html"),
- ("text/markdown", "html"), # S3 body is the HTML render wrapper
- ("text/x-markdown", "html"),
- ("TEXT/MARKDOWN; charset=utf-8", "html"),
+ ("text/markdown", "md"), # download recovers the embedded source
+ ("text/x-markdown", "md"),
+ ("TEXT/MARKDOWN; charset=utf-8", "md"),
("image/svg+xml", "svg"),
("application/json", "json"),
("text/css", "css"),
@@ -492,7 +492,43 @@ def test_download_returns_attachment(aws_env) -> None:
assert "content-security-policy" not in resp["headers"]
-def test_download_markdown_uses_html_extension(aws_env) -> None:
+def _markdown_wrapper(markdown: str) -> str:
+ """A minimal HTML render wrapper carrying the writer's base64 marker,
+ matching the `"
+ "