Openness: why there is no Map, and the case for any¶
Status: decision record + design study, 2026-07-06.
Map— refused, settled (fourth recurrence of the question).any— shipped in v0.5.0 (maintainer go, 2026-07-07, after the study below concluded the algebra can carry it, §7–§8). The full normative specification is any-type-spec.md, and model.md §1 now describes the shipped model,anyincluded.
1. Summary¶
The same design question keeps returning in different disguises: can the
schema be a little more flexible about what counts as a match? It arrived
three times during the original design — as scalar unions, as multi-shape
nullability, and as "should a record stay open to whatever keys show up" —
and was refused each time, for one reason: the moment a value can match
more than one candidate type, there is no principled way to decide which
one it "really" is, and the algebra's decidable yes-or-no answers degrade
into heuristic guesses. A fourth arrival came in 2026-07 via an external
code review, dressed as a Map type.
This document settles the question in both directions at once:
Map(open keys, uniform values) is refused — not because it breaks decidability (a homogeneous map doesn't), but because it opens the schema's label alphabet, which is the thing every algorithm in the algebra reasons over. §3 gives the full argument.any(a declared field whose subtree is unchecked) is supportable and recommended — it opens only the value domain at an explicitly marked leaf, leaves the label alphabet closed and finite, and every operation extends to it with two-line rules rather than pervasive special cases. §5–§8 give the study and worked examples; §9 states the costs honestly.
The distinction generalizes: open values at a visible leaf — acceptable; open labels in the structure — never. That is the line, and it is why the two proposals that superficially resemble each other get opposite answers.
2. Definitions¶
Map would be a field position whose keys are unconstrained but
whose values all share one type — the { [string]: T } of TypeScript, the
additionalProperties: {...} of JSON Schema:
record Config { "env" map[string]: string } # NOT valid OSD — refused
any would be an eighth type keyword (lowercase, joining the seven
scalar keywords) usable as a field's type. A field typed any accepts
any value — any scalar, null, or any record subtree of any shape — and
validation does not descend into it. The field's label is still fixed,
declared, and counted; its cardinality still applies; only the value's
shape is unconstrained:
record Event {
"id": string,
"type": string,
"created": datetime,
"data": any, # payload varies by event type; unchecked
"attachments" [0,]: any, # cardinality still applies to the label
}
root Event
any includes null (it is the top of the value lattice), so any? is
redundant and rejected by the grammar. The root statement continues to
name a record — the grammar (schema := record* 'root' NAME) is unchanged;
a maximally permissive schema, if ever wanted, is a one-field wrapper
record, not a new root form.
3. Why Map is refused¶
First, the honest steelman: a homogeneous map is decidable. Because
every value shares one type, compatible_with over two maps reduces to
containment of their value types — no union-style candidate ambiguity. And
the ergonomic appeal is real: settings objects, lookup tables, and
{ [id]: User } indexes are everyday shapes. The refusal is not "it
can't be done."
It is refused because decidability is the floor, not the bar, and Map
fails three tests above the floor:
3.1 It opens the label alphabet. Every algorithm in the algebra —
prune's satisfiability walk, normalize's partition refinement over
local signatures, the isomorphism oracle, subschema's coinductive
containment, and above all extract — reasons over the finite set of
labels the schema declares. A Map position has an infinite label
alphabet. This is not one feature; it is a special case threaded through
every operation. extract(labels...) becomes outright ill-defined at a
Map: every label matches, so "the minimal subschema recognizing only
these labels" has no answer there.
3.2 It reintroduces the union ambiguity at the shape level. Is
{"red": 5, "blue": 3} a Record with fields red and blue, or a
Map of string → integer? There is no principled answer — only
heuristics (key-count thresholds, shape-uniformity guesses). That is
exactly the "more than one candidate, no principled winner" failure that
disqualified unions, relocated from the scalar level to the shape level.
infer in particular would be forced to guess.
3.3 It breaks the multi-format claim. Arbitrary map keys are not valid
XML element names. A canonical model that cannot round-trip one of its
four formats at Map positions has quietly given up "multi-format from
day one." (Contrast §5: an any subtree is still an ordinary Document
subtree, so every format constraint that already holds keeps holding.)
3.4 The sanctioned encoding for key-value data¶
Open key-value data already has a fully closed representation — the entry list:
record Env { "entry" [0,]: EnvEntry }
record EnvEntry { "key": string, "value": string }
root Env
This round-trips all four formats (repeated <entry> elements in XML,
naturally), touches zero algorithms, and keeps every guarantee intact.
Its honest limits, stated rather than hidden: it does not enforce key
uniqueness (two entries with the same "key" both validate), it loses the
"these are semantically keys" signal, and — most importantly — it requires
owning the data shape. You can use it for data you produce; you cannot
use it to validate third-party data whose shape you don't control. That
last gap is one of the motivations for any (§6.1), not a reason to
readmit Map.
4. The dividing line, stated precisely¶
Two observations explain why any survives the tests Map fails:
4.1 any is a lattice top; Map is a partially overlapping sibling.
any absorbs totally: every type T satisfies T ⊑ any, and any ⊑ T
only when T is any. Total absorption yields trivially clean, decidable
containment rules. Map, by contrast, partially overlaps Record — a
closed record instance is also a valid instance of a compatible map — and
partial overlap between kinds is precisely what makes containment,
minimization, and inference messy. Tops are clean; siblings are messy.
(A pleasant corollary: with any, the subschema lattice becomes bounded —
the empty schema at the bottom, the all-accepting schema at the top.)
4.2 any never opens the label alphabet. An any field's label is
declared and fixed; the field is a schema leaf. Nothing inside it is
schema-addressable, so no algorithm's finite-alphabet assumption is
touched. Map opens labels — the structural currency of the whole
algebra; any opens only a leaf's value domain.
And any passes the no-ambiguity test that killed unions: a union is
several candidates competing for one value with no principled winner;
any is one declared candidate that accepts everything. Validation
never has to decide anything.
5. Precedent¶
The concept is classical in exactly this domain and ubiquitous elsewhere:
| System | Escape hatch | Discipline |
|---|---|---|
| DTD / XSD | ANY, xs:any |
Explicit, with processContents modes |
| JSON Schema / OpenAPI | {} / true, additionalProperties |
Open by default; ecosystem lint fights to close it |
| Protobuf | google.protobuf.Any, Struct |
Explicit; standard for passthrough payloads |
| TypeScript | any vs unknown |
any infects and propagates; unknown is contained — the community's hard-won lesson |
| Pydantic | typing.Any |
Explicit unvalidated passthrough |
The pattern worth copying is not "have an escape hatch" but "make it
explicit, contained, and never chosen by the tool." Omnist's proposed
any is closed-by-default with a single grep-visible opening that infer
never produces — the configuration other ecosystems' lint rules try to
reach retroactively, here by construction.
6. Why any earns its place (the industry case)¶
6.1 Schemas usually describe data the author doesn't control. Without
any, a schema must describe the entire document or validation fails on
the parts not yet modeled — so Omnist can only validate documents that fit
entirely inside the author's modeling budget. Third-party webhooks,
vendor configs, and spec'd-open sections are simply out of reach, not
merely inconvenient. The entry-list encoding (§3.4) doesn't help here: it
requires restructuring data you don't own.
6.2 The envelope pattern. Possibly the most common JSON validation
task in industry: a rigorously typed envelope around a variant payload —
Stripe/GitHub webhooks, CloudEvents, message-queue messages. Omnist has no
unions, so today it cannot process these documents at all. With any, the
clean two-stage pattern works: validate the envelope (closed), dispatch on
the type field in application code, then validate the payload against a
per-event-type schema (also closed). Note this does not smuggle unions
back in: the schema never chooses among candidates — the application
dispatches explicitly, and each validation step remains fully closed and
decidable. The choice lives in user code, where it is visible and
debuggable, not in the algebra.
6.3 Spec'd-open formats. pyproject.toml is the canonical TOML
example: the core tables are specified, but [tool.*] is defined to be
open — third-party tools own those subtrees. A pyproject schema is
impossible in today's model and natural with any. The same shape recurs
everywhere: Kubernetes annotations, docker-compose x- extensions, CI
configs with plugin sections.
6.4 Gradual adoption. Gradual typing is why TypeScript and mypy won
adoption: "type the 80% you understand, escape-hatch the rest, tighten
over time." All-or-nothing schemas are an adoption cliff. And Omnist ships
its own tightening ratchet: infer never emits any (§7), but pointed
at the real documents flowing through an any region it proposes the
replacement schema. The escape hatch comes with its own exit.
6.5 Signal-to-noise. Validating a document with an unmodeled section
today produces a wall of unexpected field errors — noise that teaches
users to distrust the tool. Deliberately silencing a known-unknown region
makes real errors stand out.
7. Operation-by-operation study¶
The requirement for adoption is that every operation extends to any with
small, local, provably correct rules — no pervasive special-casing. The
study finds exactly that:
| Operation | Rule for any |
Notes |
|---|---|---|
validate |
At an any-typed field: accept, do not descend. |
Cardinality of the label still enforced; null accepted (any is top). |
compatible_with |
T ⊑ any for every T; any ⊑ T only if T is any. |
Two clauses in the coinductive _sub; everything else unchanged. |
equivalent |
any ≡ any only. |
Follows from bidirectional containment. |
normalize |
any is one more atomic kind in the partition refinement — effectively an eighth scalar for signature purposes. |
Records merge only when any-ness of corresponding fields matches. |
prune / is_empty |
any is always satisfiable. |
Can never create a useless or unsatisfiable state; if anything, pruning simplifies. |
extract |
An any field is a schema leaf: kept or dropped wholesale by its own label. |
Nothing inside is schema-addressable, so extraction cannot (and need not) reach in. |
infer |
Never emits any. |
Not a carve-out: infer's contract is "the most specific schema accepting the examples," and any is never most-specific. The rule falls out of the spec. |
materialize |
Pass the subtree through exactly as the reader produced it — no leaf upgrades inside any. |
The conformance guarantee holds trivially. Inside any, values behave as under schema=None, scoped to the subtree. |
writers / check_* |
Untouched. | They operate on Documents, not schemas; an any subtree is an ordinary Document subtree. |
| isomorphism oracle | any matches only any in local_signature. |
One more kind tag. |
Testing note (the one real piece of infrastructure work): the semantic
oracle brute-force-enumerates documents against set-theoretic ground
truth. any's value space is infinite, so the oracle needs a finite
witness strategy for any positions — a representative sample (one value
of each scalar kind, null, a small record, bounded nesting). This is
test-harness design, not a soundness question, but it is real work and
part of the implementation cost.
8. Worked examples: the trade-offs, concretely¶
The
anyexamples below are illustrative — the keyword does not parse today. Ifanyis adopted, they become executable doc-tests per the project's docs-as-tests convention. TheMapexamples are deliberately not valid OSD, and never will be.
8.1 What any buys: one schema for a webhook stream¶
record Event {
"id": string,
"type": string,
"created": datetime,
"data": any,
}
root Event
Both of these documents validate against that one schema:
{"id": "evt_1", "type": "user.created",
"created": "2026-07-01T09:30:00",
"data": {"name": "Ann", "email": "ann@example.com"}}
{"id": "evt_2", "type": "payment.settled",
"created": "2026-07-01T09:31:00",
"data": {"amount_cents": 1250, "currency": "EUR"}}
Today's closed model cannot express this schema at all: there is no type
for "data" that accepts both payloads, because there are (deliberately)
no unions. Stage two of the pattern: the application reads "type", then
validates the payload against a per-event-type closed schema —
record PaymentSettled { "amount_cents": integer, "currency": string }
root PaymentSettled
— so each validation step stays closed and decidable; the dispatch lives in user code, visibly.
8.2 What any costs: the compatibility blind spot¶
The producer team renames a payload field between versions. Both schema
versions type the payload any:
# v1 # v2
record Event { record Event {
"type": string, "type": string,
"data": any, "data": any,
} }
root Event root Event
Payloads change from {"amount_cents": 1250} to {"amount": 1250} — a
breaking change for every consumer. Yet v2.compatible_with(v1) returns
True, vacuously: inside any, nothing is compared. Had the payload
been closed —
record Event { "type": string, "data": Payment }
record Payment { "amount_cents": integer }
root Event
— the same rename would make compatible_with return False, catching
the break before it ships. Checking ends exactly where any begins:
the flagship guarantee is only as strong as the schema's closed portion.
8.3 A second cost of any: no conversions inside¶
record R { "when": datetime, "data": any }
root R
Reading {"when": "2026-07-01T09:30:00", "data": {"since": "2024-01-01"}}
with schema= upgrades "when" to a real datetime object — but
data.since stays the plain string "2024-01-01". Inside any, values
arrive exactly as the reader produced them (the schema=None behavior,
scoped to the subtree). Predictable, but a surprise if undocumented.
8.4 The Map ambiguity, concretely¶
One document, two would-be readings:
{"red": 5, "blue": 3}
record Palette { "red": integer, "blue": integer } # closed reading (valid today)
record Counts { map[string]: integer } # open reading (refused)
Both accept the document, and nothing in the data says which is meant.
infer would be forced to guess between them on every input;
extract("red") is well-defined under the first reading and meaningless
under the second. And in XML, a key like "light blue" cannot even be an
element name. This is §3.2 and §3.3 run on real data.
8.5 The entry-list encoding: what the closed alternative gives and gives up¶
The sanctioned pattern (§3.4), valid today:
record Env { "entry" [0,]: EnvEntry }
record EnvEntry { "key": string, "value": string }
root Env
What it gives: full guarantees, extract/normalize/compatible_with
all meaningful, round-trips XML naturally as repeated <entry> elements.
What it gives up, shown rather than told — duplicate keys validate:
{"entry": [{"key": "A", "value": "1"},
{"key": "A", "value": "2"}]}
…and the data must be authored in this shape: a third party's
{"A": "1", "B": "2"} cannot be validated without restructuring it
first, which is exactly what you cannot do to data you don't own.
8.6 Trade-off summary¶
| Closed model (today) | + any |
+ Map (refused) |
|
|---|---|---|---|
| Envelope / variant payloads | impossible | ✓ two-stage dispatch (§8.1) | ✗ (map values must share one type — doesn't help) |
| Open-key data (env vars, labels) | entry-list encoding; requires owning the data shape (§8.5) | accepted but unchecked (over-broad) | ✓ natively, values still typed |
compatible_with guarantee |
total | total outside any; vacuous inside (§8.2) |
total in theory, but complexity leaks into every operation |
| Label alphabet (what the algebra reasons over) | finite, closed | finite, closed | infinite at map positions |
| XML round-trip | ✓ | ✓ | breaks on arbitrary keys |
infer |
most-specific, deterministic | unchanged (never emits any by default; --allow-any opts in loudly) |
forced heuristic guessing (§8.4) |
The table is deliberately honest about the residual gap: for open-key
data specifically, Map would have been the better tool than any —
values would still be typed, keys still open. The refusal is about what
Map costs the algebra (row 4), not a denial that the use case exists.
With Map refused and any adopted, natively-validated open-key data
with per-value typing remains unserved; the entry-list encoding and any
each cover part of it from different sides.
9. The costs, stated plainly¶
Adopting any changes the model's headline from "closed by
construction" to "closed by default; open only where explicitly
marked." That is a real weakening and this document does not pretend
otherwise. What keeps it acceptable:
- Openness is demarcated and grep-visible. Every hole in a schema's
guarantees is a literal
anytoken in its text.Map's openness would have been structural and pervasive; this is localized and auditable. compatible_withbecomes locally vacuous atany— the sharpest cost. Any change inside ananyregion is "backward compatible" by definition: true, but empty. A schema that is 40%anygives compatibility verdicts that are 40% meaningless while looking authoritative — the green-test-suite-full-of-mocks failure mode. Documentation must say this loudly whereveranyis described.- Downstream tooling burden. Any future codegen, form generator, or doc generator needs an "unchecked node" branch. Small but permanent.
- Positioning. "Everything checked, decidable" is the project's
differentiator. The precise claim survives: the differentiator was
never "no escape hatch," it was a decidable algebra — which
anypreserves and JSON Schema's default openness does not. But the marketing language must be updated honestly, starting with model.md §1, whose "no structureless escape hatches (Any, open objects, maps)" clause splits: open objects and maps stay refused;anybecomes the model's one deliberate, contained opening.
10. Guardrails (adopted as requirements, not suggestions)¶
If and when any is implemented, these ship with it:
- Never inferred.
inferproposes only closed schemas — the analog ofnoImplicitAny: the tool never introduces the hole; a human must write it. - Grammar discipline. Lowercase
anykeyword alongside the seven scalar keywords;any?rejected as redundant;rootgrammar unchanged. - The ratchet is documented. The
any-tightening workflow — runinferover real documents at ananyposition, review, replace — is documented next toanyitself, so the escape hatch and its exit are learned together. - Loud docs. Every place
anyis documented also states what it costs (§9.2, and the worked example in §8.2, in particular). - Future, not blocking: a
schema lint-style count/flag ofanyusage would give teams a governance dial (TypeScript'sno-explicit-anyprecedent). Noted for later; not part of the initial implementation.
11. Decision record¶
Map: refused, settled. Fourth recurrence of the openness question (scalar unions → multi-shape nulls → open records, during initial design;Mapvia external review, 2026-07). The refusal is now a documented position with a stated dividing line (§4), not a recurring debate. Future proposals that open the label alphabet — maps, open records, wildcard keys — are answered by §3 of this document.any: recommended for adoption; not yet scheduled. The study (§7–§8) finds every operation extends with small local rules and the algebra remains sound and decidable end to end. Implementation is a separate go/no-go: it requires an issue with the full plan (grammar + parser,_subclauses, signature/minimize kind, oracle witness strategy, docs including the model.md §1 revision, full test/fuzz coverage to the project's usual bar), a minor version bump (new capability), and the guardrails of §10. Until that lands, the shipped model remains exactly as model.md describes it.