spec: withdraw the quote attribution, a captioned quote is a figure again - #1213
Merged
Conversation
…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.
This was referenced Aug 15, 2026
dereuromark
marked this pull request as ready for review
August 15, 2026 00:43
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.
This was referenced Aug 15, 2026
fix: withdraw the quote attribution, a captioned quote is a figure again
markup-carve/carve-php#1264
Merged
Merged
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.
This was referenced Aug 15, 2026
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.
This was referenced Aug 17, 2026
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.
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.
What
PART 9 section 4a (#1161) and PART 11 section 10d are withdrawn. A captioned block quote is a
figurewhose 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
blockquotesection: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
blockquoteitself)":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.
"
figurewas doing two jobs." It does one. A captioned code block is already afigurewhose target is the code block: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 Shakespearetook 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
figurenode 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.mdloses 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 sawblock_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.txtand inSCHEMA_ROLLOUT_PENDING, in the opposite direction from #1161.npm run engine:report -- --checkreports 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-attributionare regenerated from the pre-4a engine rather than typed by hand.Follow-ups this creates
attributionontocarveCaptionin the tiptap loader, and carve-wysiwyg#14 pins it; both need the figure projection back