Skip to content

chore(api)!: lock the shipped public API surface; graduate VellumPdf.Conformance to Stable - #173

Merged
Tim81 merged 1 commit into
mainfrom
api-surface-2.0
Aug 17, 2026
Merged

chore(api)!: lock the shipped public API surface; graduate VellumPdf.Conformance to Stable#173
Tim81 merged 1 commit into
mainfrom
api-surface-2.0

Conversation

@Tim81

@Tim81 Tim81 commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Summary

Two halves of one inconsistency, both cheapest to fix at a major version.

The analyzer gate was mostly not in place

README.md has claimed "The public API is locked (analyzer-enforced)" since 1.0, and
docs/barcodes-roadmap.md records the convention behind it — a Preview package keeps its
surface in PublicAPI.Unshipped.txt because Preview means the surface can still move, and
on graduation the whole surface moves to Shipped.txt, "subject to the same analyzer gate
as every other Stable package"
.

For most packages it wasn't:

Package Shipped.txt before Unrecorded entries
VellumPdf.Signing 0 bytes — not even a header 67, shipped since 1.5
VellumPdf.Conformance header only 50
VellumPdf.Fonts.Standard14 header only 2
VellumPdf.Kernel untouched since 1.2.0 65
VellumPdf.Layout 48

PdfSignatureSettings, PadesLevel, ITimestampClient, IRevocationClient — an API
shipped since 1.5 — all sat where RS0017 does not fire on removal. For those packages a
breaking removal compiled without complaint.

232 entries move to Shipped.txt. Every Unshipped.txt resets to its header, so a
2.x addition shows as a diff against an accurate baseline rather than joining a backlog of
entries that shipped releases ago.

VellumPdf.Barcodes and VellumPdf.Cli needed nothing — Barcodes graduated properly in
1.10.0, and the CLI exposes no public surface.

VellumPdf.Reader is deliberately untouched, all 16 entries still Unshipped. It stays
Preview through the v2.1 structural-reader work, and the convention is that the surface
moves at graduation, not before.

VellumPdf.Conformance graduates Preview → Stable

VellumPdf.Cli was already Stable while the engine it wraps was Preview — the
opposite of what shipping a stable CLI on top of it implies. veraPDF parity is about 99%,
both paths of every rule are cross-validated against it in CI, and the remaining gaps are
tracked as issues. That is better represented as known issues on a stable package than as
a preview label on the whole engine.

Status tables in all nine README files (root + eight packages) are updated in
lockstep, per the repo's sync obligation. The root README's "marked Preview" note now
names only Reader, and the Conformance README's Preview callout is replaced with a Stable
one that keeps the coverage caveat rather than dropping it.

One trap worth recording

Kernel's Unshipped.txt carried a UTF-8 BOM. Left in place it reads as part of the
first entry and moves into Shipped.txt as a symbol that does not exist — the first
attempt failed with:

error RS0017: Symbol '#nullable enable' is part of the declared API,
but is either not public or could not be found

Stripped, and the reset file is written without it.

Test plan

  • dotnet build VellumPdf.slnx -c Debug — 0 warnings. This is the real check:
    RS0016/RS0017/RS0025 fire if an entry is misplaced, duplicated, or names a
    symbol that does not exist, so a clean build means all 232 entries correspond to
    real public API and none is double-listed.
  • dotnet format VellumPdf.slnx --verify-no-changes — clean
  • dotnet test VellumPdf.slnx -c Debug — 3,623 passed, 0 failed, 274 skipped
    (the veraPDF-oracle tests, gated on the external tool). Unchanged from this
    branch's baseline, as expected: no test changes here.
  • Confirmed no test asserts README content, so the docs edits carry no test risk
  • Full CI matrix

Effect

Breaking in the sense that matters for a major version: removing any of these 232 members
is now an analyzer error rather than a silent break. Nothing about the emitted PDFs or the
runtime behaviour changes.

…Conformance to Stable

The README has claimed "the public API is locked (analyzer-enforced)" since 1.0, and
docs/barcodes-roadmap.md records the convention behind it: a Preview package keeps its
surface in PublicAPI.Unshipped.txt because Preview means the surface can still move, and
on graduation to Stable the whole surface moves to PublicAPI.Shipped.txt, "subject to the
same analyzer gate as every other Stable package".

That gate was mostly not in place. VellumPdf.Signing's PublicAPI.Shipped.txt was a
zero-byte file, so all 67 entries of an API shipped since 1.5 — PdfSignatureSettings,
PadesLevel, ITimestampClient, IRevocationClient — sat where the analyzer permits silent
removal. Conformance and Fonts.Standard14 held nothing but their header. Kernel's
Shipped.txt had not been touched since 1.2.0, leaving 65 entries unrecorded, and Layout
48. For those packages a breaking removal would have compiled without complaint.

232 entries move to Shipped across Kernel (65), Signing (67), Conformance (50),
Layout (48) and Fonts.Standard14 (2). Barcodes and Cli needed nothing: Barcodes
graduated properly in 1.10.0, and the CLI exposes no public surface. Every Unshipped
file is reset to its header, so a 2.x addition shows up as a diff against an accurate
baseline instead of joining a backlog of entries that shipped releases ago.

VellumPdf.Reader is deliberately left alone, all 16 entries still Unshipped. It stays
Preview through the v2.1 structural-reader work, and the convention is that the surface
moves at graduation, not before.

VellumPdf.Conformance graduates Preview to Stable. VellumPdf.Cli was already marked
Stable while the engine it wraps was Preview, which said the opposite of what shipping a
stable CLI on top of it implies. Parity with veraPDF is about 99%, both paths of every
rule are cross-validated against it in CI, and the remaining gaps are tracked as issues
— better represented as known issues on a stable package than as a preview label on the
whole engine. The status tables in all nine README files are updated in lockstep, and
the "marked Preview" note now names only Reader.

Kernel's Unshipped file carried a UTF-8 BOM. Stripped, because otherwise it reads as
part of the first entry and moves into Shipped as a symbol that does not exist (RS0017).

Breaking in the sense that matters for a major version: removing any of these 232
members is now an analyzer error rather than a silent break.
@Tim81 Tim81 added this to the v2.0 — Breaking changes milestone Aug 17, 2026
@Tim81
Tim81 merged commit 258b9b4 into main Aug 17, 2026
3 checks passed
Tim81 added a commit that referenced this pull request Aug 17, 2026
…ee to fix (#176)

All three were found by the adversarial review of #173 and share a deadline: locking the
surface in that PR made every one of them permanent for 2.x, and v2.0 is not yet tagged.

PdfPreflight.Validate(PdfDocumentReader, PdfConformance) becomes internal. #173 did two
incompatible things at once: it recorded this overload in VellumPdf.Conformance's
PublicAPI.Shipped.txt, making it RS0017-locked for a now-Stable package, while
deliberately leaving VellumPdf.Reader's surface unlocked so PdfDocumentReader can move
during the v2.1 structural-reader work. It was the only Reader type in any Shipped file,
so the first rename in Reader would have been both a build break here and a source and
binary break for callers. There are no external callers — the CLI uses the byte[] overload
and the two public overloads delegate to this one internally — so the only capability
withheld is reusing an already-open reader across validations, worth revisiting when
Reader graduates. The XML cref on the public PdfConformance enum is repointed at a public
overload rather than pointing consumers at an internal member.

PdfLinkAnnotation.Flags becomes PdfAnnotationFlags instead of int. ISO 32000-1 Table 165
is a bitfield and no enum for it existed, so callers had to write Flags = 4 to satisfy the
ISO 19005-2 §6.3.2 requirement the property's own documentation describes. Emitted bytes
are unchanged, and that is asserted rather than assumed: the default is
PdfAnnotationFlags.Print, /F 4 is still written, and a test pins both the numeric value of
Print and a non-default combination emitting /F 68, because the enum is only conformant if
Print really is bit 3.

PdfSignatureSettings.SubFilter is validated. It was copied verbatim into the signature
dictionary's /SubFilter, so any string produced a signature claiming a format its CMS
content does not match — discovered in a verifier rather than at assignment. It stays a
string rather than becoming an enum so a future sub-filter is not a breaking change, but
only the two values this library actually produces a conforming signature for are
accepted, exposed as SubFilterEtsiCAdESDetached and SubFilterAdbePkcs7Detached so callers
need not hardcode them. Rejection is case-sensitive, matching /SubFilter itself.

Not taken: shipping VellumPdf.Reader as a prerelease version. It remains labelled Preview
with an unlocked API while shipping a plain 2.0.0, so NuGet presents it as stable beside
seven genuinely stable siblings. That is a real signalling problem and is left as a
separate decision rather than folded in here.
Tim81 added a commit that referenced this pull request Aug 17, 2026
… the tag (#177)

* chore(api)!: fix three public-surface defects while they are still free to fix

All three were found by the adversarial review of #173 and share a deadline: locking the
surface in that PR made every one of them permanent for 2.x, and v2.0 is not yet tagged.

PdfPreflight.Validate(PdfDocumentReader, PdfConformance) becomes internal. #173 did two
incompatible things at once: it recorded this overload in VellumPdf.Conformance's
PublicAPI.Shipped.txt, making it RS0017-locked for a now-Stable package, while
deliberately leaving VellumPdf.Reader's surface unlocked so PdfDocumentReader can move
during the v2.1 structural-reader work. It was the only Reader type in any Shipped file,
so the first rename in Reader would have been both a build break here and a source and
binary break for callers. There are no external callers — the CLI uses the byte[] overload
and the two public overloads delegate to this one internally — so the only capability
withheld is reusing an already-open reader across validations, worth revisiting when
Reader graduates. The XML cref on the public PdfConformance enum is repointed at a public
overload rather than pointing consumers at an internal member.

PdfLinkAnnotation.Flags becomes PdfAnnotationFlags instead of int. ISO 32000-1 Table 165
is a bitfield and no enum for it existed, so callers had to write Flags = 4 to satisfy the
ISO 19005-2 §6.3.2 requirement the property's own documentation describes. Emitted bytes
are unchanged, and that is asserted rather than assumed: the default is
PdfAnnotationFlags.Print, /F 4 is still written, and a test pins both the numeric value of
Print and a non-default combination emitting /F 68, because the enum is only conformant if
Print really is bit 3.

PdfSignatureSettings.SubFilter is validated. It was copied verbatim into the signature
dictionary's /SubFilter, so any string produced a signature claiming a format its CMS
content does not match — discovered in a verifier rather than at assignment. It stays a
string rather than becoming an enum so a future sub-filter is not a breaking change, but
only the two values this library actually produces a conforming signature for are
accepted, exposed as SubFilterEtsiCAdESDetached and SubFilterAdbePkcs7Detached so callers
need not hardcode them. Rejection is case-sensitive, matching /SubFilter itself.

Not taken: shipping VellumPdf.Reader as a prerelease version. It remains labelled Preview
with an unlocked API while shipping a plain 2.0.0, so NuGet presents it as stable beside
seven genuinely stable siblings. That is a real signalling problem and is left as a
separate decision rather than folded in here.

* chore(api)!: settle the four API-debt items the review raised, before the tag

Each of these was reported as "should have been decided before locking the surface". None was
a defect on its own; all four are cheap now and impossible after v2.0 is tagged.

The synchronous network-I/O surface stays, and the hazard it was flagged for is fixed instead.
detect_antipatterns flagged AP002 on HttpTimestampClient and HttpRevocationClient — blocking on
an async call, which deadlocks on a synchronization context and starves the thread pool under
load — and the question raised was whether v2.0, the release that added the async surface,
should be the one to retire the blocking path. It should not: IRevocationClient.GetRevocationData
and ITimestampClient.GetTimestampToken are the *required* interface members while their async
counterparts are default-implemented, so removing them breaks every existing implementation, and
the synchronous Sign overloads depend on them. What was actually wrong was the implementation,
not the contract. Both already issued the request through HttpClient.Send; only the response body
was read by blocking on ReadAsByteArrayAsync, and that is now HttpContent.ReadAsStream, which is
genuinely synchronous. No GetAwaiter().GetResult() remains anywhere in src/. The decision and its
reasoning are recorded on both interfaces rather than left implicit.

HttpRevocationClient's constructors match HttpTimestampClient's shape. Both parameters are
optional, so the timeout can be set without supplying an HttpClient — previously impossible,
because the two-argument form demanded one and the shared instance is private.

TextEncodingWarning carries a Rune instead of a char. An astral character was reported twice, as
its two UTF-16 surrogate halves, with a CodePoint in 0xD800-0xDFFF — a value no Unicode character
has. It is now reported once, as itself, and an unpaired surrogate is reported as U+FFFD, which is
what it is. Emitted bytes are deliberately unchanged: an astral character still writes two '?'
bytes, because collapsing it to one would alter the width a caller already measured for the string
and shift laid-out text. Only the reporting changed, and a test pins both halves of that.

CcittImageLoader takes a CcittOptions record instead of four positional knobs. Optional parameters
are the hardest part of an API to evolve once locked, and the concrete defect was that the sibling
overload taking ImageLoadOptions could not carry the CCITT knobs at all — so decode-to-raster on a
Group 3 1-D stream with byte-aligned rows was unreachable through the public API. The two overloads
collapse into one that can express every combination, and a test covers the combination that was
previously impossible.

One contract change worth stating: a null options argument now means "use the default" rather than
throwing. With `= null` defaults there is no way to distinguish an omitted argument from an
explicitly null one, so the old ArgumentNullException would fire on exactly the call the default
exists to serve. CcittLoad_DecodeToRaster_NullOptions_ThrowsArgumentNullException is inverted
accordingly, and the same convention now holds for the HttpRevocationClient constructor.

Verified with nothing skipped: Kernel 908, Layout 217, Reader 184, Cli 344, Barcodes 1,051, and
Conformance 1,236 with veraPDF available.
@Tim81
Tim81 deleted the api-surface-2.0 branch August 28, 2026 14:40
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