feat(listing): constrain the description Markdown subset, and state the vocabulary governance rule - #39
Merged
justinmerrell merged 3 commits intoAug 17, 2026
Conversation
`spec.description` is third-party Markdown rendered by the storefront in a first-party origin, bounded only at 20 000 characters and constrained by nothing. CommonMark permits raw HTML by design, so a conforming listing may contain `<script>` today. The decision is CommonMark 0.31.2 minus raw HTML, with link destinations restricted to a scheme list and image destinations held to listing §5's media-path rule. Naming a grammar rather than describing one is what lets a listing document HTML inside a code fence and stay conforming — a rule phrased lexically would have rejected the authors writing honest documentation. Two parts are stated deliberately rather than inherited. The rule binds the renderer as well as the author, because the document it exists to stop is written by someone who will not run the validator; and the same scheme rule reaches homepageUrl, sourceRepoUrl and supportUrl, because `javascript:` in a scalar field is the same stored XSS as `javascript:` in a description link. Refs #22 Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
…fields listing §4 constrained `summary` — the field that cannot hurt anyone — and left `description` unconstrained. A description is third-party Markdown rendered by the storefront in a first-party origin, and CommonMark permits raw HTML by design, so a conforming listing could contain `<script>` today. §4.1 narrows it to CommonMark 0.31.2 minus raw HTML, with link destinations on `https`, `http`, `mailto` or a fragment and image destinations held to §5's media-path grammar. Three semantic diagnostics carry it: ERR_RAW_HTML, ERR_DISALLOWED_SCHEME, ERR_IMAGE_NOT_LOCAL. The rule is written in CommonMark's own constructs — HTML block (§4.6) and raw HTML inline (§6.6) — rather than as a search for angle brackets, because a code span and a fenced code block are different constructs in that grammar. A listing may therefore document `<script>` inside a fence and stay conforming, which semantic-009 pins; the same `<script>` outside the fence is semantic-008. Deciding that correctly is what the commonmark dependency in tools/ buys, and tools/ is non-normative (ADR 0001 §7). §4.1 also reaches the renderer: a consumer MUST NOT emit a forbidden construct whether or not it validated first. The document this profile exists to stop is written by someone who will not run the validator, so an authoring rule alone would protect nobody. No fixture can observe a storefront's output, so the strongest rule here is the one with no case behind it. homepageUrl, sourceRepoUrl and supportUrl were bare maxLength strings and now carry the same scheme set as a `pattern` — anchored and lookahead-free, so it compiles under RE2 and cannot backtrack. Same rule, two placements: structural where JSON Schema can decide it offline, semantic where it cannot parse Markdown. Six fixtures. The four negatives were verified red before this commit and green after; structural-012 pins that the scheme set is three terms and not one. An image destination inherits §5's existence and containment rules, reported at /spec/description. Basename uniqueness does not — that rule is about the screenshot gallery, which a description image is not part of. Refs #22 Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
… term The TODO had already worked out the compatibility mechanics — adding a term is minor, removing one is major. What was missing is the editorial rule, and that is the part that decays: growth is cheap in every individual case and irreversible in aggregate, because correcting an over-grown taxonomy means a removal and therefore a new major. ADR 0003 §1 had already decided the placement and left applying it to listing as Follow-up 1. §4.2 now says both fields are placement one deliberately rather than by default, and the two stop resembling each other there. category is an open taxonomy and gets an admission test in GOVERNANCE.md: the proposing PR must name which existing terms it was tested against, show the term is how a buyer browses rather than what the software is built with (that is what tags carries), and show it is not a subset of an existing term. Approval stays at one maintainer, with the note that a reviewer is expected to reject a well-formed term on editorial grounds. No numeric ceiling, and the reason is stated rather than left implied. A cap could only be honoured by refusing every candidate once reached, since merging two terms is a removal — a limit unenforceable inside the major it applies to is a limit in name, and it would displace the judgement doing the actual work. lifecycleStage is a closed progression and is ADR-gated instead. Its ordering — EXPERIMENTAL, BETA, STABLE, with SUNSET terminal rather than a fourth point on the scale — is now stated, because the storefront sorts and filters on it and a consumer inferring it from the names would be inventing contract. With this, no spec.md carries a TODO section. Listing §9 records what is left as gaps recorded where they apply, and CLAUDE.md drops the pre-stable note. Refs #23 Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
justinmerrell
deleted the
spec/listing-description-subset-and-vocabulary-governance
branch
August 17, 2026 02:08
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the last two
TODOsections in the repository. Both were in listing §4,and neither was undocumented behaviour — one is a security question and one is a
governance question, which is why #9 supplied answers for neither.
§4.1 — the description Markdown subset
summarywas constrained ("MUST NOT be rendered as Markdown") anddescriptionwas not. The field that cannot hurt anyone had a rule; the field that can did
not. A description is third-party Markdown rendered by the storefront in a
first-party origin, and CommonMark permits raw HTML by design, so a conforming
listing could contain
<script>today.The subset is CommonMark 0.31.2, narrowed by three rules:
ERR_RAW_HTMLhttps,http,mailto, or a fragmentERR_DISALLOWED_SCHEMEERR_IMAGE_NOT_LOCALWhy the rule is written in CommonMark's constructs rather than lexically. A
code span and a fenced code block are their own constructs in that grammar, so a
listing may document
<script>inside a fence and stay conforming. A rulephrased as a search for angle brackets would have rejected exactly the authors
writing honest documentation.
semantic-009pins that; the same<script>moved outside the fence is
semantic-008, and I verified both directions byhand. That distinction is the entire justification for the
commonmarkdependency in
tools/, which is non-normative code (ADR 0001 §7).The rule reaches the renderer, and that is deliberate rather than inherited.
A consumer MUST NOT emit a forbidden construct whether or not it validated
first. An authoring rule alone protects nobody here: the document this profile
exists to stop is written by someone who will not run the validator. The cost is
recorded — no fixture can observe a storefront's output, so §4.1's strongest
rule is the one with no case behind it, and it is the first clause in the family
constraining an implementation's output rather than a document.
Remote images are rejected, and this is the decision most likely to be
argued. A remote image executes nothing, but it discloses every storefront
viewer's IP and user agent to a host the listing author chose, on every page
view, with no interaction. §5 already fixed
media/as the one directory an itemships assets from, so a description image is held to the same grammar and a
remote one is unspellable rather than discouraged. This rejects badge
shields, which is a real cost and is recorded as one in ADR 0004 — a badge is a
live third-party surface in a first-party origin, which is the thing being ruled
out, and it cannot be cached away without breaking the badge.
Scope extended past what #22 names
homepageUrl,sourceRepoUrl, andsupportUrlwere baremaxLength: 2048strings with no
formatand no prose.javascript:inhomepageUrlis the samestored injection as
javascript:in a description link, and the storefrontrenders both — so it is one rule with two placements:
patternin the schemastructuraldescriptionsemanticThe pattern is anchored and lookahead-free, so it compiles under RE2 as well as
ECMA-262 and cannot be driven to backtrack — SECURITY.md names ReDoS in a
published pattern as a vulnerability class, and a pattern added to close a
security gap is a poor place to open one.
§4.2 and GOVERNANCE.md — the vocabulary rule
The TODO had already worked out the compatibility mechanics. What was missing is
the editorial rule, and that is the part that decays.
ADR 0003 §1 had decided the placement and left applying it to listing as its
Follow-up 1, so §4.2 says both fields are placement one deliberately rather than
by default — and then separates them, because they are not the same kind of
thing.
categorygets an admission test in GOVERNANCE.md → Changing a controlledvocabulary: the proposing PR must name which existing terms it was tested
against, show the term is how a buyer browses rather than what the software is
built with (that is what
tagscarries), and show it is not a subset of anexisting term. Approval stays at one maintainer, with the note that a reviewer is
expected to reject a well-formed term on editorial grounds.
No numeric ceiling, and the reason is stated rather than left implied: a cap
could only be honoured by refusing every candidate once reached, since merging
two terms is a removal and therefore a new major. A limit unenforceable inside
the major it applies to is a limit in name, and it would displace the judgement
doing the actual work.
lifecycleStageis a closed progression, not a taxonomy, and is ADR-gatedinstead. Its ordering —
EXPERIMENTAL,BETA,STABLE, withSUNSETterminalrather than a fourth point on the scale — is now stated, closing a gap nobody
filed: the storefront sorts and filters on the field, and a consumer inferring
the order from the names would be inventing contract.
Timing
This must land before #1, #2, and #3. Constraining the subset rejects
listings that validate today, which GOVERNANCE.md → Compatibility review makes a
breaking change requiring a
v2directory. No tag exists and the1.0.0releasePRs are still open, so it is free right now and expensive immediately after —
the same window #13 and #35 used.
Evidence
task checkis green: 121 conformance cases, 9 examples, 3 bundles matchingtheir sources.
The four negatives were verified red before the schema and
semantic.tscommit and green after — with enforcement reverted and fixtures kept, all four
reported "expected to fail but validated cleanly" while both positives stayed
green.
semantic-009was checked in both directions separately.Not done
spec.mdstill says Status: Draft (pre-stable). Closing these twoTODOs clears the stated blocker, but declaring v1 stable is a release act
belonging to chore(main): release component 1.0.0 #1/chore(main): release blueprint 1.0.0 #2/chore(main): release listing 1.0.0 #3, so I left the header alone rather than deciding it here.
fixture contract (an ADR 0002-shaped change, not a fixture someone can add),
tagsis unbounded, andlicenselooks like SPDX and is constrained tonothing.
Closes #22
Closes #23
🤖 Generated with Claude Code