Milano Document Model
Status: Beta v0.1.0 · 2026-08-15
Defines the abstract document model and its canonical JSON encoding: the top-level structure, the node envelope, values and expressions, core constructs, identity, validation, the error taxonomy, and resource limits. Everything here operates within the guarantees fixed by Foundations.
Version 0.1 scope. Per Foundations, v0.1 targets banners, interstitials, and simple document-defined forms.
Document structure
A document is a single JSON object, encoded in UTF-8, with reserved top-level sections. Unknown core fields follow the contract tolerance rules from Foundations.
| Section | Required | Purpose |
|---|---|---|
version | yes | The contract version the document targets (major.minor.patch) |
context | no | Declaration of the context keys the document reads: name and type |
state | no | Document-level state declarations: name and type |
actions | no | Document-local custom action declarations, in the vocabulary schema’s declaration format |
root | yes | The single root node |
metadata | no | Free-form producer data; validated as JSON, never interpreted by Milano, passed through to the host |
Documents separate structure from data. The context and state sections declare shapes only; no section of a document ever carries a variable data value, so a document is cacheable independently of the data it renders. Context values are supplied by the host’s context source; every declared key is required, and values are validated at the gate and on every update. State initial values are supplied by the host’s asynchronous state data provider, awaited during building and validated against the declarations. Provider failures propagate to the caller unchanged: they are host errors, not Milano errors.
Type system
Every declaration (state, context, component properties, action parameters, event payloads) uses one type language:
- Primitives:
bool,int(64-bit signed),double(IEEE 754 binary64),string(Unicode). arrayof any type.recordwith named, typed fields.- Types are non-nullable by default. Any type may be marked optional; JSON
nullis valid only for optional types, and an omitted optional value is equivalent tonull.
In JSON, a type descriptor takes one of three forms:
- A primitive is its name string, with a trailing
?for optional:"int","string?". - An array is an object with an
arraykey holding the element’s type descriptor, plus"optional": truewhen the array itself is optional. - A record is an object with a
recordkey holding a map of field name to type descriptor, plus"optional": truewhen the record itself is optional.
Integer and floating-point behavior (ranges, overflow, coercion rules in expressions) is fixed by the expression language spec and conformance-tested; a JSON number with a fractional part never satisfies an int declaration.
Validation of data values (supplied context, provider state, literal properties and parameters) against declarations follows two further rules, identical in both runtimes:
- An
intvalue satisfies adoubledeclaration and is canonicalized todouble, mirroring expression promotion. Adoublevalue never satisfies anintdeclaration. - Record values are validated strictly against their declared shape: an undeclared field is a mismatch; a missing optional field canonicalizes to
null; a missing non-optional field is a mismatch.
Node envelope
Every node is a JSON object with reserved keys. The envelope belongs to the contract: additions arrive only through contract versions, and a runtime encountering an envelope key it does not know applies the contract tolerance rules from Foundations (ignored, since only semantics-preserving additions can be minor).
| Key | Required | Purpose |
|---|---|---|
type | yes | Component type name from the vocabulary, or a core construct name |
id | no | Document-unique identifier; uniqueness validated at the gate |
properties | no | Map of property name to value (literal or expression) |
children | no | Ordered list of child nodes |
on | no | Map of event name to one action or an ordered list of actions |
There is no envelope key for data: nodes reference state and context through expressions, never carry values for them.
The $ prefix in type names is reserved for the contract; vocabulary component types must not begin with it. Whether a component type accepts children is declared by the vocabulary schema per component type; the gate enforces the declaration.
Values and expressions
Every property value is either a literal of the declared type or an expression.
- An expression is written as a reserved single-key wrapper object; its key is the expression marker
$expr, its value is an expression string in the grammar of the expression language spec. No other form of a value is ever dynamic: literal strings are always literal, no delimiters, no escaping rules. - Any declared property may be dynamic. The gate type-checks each expression’s result type against the property’s declared type.
- Expressions read document state and host context.
- References are namespaced by reserved roots:
state,context, andevent(only withinonaction bindings, giving access to the triggering event’s payload).
Actions
An action is a JSON object whose reserved action key names its type. Built-in actions live in the reserved $ namespace; custom action types use consumer-defined names with typed parameters, declared either in the engine’s vocabulary (global, shared by every document) or in the document’s own actions section (local). Local declarations use the same format and are validated identically; a local name that collides with a vocabulary action is a SchemaViolation. Dispatch is identical for both: the host handler receives name and parameters as data either way. Parameters are sibling keys of action, and any parameter value may be a literal or an expression (including expressions over event).
Components have no local equivalent: a component type requires a registered renderer, which is code, so component types are global by nature.
| Action | Parameters | Purpose |
|---|---|---|
$set | key: a declared top-level state key; value: the new value, literal or expression | Mutate document state; field-level targets inside records are not supported, the whole key is assigned |
$sequence | actions: ordered list of actions | Run actions in order |
$when | condition: bool expression; then: action list; else: optional action list | Conditional dispatch |
| custom | per its vocabulary declaration | Routed as data to the host’s action handlers |
- A bare JSON array of actions is shorthand for
$sequence. - Custom actions may carry
onSuccessandonFailurekeys, each an action or action list, bound to the handler’s asynchronous completion. Built-ins complete synchronously and do not take them. - Dispatch semantics (ordering, concurrency, completion after view teardown, event payload rules) are fixed in the state and actions spec; this spec fixes only the encoding.
Identity and paths
idis optional and must be unique across the document.- Every node also has a canonical structural path computed from its position: the root node’s path is
root, and each child appends/children[i]with its zero-based index (for exampleroot/children[2]/children[0]). - Observer reports and error details reference nodes by
idwhen present, canonical path otherwise.
Validation
The gate validates in a fixed, conformance-tested order, so identical documents fail identically on both platforms:
- Parse: well-formed JSON, correct envelope shapes.
- Version: declared major must be in the runtime’s supported set.
- Vocabulary: every
typeresolved against the schema; properties and events validated per component type; action objects validated (built-in parameters against this spec, custom actions against their vocabulary or document-local declarations); unknown types trigger the unknown-type policy; undeclared properties follow the strict-mode rules from Foundations. - Expressions: parse every expression, type-check results against declared property, state, and context types.
- Cross-checks:
iduniqueness, event bindings against declared events, context declarations versus supplied values, state declarations versus the values returned by the state data provider.
Steps 1 through 4 need only the document and the engine. Building is asynchronous overall: the gate then awaits the state data provider and completes the cross-checks. A provider failure propagates to the caller unchanged; values that do not match the declarations are a SchemaViolation.
Three rules complete the gate’s behavior:
- Supplied context and provider values may contain keys the document does not declare; they are ignored. The document reads only what it declares, which lets one context source serve many views.
- Occurrences detected during the gate (skipped or placeholder nodes, undeclared properties) are reported to the observer only when building succeeds; a failed build reports nothing.
- When the root node itself is an unknown type under the skip policy, the result is a valid, empty view: there are no siblings to keep.
Error taxonomy
The closed set of typed errors the gate can throw, with the structured detail each carries. Every error also carries a non-normative diagnostic message.
| Error | Raised when | Detail fields |
|---|---|---|
MalformedDocument | Input is not well-formed JSON or violates envelope structure | Location of the defect (path when determinable) |
UnsupportedVersion | Declared major is outside the runtime’s supported set | Declared version; the runtime’s supported majors |
SchemaViolation | Vocabulary, expression typing, action encoding, event, id, or namespace rules are violated; supplied context or initial-state values do not match declarations | Node reference (id or path); the rule violated; expected; found |
UnknownComponentType | A type not declared in the vocabulary is found and the effective policy is fail | Node reference; the unknown type name |
LimitExceeded | Any resource limit is exceeded at the gate | The limit’s name; its configured value; the actual value |
Runtime occurrences (rejected context updates, over-limit updates, dropped events, skipped or placeholder nodes) are not errors: they flow to the engine observer as defined in Foundations.
Resource limits
Defaults, adjustable per engine.
| Limit | Default |
|---|---|
| Maximum tree depth | 32 |
| Maximum node count | 10,000 |
| Maximum document size | 1 MiB |
| Maximum expression length | 1,024 characters |
Gate limits bound the document; update-triggered evaluation is bounded at runtime, where an over-limit update is rejected atomically per Foundations.
Notes
- An official schema,
schemas/document.schema.jsonin this repository, validates the canonical encoding’s structure (envelope shapes, type descriptors, expression wrappers, action encoding). Semantic rules stay with the gate: vocabulary resolution, expression typing, id uniqueness, and resource limits are not schema-checkable. metadatais host-only: it is never visible to expressions.- The identifier grammar for state keys, context keys, and all vocabulary names is defined in the vocabulary schema spec.
- Custom action and event payload declarations are owned by the vocabulary schema spec. Expression grammar and semantics are owned by the expression language spec. Dispatch semantics are owned by the state and actions spec.