Schema
Bsft.Schema validates a document against a schema that arrived at runtime from a party the platform
does not trust: an application manifest's config schema, a webhook payload contract, a settings blob
whose shape is not known at compile time.
It is not Bsft.Settings. .Constrain(x => x.Prop, predicate, message) validates a typed record whose
type the compiler knows; here both the shape and the document are input, and the schema is itself
something a stranger submitted.
The subset
type: "object" at the root. Six types, and per type the keywords that apply to it:
| Where | Keywords |
|---|---|
| Any node | type, enum, plus the annotations $schema, title, description, default |
string |
format, pattern, minLength, maxLength |
integer, number |
minimum, maximum |
array |
items, minItems, maxItems |
object |
properties, required, additionalProperties (only false) |
format is email, uri, date, date-time or uuid, plus the two presentational values
textarea and markdown, which say how a string is edited and constrain nothing.
uri means an absolute URI that carries its own scheme, and the scheme has to be longer than one
character. Both rules exist so the same manifest gets the same answer on every host: .NET reads
/etc/passwd as an absolute file: URI on Unix and refuses it on Windows, and C:\share parses as a
drive path on one and as a one-letter scheme on the other.
That is the whole of it. No $ref, no allOf/anyOf/oneOf/not, no union type, no conditional
schema, nothing that can recurse without a bound. The list is the one twenty shipped manifests in the
app store surveyed in a design survey actually used, so
it is a floor drawn from practice rather than from taste.
UI metadata belongs beside the schema, not in it. A sibling object keyed by property name,
carrying a label and a widget, duplicating nothing: required lives only in required and the
choices only in enum, so the two cannot disagree. Nothing here reads such an object, and nothing
here should.
Reading a schema, then validating a document
services.AddBsftSchema(); // or AddBsftSchema(builder.Configuration)
var read = reader.Read(submittedSchema);
if (!read.IsUsable)
return Refuse(read.Violations); // the developer's problem: a review queue
var check = validator.Validate(read.Schema!, tenantConfiguration);
if (!check.IsValid)
return Refuse(check.Violations); // the tenant's problem: the install screen
The two calls are the two failures, and which call refused is what tells them apart. A schema outside
the subset never reaches the validator, so every refusal the validator produces is about the document.
Read a schema once, at submission, and keep the SchemaNode; reading is where the bounds are checked
and where a pattern is compiled.
Three decisions worth arguing about
Each of these refuses documents a general-purpose validator would accept.
An unknown keyword is refused, not ignored. A schema writing maxLenght declares a bound the
platform then does not enforce, on an install screen that tells a tenant admin the field is validated.
Ignoring it is the silent failure; refusing it puts the typo in front of the developer at submission,
where it costs nothing. The survey records this exact class of defect being caught by a strict mode
that CI turned on, and the same one reaching production where the mode was off.
additionalProperties may only be false. An object with room in it for anything cannot be read
back by anything downstream, and in a store it is where a tenant's own data ends up inside a document
the application's developer can read.
A pattern is compiled with backtracking off (RegexOptions.NonBacktracking) and a timeout. That
removes the catastrophic case outright rather than budgeting for it. The cost is that backreferences
and lookaround are refused — a narrower pattern language than JSON Schema's — and that is the
intended trade for an expression that arrived with somebody's application.
A $ in a pattern also matches before one trailing newline. JSON Schema's patterns are ECMA-262,
where $ without the multiline flag anchors at the end of the input and nowhere else; .NET's $
anchors there too but additionally just before a single final newline. So ^[a-z]+$ accepts a value
that ends in one here and would not in a browser. The divergence is left in place rather than papered
over by rewriting the expression: a schema's pattern is run exactly as it was written, and silently
editing an anchor would make the refusals of this framework impossible to reproduce against any other
JSON Schema implementation. A publisher who needs the stricter reading writes \z instead of $,
and a value whose trailing whitespace matters is better bounded by a format than by an anchor.
The validator is itself an attack surface
Both documents are untrusted, so neither is read without a ceiling. Every bound is an option and every one of them must be greater than zero, because a zero here is not a permissive setting but an absent one.
| Option | Default | Effect |
|---|---|---|
MaxDepth |
8 | How deeply a schema or a document may nest. |
MaxProperties |
100 | How many properties one object may declare or carry. |
MaxSchemaNodes |
500 | How many nodes one schema may contain in total. |
MaxArrayItems |
1000 | How many items one array in a document may carry, above whatever the schema's own maxItems says. |
PatternTimeout |
100 ms | How long one pattern match may run. |
MaxArrayItems is the host's ceiling rather than the developer's: it applies to an array that declared
no bound of its own.
Refusals
Every refusal, not the first: a form with four wrong fields that reports one of them is a form somebody submits four times.
A SchemaViolation carries three things. The path is an RFC 6901 JSON Pointer (/smtp/port,
/hosts/2, /a~1b for a name containing a slash) — a pointer rather than a dotted path, because a
property name may itself contain a dot and a path that cannot be parsed back is a path a form cannot
highlight the right field from. The code is what a caller branches on. The message is the only
part a person reads, and it says what the value should have been rather than which keyword failed.
The codes split in two, and the split is about who is looking at them:
| Prefix | Whose problem | Where it belongs |
|---|---|---|
SCHEMA_* |
The developer who submitted the application | A review queue, at submission |
VALUE_* |
The tenant admin configuring an install | The install screen, immediately |
Extend
ISchemaReader and ISchemaValidator are registered with TryAdd. A host with a schema language of
its own registers its own reader; the validator then works against whatever SchemaNode that reader
produced. SchemaNode is the boundary between the two, and reaching a validator means the subset and
the bounds were already checked.
Packages
| Package | Entry point |
|---|---|
Bsft.Schema |
SchemaServiceCollectionExtensions.AddBsftSchema |