Skip to content

Publish the output-option rules with conformance vectors - #6

Merged
pingviini merged 8 commits into
mainfrom
feat/output-rules
Sep 15, 2026
Merged

pingviini merged 8 commits into
mainfrom
feat/output-rules

Conversation

@pingviini

@pingviini pingviini commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #5.

PdfOutput.validate_pdf_options and _ImageOutputBase.validate_image_options were the only
statement of the output rules, so a client-side implementation was a hand transcription of Python
with nothing to compare it against — including two lookup tables a mirror cannot derive.

What this publishes

A deployment-independent output-rules document, nested in GET /v1/constraints as output_rules
and exported to docs/api/output-rules.json, following the pattern the files-key rules and the
error codes already use. It carries the vocabulary, the field-level bounds, the standard-to-version
map, the tagging set, the PDF/A-4 family, the pages grammar, twelve named rules and 34
conformance vectors.

app/core/output_rules.py restates what app/models.py enforces and holds no rule of its own —
the contract app/core/constraints.py has with templates.py.

The decisions behind it

Rule ids, surfaced in context.rule. Every output-rule violation answers invalid_request at
the same loc with type: value_error, so only the prose distinguished them. Each rule now has a
stable id. The error type stays value_error deliberately: errors[].type is published, so the id
arrives as a new context key rather than as a new type, keeping the change additive.

detail, code and rule are read from one error, never from whichever one happens to carry an
id — a body that omitted source and broke an output rule otherwise answered "source: Field
required" beside a rule about the page selection.

Every block says which formats it governs. pdf.formats and image.formats are published
alongside the top-level formats. Without them a mirror decides the image rules by exclusion —
"not pdf, therefore an image" — the same defect pdf_a_4_standards exists to avoid one level
down, and one that would misapply pages_requires_archive to any format added later. Both lists
are derived from the model hierarchy, and a test requires them to cover formats without
overlapping, so a fourth OutputFormat fails here rather than in a caller's mirror.

Its own version counter. output_rules_version is separate from rules_version: the two
contracts change at different rates, and one counter would send a caller back through the key rules
because a PDF standard was added. Nesting keeps one fetch answering both.

The pages grammar is in scope. pages is an output option, so a mirror needs its grammar
whichever format it supports; excluding it would have left a second unpublished grammar.

One observable change to context.errors

Carrying the rule id through Pydantic means the twelve validators raise PydanticCustomError
instead of ValueError, which drops Pydantic's "Value error, " prefix from errors[].msg:
{"format":"png","pages":"1-2"} now reports pages requires archive 'zip' where it reported
Value error, pages requires archive 'zip'. errors[].type is unchanged, and msg was already
documented as prose; docs/api/errors.md now says outright that its wording, leading words
included, may change, and to branch on type or context.rule instead.

What proves it

tests/test_output_rules.py runs every vector against the service — accepted ones through the
models, where the decision is made, rejected ones over HTTP, where the published code and
context.rule are what a caller reads — and against _mirror, a validator built from the published
document alone with nothing imported from app.models. A rule that reaches the validators without
reaching the document fails there rather than in a caller's deployment.

The mirror walks rules in the order the document lists them rather than in an order of its own,
so rule_evaluation's promise is load-bearing: one vector breaks multiple_pdf_a_standards and
version_conflicts_with_standard at once, and a second test moves a rule in the document and
requires the answer to move with it. Reorder RULES and both fail.

Writing that mirror found three gaps, each now fixed in the document: the PDF/A-4 family was
identified by how a standard's name begins, the rules had an evaluation order no field stated, and
max_length did not say it counts code points rather than bytes — a byte-measuring mirror
disagreed about which rule fired while agreeing that one did.

Verification

make lint, make type-check, uv run pytest (717 passed, 2 skipped, integration included) and
make docs-build all pass.

Every output-option rejection answers invalid_request at the same loc with
type value_error, so the only thing separating "you asked for two PDF/A
profiles" from "that version contradicts your profile" was msg — prose that
docs/api/errors.md reserves the right to reword. A client branching on it
was transcribing our wording, and a mirror could not tell a rejection for
the right reason from one for the wrong reason.

Each rule now carries a stable id from the OutputRule enum, raised through
_reject and lifted out of Pydantic's ctx onto context.rule, the same key
the whole-set files-key rules already use. The error type stays
value_error deliberately: errors[].type is published, and an existing field
changing meaning needs a new API version, so the id had to arrive as a new
context key rather than as a new type.

The pages length and segment-count checks were one condition sharing one
message; they are two published rules now, so they are separated, count
first — it is the cheap half and it bounds the work the pattern loop would
otherwise do. Nothing observable changes for a value that breaks both.
PdfOutput.validate_pdf_options and _ImageOutputBase.validate_image_options
were the only statement of eleven rules, so a client-side implementation
was a hand transcription of Python with nothing to compare it against.
Two lookup tables a mirror cannot derive — the standard-to-version map and
the tagging set — were copied along with them.

app/core/output_rules.py restates what models.py enforces and holds no
rule of its own, the same contract app/core/constraints.py has with
templates.py. It carries its own OUTPUT_RULES_VERSION: the key rules and
the output rules change at different rates, and one counter would send a
caller back to the key rules because a PDF standard was added.

The page-selection grammar is included. pages is an output option, so a
mirror needs it whichever format it supports, and leaving it out would
publish one grammar while a second stayed in the source.

Field-level limits move out of Field(...) into named constants. The
document states each of them, and a literal spelled in two files is the
drift this artefact exists to prevent.
A client validating before dispatch already fetches this endpoint for the
key rules and the deployment's limits; making it fetch a second document
for the output rules would leave the two halves of one pre-flight check
arriving separately.

The document is nested whole rather than merged into the response's own
fields, so output_rules_version stays its own counter. Merging was the
only other way to answer both contracts in one fetch, and it would send a
caller back through the key rules because a PDF standard was added.

The enum of rule ids becomes OutputRuleId, freeing OutputRule for the
model that publishes one rule's statement — the name the schema shows a
caller.
…ly mirror

The vectors are the reason the document exists, so they are executed, not
illustrated: accepted ones through the models, where the decision is made
and no typst is forked, and rejected ones over HTTP, where the published
code and context.rule are what a caller actually reads.

_mirror is the client the artefact exists for, written the way a caller
has to write it — tables from pdf, grammar from page_selection, rules in
the order rules lists them, and nothing imported from app.models. A rule
that reaches the validators without reaching the document fails there
rather than in a caller's deployment.

Writing it found two gaps. The PDF/A-4 family was identified by how a
standard's name begins, so a mirror had to parse names to apply
ua_1_with_pdf_a_4; it is derived from the version map once and published
as pdf_a_4_standards. And the rules have an evaluation order that decides
which id an object breaking two of them reports, which no field stated:
they are now listed in that order, with rule_evaluation saying so as data.
The key rules and the error codes are each readable without the source,
and the artefact is the machine-readable half of a page rather than the
only statement. The output rules now have the same: what the document
carries, which id each rule answers with, the evaluation order that
decides which one an object breaking two of them reports, and what a
conformance vector is for.

The constraints page points at it, since output_rules arrives in the same
response as the key rules.
… rule

context.rule was lifted from whichever validation error carried one, while
detail always reports errors[0]. A body that omitted source and broke an
output rule answered "source: Field required" beside a rule naming the
output option, so a client doing what the documentation tells it to —
branch on rule, not on prose — reported a cause the response was not
about. Past the published-error cap the named rule need not have appeared
in context.errors at all.

The id now comes from errors[0] alone, through setdefault so a classified
files-key error keeps the rule whose code the response carries. A request
breaking an identified rule and something the rules do not name reports
the latter and carries no rule until it is fixed; errors.md and
output-formats.md say so.

page_selection.max_length counts Unicode code points and is checked before
the pattern, which the field did not state: a mirror whose string length
is a byte count answers page_selection_too_long where Prelum answers
page_selection_malformed, and both reject, so nothing reveals the
disagreement. max_length_unit states it and a non-ASCII vector catches it.

Drops an assertion that the two version counters differ in value. They are
independent because neither document carries the other's counter, which
the assertion above it already pins; comparing the numbers would fail the
day OUTPUT_RULES_VERSION reached 5.
The image block never said which format values it applies to, so a mirror
had to read "not pdf, therefore an image" — deciding by exclusion, which
is the defect pdf_a_4_standards was added to avoid one level down. Add a
fourth OutputFormat that is neither PDF nor image-like and every mirror
silently applies pages_requires_archive and page_with_archive to it while
Prelum does not.

pdf.formats and image.formats are now published beside the top-level
formats, both derived from the model hierarchy rather than listed a second
time. A test requires the two to cover formats without overlapping, so a
format claimed by neither fails here rather than in a caller's deployment.

The mirror in tests also no longer hardcodes the order it applies the
rules in, although rule_evaluation and output-formats.md tell callers to
rely on the published one. It walks document["rules"] instead, one
predicate per id, none of them assuming an earlier rule has run. Moving
version_conflicts_with_standard above multiple_pdf_a_standards used to
leave the whole suite green while a caller implementing the published
order answered the other rule for
{"format":"pdf","version":"1.4","standards":["a-2b","a-3b"]}; that object
is now a conformance vector, and a second test moves a rule in the
document and requires the answer to move with it.
code follows the classified files-key error wherever it sits among the
errors, while detail and rule were read from errors[0]. The two cannot
coexist today — the files-key checks run in RenderRequest's model
validators, which Pydantic skips once a field such as output has failed —
so the setdefault guarding it held by construction rather than by the
code. Move a per-key check into a field validator on files and detail
would name the output rule at errors[0] while rule named a files-key rule
from a later error, which is exactly the mismatch the previous commit
fixed.

All three now come from the same error: the classified one where there is
one, the first failure otherwise. Today that is the same error the handler
already used, so no response changes.

Carrying the rule id through Pydantic also means the output validators
raise PydanticCustomError rather than ValueError, which drops Pydantic's
"Value error, " prefix from context.errors[].msg. errors[].type is
unchanged and msg was already documented as prose, but the field did not
say its wording could change; it now says so outright, leading words
included, and points at type and context.rule instead.
@pingviini
pingviini merged commit 98c1570 into main Sep 15, 2026
5 checks passed
@pingviini
pingviini deleted the feat/output-rules branch September 15, 2026 16:17
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.

Publish the output-option rules, so a mirror can be tested instead of transcribed

1 participant