Skip to content

ADR-0028: Schema construction enforces its grammar at the Add site#84

Merged
pavlovtech merged 1 commit into
masterfrom
adr-0028-schema-construction-guards
May 20, 2026
Merged

ADR-0028: Schema construction enforces its grammar at the Add site#84
pavlovtech merged 1 commit into
masterfrom
adr-0028-schema-construction-guards

Conversation

@pavlovtech
Copy link
Copy Markdown
Owner

ADR-0028 — Schema invariants move from the fold to the construction site

The Schema is the user-facing DSL — the very first thing every consumer constructs. Its grammar rules (Field non-empty, leaf Selector non-empty, list-container Selector non-empty) lived in the fold (SchemaContentParser), so an invalid Schema constructed cleanly and failed only at parse time, asymmetrically: a leaf-list with no Selector got swallowed by the per-leaf catch (Exception) and silently dropped the field; an object-list with no Selector aborted the whole parse. The pre-0028 test ListSchemaWithoutSelectorThrows documented the symptom — its name said Throws and its body asserted silent unset.

Moved enforcement to Schema.Add(SchemaElement) — validates Field non-empty, leaf Selector non-empty, list-container Selector non-empty (a non-list nested Schema is exempt; the fold never reads its own Selector by design). The throw site is the exact line the user wrote the bad element — best possible error location.

Added Schema.ListOf(string field, string selector, params SchemaElement[] children) static factory that bundles the IsList + Selector + Children triple in one call, with construction-time validation of field+selector. The old shape (new Schema(field) { Selector = ..., IsList = true, Children = { ... } }) keeps working; the factory is the recommended path going forward.

The fold's existing Field is null guard and three RequireSelector calls stay in place, annotated as belt-and-suspenders for the one remaining path (mutation of a SchemaElement after Add — records here use { get; set; }, not init-only). They are no longer the primary line of defence.

Design, the five rejected alternatives (fluent-builder DSL replacement / init-only properties / sibling-type model split / parameterless-ctor internalisation / documentation-not-enforcement) with load-bearing reasons, and the implementation status: docs/adr/0028-schema-construction-guards.md.

Narrowly breaking

  • Constructing a pathological Schema (leaf or list container with empty Selector, child with empty Field) now throws ArgumentException at the Add call instead of failing at parse time (or silently). The ListSchemaWithoutSelectorThrows test was changed to assert the construction-time throw (ConstructingALeafListWithoutASelectorThrowsAtTheAddSite); a sibling pins the same for the object-list arm.
  • Surface-additive: Schema.ListOf is purely new; no public method removed.
  • The non-list nested Schema (an object container that uses the parent scope) is exempt from the Selector rule — by design. ADR-0002's missing-selector unified across single-value vs list paths update becomes structural at construction, not just at the fold.

Guardrail

Whole-solution build 0 errors · 107 unit tests (94 pre-0028 + 1 replaced + 1 new sibling + 12 new SchemaConstructionTests) · 19 satellite tests (Sqlite/Puppeteer/Mongo/Cosmos/AzureServiceBus) · Native-AOT smoke ALL PASS. Integration tests deferred to CI (live alexpavlov.dev + real Puppeteer, slow by design).

🤖 Generated with Claude Code

The Schema grammar's rules — Field non-empty, leaf Selector non-empty,
list-container Selector non-empty — lived in the fold (SchemaContentParser),
so an invalid Schema constructed cleanly and failed only at parse time.
The two arms were asymmetric: a leaf-list with no Selector got swallowed
by the per-leaf catch and silently dropped the field; an object-list
with no Selector aborted the whole parse.

Moved enforcement to the construction site: Schema.Add validates every
child at the add call (the exact line the user wrote the bad element).
Added Schema.ListOf(field, selector, ...children) static factory that
bundles the IsList + Selector + Children triple a user previously had
to remember together. The fold's existing checks stay as belt-and-
suspenders for the mutation-after-Add path; they are no longer the
primary line of defence.

The previously-misleading ListSchemaWithoutSelectorThrows test (which
asserted silent swallow-and-log, contradicting its own name) actually
throws now, with a sibling pinning the same fast-fail for the
object-list arm — the two arms are now uniform.

Guardrail: 0 errors, 126 tests pass (107 unit + 19 satellite), AOT
smoke ALL PASS. Surface-additive on Schema; one narrowly-breaking
behaviour edge for code that constructed pathological Schemas.

Design, the five rejected alternatives (fluent-builder DSL replacement,
init-only properties, sibling-type model split, parameterless-ctor
internalisation, documentation-not-enforcement), and the implementation
status: docs/adr/0028-schema-construction-guards.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@pavlovtech pavlovtech force-pushed the adr-0028-schema-construction-guards branch from a35f062 to 90f0373 Compare May 20, 2026 12:16
@pavlovtech pavlovtech self-assigned this May 20, 2026
@pavlovtech pavlovtech merged commit 75f1b9b into master May 20, 2026
2 checks passed
@pavlovtech pavlovtech deleted the adr-0028-schema-construction-guards branch May 22, 2026 14:27
pavlovtech added a commit that referenced this pull request May 23, 2026
…uards

ADR-0028: Schema construction enforces its grammar at the Add site
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.

2 participants