Skip to content

Publish the block-content position vectors and fixtures, and harden the position gate - #128

Merged
gvonnessi merged 5 commits into
mainfrom
conformance/121-position-keyed-canon
Aug 29, 2026
Merged

gvonnessi merged 5 commits into
mainfrom
conformance/121-position-keyed-canon

Conversation

@gvonnessi

Copy link
Copy Markdown
Collaborator

Closes the first half of #121. §4.3.1 item 4 (merge adjacent text nodes) and §4.3.2 item 2 (concatenated-NFC) were implemented on element shape — "is this object {type:"text",value:string}?" — so any array whose members merely resembled text nodes was merged and run-checked, and canonicalization REJECTED conformant documents. Both rules are now keyed on position: the schema-declared block-content arrays, with attributes as a sticky barrier.

The scoping itself landed in the two commits already on this branch. This PR adds what makes it checkable by someone outside this repository, and hardens the gate that guards it.

What is here

Six vectors, six fixtures. A fixture pins what a READER reports; a vector pins what a CANONICALIZER produces, and the behaviour that changed is the canonicalizer's — without them an implementation that still merges inside attributes passes every published vector while computing a different document ID. Each canonical form was hand-derived from the §4.3 rules, its bytes and digest generated by the independent Python oracle, then reproduced by the TypeScript canonicalizer through the adapter protocol.

hints and preamble are covered because they are live positions. Both declare items as $defs/block, and a block dispatches type:"text" to $defs/textNode in its first allOf branch — so a text node is admissible there exactly as in the root blocks array. A schema-valid academic:exercise with a split combining sequence in hints is rejected today. Before this PR, dropping hints, preamble or content from both transcriptions at once left every gate green except the schema-derivation gate; the first two now have four guards each.

The gate could under-derive silently. deriveKeys accepted a property only when its own type was array, so a position behind a $ref to an array-typed $def, or on an inline nested object inside items, was invisible — and a key it never saw is a key both transcriptions agree about, so it reported agreement and exited 0. It now recurses and resolves local $refs. CONTENT_BARRIER_KEY was compared against nothing and now is.

The attributes witnesses nest one level below semantic, so the barrier flag must survive an array-to-item hop. Ten flag-propagation mutants across the six walks survived all 30 gates against the shallower form.

What is deliberately not here

The scope is a narrowing whose licence is not yet in spec/ — filed as #126. §4.3 applies the transforms across all block types "uniformly" and §4.3.2 binds to "a block" with no field list, where item 5 names lines/subfigures as exhaustive when it means to scope by position. Measured: a vendor:callout carrying body:[text,text] with a split combining sequence REJECTed before and loads clean now. Recorded in CHANGELOG.md so it is not silent.

Gating id-namespace membership on the barrier was implemented and reverted — filed as #125. It removes ids from the namespace but rewriteIds still relabels them, so two documents differing only in an id under attributes canonicalize to identical bytes (sha256:42893c8a…) — a silent document-ID collision in place of a loud false REJECT. The barrier has to reach definedId, rewriteIds and the reference resolver together or not at all.

Gate residue — filed as #127: refsOf does not resolve local $refs, if/then is unwalked, the floor is cardinality rather than names, and the gate's own schema-vs-pairwise property is unpinned.

content keeps a single guard on purpose: it is the position where the merge and the run rule diverge, and pinning today's behaviour would publish an expectation the follow-on changes.

Review

Two /review-fix cycles, the branch's cap.

Cycle 1 — 6 lenses launched, 5 reported (the tests lens went idle twice without answering; recorded as 5, not rounded up). 14 verified findings. Six were false factual claims in prose I had written, all green on 30 gates: the hints/preamble justification, a "MUST agree" claim in a published vector that is false at content, "either implementation … every gate green" for caption (a pre-existing test catches the TypeScript half), and a walk-attribution error.

Cycle 2 — 3 bounded lenses against a closed list. Found that a Cycle 1 fix of mine was a regression: the id-collision above. Reverted.

Every added case is the named killer of a mutant nothing else catches, measured against the two implementations independently.

Verification

30/30 gates green, plus the three CI steps that are not npm scripts (npm ci, npm audit --audit-level=high → 0 vulnerabilities, generate:template into a fresh empty dir). check:conformance: 378/378 (215 vectors + 163 fixtures), 0 failed, 0 advisory, 0 skipped. suiteVersion 0.2.3 → 0.2.4.

Six walks scoped across both implementations, plus a derivation gate.
Checkpoint commit so the work survives; not yet reviewed.
Fixtures pin what a READER reports; vectors pin what a CANONICALIZER
produces, and the behaviour this branch changes is the canonicalizer's.
Without these a third-party implementation that still merges inside
`attributes` passes every published vector while computing a different
document ID.

Six vectors, six fixtures. Each canonical form was hand-derived from the
§4.3 rules, its JCS bytes and digest generated by the independent Python
oracle, then reproduced by the TypeScript canonicalizer through the
adapter protocol.

`caption` takes both a merge vector and a reject vector: a merge vector
alone cannot catch an implementation scoping the run rule more narrowly
than the merge, since both scopes yield the same bytes for a document
that merges. The reverse mutant is caught by the merge vector alone.

`hints` and `preamble` are covered because they are LIVE positions, not
inert ones. Both declare `items` as `$defs/block`, and a `block`
dispatches `type:"text"` to `$defs/textNode` in its first `allOf` branch
— so a text node is admissible there exactly as in the root `blocks`
array, and a schema-valid `academic:exercise` with a split combining
sequence in `hints` is rejected today. Before this commit, dropping
`hints`, `preamble` or `content` from BOTH transcriptions at once left
every gate green except the schema-derivation gate; `hints` and
`preamble` now have four guards each.

`content` is deliberately left with that one guard. It is the position
where the merge and the run rule diverge — canon does not recurse into a
text node's `marks` — so pinning today's behaviour would publish an
expectation the follow-on is scheduled to change. The divergence is
stated in the vector text rather than left for an implementer to infer.

The `attributes` witnesses nest their array one level below `semantic`,
so the barrier flag must survive an array-to-item hop. A witness sitting
directly on the annotation leaves that propagation untested: ten
flag-propagation mutants across the six walks survived all 30 gates
against the shallower form.

All twelve content parts were schema-validated against
content.schema.json with negative controls, which no gate does. That is
why an earlier draft was wrong: it authored the `attributes` control
through `attributes.hist`, which `blockAttributes` forbids
(additionalProperties: false), making the artifact that pins
"canonicalization rejected a conformant document" itself non-conformant.

suiteVersion 0.2.3 -> 0.2.4 (patch position: added cases, pre-1.0).
209 -> 215 vectors, 157 -> 163 fixtures, 366 -> 378 checks.
The derivation gate was the sole guard for three of the six positions,
and it could under-derive silently: `deriveKeys` accepted a property only
when its own `type` was `array`, so a position declared behind a `$ref`
to an array-typed `$def`, or on an inline nested object inside `items`
(the shape `academic:abstractBlock.sections[].children` already uses),
was invisible. A key the derivation never saw is a key both
transcriptions agree about, so the gate reported agreement and exited 0.
The derivation now recurses and resolves local `$ref`s; both shapes fail
it, named.

`CONTENT_BARRIER_KEY` was compared against nothing — the two
implementations could barrier different fields, or a field no block
carries, and every check still passed. It is now compared across the two
and against `blockBase`.

Two rows added to the raw-walk differential, and an agreement assertion
for an id carried under `attributes`. The assertion is about agreement,
not correctness: whether such an id belongs in the shared namespace is
open. What must not happen is the raw walk and canon disagreeing, which
is an inverted verdict rather than a missed one — a duplicate canon
rejects while the walk feeding the verdict reports nothing. Two mutants
that previously survived all 30 gates now fail it. The Python oracle's
counterpart walk stays uncovered; it needs a witness that only exists in
the disputed territory above.

Comment corrections: the run rule and the merge share a key test but not
a set of reachable positions, since canon skips a text node's `marks` and
`content` occurs only there. Three comments claimed they mirror exactly.
CDX-E-PART-STRING-NOT-NFC described the run arm as scoped to hashed,
non-deleted material. It has a third condition — position — and an
implementer building from the registry alone over-reports and fails the
new clean fixture. Note rewritten; errors.json stays at v0.14 / 86 codes,
which its own compatibilityPolicy permits for a note.

The changelog states two things it would be easy to leave implicit. The
scope is a NARROWING whose licence is not yet in spec/: §4.3 applies the
transforms across all block types "uniformly" and §4.3.2 binds the rule
to "a block" with no field list, where item 5 names lines/subfigures as
exhaustive when it means to scope by position. An extension block whose
text array is not one of the six keys is no longer enforced.

It also records what was implemented and reverted. Gating id-namespace
membership on the barrier removes ids from the namespace but leaves
`rewriteIds` relabelling them, so two documents differing only in an id
under `attributes` canonicalize to identical bytes — a silent document-ID
collision in place of a loud false REJECT, which is the worse trade in a
content-addressed format. The barrier has to reach `definedId`,
`rewriteIds` and the reference resolver together or not at all.
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