Repository navigation
softschema 0.9.0
softschema 0.9.0 is a minor release: the Python and TypeScript implementations now read
the same YAML the same way or refuse it in both, every validation result records which
checks actually ran, and callers can require that a check completed.
Until now, a document could read one way under uvx softschema and another under
npx softschema: a raw U+0085 became a space in Python and stayed a character in
TypeScript, 1_000 was a number in one and a string in the other, a block scalar at the
end of the text kept its final newline in one only. Each such shape now gets one verdict
in both runtimes, and a shape the two engines cannot agree on is refused by both with a
message naming it. Separately, a declared status still selects the validation mode,
but it never proved a check ran; the new per-layer execution field does. The
TypeScript package also moves to zod 4.6 and a patched fast-uri.
What's Changed
Breaking changes and compatibility
Newly rejected input. Both implementations now reject, as yaml_parse_error, any
raw character outside YAML 1.2 c-printable (C0 controls other than tab, line feed and
carriage return; DEL; the C1 controls; U+FFFE and U+FFFF), the line separators U+0085,
U+2028 and U+2029, and a carriage return that does not begin \r\n, anywhere in the
YAML they read: frontmatter or a pure-yaml document, comments and keys included, and
every compiled schema file. The Markdown body is not checked. TypeScript previously read
all of these as content; Python folded a raw U+0085 in a flow scalar to a space, kept
U+2028 and U+2029, and treated a lone carriage return as a line break.
Write such a character as a double-quoted escape ("\N", "\L", "\P", "\r", "\f",
"\v", "\0", "\xHH"), which both engines decode identically. repair writes the
escape for you inside a single-line scalar value. To find affected files before
upgrading:
rg -l '[\x{85}\x{2028}\x{2029}\x{FFFE}\x{FFFF}\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]|\r(?:[^\n]|$)'Shapes the two engines read differently, now one verdict. Each of these was read by
one runtime and refused, or read differently, by the other. The CHANGELOG has the exact
rules and the fix for each.
| Shape | Now |
|---|---|
| A raw tab in a plain scalar or as block spacing | Rejected in both; quote the value |
A plain = |
The string = in both, as YAML 1.2 reads it |
| A flow or quoted continuation line not indented past its parent | Rejected in both; indent it with spaces |
A flow-mapping key whose : is past 1,024 characters or on a later line |
Rejected in both; the 1,024 limit counts UTF-16 code units in both |
1_000, 0b101, -0x10, +0x10, -0o17, .5e3 and similar |
yaml_unsupported_scalar in both; write the core form or quote it |
A :, ? or # glued to a flow scalar, such as [a:, b] or "a"#c |
Rejected in both; add the space or quote the scalar |
| Spaces wider than an explicit block-scalar indentation indicator | Rejected in both |
A mapping entry with content but no : |
Rejected in both |
%YAML 1.1, 1.0, 1.3, or an unknown directive |
Rejected in both; only %YAML 1.2 and %TAG are accepted |
The integer -0 (also -00) |
Rejected in both, per the spec’s negative-zero rule |
--- followed by U+0085, U+001C–U+001F or U+FEFF |
A fence in neither |
A surrogate pair written as two \u escapes |
The one character it encodes, in both |
Python values change in one case. A block scalar that ends the YAML text with no
line break after it (the last key of a frontmatter block, or the end of a pure-yaml file
with no final newline) now ends with "\n" under clip (|, >) and keep (|+, >+)
chomping, as it does anywhere else and as TypeScript already read it. Write |- to keep
a value without its final line break.
Depth limit. TypeScript counts an empty collection toward the 64-collection limit
and reports the limit before an earlier duplicate key or a later parse error, as Python
does.
Messages and kinds. message text may change in a minor release, and these did;
kind is the matching key. Frontmatter-md line numbers now count from the top of the
file. A YAML parse failure, and a schema_invalid record whose schema the reader
refused, begins with the backticked file path. The preflight message names the escape
to write. Control characters in a filename are written as #xHHHH. repair without
--contract reports a fenceless document or a non-mapping root with validate’s kinds
(no_frontmatter, yaml_not_mapping), and compile --check reports an unreadable
committed sidecar as drift (exit 1) rather than a usage error (exit 2). repair adds the
record code yaml_escaped_character; a consumer matching repair codes exhaustively
should add it.
Result shape. Every structural and semantic layer record gains a required
execution. Python layer-result constructors take it as an explicit keyword-only
argument, and the TypeScript layer interfaces require the same field. A consumer that
validates serialized results against a closed schema must accept the new fields.
A report written before 0.9.0 has unknown execution; preserve the absence or
revalidate rather than filling it in.
Schemas both runtimes now refuse. Each of these turns an artifact bound to such a
schema invalid (ok false, validate exit 1, under every status, with or without
--require) until the schema is fixed. They affect hand-written or third-party schemas
only: schemas written by softschema compile carry the canonical $schema and absolute
ids and use none of the refused keywords.
| Construct | Was | Now | Write instead |
|---|---|---|---|
Unresolvable $ref on a branch the payload never reaches |
Python accepted; TypeScript refused | schema_invalid, reason: reference, structural not_run, in both |
Fix or remove the reference |
$schema other than Draft 2020-12 (draft-07 and others) |
Python validated as 2020-12; TypeScript refused as compilation |
schema_invalid, reason: dialect |
Delete the line or use the 2020-12 URI |
$recursiveRef, $recursiveAnchor |
Python ignored; TypeScript evaluated | reason: dialect |
$dynamicRef, $dynamicAnchor |
Legacy dependencies |
TypeScript enforced; Python ignored | reason: dialect |
dependentSchemas or dependentRequired |
OpenAPI nullable |
TypeScript honored; Python ignored | reason: dialect |
type: [..., "null"] |
| A resource identity outside the identity grammar | Each engine normalized it differently | reason: resource_identity; compile --schema-id exits 2 |
https://host/path, file:///path, lowercase urn:<nid>:<name>, or another scheme:path, with no fragment |
The identity grammar admits only spellings that neither Python’s urllib.parse nor
Ajv’s fast-uri rewrites. Ordinary-looking ids it refuses include https://example.com
with no path (write https://example.com/), an explicit default port, an uppercase
urn:uuid:, a trailing empty # as in http://example.com/schema# (common in
draft-07-era schemas), and s3:// or gs:// ids. The spec lists the rules, and
tests/vectors/identities.yaml has a case for each.
Compiled schemas. A Python- or TypeScript-compiled schema changes only where a model
string carries one of the newly rejected characters, a carriage return, a tab, or is
exactly =: such strings are now double-quoted with escapes, so the sidecar reads back
in both runtimes. schema_sha256 is computed over canonical JSON and is unchanged by the
escaping.
TypeScript compiled schemas change for some Zod constructs; recompile. Zod 4.5 and
4.6, with the compiler’s normalizations of their output, change the compiled schema and
schema_sha256 of models that use tuples, empty tuples, intersections of inline
objects, number- or int-keyed records, records with constrained keys, duplicate union
members, z.preprocess(), a second .multipleOf(), the zod-built patterns for
z.email(), z.emoji(), z.ulid(), z.cidrv6(), z.base64url() and positional
.includes(), or z.ipv4()/z.ipv6() followed by another format check. A
.default() over a transform, or an authored default or examples on one, now keeps
that value as its Pydantic mirror does. Several of these now compile to exactly their
Pydantic mirror’s schema. compile --check reports drift for an affected model;
recompile its committed schema.
Zod 4.6 semantics in TypeScript models. String .min(), .max() and .length()
count code points, as Pydantic does. An object-level .refine() or .superRefine() on a
strict object now runs when the input has an unknown key and reports beside the
unknown-key issue. z.iso.datetime() rejects minute-precision input such as
2020-01-01T06:15Z, which Pydantic accepts.
Dependency floors. The Python package requires frontmatter-format>=0.5.0; the
TypeScript package requires zod ^4.6.6.
The migration plan
covers downstream adoption of the execution fields.
New features
- Per-layer execution evidence.
structural.executionandsemantic.executionare on
every returned validation record, including CLI JSON and repair results. The public
ValidationExecutiontype isnot_run,completed, orerrored; a completed check
may accept or reject, so readokand errors for its verdict. A missing or malformed
schema reports a preparation failure without claiming the payload was evaluated, and
both layers stay visible when a schema accepts and a model rejects. - Required checks.
require=on Pythonvalidate_artifact,load_artifactand
validate_values, arequireoption on their TypeScript counterparts, and a
repeatablevalidate --require structural|semanticin both CLIs. A required layer that
did not complete is not ok and gains acheck_not_completederror carryinglayerand
execution: an otherwise valid result becomesinvalid, strict reads raise or throw,
andvalidateexits 1. An unreadable artifact keeps itsinput_erroroutcome and its
exit 2. Without a requirement, results are unchanged. - Enforcement warnings. When the effective mode is
enforcedand structural
validation did not complete, the result carriesdocument-enforcement-via-model-only
if semantic validation completed, ordocument-enforcement-not-appliedotherwise. repairescapes a raw rejected character. A U+0085, U+2028, U+2029 or control
character (other than a carriage return) inside a single-line scalar value is rewritten
as a double-quoted scalar carrying the YAML escape, with oneyaml_escaped_character
record per value. The decoded value is exactly what YAML 1.2 gives the raw text; no
character is replaced by a guess at what was meant. A character in a key, a comment, or
a block scalar stays a parse error for the author. Both implementations.- Faster reads, same results. Python parses a strict JSON document with
json
wherever ruamel would read it identically, so a large.jsonartifact validates many
times faster, and parses YAML with ruamel’s optional C extension wherever libyaml reads
exactly as the pure-Python parser (whenruamel.yaml.clibis installed and is the
checked libyaml build); every other text goes to the pure parser, so values and errors
do not change.yaml_parser_mode()andsoftschema doctorreport which parser is
active, andSOFTSCHEMA_PURE_YAML=1turns the C parser off. The TypeScript reader is
now linear in the width of a mapping: a 0.4 MB block mapping that took 6 s takes 0.4 s.
Fixes and hardening
Reading and repair:
compilewrites schemas the portable reader accepts. Both writers emitted U+0085,
U+2028 and U+2029 raw, so a model whose description, default or enum value carried one
produced a sidecar every later read refused. Each writer now escapes them,compile
checks its own output, and two older holes of the same kind are closed: a CRLF string
in the Python writer and a tab or bare=in the TypeScript writer.- Conform never folds a raw U+0085. Python
repair --contract … --schema …could
fold the character to a space without a record and reportvalid; conform now runs
the preflight first, in both runtimes. repair’s quoting pass trims space and tab only, so a rejected character in a
comment is no longer dropped in one runtime and refused in the other.- A raw rejected character right after a backslash in a double-quoted value is left
for the author instead of being escaped into a different value. - The Python frontmatter reader splits lines on
\nalone, as TypeScript does;
str.splitlines()also broke on U+2028, U+2029, U+0085 and form feed. - Python reader crashes fixed: a collection used as a mapping key is now
yaml_non_string_key, and%YAML 1.0or1.3is ayaml_parse_error, not a
traceback. An unexpected exception in the TypeScript YAML reader reaches the caller as
itself rather than as a parse error. - A YAML parse failure names the artifact, in both profiles, both commands and both
implementations, and a schema file is named once in its diagnostic. repairreports a non-UTF-8 artifact the wayvalidatedoes
(artifact_invalid_utf8atinput_error).- Control characters in a filename never reach stderr raw, so a hostile filename
cannot drive the terminal. The TypeScript preflight’s error path no longer
materializes a large input to count lines.
Validation and compile:
- Python
repair_and_validate_artifact(..., model=...)uses the supplied model for the
final verdict as well as conformance, so a model rejection returnsinvalidafter
repair. - TypeScript repair reports
input_errorfor a missing or invalid-UTF-8 artifact
without an inferred contract, matching Python. - TypeScript
validateArtifactwithout a bound schema reports
skipped_reason: inferred_via_modelonly when the call supplies asemanticModel, as
Python does. - Python
validate_values(schema=...)with a schema file that does not exist reports
schema_missingwith the path. - Python schema preparation resolves every reachable
$refbefore checking a
payload, as Ajv already did while compiling, so--require structuralgives one
answer in both runtimes. - TypeScript-compiled schemas match Pydantic again for nullable fields under zod 4.5
and later, for.startsWith(),.endsWith()and.includes()under zod 4.6, for
.catch()fields under zod 4.6, and for a.default()over a transform. - TypeScript compile rejects a default that canonical JSON cannot carry: a NaN or
infinite default or example fails withcanonical JSON requires finite numbersinstead
of compiling todefault: null, as Python compile already did. - Checked enforcement accepts nested nullable references to explicitly closed models.
- The TypeScript
validate --requirewith an unknown layer reports
softschema validate: invalid require: <value>(exit 2), as the Python CLI does.
Host isolation (Python):
compileno longer restyles the host process’s own YAML output. With
frontmatter-format 0.4.0, a compile left its representers on ruamel’s shared
SafeRepresenter, so every laterYAML(typ="safe")dump in the same process wrote
block-style mappings and|-literals. frontmatter-format 0.5.0 keeps them on a
private subclass.repairno longer changes how other ruamel emitters spellnull. Its null
representer now lives on a softschema-private subclass instead of ruamel’s shared
RoundTripRepresenter.
Packaging: the sdist no longer ships AGENTS.md, which carries repository policy for
agents, not package documentation. The wheel and npm package never included it.
Security.
- The
fast-urioverride is raised to exactly 3.1.9, closing seven advisories against
the 3.1.6 pin (GHSA-qw65-cvwx-89v3, GHSA-58mr-gqgx-xq4g, GHSA-hrr3-gc8f-f4qj,
GHSA-w74x-v6f9-j3xg, GHSA-wjr9-j795-jxcr, GHSA-xhq7-xmqj-769h, GHSA-333x-w4r6-6crp).
The override governs this repository’s own install; the published package does not
bundlefast-uri, so a project that installs softschema should update thefast-uri
its own lockfile resolves forajvto 3.1.9 or later. - The TypeScript package requires zod
^4.6.6, which closes GHSA-mrhx-c7xc-m3w5:
validating an oversized array, set or map against a size bound could exhaust memory.
CI. bun audit runs as its own dependency-audit job, so a new upstream advisory no
longer skips the TypeScript typecheck, tests, build, publint and golden runs. The
typescript and cross-impl jobs no longer persist the checkout token and install with
--ignore-scripts.
Testing and release safety
The cross-runtime agreement is measured, not assumed. A generated sweep of 350,355
number-like plain scalars, 210,000 flow-collection scalars around the indicator
characters, and a 160,000-case fuzz of flow and block structures find no shape the two
runtimes read differently, and none that one accepts and the other refuses, outside one
loud, unchanged class: a block scalar without an indentation indicator whose leading
space-only lines are indented differently from its first content line (429 cases).
Write an indentation indicator, or trim those lines. A 4,686-case
writer-to-other-reader sweep finds no compiled schema one runtime writes and the other
reads differently.
Release checklist Phases 1–4 pass on the release tree: lint, 587 pytest plus 21 in the
slow tier, both C-first and with SOFTSCHEMA_PURE_YAML=1; 503 bun tests with the
coverage gate, bun audit clean, tsc/biome/publint; golden corpus 102 py / 97 node / 102
bun; cross-impl parity OK; compile --check with no drift; and Markdown format-check
clean. The wheel and the npm tarball were installed into clean environments and
exercised on both artifact profiles, the quickstart produced byte-identical output
across implementations, and the skill bootstrap created both mirrors from each installed
CLI.
Downstream integration was verified before release: Metaproc’s serializer integration
passed 96 tests against the validation-execution source, and downstream verification
surfaced the nested nullable-reference defect fixed above.
Full changelog: v0.8.1...v0.9.0