Constructor preservation arguments

Contract: Gargamelle 0.2.1 technical contract. These are implementation design arguments, not formal proofs or passed release gates.

The opaque Definition(a) pairs a closed Node tree with a conversion function. Only library constructors can build definitions. validate traverses only the node; decode validates first, then converts. Emission traverses only the node. Thus ordinary schema rejection does not execute author code. Total/pure mapping functions remain an explicit assumption. Callback exceptions are deliberately not caught as validation errors.

Base cases

Composition

Guarded recursive graphs

recursive/recursive_pair build immutable schema bindings and independently typed conversion bodies. Builders run once with construction plans, not decoded values. Only Plan(a, Nil) is an executable Definition(a); open builder plans cannot cross public decode, validate, emission or renderer type boundaries. Completed runners capture their bodies and supply typed recursion functions at execution time. Mutual outputs remain independently typed without generic casts. Completed definitions and closures capturing them survive creator-process exit.

Graph finalization checks names, definitions and every cycle reachable without consuming a child. Arrays, tuple positions, dictionary values and record fields select strict subvalues of finite admitted JSON. Union, nullable, name, reference, uniqueness and annotation wrappers do not break same-input cycles. The guard is conservative and does not assert satisfiability or bounded resource use.

Validation follows immutable reference bindings. Induction on strict child values, with finite same-input paths between descents, gives the same structural acceptance as local schema references. The checker accumulates full diagnostics and private evidence of accepted child shapes and union selection. Conversion uses that evidence only after the whole input succeeds, without repeating checks or retaining schemas in converter closures. Defaults retain evidence from their construction-time structural check; they apply only on absence. Auxiliary constraints discard their evidence and never execute their converters.

Each library node carries a fresh opaque identity. Walk caches key by both binding scope and node identity: importing a completed graph must not hide an unresolved foreign reference through an earlier visit of a shared node. A retained regression and cache mutation test exercise this distinction. Names still register globally; colliding names must emit equal local structures, including transitive targets. Emission produces finite local references. Rendering consumes that finite schema.

Defaults cannot be checked against unresolved references. Captured construction plans remain open; finalization does not mutate them into completed definitions. There are no construction cells or reference registries. JavaScript uses temporary closure-owned walk maps; Erlang brackets temporary walk maps and restores earlier state on every exit, including exceptions. Walk caches do not own runnable references. These arguments do not establish native regex compatibility or bound shared-term copying and schema expansion.

Admission, emission and trusted runtime

Public Value is opaque. The public admission operation accepts JSON text, parses using JavaScript JSON.parse, rejects non-finite parsed numbers and unpaired surrogates in final string values or object keys, and recursively invokes library-owned constructors with actual parsed values. It never invokes definition-author functions. Syntax and final-domain failures are typed admission errors. Other runtime exceptions, including recursion exhaustion, escape as operational failures; callers/harness must not count them as rejection.

The 0.2.0 Erlang parser owns JSON structural, numeric and string-token scanning. Surrogate pairs become their UTF-8 scalar encoding. Unpaired units are retained privately until syntax and duplicate replacement finish, then rejected as domain errors; they never escape as Gleam Strings. Numeric overflow likewise remains private until final-domain checking. Thus overwritten invalid values do not cause admission failure, and later malformed syntax is still a syntax error.

Conversion to emitted text builds Gleam JSON, parses its string, recursively creates null-prototype objects with sorted keys, then uses JSON.stringify. This preserves own __proto__ keys and follows JavaScript index-key enumeration for final text. required is independently sorted with JavaScript’s default UTF-16 comparator. Nested defaults use the same canonicalization. All tree operations construct fresh values, so parsed input is not mutated.

The number Int/Float runtime helpers are identity functions because both Gleam numeric types use JavaScript numbers; they are confined to checked primitive conversion. There is no generic FFI cast or forged typed provider value.

Verification

Typed-output and rejection tests, generated compositions, independent schema validators, compile-negative fixtures and isolated implementation mutations exercise the contract. The verification guide describes coverage and limitations; generated notes describe retained findings. Operational failures are not validation rejection.

These arguments are not formal proofs. Independent review and counterexamples may expose implementation errors despite the reasoning and tests.

Exact-length tuples and shared recursion guards

A tuple with N positional children accepts precisely arrays of length N whose position i satisfies child i. Its schema combines array type, N prefix schemas, items: false, and minItems: N; together these require the same length and positional acceptance. N=0 omits the nonempty-only prefix keyword. Only after all positions validate does the curried constructor consume each child’s decoded value in declaration order. No element is defaulted or fabricated. Tuple edges consume a strict input child and therefore guard recursion.

Guard finalization uses identity sets local to the traversal. A revisit of an active node establishes a same-input back edge and is rejected. A completed node has already had every same-input outgoing edge checked, so revisiting it cannot introduce a new cycle; it may safely be skipped. Only successful completion marks a node checked. Both sets are shared across all collected definition roots and discarded when finalization returns. Shared union layers therefore no longer recheck each duplicate branch exponentially. This does not change schema emission or impose a decoding-depth limit.

Object property names

For an admitted object, every own property name is a JSON string. The property-name wrapper retains the original object’s validation and applies each key node to that string; emitted propertyNames evaluates the same string with the same child schemas. Repeated constraints use conjunction, not replacement. The explicit object type is redundant with the supported dictionary/record body and lets strict validators check constraints adjacent to a named reference. An empty object vacuously satisfies its key constraints; nonobjects still fail the original typed body.

The original converter is retained unchanged. Key converters are never stored or called; key names remain unchanged even if the key definition has a mapping. Open-record projection and default insertion occur only after input validation and do not add keys to the input being checked. Definition collection visits both body and key nodes so missing references and name conflicts cannot be hidden. The recursion guard follows the body as a same-input edge; key checks inspect synthesized primitive strings, not the enclosing object. Named key bodies are independently finalized under the existing graph guard.

Array membership

Membership retains the base array/tuple validator and counts positions accepted by the matching node. Emitted contains and its explicit inclusive minimum and optional maximum impose the identical count constraint. Duplicated values count separately. Multiple requirements are conjoined using allOf; they do not replace one another. A zero minimum without a maximum is true for every array, so its redundant schema keywords and counting work can be omitted. Nonarrays still fail the base typed definition.

Only the original converter is retained. Matching converters are not invoked, and unmatched elements are neither removed nor changed. Thus the accepted output is exactly the base definition’s typed result. Membership checks consume an array item, so graph guards follow only the base as a same-input edge while collection still visits every matcher to reject unresolved/conflicting names. Repeated evaluation of overlapping complex schemas may be expensive; this extension does not claim linear validation cost for arbitrary compositions.

Structural conditionals

A conditional retains the base’s acceptance and inspects the same admitted input with its condition node. If that node accepts, the consequence must accept; otherwise the optional alternative must accept. Absent alternatives impose no additional constraint. This is the same selection rule as emitted JSON Schema if/then/else. Repeated conditions are conjoined in allOf alongside the base, rather than replacing schema keywords. Typed node validation remains the source of both condition selection and selected-branch acceptance.

Only the base converter is retained. Auxiliary converters are never called, so conditions cannot mutate input, insert defaults, project fields, or change the output type through their conversion maps. Defaults of the base are materialized only after all input validation succeeds. Condition failures do not become data errors; selected-branch errors retain ordinary paths. Operational exceptions do not select the alternative branch.

Collection and same-input guard traversal visit the base, condition, consequence, and optional alternative. This rejects non-progressing cycles even in unreachable branches and checks unfinalized references/name conflicts before any emission or decoding. Conditionals do not introduce input descent or a hidden depth cap.

Structural exclusion

Exclusion accepts exactly when the base accepts and every forbidden node rejects the same admitted input. Each emitted not negates precisely that forbidden schema, and allOf conjoins those negations with the base. Repeated constraints therefore intersect rather than overwrite keywords. No conversion result is used to choose exclusion; defaults, projection, and maps do not alter input.

Only the base converter is retained, so accepted values decode to the unchanged base output type. Forbidden callbacks are neither invoked nor given invented values. A forbidden acceptance yields an excluded error at the current path; forbidden rejection contributes no error. Operational exceptions escape instead of establishing forbidden rejection. Graph collection and same-input guards visit both base and forbidden nodes, rejecting unresolved references, conflicting names, and non-progressing recursive cycles. Negation does not introduce descent or a decoding-depth cap and does not require a satisfiability solver.

✨ Search Document