Skip to content

spec: withdraw the quote attribution, a captioned quote is a figure again - #1213

Merged
dereuromark merged 1 commit into
mainfrom
spec/revert-quote-attribution
Aug 15, 2026
Merged

spec: withdraw the quote attribution, a captioned quote is a figure again#1213
dereuromark merged 1 commit into
mainfrom
spec/revert-quote-attribution

Conversation

@dereuromark

@dereuromark dereuromark commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

What

PART 9 section 4a (#1161) and PART 11 section 10d are withdrawn. A captioned block quote is a figure whose target is the quote again, exactly as it was and as every other captionable host is.

Why

The clause had three legs. Two of them are false and the third is smaller than it looked.

"Both spellings are valid HTML." They are not. The HTML Standard's blockquote section:

Attribution for the quotation, if any, must be placed outside the blockquote element.

and its own recommended pattern, given as the way "to clearly relate a quote to its attribution (which is not part of the quote and therefore doesn't belong inside the blockquote itself)":

<figure>
 <blockquote>
  <p>The truth may be puzzling. ...</p>
 </blockquote>
 <figcaption>Carl Sagan, in "<cite>Wonder and Skepticism</cite>", ...</figcaption>
</figure>

So section 4a did not pick one of two valid spellings. It put the attribution in the one place the standard names as wrong, and asserted the opposite in its own text.

"figure was doing two jobs." It does one. A captioned code block is already a figure whose target is the code block:

``` php
$x = 1;
```
^ Listing #: Example
<figure>
  <pre><code class="language-php">$x = 1;
</code></pre>
  <figcaption>Listing 1: Example</figcaption>
</figure>

The node is the generic captioned wrapper and always was. What a captioned thing is called and counted as comes from the caption's own label, which is the counter bucket key, not from the host's node type. A quote was never the odd host out.

"An epigraph consumed a figure number." Only where the author asked for one. Numbering keys on a bare # in the caption's top-level text, so ^ William Shakespeare took no number under either model. The repro the clause showed, ^ Figure #: Hamlet, is an author writing "Figure" on a quote, which is the same thing a code block captioned ^ Figure #: does and which nothing polices either.

What replaces it

Section 4b, EXPLANATORY rather than normative, because it states what the other clauses already imply and what nobody had written down: the figure node is the generic captioned wrapper, the caption's label decides what the thing is called and counted as, and a quote is therefore not a special host. It records the withdrawn clause and why, so the same turn is not taken twice.

Section 10d goes with it and leaves a numbering gap rather than renumbering 10e and 10f, which other clauses cite. Section 10e cited 10d five times for its reasoning about attachment by adjacency; those passages now make the same argument without leaning on a withdrawn clause, and none of its conclusions change.

Nothing shipped

Latest tags: carve 0.1.2, carve-js 0.1.3, carve-php 0.1.4, carve-rs 0.1.2, all of 2026-08-10. Section 4a landed on 08-13. So docs/versioning.md loses its entry entirely rather than gaining a second one: a state that existed only between releases is invisible to an upgrader, and no consumer ever saw block_quote.attribution.

What was kept

The multi-block generality from #1189. The pre-4a oracle refused a captioned quote holding anything but a single paragraph; corpus category 306 pins all five shapes and the restored figure path renders them. Oracle output was checked against a real pre-4a engine build (carve-js 0.1.3) on every affected document and is byte-identical.

Drift, declared in both valves

The pinned build now carries the withdrawn shape, so the nine affected documents are listed in resources/engine-pin-drift.txt and in SCHEMA_ROLLOUT_PENDING, in the opposite direction from #1161. npm run engine:report -- --check reports exactly those nine and agrees with the declaration. Both lists clear when the engine reverts land and the pin moves.

The three sidecars for 07-blockquote-with-attribution are regenerated from the pre-4a engine rather than typed by hand.

Follow-ups this creates

…gain

PART 9 §4a (carve#1161) made a caption on a block quote its ATTRIBUTION: the
tree became a `block_quote` carrying an `attribution`, the HTML put the source
in a `<footer>` INSIDE the `<blockquote>`, and `block_quote` left
`figure.target`. PART 11 §10d then carried that attachment onto the Markdown,
plain-text and terminal targets. Both are withdrawn here. A captioned quote is
a `figure` whose target is the quote, exactly as it was and as every other
captionable host is.

THE CLAUSE HAD THREE LEGS AND TWO OF THEM ARE FALSE.

  "BOTH SPELLINGS ARE VALID HTML." They are not. The HTML Standard's
  `blockquote` section states that "attribution for the quotation, if any, must
  be placed outside the `blockquote` element", and gives a `<figure>` wrapping
  the quote with a `<figcaption>` as the way "to clearly relate a quote to its
  attribution (which is not part of the quote and therefore doesn't belong
  inside the `blockquote` itself)". So §4a did not pick one of two valid
  spellings. It put the attribution in the one place the standard names as
  wrong, and said the opposite in its own text.

  "`figure` WAS DOING TWO JOBS." It does one. A captioned code block is already
  a `figure` whose target is the code block, rendering
  `<figure><pre><code>…<figcaption>Listing 1: …</figcaption></figure>`; the same
  holds for a captioned display-math paragraph. The node is the generic
  captioned wrapper and always was, and what a captioned thing is CALLED and
  counted as comes from the caption's own label - the counter bucket key - not
  from the host's type. A quote was never the odd host out.

  "AN EPIGRAPH CONSUMED A FIGURE NUMBER." Only where the author asked for one.
  Numbering keys on a bare `#` in the caption's top-level text, so
  `^ William Shakespeare` took no number under either model, and the repro the
  clause showed (`^ Figure #: Hamlet`) is an author writing "Figure" on a quote -
  the same thing a code block captioned `^ Figure #:` does, which nothing
  polices either.

WHAT REPLACES IT is §4b, EXPLANATORY rather than normative, because it states
what the other clauses already imply and what nobody had written down: the
`figure` node is the generic captioned wrapper, the caption's label decides what
the thing is called and counted as, and a quote is therefore not a special host.
It records the withdrawn clause and why, so the same turn is not taken twice.

§10d GOES WITH IT and leaves a numbering gap rather than renumbering §10e and
§10f, which four other clauses cite. §10e cited §10d five times for its
reasoning about attachment by adjacency; those passages now make the same
argument without leaning on a withdrawn clause. Its conclusions do not change.

THE ORACLE RENDERS THE FIGURE AGAIN, and the multi-block generality from
carve#1189 is KEPT: the pre-§4a code refused a captioned quote holding anything
but a single paragraph, and corpus category 306 pins all five shapes. Oracle
output was checked against a real pre-§4a engine build (carve-js 0.1.3) on every
one of them and is byte-identical.

NOTHING SHIPPED, so no consumer ever saw the field. The most recent tags are
carve 0.1.2, carve-js 0.1.3, carve-php 0.1.4 and carve-rs 0.1.2, all of
2026-08-10; §4a landed on 08-13. `docs/versioning.md` therefore loses its entry
entirely rather than gaining a second one: a state that existed only between
releases is invisible to an upgrader.

THE DRIFT IS DECLARED IN BOTH VALVES, in the other direction from carve#1161.
The pinned build now carries the withdrawn shape, so the nine affected documents
are listed in `resources/engine-pin-drift.txt` and in `SCHEMA_ROLLOUT_PENDING`;
`npm run engine:report -- --check` reports exactly those nine and agrees. Both
clear when the engine reverts land and the pin moves.

The three sidecars for `07-blockquote-with-attribution` are regenerated from the
pre-§4a engine rather than typed by hand, so the Markdown, plain and terminal
fixtures record what an implementation produces.

Engine reverts follow in carve-js, carve-php and carve-rs, plus the tiptap
projection in carve-grammars, which learned the attribution field yesterday.
@dereuromark
dereuromark marked this pull request as ready for review August 15, 2026 00:43
@dereuromark
dereuromark merged commit 7467808 into main Aug 15, 2026
4 checks passed
@dereuromark
dereuromark deleted the spec/revert-quote-attribution branch August 15, 2026 00:44
dereuromark added a commit to markup-carve/carve-php that referenced this pull request Aug 15, 2026
The new case used a `block_quote` at `figure.target` as its example of a type the
schema refuses. That was true of the schema on the day it was written and is
about to stop being true: markup-carve/carve#1161 removed `block_quote` from the
admitted set and markup-carve/carve#1213 puts it back, so the assertion would
have flipped on a submodule bump and read as a regression in this diff.

A `heading` is not a captionable host under any version of the clause, so the
case pins the behavior under test - a typed union names the admitted set rather
than the first branch's required field - and nothing else. The assertion on the
set is a substring for the same reason.

The CHANGELOG entry drops its verbatim message quote, which carried the same
dependency and had a stray escape inside the code span.
dereuromark added a commit to markup-carve/carve-js that referenced this pull request Aug 15, 2026
…ain (#1070)

markup-carve/carve#1213 withdraws PART 9 §4a and PART 11 §10d. A caption on a
block quote is not an attribution; it is a caption like any other, so a captioned
quote is a `figure` whose target is the quote - the shape this engine produced
before #1033.

WHY THE CLAUSE FELL. The HTML Standard's `blockquote` section requires that
"attribution for the quotation, if any, must be placed outside the `blockquote`
element", and gives a `<figure>` wrapping the quote with a `<figcaption>` as the
way to attach it, "which is not part of the quote and therefore doesn't belong
inside the `blockquote` itself". §4a put it inside, in a `<footer>`, and asserted
that both spellings were valid. Its model argument fails too: `figure` is already
the generic captioned wrapper, since a captioned code block is a `figure` whose
target is the code block, and what a captioned thing is called and counted as
comes from the caption's own label rather than the host's node type.

Nothing had shipped - 0.1.3 predates the port - so this removes code no release
carried, and the CHANGELOG loses those entries rather than gaining a reversal.

WHAT COMES BACK:

  `> To be` + `^ Hamlet` renders

      <figure>
        <blockquote><p>To be</p></blockquote>
        <figcaption>Hamlet</figcaption>
      </figure>

  The caption numbers like any other, so `^ Figure #: Hamlet` takes the next
  Figure from the same bucket an image uses and a numbered cross-reference to the
  quote's id resolves to "Figure 1" again.

  `block_quote.attribution` is gone from the node, from the AST JSON wire and
  from the generated member sets, and `figure.target` admits a `block_quote`.
  The spec submodule moves onto the withdrawal, and `src/wire-fields.ts` is
  regenerated from it rather than hand-edited.

  The HTML importer reads `<figure><blockquote>…<figcaption>` back as the figure,
  and a `<footer>` inside a quote is ordinary quoted content again.

  The Markdown, plain-text and terminal writers lose the attribution-specific
  handling §10d added - the `<footer>` element, the quote bar carried onto the
  attribution line, the removed blank line - and render the quote's caption the
  way they render any other figure caption.

WHAT STAYS. PART 11 §10e landed in the same commits and is untouched: a table
caption still survives the Markdown target, and a code fence's title and grouping
label still survive plain text and the terminal. Multi-block captioned quotes
also keep working; the pre-§4a code refused them in the oracle and that
regression does not come back.
dereuromark added a commit to markup-carve/carve-php that referenced this pull request Aug 15, 2026
…ain (#1264)

markup-carve/carve#1213 withdraws PART 9 §4a and PART 11 §10d. A caption on a
block quote is not an attribution; it is a caption like any other, so a captioned
quote is a `figure` whose target is the quote - the shape this engine produced
before #1239.

WHY THE CLAUSE FELL. The HTML Standard's `blockquote` section requires that
"attribution for the quotation, if any, must be placed outside the `blockquote`
element", and gives a `<figure>` wrapping the quote with a `<figcaption>` as the
way to attach it, "which is not part of the quote and therefore doesn't belong
inside the `blockquote` itself". §4a put it inside, in a `<footer>`, and asserted
that both spellings were valid. Its model argument fails too: `figure` is already
the generic captioned wrapper, since a captioned code block is a `figure` whose
target is the code block, and what a captioned thing is called and counted as
comes from the caption's own label rather than the host's node type.

Nothing had shipped - 0.1.4 predates the port - so this removes code no release
carried, and the CHANGELOG loses those entries rather than gaining a reversal.

WHAT COMES BACK:

  `> To be` + `^ Hamlet` renders

      <figure>
        <blockquote><p>To be</p></blockquote>
        <figcaption>Hamlet</figcaption>
      </figure>

  The caption numbers like any other and shares the Figure bucket with images, so
  a numbered cross-reference to the quote's id resolves again.

  `BlockQuote::$attribution` is gone from the node, the AST codec, the ProseMirror
  bridge and the renderers, and `figure.target` admits a `block_quote`.
  `resources/ast-schema.json` is re-copied from the spec submodule, which moves
  onto the withdrawal.

  `HtmlToCarve` reads `<figure><blockquote>…<figcaption>` back as the figure, and
  a `<footer>` inside a quote is ordinary quoted content again.

  The Markdown, plain-text and terminal renderers lose the attribution-specific
  handling §10d added and render a quote's caption the way they render any other
  figure caption.

WHAT STAYS. PART 11 §10e landed in the same commits and is untouched: a table
caption still survives the Markdown target, and a code fence's title and grouping
label still survive plain text and the terminal.

`tests/fixtures/ast-schema.json` is the reflection-derived field map, NOT a copy
of the published schema, so it loses exactly one entry - `block_quote.fields`
drops `attribution` - and `AstCodecSchemaTest` keeps comparing it against
`AstCodec::schema()`. That golden is the rename detector for the wire format and
is not the vendored-copy check; conflating the two would have removed a guard
while appearing to update a fixture.
dereuromark added a commit to markup-carve/carve-rs that referenced this pull request Aug 15, 2026
…ain (#984)

markup-carve/carve#1213 withdraws PART 9 §4a and PART 11 §10d. A caption on a
block quote is not an attribution; it is a caption like any other, so a captioned
quote is a `figure` whose target is the quote - the shape this engine produced
before #946.

WHY THE CLAUSE FELL. The HTML Standard's `blockquote` section requires that
"attribution for the quotation, if any, must be placed outside the `blockquote`
element", and gives a `<figure>` wrapping the quote with a `<figcaption>` as the
way to attach it, "which is not part of the quote and therefore doesn't belong
inside the `blockquote` itself". §4a put it inside, in a `<footer>`, and asserted
that both spellings were valid. Its model argument fails too: `figure` is already
the generic captioned wrapper, since a captioned code block is a `figure` whose
target is the code block, and what a captioned thing is called and counted as
comes from the caption's own label rather than the host's node type.

Nothing had shipped - 0.1.2 predates the port - so this removes code no release
carried, and the CHANGELOG loses those entries rather than gaining a reversal.

WHAT COMES BACK:

  `> To be` + `^ Hamlet` renders

      <figure>
        <blockquote><p>To be</p></blockquote>
        <figcaption>Hamlet</figcaption>
      </figure>

  The caption numbers like any other and shares the Figure bucket with images, so
  a numbered cross-reference to the quote's id resolves again.

  `BlockQuote.attribution` is gone from the node, from the AST JSON codec and
  from every walk, extension and renderer that carried it, and
  `FigureTarget::BlockQuote` is back. `src/wire_fields.rs` is regenerated from
  the moved spec submodule rather than hand-edited.

  A `<footer>` inside a quote is ordinary quoted content again on the import
  path.

  The Markdown, plain-text and terminal renderers lose the attribution-specific
  handling §10d added and render a quote's caption the way they render any other
  figure caption.

WHAT STAYS. PART 11 §10e landed in the same commits and is untouched: a table
caption still survives the Markdown target, and a code fence's title and grouping
label still survive plain text and the terminal.

Measured against carve-js on the same branch: the HTML, Markdown, plain-text and
terminal output for a captioned quote is byte-identical in both engines, and a
numbered caption resolves to the same "Figure 1" cross-reference.
dereuromark added a commit to markup-carve/carve-grammars that referenced this pull request Aug 15, 2026
…ith it (#220)

markup-carve/carve#1213 withdraws PART 9 §4a. A caption on a block quote is not
an attribution: the engine emits a `figure` whose target is the quote again, and
`block_quote.attribution` no longer exists.

The HTML Standard's `blockquote` section requires that "attribution for the
quotation, if any, must be placed outside the `blockquote` element" and gives
`<figure>` + `<figcaption>` as the way to attach it, so the clause put the source
in the one place the standard names as wrong. Its model argument fails too:
`figure` is already the generic captioned wrapper, since a captioned code block
is a `figure` whose target is the code block.

So #218 is reverted. The projection it added - `attribution` onto a
`carveCaption` INSIDE the quote - has nothing to read, and the
`carveFigure`/`carveCaption` pair it replaced is correct again. The
`tiptap/schema-map.json` note describing where the field lands goes with it.

THE PROPERTY #218 PINNED SURVIVES ITS OWN REVERT, and that is why the test file
does not simply go away. `tests/blockquote-caption-test.js` replaces
`blockquote-attribution-test.js` with the same five documents and the same
method: mount a real editor, make an UNRELATED edit, and read what the pane would
write back. A load-only assertion cannot see this class of bug, because the
whole-document `carveSource` envelope still holds the source at load time and
only the first edit invalidates the fingerprint it is keyed to. What changed is
the shape the probe looks for - a `carveCaption` beside a `blockquote` inside a
`carveFigure`, rather than one inside the quote - and the four cases about a
caption living inside a quote, which this projection cannot produce, are gone
with the field.

The mounted ratchet moves 177 -> 173, and the four documents move back with it:
05-lists-20, 07-blockquote-with-attribution,
55-blockquote-caption-after-a-blank-line and
282-two-blank-lines-detach-a-caption-5 are render-equivalent through a mount
again. They stay in the protected list, since the failure mode is silent content
loss on the first edit rather than a visible fallback.

THE PIN IS ON AN UNMERGED BRANCH. `@markup-carve/carve` points at
carve-js `2bfa5cfe`, the head of markup-carve/carve-js#1070, because the revert
has to be pinnable before this can be measured at all. It must be re-pinned to
the merged sha before this merges.
dereuromark added a commit to markup-carve/carve-php that referenced this pull request Aug 15, 2026
The new case used a `block_quote` at `figure.target` as its example of a type the
schema refuses. That was true of the schema on the day it was written and is
about to stop being true: markup-carve/carve#1161 removed `block_quote` from the
admitted set and markup-carve/carve#1213 puts it back, so the assertion would
have flipped on a submodule bump and read as a regression in this diff.

A `heading` is not a captionable host under any version of the clause, so the
case pins the behavior under test - a typed union names the admitted set rather
than the first branch's required field - and nothing else. The assertion on the
set is a substring for the same reason.

The CHANGELOG entry drops its verbatim message quote, which carried the same
dependency and had a stray escape inside the code span.
dereuromark added a commit to markup-carve/carve-php that referenced this pull request Aug 15, 2026
The expectation named "code_block, image, paragraph, table" while the spec
this branch pins already admits block_quote too: markup-carve/carve#1213
withdrew the attribution model and put a captioned quote back to a figure,
so figure.target regained the type carve#1161 had removed.

The refused type stays a heading, which is the point of choosing it - a
heading is not a captionable host under any version of the clause, so only
the admitted list moves with the pin. The docblock now says that rather
than describing the churn as still pending.

The changelog example moves off a block quote for the same reason: it is
admissible again, so it no longer illustrates a node the union rejects.
dereuromark added a commit to markup-carve/carve-wysiwyg that referenced this pull request Aug 15, 2026
* feat: a composite figure is an editable figure in the editor

markup-carve/carve-grammars#225 gave the Tiptap layer a
`carveFigureGroup` node and moved its engine pin onto a carve-js build
that parses a bare `::: figure` into a `figure_group`. Both halves were
the blocker; this bumps the pin onto them and wires the editor up.

A group now loads as one editable node: its direct `carveFigure` and
table children are the panels in source order, the `^ ` line below the
closing fence is the group's own caption, and everything else in the body
stays where it was written. Before this, the whole document became one
opaque `carveUnsupported` atom whose source lived in the document
envelope - lossless to load and save, and gone on the first edit
anywhere.

`tests/composite-figure.test.ts` said in as many words that it would go
red the day carve-grammars gave the group a schema entry, and that this
was the signal to model it here. It went red, and it now asserts the rich
shape, the round trip through the app's own import and serialize path,
and an EDIT - the case the opaque atom could never survive. The titled
and labelled openers, which are a different production, keep their old
reading; that pair used to prove nothing because the engine could not
tell them apart, and the test now asserts the two READINGS DIFFER so it
fails rather than agrees with itself if the pin ever moves back.

THE BUMP ALSO SURFACED A SILENT REGRESSION, and it is not about figures.
`pruneDefaults` in src/editor.ts removed attributes the editor
materialized from schema defaults, so an unedited document could be
recognized and its source envelope honored - but it only removed NULL
ones. A default does not have to be null: carve-grammars#221 gave
`carveCaption` a `short` attribute defaulting to `false`, so every
document holding a caption came back carrying `{"short":false}`, never
compared equal, lost its envelope, and was written back from the lossy
rich model. The visible loss was `{#fig-x}` above a titled `::: figure`
disappearing on save. Nothing failed - the round trip just became lossy,
which is the failure the envelope exists to prevent.

The fix compares the mounted document with the one that was loaded, both
reduced to what the author wrote, and hands the serializer the ORIGINAL
document when they match. Reducing BOTH sides is the other half: the
bridge does set some attributes to a value that is also the schema
default (`carveComment` writes `block: false` for a `%%` line), so
pruning only the mounted side would break exactly the documents pruning
exists to keep. Handing over the original rather than a reconstruction of
it is what makes the envelope usable at all - its fingerprint was taken
over that exact JSON.

Also here:

- Preview and editor styles for the group. The preview pane holds the
  engine's HTML (`figure.carve-figure-group` around
  `div.carve-figure-panels`) and the editor holds CarveKit's node, which
  has no panels row, so both shapes are styled to read as one figure.
- `tests/blockquote-attribution.test.ts` is `blockquote-caption.test.ts`.
  Its assertions were already shape-independent and passed unchanged; its
  docblock claimed the engine carries a quote's caption as an
  `attribution` field, which PART 9 §4a said and §4b withdrew
  (markup-carve/carve#1213). A captioned quote is a `figure` whose target
  is the quote, and the loader builds the `carveFigure`/`carveCaption`
  pair for it.
- Two README limitations retired. The composite-figure one is this
  change. The `{:TAG}`-in-the-source-pane one was already false before
  it - carve-wysiwyg#14 moved the pin onto a build that parses it, and
  `tests/language-attribute.test.ts` has asserted it since - so the
  README was contradicting a passing test.

* test: the envelope check has to reduce BOTH documents, not just the mounted one

Reducing only the mounted document was a mutation the suite did not
notice. It is a real failure: the bridge sets some attributes to a value
that is also the schema default - `carveComment` declares `block` with a
default of `false` and writes `block: false` for every `%%` line - so a
one-sided reduction makes those documents stop matching their own loaded
form and discards the envelope they depend on.

The pair is what pins it. A `{#fig-x}` above a `::: note` passes either
way; the same document with a `%%` line inside loses the attribute line
under a one-sided reduction. A third case edits the document and asserts
the output CHANGED, so a serializer that simply returned the loaded
source could not pass the first two.
dereuromark added a commit to markup-carve/carve-php that referenced this pull request Aug 16, 2026
The new case used a `block_quote` at `figure.target` as its example of a type the
schema refuses. That was true of the schema on the day it was written and is
about to stop being true: markup-carve/carve#1161 removed `block_quote` from the
admitted set and markup-carve/carve#1213 puts it back, so the assertion would
have flipped on a submodule bump and read as a regression in this diff.

A `heading` is not a captionable host under any version of the clause, so the
case pins the behavior under test - a typed union names the admitted set rather
than the first branch's required field - and nothing else. The assertion on the
set is a substring for the same reason.

The CHANGELOG entry drops its verbatim message quote, which carried the same
dependency and had a stray escape inside the code span.
dereuromark added a commit to markup-carve/carve-php that referenced this pull request Aug 16, 2026
The expectation named "code_block, image, paragraph, table" while the spec
this branch pins already admits block_quote too: markup-carve/carve#1213
withdrew the attribution model and put a captioned quote back to a figure,
so figure.target regained the type carve#1161 had removed.

The refused type stays a heading, which is the point of choosing it - a
heading is not a captionable host under any version of the clause, so only
the admitted list moves with the pin. The docblock now says that rather
than describing the churn as still pending.

The changelog example moves off a block quote for the same reason: it is
admissible again, so it no longer illustrates a node the union rejects.
dereuromark added a commit to markup-carve/carve-php that referenced this pull request Aug 16, 2026
* fix: an ingest refusal at a typed union names the admitted types

`figure.target` admits an image, a table, a code block or a paragraph. Feed it a
`block_quote` - the type PART 9 §4a removed when a caption on a quote became its
attribution - and this engine refused the payload, correctly, and then described
the wrong problem:

    $.children[0].target is missing `src`, which the schema requires

`src` is the required property of the IMAGE branch, which is simply the first
alternative in the `oneOf`. A producer reading that message would add `src` to a
block quote. carve-js, from the same schema, says the useful thing:

    a "block_quote" node sits where the schema admits only code_block, image,
    paragraph, table

THE CAUSE IS ONE LINE. `checkComposition` returns `$first` - the failure of the
first branch that failed - whenever no branch of an `anyOf` / `oneOf` matched.
For a union of typed node definitions that is nearly always the wrong story,
because the branches differ by TYPE and the first one's missing field is an
artifact of branch order rather than a fact about the payload.

So a union of typed node definitions now reports the type mismatch:

    $.children[0].target holds a "block_quote" node where the schema admits only
    code_block, image, paragraph, table

BOTH CONDITIONS ARE REQUIRED before the message is built - the value identifies
itself as a node, and every branch pins a `type` constant - and anything else
keeps `$first` exactly as before. That is what stops the change from swallowing
a useful message: an `image` at `figure.target` with no `src` is a type the
position DOES admit, so the missing field is the real problem and is still what
gets reported. A target that is not even an object keeps its missing-`type`
report.

Which payloads are accepted and refused does not change; only what the refusal
says. The four tests pin the four paths: a refused type, an admitted type
missing a field, a value with no type at all, and a legitimate image target.

The reciprocal engine gap is markup-carve/carve-rs#982 - carve-rs accepted that
same payload rather than refusing it - and markup-carve/carve#1211 adds the
schema-side test for the admitted set.

* test: pick a figure target no version of the clause admits

The new case used a `block_quote` at `figure.target` as its example of a type the
schema refuses. That was true of the schema on the day it was written and is
about to stop being true: markup-carve/carve#1161 removed `block_quote` from the
admitted set and markup-carve/carve#1213 puts it back, so the assertion would
have flipped on a submodule bump and read as a regression in this diff.

A `heading` is not a captionable host under any version of the clause, so the
case pins the behavior under test - a typed union names the admitted set rather
than the first branch's required field - and nothing else. The assertion on the
set is a substring for the same reason.

The CHANGELOG entry drops its verbatim message quote, which carried the same
dependency and had a stray escape inside the code span.

* docs: say why three narrowing returns are not covered

codecov flags three `return null` lines in the new typed-union helper. They are
type narrowing rather than guards: both unions the published schema writes today
are typed node unions, so the branch shapes always resolve, and the checks exist
because decoded JSON reaches PHP as `mixed` and the function has to be total for
a union some later schema writes differently.

Saying so in the file is the honest resolution. Removing them to satisfy the
patch threshold would trade a coverage number for a crash on the first union that
is not a typed one, and PHPStan requires the narrowing regardless.

* test: expect the admitted set the pinned spec actually publishes

The expectation named "code_block, image, paragraph, table" while the spec
this branch pins already admits block_quote too: markup-carve/carve#1213
withdrew the attribution model and put a captioned quote back to a figure,
so figure.target regained the type carve#1161 had removed.

The refused type stays a heading, which is the point of choosing it - a
heading is not a captionable host under any version of the clause, so only
the admitted list moves with the pin. The docblock now says that rather
than describing the churn as still pending.

The changelog example moves off a block quote for the same reason: it is
admissible again, so it no longer illustrates a node the union rejects.
dereuromark added a commit to markup-carve/pandoc-carve that referenced this pull request Aug 16, 2026
…77)

PART 9 section 4b's withdrawal of the quote attribution (markup-carve/carve#1213)
is already implemented here: the engine pin names a carve-js commit that
postdates the withdrawal, `convert.ts` maps a `figure` whose target is a
`block_quote` to a pandoc `Figure` around the `BlockQuote` with the caption,
and `reverse.ts` reads that shape back. What was missing is the part that
says so when it stops being true.

THE PIN IS CHECKED AGAINST THE INSTALLED TREE, NOT THE MANIFEST. Three files
name the engine - the manifest, the lockfile, and
`node_modules/.package-lock.json`, which records what npm actually wrote to
disk. Nothing read the third, so a checkout whose `node_modules` predates a
pin bump ran every engine-facing test against a different engine and reported
the same green. Measured on a long-lived checkout of this repo, which held:

```
package-lock.json                git+...carve-js.git#3f5dd8cb
node_modules/.package-lock.json  registry.npmjs.org/...carve-0.1.3.tgz
```

0.1.3 is the last released tag and predates the withdrawal, so that tree still
carried the `block_quote` `attribution` field. The new check in
`test/participants.test.mjs` compares the commit named by all three and fails
with "run npm ci" when they disagree. Mutated by bumping the manifest and
leaving the lockfile behind: exactly one test goes red, and it is this one.
Nothing else in the suite can see that drift.

THE ENGINE'S OWN SERIALIZED AST IS ASSERTED, not only the converted pandoc
tree. Every other assertion about a captioned quote reads what `convert.ts`
produced, which cannot tell a correct engine from a stale one the converter
compensated for - the failure class markup-carve/carve#755 collects. The new
check in `test/ast-json.test.mjs` reads `carveToCarveAst` directly: a `figure`
whose target is a `block_quote`, carrying the caption, with no `attribution`
anywhere, and an uncaptioned quote as the control that keeps the caption
load-bearing. Mutated by installing a pre-withdrawal engine: 20 tests red,
this one among them.

THE DRIFT JOB WENT RED THE FIRST MORNING AFTER THE PIN LANDED, and said the
opposite of the truth. It resolved the dependency spec with `npm view`, which
cannot read a git spec at all - it answers "GitFetcher requires an Arborist
constructor to pack a tarball" - so an empty resolution reached the branch
that reports "resolves to nothing on the registry, an install here is broken"
while `npm ci` was installing the pin perfectly well. It did not even get that
far: under `set -e -o pipefail` the failing `npm view` killed the step before
its own error message ran, so the job failed with an empty log.

The spec's shape now decides which comparison is meaningful. A range is
compared against the registry as before; a commit is compared against carve-js
`main` directly, which is the same "how far behind the language is this repo"
question with the registry taken out of the middle. The one failure the git
path owns is a commit carve-js does not have, which is a dependency nobody can
install and the real version of the error the range path was reporting
falsely. Both `npm view` reads tolerate a non-zero exit so the explaining
message is what the log shows.

Every path was run locally against the extracted step scripts: the current git
pin (warns, exit 0), a semver range (resolves, exit 0), a git spec naming a
branch rather than a commit (errors), an unresolvable range (errors, with its
message), a pinned commit present upstream (reports 8 commits behind), and a
pinned commit that is not (errors).

Two comments left over from the section 4a era are corrected. The one in
`convert.ts` was not merely stale but false about the code directly beneath
it: it told a reader that outside a group a captioned quote never reaches the
generic figure arm, which is the arm it has taken since the reroute came out.
The one in `reverse.ts` pointed at a "section 4a quote branch above" that no
longer exists, and the table collapse it was comparing itself to is now the
only collapse of its kind.

No CHANGELOG entry: nothing a consumer installs behaves differently. The
behavior these checks pin was described when it landed.
dereuromark added a commit to markup-carve/carve-js that referenced this pull request Aug 18, 2026
…there (#1196)

Five comments cite `PART 10 §10a` for the rule that an unused definition
survives the non-HTML targets. The clause is real; the part is not - it is
PART 11 §10a, and the spec repointed the same mistype on its own side.

One more cites `§10d`, which markup-carve/carve#1213 withdrew along with the
attribution it carried. Its number is retired rather than reused, so PART 11 runs
10c, 10e and the citation can never resolve again. The sentence is about what
adjacency may and may not do on the Markdown target, which stands on its own -
so it says that, rather than pointing at a clause a reader cannot find.

Comments only; no behavior changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant