Repository navigation
Publish the output-option rules with conformance vectors - #6
Merged
Merged
Conversation
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.
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.
Closes #5.
PdfOutput.validate_pdf_optionsand_ImageOutputBase.validate_image_optionswere the onlystatement 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/constraintsasoutput_rulesand exported to
docs/api/output-rules.json, following the pattern the files-key rules and theerror 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
pagesgrammar, twelve named rules and 34conformance vectors.
app/core/output_rules.pyrestates whatapp/models.pyenforces and holds no rule of its own —the contract
app/core/constraints.pyhas withtemplates.py.The decisions behind it
Rule ids, surfaced in
context.rule. Every output-rule violation answersinvalid_requestatthe same
locwithtype: value_error, so only the prose distinguished them. Each rule now has astable id. The error type stays
value_errordeliberately:errors[].typeis published, so the idarrives as a new context key rather than as a new type, keeping the change additive.
detail,codeandruleare read from one error, never from whichever one happens to carry anid — a body that omitted
sourceand broke an output rule otherwise answered "source: Fieldrequired" beside a rule about the page selection.
Every block says which formats it governs.
pdf.formatsandimage.formatsare publishedalongside the top-level
formats. Without them a mirror decides the image rules by exclusion —"not
pdf, therefore an image" — the same defectpdf_a_4_standardsexists to avoid one leveldown, and one that would misapply
pages_requires_archiveto any format added later. Both listsare derived from the model hierarchy, and a test requires them to cover
formatswithoutoverlapping, so a fourth
OutputFormatfails here rather than in a caller's mirror.Its own version counter.
output_rules_versionis separate fromrules_version: the twocontracts 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
pagesgrammar is in scope.pagesis an output option, so a mirror needs its grammarwhichever format it supports; excluding it would have left a second unpublished grammar.
One observable change to
context.errorsCarrying the rule id through Pydantic means the twelve validators raise
PydanticCustomErrorinstead of
ValueError, which drops Pydantic's"Value error, "prefix fromerrors[].msg:{"format":"png","pages":"1-2"}now reportspages requires archive 'zip'where it reportedValue error, pages requires archive 'zip'.errors[].typeis unchanged, andmsgwas alreadydocumented as prose;
docs/api/errors.mdnow says outright that its wording, leading wordsincluded, may change, and to branch on
typeorcontext.ruleinstead.What proves it
tests/test_output_rules.pyruns every vector against the service — accepted ones through themodels, where the decision is made, rejected ones over HTTP, where the published
codeandcontext.ruleare what a caller reads — and against_mirror, a validator built from the publisheddocument alone with nothing imported from
app.models. A rule that reaches the validators withoutreaching the document fails there rather than in a caller's deployment.
The mirror walks
rulesin the order the document lists them rather than in an order of its own,so
rule_evaluation's promise is load-bearing: one vector breaksmultiple_pdf_a_standardsandversion_conflicts_with_standardat once, and a second test moves a rule in the document andrequires the answer to move with it. Reorder
RULESand 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_lengthdid not say it counts code points rather than bytes — a byte-measuring mirrordisagreed about which rule fired while agreeing that one did.
Verification
make lint,make type-check,uv run pytest(717 passed, 2 skipped, integration included) andmake docs-buildall pass.