Skip to content

feat(listing): constrain the description Markdown subset, and state the vocabulary governance rule - #39

Merged
justinmerrell merged 3 commits into
mainfrom
spec/listing-description-subset-and-vocabulary-governance
Aug 17, 2026
Merged

feat(listing): constrain the description Markdown subset, and state the vocabulary governance rule#39
justinmerrell merged 3 commits into
mainfrom
spec/listing-description-subset-and-vocabulary-governance

Conversation

@justinmerrell

Copy link
Copy Markdown
Contributor

Closes the last two TODO sections 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

summary was constrained ("MUST NOT be rendered as Markdown") and description
was 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:

Rule Diagnostic
No raw HTML — HTML block (CommonMark §4.6) or raw inline HTML (§6.6) ERR_RAW_HTML
Link destinations on https, http, mailto, or a fragment ERR_DISALLOWED_SCHEME
Image destinations must be item media paths per §5 ERR_IMAGE_NOT_LOCAL

Why 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 rule
phrased as a search for angle brackets would have rejected exactly the authors
writing honest documentation. semantic-009 pins that; the same <script>
moved outside the fence is semantic-008, and I verified both directions by
hand. That distinction is the entire justification for the commonmark
dependency 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 item
ships 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, and supportUrl were bare maxLength: 2048
strings with no format and no prose. javascript: in homepageUrl is the same
stored injection as javascript: in a description link, and the storefront
renders both — so it is one rule with two placements:

Surface Placement Phase
The three scalar URL fields a pattern in the schema structural
Destinations inside description a check over the parsed document semantic

The 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.

category gets an admission test in GOVERNANCE.md → Changing a controlled
vocabulary
: 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 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.

lifecycleStage is a closed progression, not a taxonomy, and is ADR-gated
instead. Its ordering — EXPERIMENTAL, BETA, STABLE, with SUNSET terminal
rather 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 v2 directory. No tag exists and the 1.0.0 release
PRs are still open, so it is free right now and expensive immediately after —
the same window #13 and #35 used.

Evidence

task check is green: 121 conformance cases, 9 examples, 3 bundles matching
their sources.

The four negatives were verified red before the schema and semantic.ts
commit and green after — with enforcement reverted and fixtures kept, all four
reported "expected to fail but validated cleanly" while both positives stayed
green. semantic-009 was checked in both directions separately.

Not done

Closes #22
Closes #23

🤖 Generated with Claude Code

`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
justinmerrell merged commit cf129c4 into main Aug 17, 2026
4 checks passed
@justinmerrell
justinmerrell deleted the spec/listing-description-subset-and-vocabulary-governance branch August 17, 2026 02:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant