Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Lattice Dashboard Spec

Lattice is a declarative JSON format for describing dashboards and a companion tool, lattice, that loads, validates, and resolves those documents into a flat, renderer-agnostic tree.

A dashboard document is a single JSON file: a manifest, a recursive root item tree, optional document-scoped variables, and optional document-scoped connections (data sources). Item types and connection types are described by versioned JSON Schemas in a local catalog; each node in the document is an instance of one of those types, referenced by $ref.

lattice does two things with such a document:

  • resolve — runs a two-pass validation and prints the resolved tree: a fully validated, interpolated, secret-redacted JSON structure that a renderer (or any downstream consumer) can walk without re-reading the source document or the schemas.
  • serve — starts a minimal HTTP server that re-resolves the document on every request and exposes both an HTML structural sketch and a JSON resolved-tree endpoint, with AlpineJS-driven runtime inputs.

This book is the human-facing reference for the format and the schema-catalog conventions, written so a fresh author — or a future renderer implementer — can use the spec without reading the Go source.

Where to start

Scope of this effort

This effort delivers the format, the schema catalog, and the resolver. It deliberately does not dial connections, render real charts, or apply styling. The precise boundary is enumerated in Out of Scope; read it before assuming a behavior exists.

Overview

What Lattice is

Lattice is a specification, not a running product. The deliverable is:

  1. A JSON document format for dashboards (schemas/dashboard.schema.json).
  2. A catalog of typed schemas for item types and connection types (schemas/items/, schemas/connections/).
  3. A resolver, shipped as the lattice binary, that validates a document against the catalog and emits a resolved tree.

A dashboard is a tree of items. The only structurally special item is the container, which arranges its children on a relative-weight grid. Every other item type (today: table and the variable widgets such as select) is a leaf. Items can declare variables, reference document-scoped connections for their data, and interpolate variable values into their configuration.

The resolution pipeline

lattice resolve runs a fail-fast, two-pass pipeline. The first error stops the run and is reported as a coded error (see Error Codes); errors are never aggregated.

  1. Pass 1 — structural. The whole document is validated against the dashboard JSON Schema. A document that is not well-formed JSON, or that violates the top-level shape, fails here (RESOLVE_DOCUMENT_INVALID).
  2. Reference linking. Every instance $ref is resolved to an item-type schema in the catalog (SCHEMA_* on failure).
  3. Variable model. The tree-scoped variable environment is built: document and per-container declarations are layered with inner-shadows-outer semantics, computed expr variables are evaluated, and any runtime overrides are applied.
  4. Pass 2 — per-instance. For each node, in one walk: layer its variable scope, interpolate variable references into its config, validate the interpolated config against its item-type schema, and enforce the container-only-children rule.
  5. Connections. Each document-scoped connection’s $ref is resolved and its config validated against the connection-type schema; $secret references are resolved from the environment for validation and then discarded. Duplicate connection ids fail fast.
  6. Bindings & contracts. Items that declare a connectionId are wired to their connection, their query (already interpolated) is lifted onto the node, and the item↔connection result-shape contract is validated.

The output is the resolved tree: the manifest passed through verbatim, the recursively resolved root, and the resolved connections. Its shape is a stable, documented contract — see Document Structure.

The two commands

CommandPurpose
lattice resolve <document>Validate a document and print the resolved-tree JSON to stdout.
lattice serve <document>Serve the document over HTTP: an HTML structural sketch plus a JSON resolved-tree endpoint, re-resolved per request with runtime inputs.

Both are covered in Building & Running.

Building & Running

Prerequisites

  • Go 1.26 or newer.
  • The build is pure GoCGO_ENABLED=0 is enforced globally by the Makefile so that no C-toolchain dependency can sneak into the build graph.
  • mdBook is only needed to build this documentation, not the binary.

Build

make build

This produces the CLI at bin/lattice (built with -trimpath and stripped linker flags). make with no target also builds, since build is the default goal.

Other Makefile targets mirror the conventions described in Conventions: make test, make vet, make lint (vet + staticcheck), make cover, make fmt, and make bench.

resolve

Validate a dashboard document and print its resolved tree as JSON:

lattice resolve examples/minimal-dashboard.json
FlagDefaultMeaning
--schemas <dir>schemasDirectory holding dashboard.schema.json and the item/connection catalog.
--jsonoffEmit a failing error as a machine-readable JSON envelope ({code,message,details}) on stderr instead of the CODE: message text form.

On success, the resolved-tree JSON is written to stdout and the process exits 0. On the first validation failure, the error is printed and the process exits non-zero. The --json flag is global, so it goes before the subcommand:

lattice --json resolve examples/minimal-dashboard.json

Documents with secrets

A document whose connections reference secrets via { "$secret": "NAME" } requires those environment variables to be set at resolution time, because the resolved value is needed to validate the connection config. For example, examples/kitchen-sink-dashboard.json references METRICS_API_TOKEN:

METRICS_API_TOKEN=xyz lattice resolve examples/kitchen-sink-dashboard.json

The token’s value never appears in the output — only the $secret reference object and a sorted list of consumed secret names are kept. See Connections.

serve

Serve a document over HTTP, re-resolving it on each request:

lattice serve examples/dropdown-dashboard.json
# lattice serving examples/dropdown-dashboard.json on http://localhost:8080
FlagDefaultMeaning
--schemas <dir>schemasCatalog directory (as for resolve).
--port <n>8080TCP listen port (1–65535).
--jsonoffJSON error envelope for invocation errors.

The server re-resolves on every request, so editing the document and reloading the page reflects the change, and a resolution error renders as an HTML error page (a rendered coded error) rather than crashing the server. Runtime variable overrides — widget selections and ?var=value URL query parameters — are threaded into resolution per request, which is what drives the live re-resolve loop. See Variables — Runtime inputs.

This server is a deliberately minimal structural sketch, not a rendering engine; see Out of Scope.

Using lattice as a library

Beyond the lattice binary, the resolver and write pipeline are usable directly from another Go module. The entire public surface is a single package, github.com/frankbardon/lattice/service — a transport-agnostic facade over the resolver, the changeset (RFC 6902) write pipeline, and the storage backends.

Everything else lives under internal/ and is not importable by Go’s internal-package rule. An external module programs against service.* (plus the root errors package) and never names an internal/... path. The CLI, and the future WASM and MCP frontends, are each a thin adapter over this same facade.

Two rules govern this surface — boundary types are opaque handles (name and read them, but construct only via the facade), and store capabilities are probed by error code. Both are spelled out in the Library API Contract.

Install

go get github.com/frankbardon/lattice@v0.4.0
import "github.com/frankbardon/lattice/service"

Two ways to construct a Service

Both return the same wired facade and expose the same methods.

ConstructorUse when
service.Open(Options{Backend, Root, Schemas})Batteries-included, real filesystem. Wires a store rooted at Root and a resolver over the Schemas fs.FS.
service.New(store, res)Injection. Wires an already-built Store and *Resolver without touching the filesystem — pair with NewStore / NewResolver.

Options.Schemas is a stdlib fs.FS: an os.DirFS("schemas") on a real disk, or an embed.FS whose top level holds dashboard.schema.json and the item-type catalog. Documents are addressed by their manifest.id, so a document with id example-minimal lives at <Root>/example-minimal.json.

Open, Resolve, Patch

svc, err := service.Open(service.Options{
	Backend: service.BackendFS,  // or service.BackendGit for write history
	Root:    ".",                // directory of <id>.json documents
	Schemas: os.DirFS("schemas"),
})
if err != nil {
	// every failure is an *errors.CodedError with a stable Code + Details map
}

// Resolve loads ./example-minimal.json by manifest.id and runs the two-pass
// resolver. The second argument is the runtime override map (nil applies none).
tree, err := svc.Resolve("example-minimal", nil)

// A validated RFC 6902 edit: ParseChangeset returns an opaque handle; Patch runs
// the atomic apply -> validate -> re-resolve -> save pipeline and persists only
// on full success. Pointers are id-rooted ("/<node-id>/config/..."), with
// document scopes under a "$"-prefixed token ("/$manifest", "/$theme").
cs, err := svc.ParseChangeset([]byte(
	`[{"op":"replace","path":"/$manifest/title","value":"Renamed"}]`))
res, err := svc.Patch("example-minimal", cs)

The verb set

A *Service built either way exposes:

  • ReadResolve(id, overrides) and ResolveBytes(b, src, overrides) run the two-pass resolver (store-addressed vs. in-memory); Load(id), List(), Exists(id) are byte-level store reads.
  • WriteParseChangeset(b) → opaque *Changeset; Patch(id, cs, opts...) applies it atomically (pass WithExpectedRevision(rev) for an optimistic-concurrency precondition); Save(document) writes unvalidated whole bytes; Delete(id) removes a document.
  • HistoryHistory(id) and LoadAt(id, rev) read version history (git backend only); Revision(id) returns the current token to pair with WithExpectedRevision. These are capability-gated: a backend that lacks the capability is rejected with STORAGE_CAPABILITY_UNSUPPORTED rather than degrading silently. See the capability matrix for which backend supports what and how to probe.

Injection path (custom store / embedded schemas)

When the store or schema catalog should not come from the OS filesystem — an in-memory store, a custom Store implementation, or schemas baked in via embed.FS — build the pieces with the low-level builders and wire them with New. This is the path the future WASM and MCP frontends use.

//go:embed schemas
var schemaFS embed.FS

res, err := service.NewResolver(schemaFS)  // fs.FS: os.DirFS or embed.FS
store, err := service.NewStore(service.BackendFS, afero.NewMemMapFs(), "docs")

svc := service.New(store, res)             // same verb set as Open returns

NewStore(backend, fs, root) takes an afero.Fs, so any afero-backed filesystem works; NewResolver(schemas) takes a stdlib fs.FS.

Compile-checked examples

Both flows above exist as Go Example functions in service/example_test.go, so the documented usage is verified against the real signatures by go test ./service/... and cannot drift silently.

Library API Contract

This page is the reference companion to Using as a Library. That page walks through how to construct and drive a Service; this one states the two non-obvious rules a public caller must understand to use the surface correctly, plus the stability intent behind it.

The public surface

The entire public Go surface of lattice is the single package github.com/frankbardon/lattice/service, plus the root errors package. Everything else lives under internal/ and is not importable by Go’s internal-package rule. This is the v0.4.0 contract: service is the public seam, the cores stay internal/ so their shapes remain free to change, and each transport (the CLI, and the planned WASM and MCP frontends) is a thin adapter over this one facade.

Rule 1 — Boundary types are opaque handles

The boundary types service re-exports — ResolvedTree, Changeset, ApplyResult, ApplyOption, Store, RevisionedStore, VersionedStore, Revision, Resolver, OverrideSet, Backend — are Go type aliases (=) of their internal/... definitions, not new named types. A service.ResolvedTree is the resolver.ResolvedTree the cores produce, so no conversion happens at the seam.

What an alias gives you and what it does not:

  • You can name and read these types through service.* — declare variables of them, range over []service.Revision, read fields like Revision.Hash or ResolvedTree.Manifest.
  • You cannot construct the ones carrying unexported fields (Resolver, the Store implementations, Service itself) by building a struct literal — Go forbids setting unexported fields from outside the defining package.

Instead, you obtain values of these types only from the provided constructors and methods:

To get a…Call
*ServiceOpen(Options{...}) or New(store, res)
*ResolverNewResolver(schemas)
StoreNewStore(backend, fs, root)
*Changesetsvc.ParseChangeset(b)
ResolvedTreesvc.Resolve(...) / svc.ResolveBytes(...)
ApplyResultsvc.Patch(...)
ApplyOptionWithExpectedRevision(rev)

Why it is built this way: keeping construction under the facade keeps the invariants those values carry — structural and config validation, apply atomicity, byte-faithful storage — under the facade’s control too. A caller can never hand a half-built Resolver or an unwired Service to a method, because it cannot build one. The boundary types behave as opaque handles: name them, read them, pass them back to the facade — but mint them only through the constructors above.

Rule 2 — Capabilities are flat, probed by error code

Version history and the current-revision token are optional store capabilities, and they are not uniform across backends. Rather than expose a separate capability-checking API, service makes them flat methods on *Service that return a coded error when the wired backend lacks the capability.

Capability matrix

MethodCapability neededBackendFSBackendGit
History(id)VersionedStore
LoadAt(id, rev)VersionedStore
Revision(id)RevisionedStore

The filesystem backend derives a content hash for Revision, so the optimistic-concurrency precondition works on both backends; only read-side history (History / LoadAt) is git-only. A custom or injected Store may implement neither.

Probing a capability

There is no Supports(...) call. A caller probes by calling the method and checking the error code: when the backend lacks the capability, the method returns an *errors.CodedError with code STORAGE_CAPABILITY_UNSUPPORTED (carrying Details["id"]) rather than degrading silently.

import "github.com/frankbardon/lattice/errors"

revs, err := svc.History("example-minimal")
switch {
case err == nil:
	// backend supports history; revs is usable (newest-first)
case errors.HasCode(err, errors.STORAGE_CAPABILITY_UNSUPPORTED):
	// the wired backend is not a VersionedStore (e.g. BackendFS) —
	// fall back, or surface "history unavailable" to the user
default:
	// a real failure (e.g. STORAGE_NOT_FOUND for an unknown id)
}

errors.HasCode(err, code) is the supported way to branch on a code; the same pattern applies to LoadAt and Revision.

Optimistic concurrency

Revision(id) returns the opaque current-revision token (compare-only — never parse it). Pass it to a Patch via WithExpectedRevision(rev) to make the write conditional: the apply re-reads the store’s current revision immediately before saving and, on mismatch, rejects the whole changeset with code CHANGESET_REVISION_CONFLICT — nothing is persisted, so you can reload, re-derive the changeset against the new bytes, and retry.

rev, err := svc.Revision("example-minimal")          // current token
// ... build cs ...
res, err := svc.Patch("example-minimal", cs, service.WithExpectedRevision(rev))
if errors.HasCode(err, errors.CHANGESET_REVISION_CONFLICT) {
	// the document changed since you read it — reload and retry
}

(If you supply WithExpectedRevision to a store that does not implement RevisionedStore at all, the apply is rejected with CHANGESET_REVISION_UNSUPPORTED rather than ignoring the precondition you asked for.)

Conventions

Lattice mirrors the engineering conventions of its sibling project pulse. pulse is a convention source only — it is not a runtime dependency. The conventions below apply to the codebase; document authors mainly care about the format chapters, but these explain the shape of the tooling.

Language & build

  • Go 1.26, module path github.com/frankbardon/lattice.
  • Pure Go. CGO_ENABLED=0 is exported globally by the Makefile, making the no-C-toolchain rule a build contract rather than a convention.
  • The binary is built to bin/lattice via make build (-trimpath, stripped).

Tooling

  • Lint is go vet plus staticcheck; there is no golangci-lint. make lint runs both.
  • Tests use the standard library testing package only — no testify or other assertion libraries. Tests are func TestXxx(t *testing.T) using t.Error* / t.Fatal*, table-driven where it helps. Benchmarks live in *_bench_test.go and run via make bench (not part of make test).
  • Configuration is via CLI flags, never config files.

Code style

  • Lowercase, single or compound package names; short receiver names.
  • Enums are typed string constants: type X string with const X_FOO X = "X_FOO".
  • Every serialized type carries JSON struct tags — the serialized form is the contract, not the Go field names.

Error handling — coded errors

All failures are CodedError values carrying a typed Code, a message, optional structured Details, and an optional wrapped cause. Codes are grouped by domain (RESOLVE_*, SCHEMA_*, VAR_*, CONNECTION_*, LAYOUT_*, SERVE_*). CodedError implements json.Marshaler, which is what powers the --json output. Resolution is fail-fast: the first coded error is returned. The full list is in Error Codes.

Schema-catalog conventions

  • All schema $ids share the stable base https://lattice.dev/schemas/. This base is an identifier namespace, not a live fetch target — the resolver loads schemas from the local catalog and keys them by $id.
  • Item-type and connection-type schemas embed a semver in the path, so multiple versions can coexist: https://lattice.dev/schemas/<name>/<major>.<minor>.<patch>.

These are detailed in Schema Catalog.

Documentation

This book is an mdBook. Build it with mdbook build docs (output goes to docs/book/, which is gitignored). The live site is published from docs/src/ to https://frankbardon.github.io/lattice/.

Document Structure

A dashboard document is a single JSON object validated against schemas/dashboard.schema.json ($id: https://lattice.dev/schemas/dashboard/1.0.0). It has two required members and two optional ones:

{
  "manifest": { ... },
  "root": { ... },
  "variables": [ ... ],
  "theme": { ... },
  "connections": [ ... ]
}

No other top-level keys are allowed (additionalProperties: false).

manifest (required)

Document-level metadata.

FieldRequiredNotes
formatVersionyesSemver of the document format, e.g. "1.0.0" (pattern ^\d+\.\d+\.\d+$).
idyesStable machine-readable identifier for the dashboard.
titleyesHuman-readable title.
descriptionnoLonger-form description.
authornoAuthor or owner.

The manifest is passed through verbatim into the resolved tree.

root (required)

The root item instance. Every item in a dashboard — including the root — is an instance node of this shape:

{
  "$ref": "https://lattice.dev/schemas/items/container/1.0.0",
  "id": "root",
  "config": { ... },
  "placement": { ... },
  "variables": [ ... ],
  "children": [ ... ]
}
FieldRequiredNotes
$refyesURI of the item-type schema this node instantiates.
idnoInstance-local identifier, unique within the document.
confignoPer-instance configuration; its shape is defined by the referenced item type and is opaque at the document level.
placementnoLayout hints interpreted by the parent container’s grid.
variablesnoVariable declarations scoped to this node and its descendants (meaningful on containers).
childrennoChild instances.

The root is conventionally a container, and under the tree grammar (below) it must be a positional region type.

The tree grammar

children is structurally permitted on any instance by the schema, but where each kind of node may actually appear is governed by the tree grammar, a fail-fast resolver pass over the assembled tree. In short: the root holds only positional regions; a container holds nested regions or block wrappers (a bare content leaf must be wrapped in a block); a variable-box holds variable widgets directly; a block wraps exactly one content leaf and never re-wraps a block; and a region carries no theme. The full set of rules, the block wrapper, the positional marker, and the grammar error codes are documented on the Blocks & the Tree Grammar page.

The container is a positional region: it arranges its children on a relative-weight grid (config.grid) — unitless column and row track weights plus a gap — and children place themselves with explicit, 1-indexed placement coordinates (colStart, colSpan, rowStart, rowSpan, each defaulting to 1). The resolver normalizes the tracks to fractions summing to 1 per axis and validates each placement against the grid bounds (LAYOUT_PLACEMENT_INVALID, LAYOUT_PLACEMENT_OUT_OF_BOUNDS). No CSS keywords or absolute units appear anywhere — the grid is renderer-agnostic.

variables (optional)

An array of document-scope variable declarations. They are visible to every node unless shadowed by a same-named declaration on a descendant container.

theme (optional)

The document-scope default theme — the document’s base presentation choices, drawn from the shared semantic-token vocabulary. It is the default layer; per-block theme overrides are emitted side by side and never merged into it. See Theme.

connections (optional)

An array of document-scoped connection instances — data sources that items bind to by id. Connections are declared and validated only; they are never dialed.

The resolved tree

lattice resolve emits a resolved tree whose shape is a stable, JSON-tagged contract (additive changes only). Its top-level members are:

{
  "manifest": { ... },
  "root": { ... resolved instance ... },
  "defaultTheme": { ... },
  "connections": [ ... resolved connections ... ]
}

defaultTheme is the document’s default theme (the default layer only — per-block overrides ride on their own block nodes, never merged here); omitted when the document declares no theme.

A resolved instance records more than the source node, because resolution has already validated and computed several things:

FieldMeaning
idCopied from the source instance (omitted if none).
typeThe resolved type identity: the raw ref, the canonical id, and the parsed name/version.
containertrue when the resolved type is a container. Surfaced so consumers need not re-derive it.
configThe interpolated, schema-validated config (variable references already substituted).
placementVerbatim placement hints.
layoutFor containers only: the normalized grid (fractional track sizes + each child’s validated placement).
childrenResolved child instances, in document order.
varEnvThe variable environment visible at this node (see Variables).
bindingFor bound items only: the resolved data binding (see Connections).

Because the resolved tree is fully validated, downstream consumers (a renderer, the serve inspector, a future dependency tracker) may assume every node is structurally valid and type-checked.

Blocks & the Tree Grammar

A Lattice document is not a free-form tree of arbitrary nodes. Beyond per-item schema validation, the resolver enforces a tree grammar: a small set of rules about where each kind of node may appear. Those rules turn on two primitives this page describes together:

  • the block wrapper — the mandatory, single-layer wrapper that carries every cross-cutting per-block concern (a stable id, an optional theme override, a title, a visibility flag) and holds exactly one content leaf, and
  • positional regions — the layout-only types (container, variable-box) marked with the schema-level positional keyword, which position children and carry no chrome of their own.

The grammar pass (internal/resolver/grammar.go) runs once over the assembled resolved tree, fail-fast: the first violation stops the walk and is returned as a CodedError naming the offending path.

The block wrapper

The block (schemas/items/block.schema.json, https://lattice.dev/schemas/items/block/1.0.0) is a wrapper item type — distinct from container. A container groups and arranges a grid of children; a block arranges nothing. It wraps a single inner content item and applies its per-block concerns to that one leaf.

A block holds its inner item in config.content, not in the document children array:

{
  "$ref": "https://lattice.dev/schemas/items/block/1.0.0",
  "id": "fruits-block",
  "config": {
    "id": "fruits-block",
    "title": "Fruits",
    "visibility": true,
    "theme": { "emphasis": "high", "tone": "accent" },
    "content": {
      "$ref": "https://lattice.dev/schemas/items/table/1.0.0",
      "id": "fruits",
      "config": { "title": "Fruits", "columns": [ ... ], "rows": [ ... ] }
    }
  }
}

The block’s own concerns are:

FieldRequiredMeaning
idyesStable, machine-readable anchor for the block, unique within the document. It is what a configurator target or a JSON Patch changeset addresses the block by. Distinct from an instance’s optional layout-local id.
contentyesThe single inner content item the block wraps — one instance node referencing an item-type schema via $ref. Exactly one.
titlenoOptional human heading/label rendered with the content. Tunable at runtime (it is on the block’s configurable surface).
visibilitynoWhether the block (and its content) is shown; defaults to true. Tunable at runtime.
themenoAn optional per-block theme override, drawn from the shared theme vocabulary. A partial subset is allowed; an out-of-vocabulary token is rejected by config validation.

How a block resolves

The resolver emits the wrapper and its inner content as two separate nodes:

  • The wrapper node carries only its own concerns. The content field is lifted out of the wrapper’s resolved config, so a consumer reads the inner item once, as a node — never duplicated inside the wrapper.
  • The inner content leaf is resolved identically to how it would resolve unwrapped — same scoped variable environment, same interpolation, same config validation — and emitted as the wrapper’s single child in children.

So in the resolved tree a block appears as a node with exactly one child: the content it wraps.

Two wrapper invariants are guarded fail-fast (defense-in-depth over the schema):

  • WRAPPER_ID_MISSING — the block’s id is absent or whitespace-only.
  • WRAPPER_CHILD_COUNT_INVALID — the block does not wrap exactly one content item (its content is absent, null, or not a single instance object).

The block’s theme override rides through ordinary config validation (the block schema $refs the theme schema) and is then attached verbatim. The resolver performs no cascade, merge, or effective-theme computation — see Theme.

Positional regions and the positional marker

A positional region is a layout-only type: it positions its children and carries no chrome or theme of its own. A type is a region when its schema sets the top-level positional: true marker. Two regions ship today:

  • container — groups arbitrary items on a relative-weight grid (config.grid). See Document Structure and the Schema Catalog.
  • variable-box (schemas/items/variable-box.schema.json, .../items/variable-box/1.0.0) — the dedicated home for the variable widgets (text-input, slider, select, …). Like a container it is layout-only, but it holds its widget children directly — they are not individually block-wrapped, because the box (not a per-widget wrapper) supplies their grouped presentation downstream. Its only surface is a layout-only arrangement (stacked | inline) — the analogue of a container’s grid, with no chrome.

The marker is the single source of truth for which types are legal regions. The grammar pass reads it via the catalog (ResolvedType.IsPositional) rather than any hardcoded type list, so adding a new positional type makes it a legal root/region child with no change to the grammar code.

The grammar rules

The grammar pass enforces these structural rules over the assembled tree:

RuleViolation code
Root holds only positional regions. The document root accepts only region types (marker-driven). A content leaf or a block wrapper directly under root fails.GRAMMAR_ROOT_CHILD_INVALID
A container holds nested regions or block wrappers. A container may nest other positional regions or hold block wrappers — but a bare content leaf under a container fails: content must be block-wrapped.GRAMMAR_REGION_CHILD_INVALID
A variable-box holds variable widgets, directly. Every child must be a variable widget held directly — not wrapped, not a nested region.GRAMMAR_VARIABLE_BOX_CHILD_INVALID
A block never re-wraps a block. A block holds exactly one content leaf, and that leaf may not itself be a block wrapper. (The exactly-one count is guarded by the block pass; this rule adds the no-recursion check.)GRAMMAR_WRAPPER_NESTED
A positional region carries no theme. Regions are layout-only; only block wrappers carry chrome. A theme on a region node is rejected.GRAMMAR_REGION_THEME_FORBIDDEN

Each error names the offending path (and, where relevant, the offending type) in its details. See the Error Codes reference.

Why the wrapper is mandatory and flat. Cross-cutting concerns (identity, theme, visibility, title) live in one place — the block — rather than being scattered across every leaf item type. The single flat layer keeps the model simple: a content leaf is wrapped exactly once, a region groups wrappers, and the resolver stays dumb — it validates the shape and attaches the concerns, and a downstream builder owns mixing, rendering, AI, and persistence.

A worked shape

examples/minimal-dashboard.json is the canonical legal shape: a root container holds a body region whose two-column grid places two block-wrapped static tables. examples/themed-dashboard.json adds the three theme/grammar constructs side by side — a document default theme, a per-wrapper override on one block, and a variable-box holding a widget directly. See Examples.

Typed Schemas & Instances

Lattice separates types from instances.

  • A type is a JSON Schema (draft 2020-12) describing the configuration of one kind of item or connection. Types live in the catalog under schemas/items/ and schemas/connections/ and each has a versioned $id.
  • An instance is a node in a dashboard document that uses a type by pointing at it with $ref and supplying a config that conforms to that type’s schema.

This is the central pattern of the format: the document carries the structure and the configuration values; the schema catalog carries the rules for what a valid configuration looks like.

{
  "$ref": "https://lattice.dev/schemas/items/table/1.0.0",
  "id": "fruit-table",
  "config": { "title": "Fruit Inventory", "connectionId": "inline-fruits" }
}

Here the instance’s $ref names the table type at version 1.0.0; the resolver validates config against schemas/items/table.schema.json.

$ref resolution

The resolver links every instance $ref to a type before validating its config. A $ref may resolve in several ways:

  • A catalog $id — an absolute https://lattice.dev/schemas/... URI that matches a schema’s $id in the loaded catalog (the normal case).
  • A relative file — a path looked up under configured relative roots.
  • An inline $defs fragment — a #/$defs/... pointer carrying no versioned $id.

A $ref that resolves to nothing fails fast with SCHEMA_REF_UNRESOLVED, and a reference to a known name at a missing or mismatched version fails with SCHEMA_VERSION_MISMATCH.

The resolved tree records the resolved identity in each node’s type block: the raw ref, the canonical id, and the parsed name and version (the latter two empty for inline fragments that carry no versioned $id).

Versioned $id convention

Catalog $ids use the stable base URL https://lattice.dev/schemas/. That base is an identifier namespace, not a live fetch target — the resolver keys schemas by $id from the local catalog and never makes a network request to resolve a $ref.

Item-type and connection-type schemas embed a semver in the $id path:

https://lattice.dev/schemas/<name>/<major>.<minor>.<patch>

For example https://lattice.dev/schemas/items/table/1.0.0. Embedding the version in the identifier lets multiple versions of the same type coexist in the catalog, and lets a document pin exactly the type version it was authored against. The dashboard document schema itself follows the same convention (https://lattice.dev/schemas/dashboard/1.0.0).

Config validation is additive per type

Each item type’s schema defines its own config shape. The resolver validates the interpolated config (variable references already substituted) against the type schema; a mismatch is RESOLVE_CONFIG_INVALID, naming the offending instance path. An absent config validates as an empty object, so a type’s required-field constraints still apply.

Some keywords in a type schema are read by the resolver rather than by config validation. The most important is expectedResult on the table type: it is a schema-level keyword (not instance config) that declares the result-shape contract and is ignored by JSON Schema config validation.

Schema Catalog

The schema catalog is the set of JSON Schema (draft 2020-12) definitions the resolver loads and keys by $id. It lives under schemas/ and is described by schemas/README.md.

Layout

schemas/
├── dashboard.schema.json        # top-level document schema
├── theme/
│   └── theme.schema.json        # the semantic-token theme vocabulary
├── items/                       # item-type schemas (instance $ref targets)
│   ├── container.schema.json    # positional region (grid)
│   ├── variable-box.schema.json # positional region (variable widgets)
│   ├── block.schema.json        # mandatory single-leaf wrapper
│   ├── table.schema.json
│   ├── text-input.schema.json   # string-family widget
│   ├── slider.schema.json       # number-family widget
│   ├── toggle.schema.json       # boolean-family widget
│   ├── select.schema.json       # enum-family widget
│   └── multiselect.schema.json  # array-family widget
└── connections/                 # connection-type schemas
    ├── http.schema.json
    └── static.schema.json

The full widget catalog (13 item types across five families) is documented on the Widgets page; only a representative item per family is shown above.

The resolver scans the catalog directory (default schemas/, overridable with --schemas) recursively for *.schema.json files and indexes them by $id.

dashboard.schema.json

The top-level document schema (https://lattice.dev/schemas/dashboard/1.0.0). It defines the manifest, the recursive instance shape used for root and every node beneath it, the varDeclaration shape, and the connection instance shape. See Document Structure.

items/ — item types

Each item type is referenced by an instance $ref.

container (.../items/container/1.0.0)

A positional region (see the positional marker): it groups children and arranges them on a relative-weight grid. config.grid holds unitless columns and rows track weight lists (normalized by the resolver to fractions summing to 1 per axis) and a relative gap. An axis with no track list is a single implicit full-size track. Children place themselves with explicit 1-indexed placement coordinates. No CSS units or keywords appear.

variable-box (.../items/variable-box/1.0.0)

A positional region dedicated to holding the variable widgets. Like container it is layout-only and carries no chrome/theme; it holds its widget children directly (they are not block-wrapped). Its only surface is a layout-only arrangement (stacked | inline). See Blocks & the Tree Grammar.

block (.../items/block/1.0.0)

The mandatory wrapperdistinct from container — that wraps exactly one inner content item (held in config.content) and carries the cross-cutting per-block concerns: a required stable id, an optional theme override, a title, and a visibility flag. The resolver emits the wrapper and its inner content as separate nodes. See Blocks & the Tree Grammar.

form (.../items/form/1.0.0)

The other structurally special type: like container it may carry children, but it may only contain variable widgets. A form groups widgets compactly via one of two layout modes (flow or grid) selected by layout.mode. See Forms & Widget Placement.

table (.../items/table/1.0.0)

A tabular leaf type. It can render static columns/rows, or bind to a connection via connectionId and carry a query. It declares the expectedResult schema-level keyword — the result-shape contract describing the rows a bound connection is expected to return.

configurator (.../items/configurator/1.0.0)

A leaf type that renders an editor for another item in the same document — its target, referenced by that item’s stable instance id. The resolver validates the target reference and auto-generates an editor form from the target’s configurable surface; each control drives an ephemeral config override. See Configurators.

Content item types

Three content leavesmarkdown, heading, and image — carry page-like content (prose, a section heading, an image) rather than data or a control. Each is a non-positional leaf that must be block-wrapped to sit under a region. The markdown.source and image.src strings are opaque: the resolver stores and validates them but never parses, renders, or fetches them. Fields, the block-wrap requirement, and ${var} interpolation are documented on the Content Item Types page.

Variable widgets

Beyond container and table, the catalog ships 13 variable widgets — leaf item types that each set a single variable. They are grouped into five families by the variable type they bind:

FamilyBindsWidgets
Stringstringtext-input, textarea
Numbernumber / integernumber-field, slider, stepper
Booleanbooleantoggle, checkbox
Enumenumselect, radio-group, segmented
Arrayarraymultiselect, checkbox-group, tag-input

Each widget carries a variable config key naming the variable it drives; changing the control sets that variable’s runtime override and re-resolves the document. The resolver enforces widget↔variable type compatibility — a widget bound to a variable of an incompatible declared type fails WIDGET_TYPE_MISMATCH. select is the canonical single-choice control and the replacement for the retired dropdown item.

Per-widget config, the binding contract, and the type-compatibility rules are documented on the Widgets page. See also Variables — Runtime inputs.

connections/ — connection types

Connection types are referenced by document-scoped connection instances ({ id, $ref, config, secretRefs? }). They are loaded into the same catalog as item types and validated the same way. Connections are declared and validated only — never dialed.

http (.../connections/http/1.0.0)

A query-style backend: an endpoint url, optional method (GET/POST), static headers, and static query params. Credentials are referenced indirectly via the connection’s secretRefs or { "$secret": ... } config values — never inlined.

static (.../connections/static/1.0.0)

An inline data source: rows are embedded directly in config (objects mapping column name to a JSON-scalar cell, nulls allowed), with an optional explicit columns ordering. It exists so the result-shape contract can be exercised without a real backend.

theme/ — the theme vocabulary

theme.schema.json (.../theme/1.0.0) defines the renderer-agnostic theme: presentation choices expressed purely as closed, enum-constrained semantic tokens (emphasis, spacing, density, tone, radius, border) — no px, no hex, nothing medium-specific. It is referenced by the document default theme and a block wrapper’s theme override.

Schema-level keywords

Item-type schemas may carry top-level keywords that are not instance config and not standard JSON Schema validation — they are read by the resolver/catalog. Examples: configurable (the configurable surface), expectedResult (the result-shape contract), and:

  • positional (boolean) — designates a type as a layout-only positional region: a node that only positions children and carries no chrome/theme. container and variable-box set positional: true. The marker is the single source of truth for which types are legal root/region children — the tree grammar reads it via the catalog rather than a hardcoded type list, so a new type with the marker becomes a legal region child with no validation-code change.

Examples are fixtures

Hand-written conforming documents live in examples/ and double as downstream test fixtures. See Examples.

Variables

Variables let a document declare named, typed values once and reference them from item configuration. They can be static defaults, computed expressions, or runtime inputs.

Declaring variables

A variable declaration has the shape { name, type, default?|expr?, options? }:

{ "name": "window", "type": "integer", "default": 24 }
FieldNotes
nameIdentifier, unique within its declaring scope.
typeOne of string, number, integer, boolean, enum, array.
defaultOptional literal value, validated against type. Mutually exclusive with expr.
exprOptional computed expression. Mutually exclusive with default.
optionsThe permitted value set; required for enum, forbidden otherwise.

A bare { name, type } with neither default nor expr is a valid, value-less declaration. Declaring both default and expr, or options on a non-enum type, or an enum without options, fails fast (VAR_DECLARATION_INVALID / VAR_OPTIONS_INVALID).

Declarations appear at document scope (the top-level variables array) or on any container instance (its variables array).

Types

Values arrive as decoded JSON. integer requires a number with no fractional part; number accepts any JSON number; enum requires a string that is one of the declared options. A default (or a runtime override) that does not match its declared type fails fast with VAR_TYPE (or VAR_OPTIONS_INVALID for an out-of-set enum value).

Tree scoping & shadowing

The resolver walks the item tree and, for every node, computes the set of variables visible at that node by layering declarations from the document root down to the node itself. A declaration on an inner node shadows an outer one of the same name:

item-local  →  ancestor containers  →  document
(nearest declaration wins)

Each visible variable records where it was declared (declaredAt, the resolved-tree path of the owning node). This per-node environment is attached to every node in the resolved tree as varEnv, and because each entry carries its declaredAt, the full variable→node visibility mapping is recoverable from the tree alone. (Using that mapping for partial re-resolution is deferred — see Out of Scope.)

Sibling subtrees are independent: extending the environment always returns a new environment, so a declaration in one branch never leaks into another.

Interpolation: $var and ${}

Variable references inside an item’s config are substituted before the config is validated, so the item-type schema sees concrete, typed values rather than raw references. There are two forms:

Typed binding — { "$var": "name" }

An object that is exactly { "$var": "name" } (one key, string value) is replaced wholesale by the named variable’s value, preserving its JSON type. An integer variable stays an integer, an array stays an array:

{ "hours": { "$var": "window" } }   →   { "hours": 24 }

Any object that is not exactly this shape is treated as ordinary data, not a binding.

String template — ${name}

Every ${name} occurrence inside a string value is replaced by the named variable’s value rendered as text. The surrounding string is preserved, so the result is always a string. Integral numbers render without a trailing .0 (e.g. 24, not 24.000000):

{ "title": "Metrics for ${region}" }   →   { "title": "Metrics for us-east" }

Maps and slices are walked recursively. A reference to a name not visible at the node fails fast with VAR_UNDEFINED, naming the offending instance path and the reference form.

Interpolation is the same reusable mechanism used for connection query parameters — see Connections.

Computed variables — expr

A declaration may carry an expr string instead of a default. The expression is evaluated against the in-scope variables, and its result is coerced and validated to the declared type, then flows into the same value slot a default would. Interpolation and dependency tracking therefore treat computed and literal variables identically.

[
  { "name": "window",     "type": "integer", "default": 24 },
  { "name": "windowDays", "type": "integer", "expr": "window / 24" },
  { "name": "heading",    "type": "string",
    "expr": "\"Metrics (last \" + string(windowDays) + \"d)\"" }
]

Within a scope, computed declarations are layered after the literals and resolved in dependency order, so an expression may reference inherited variables, sibling literals, and other computed variables. A dependency cycle fails fast with VAR_CYCLE; a compile/eval failure is VAR_EXPR; a result that does not match the declared type is VAR_TYPE.

Expression syntax is intentionally generic in this spec. Expressions are evaluated with the expr-lang/expr engine, which supports arithmetic, string concatenation, comparisons, and built-in conversion functions such as string(...). Pinning the exact supported subset is deferred future work (see Out of Scope); treat the examples as illustrative rather than as a normative grammar.

Runtime inputs

Variables can be set at resolution time by runtime overrides. An override replaces the effective value of a settable variable — one backed by a literal/default, not a computed expr. Computed variables are never overridable and keep their evaluated value. Crucially, because a computed variable reads the same value slot an override writes, a computed chain that depends on an overridden literal recomputes against the runtime value — this is what lets a runtime input drive a ${var} consumer through an expr.

Overrides come from two sources, both wired by lattice serve:

  • Widgets. A widget is a leaf item that sets a single variable named by its variable config key. There are 13 widgets across five families — string (text-input, textarea), number (number-field, slider, stepper), boolean (toggle, checkbox), enum (select, radio-group, segmented), and array (multiselect, checkbox-group, tag-input). A widget may only bind a variable whose declared type its family permits, otherwise the resolver reports WIDGET_TYPE_MISMATCH. Changing the control sets that variable’s runtime override and re-resolves the document, so dependent ${var} / $var consumers update live. The widget only declares the binding and its presentation; the variable itself is declared in the document/container variables and supplies the effective default (override > default). select is the canonical single-choice control, replacing the retired dropdown item.
  • URL query parameters. serve reads ?name=value parameters as overrides for the initial render. Because query params arrive as text, a value targeting a non-string variable is parsed to the declared type before validation; a value that cannot be parsed or fails the type/enum check fails fast with the same VAR_TYPE / VAR_OPTIONS_INVALID codes a bad default would.

An override for an undeclared name is a no-op. A nil/empty override set leaves every variable at its declared default, so the resolved-tree contract is identical to a plain resolve.

Variable overrides are one half of the unified override set; the other half, config overrides addressed by <node-id>.<field>, plus the precedence between them, is covered in Runtime Overrides.

Widgets

A widget is a leaf item type that sets a single variable. Each widget carries a variable config key naming the one variable it drives. Changing the control records that variable’s runtime override and re-resolves the document, so dependent ${var} / $var consumers update live.

Widgets are grouped into families by the variable type they bind. The resolver enforces widget↔variable type compatibility: a widget may only bind a variable whose declared type its family permits. A mismatch fails fast with WIDGET_TYPE_MISMATCH. A widget never has children — only the container and form item types may.

A widget is an ordinary item instance: place one directly in a container grid cell, or group several inside a form. See Forms & Widget Placement for both arrangements.

Families at a glance

FamilyBinds variable typeWidgets
Stringstringtext-input, textarea
Numbernumber or integernumber-field, slider, stepper
Booleanbooleantoggle, checkbox
Enumenumselect, radio-group, segmented
Arrayarraymultiselect, checkbox-group, tag-input

There are 13 widgets across these five families.

The binding contract

Every widget shares the same binding shape and rules:

  • variable (required). The name of the variable the widget sets. It must be a variable visible in the widget’s scope — declared at document scope or on an ancestor container. A widget binding a name not visible in scope fails with VAR_UNDEFINED.
  • Type compatibility. The bound variable’s declared type must be one the widget’s family permits (see the table above). Otherwise the resolver reports WIDGET_TYPE_MISMATCH, naming the offending instance path, the widget, the variable, and its declared type.
  • The variable owns the value. The widget only declares the binding and its presentation; the variable itself is declared in the document/container variables array and supplies the effective default. At resolution time the override-over-default rule applies (override > default) — see Variables — Runtime inputs.
  • Leaf only. A widget never carries children.

Shared presentation config

Across the families, widgets share a small presentation floor:

FieldTypeNotes
labelstringOptional label rendered with the control.
descriptionstringOptional help text rendered beside or beneath the control.
disabledbooleanWhen true the control is read-only.
default(family-typed)Optional widget-local default shown before the viewer interacts. Presentation only — the variable’s declared default remains the authoritative resolution-time value. Not present on the option-set array widgets or tag-input.

Individual families add their own type-specific config, documented below.

String family — text-input, textarea

Free-text controls that bind a string variable.

  • text-input — a single-line field.
  • textarea — a multi-line field.

Both accept the shared label / description / disabled / default plus:

FieldTypeNotes
placeholderstringOptional placeholder shown in the empty field.
{
  "$ref": "https://lattice.dev/schemas/items/text-input/1.0.0",
  "id": "label-input",
  "config": {
    "label": "Panel label",
    "variable": "label",
    "placeholder": "Overview"
  }
}

Number family — number-field, slider, stepper

Numeric controls that bind a number or integer variable.

  • number-field — a free-entry number input.
  • slider — a draggable track (benefits from min/max to bound it).
  • stepper — increment / decrement buttons around a value.

All three accept the shared config plus an optional range:

FieldTypeNotes
minnumberOptional inclusive lower bound. Must not exceed max.
maxnumberOptional inclusive upper bound. Must not be less than min.
stepnumberOptional increment. Must be a positive number.

The resolver rejects an inverted range (min > max) or a non-positive step with RESOLVE_CONFIG_INVALID, naming the offending field. (JSON Schema already guarantees each value’s type and the positive-step bound; the cross-field min/max relationship is the resolver’s check.)

{
  "$ref": "https://lattice.dev/schemas/items/slider/1.0.0",
  "id": "threshold-slider",
  "config": {
    "label": "Alert threshold",
    "variable": "threshold",
    "min": 0,
    "max": 100,
    "step": 5
  }
}

Boolean family — toggle, checkbox

True/false controls that bind a boolean variable.

  • toggle — an on/off switch.
  • checkbox — a checkbox.

Both accept only the shared label / description / disabled / default config — no type-specific fields.

{
  "$ref": "https://lattice.dev/schemas/items/toggle/1.0.0",
  "id": "live-toggle",
  "config": { "label": "Live updates", "variable": "live" }
}

Enum family — select, radio-group, segmented

Single-choice controls that bind an enum variable to a fixed option set.

  • select — a single-choice <select> menu (the canonical runtime-input control, and the replacement for the retired dropdown item).
  • radio-group — a column of mutually-exclusive radio buttons.
  • segmented — a horizontal button row (suits small option sets).

All three require an options set in addition to the shared config:

FieldTypeNotes
optionsarray of { value, label? }Required. The selectable options in display order. At least one. label defaults to value when omitted.
sortdeclared | label | valueOptional display ordering. declared (default) keeps the listed order; label sorts ascending by display label (falling back to value); value sorts ascending by value.

The bound variable is declared with type: "enum" and its own options (the authoritative permitted value set — see Variables — Types). The widget’s options declare what is shown and how it is ordered.

{
  "$ref": "https://lattice.dev/schemas/items/select/1.0.0",
  "id": "region-select",
  "config": {
    "label": "Region",
    "variable": "region",
    "options": [
      { "value": "us", "label": "United States" },
      { "value": "eu", "label": "Europe" },
      { "value": "apac", "label": "Asia-Pacific" }
    ]
  }
}

Array family — multiselect, checkbox-group, tag-input

Multi-value controls that bind an array variable. The selected (or entered) values flow through the override path as an array.

  • multiselect — a multi-choice menu.
  • checkbox-group — a set of independent checkboxes, one per option.
  • tag-input — a freeform tag/chip entry field.

multiselect and checkbox-group are option-constrained: they require a bounded { value, label? } option set, sharing the enum family’s option shape and sort ordering:

FieldTypeNotes
optionsarray of { value, label? }The values the bound array may contain, in display order. An absent or empty set fails with RESOLVE_CONFIG_INVALID.
sortdeclared | label | valueOptional display ordering, as for the enum family.

tag-input is freeform: it declares no options (the viewer types arbitrary values that accumulate as tags) and instead accepts:

FieldTypeNotes
placeholderstringOptional placeholder shown in the empty entry field.

Unlike the enum family, an array variable declaration carries no options of its own — the bounded set, when required, lives on the widget.

{
  "$ref": "https://lattice.dev/schemas/items/multiselect/1.0.0",
  "id": "metrics-multiselect",
  "config": {
    "label": "Metrics",
    "variable": "metrics",
    "options": [
      { "value": "latency", "label": "Latency" },
      { "value": "errors", "label": "Error rate" },
      { "value": "throughput", "label": "Throughput" }
    ],
    "sort": "label"
  }
}

Error codes

CodeWhen
WIDGET_TYPE_MISMATCHThe bound variable’s declared type is not one the widget’s family permits.
VAR_UNDEFINEDThe widget binds a name not visible in its scope.
RESOLVE_CONFIG_INVALIDA number widget has an inverted range or non-positive step, or an option-constrained array widget has no options.

Worked example

examples/widgets-dashboard.json exercises one widget per family — a text-input, a slider, a toggle, a select, a multiselect, and a freeform tag-input — each bound to a matching variable and consumed by a table through $var typed bindings and ${} string templates.

Content Item Types

A content item is a leaf item type that carries page-like content — prose, a section heading, or an image — rather than data or a control. Three content types ship in the catalog:

TypeCarriesRequired fieldsOptional fields
markdownA block of prosesource
headingA section headingtext, level
imageAn image referencesrcalt, caption

Together they let a dashboard read like an article. A content leaf never has children, and — like every leaf — it is an ordinary item instance ({ "$ref", "id", "config" }). See Typed Schemas & Instances for the instance shape.

Content leaves must be block-wrapped

A content type is non-positional: it is neither a layout region nor a widget, so the tree grammar treats it as a content leaf that must sit inside a block wrapper. A bare content leaf placed directly under a container (or under the document root) is rejected with GRAMMAR_REGION_CHILD_INVALID, naming the offending instance path.

Wrapping the leaf in a block satisfies the grammar and lets the block apply its per-block concerns — a stable id, an optional theme override, a title, and a visibility flag — to the single leaf it carries. The wrapper and its inner content resolve as two separate nodes; see Blocks & the Tree Grammar.

A minimal legal placement (container → block → content leaf):

{
  "$ref": "https://lattice.dev/schemas/items/block/1.0.0",
  "id": "intro-block",
  "config": {
    "id": "intro-block",
    "content": {
      "$ref": "https://lattice.dev/schemas/items/markdown/1.0.0",
      "id": "intro-prose",
      "config": { "source": "Welcome to the dashboard." }
    }
  }
}

Opaque strings: markdown.source and image.src

The resolver stores and validates content text but never parses, renders, or fetches it. Two fields are deliberately opaque:

  • markdown.source carries Markdown prose verbatim. The schema imposes no Markdown grammar, flavor, or sanitization — it is a pass-through string a downstream renderer interprets.
  • image.src carries an image reference verbatim. The schema imposes no format: uri, URL grammar, reachability check, or fetch behavior — an absolute URL, a relative path, or a data URI are all equally valid, and a downstream renderer (never the resolver) loads it.

This is the same no-render boundary the rest of the spec keeps: the resolver validates shape and attaches concerns; a downstream builder owns rendering and loading. See Out of Scope.

Variable interpolation in text fields

Every text field on a content leaf supports variable interpolation. Interpolation runs over an instance’s config before validation, so a ${var} template (or a { "$var": "name" } typed binding) inside markdown.source, heading.text, image.src, image.alt, or image.caption is substituted from the in-scope variable environment for free:

{ "text": "Introducing ${productName} ${releaseVersion}", "level": 1 }

A reference to a name not visible at the node fails fast with VAR_UNDEFINED. Variable scoping and the two reference forms are covered in full on the Variables page.

Known limitation — a literal ${name} cannot be escaped. There is currently no escape syntax for the ${…} template marker. Any ${name} in a text field is always treated as a variable reference: if you want the four literal characters ${x} to survive into the output, you cannot — the interpolator will try to resolve x as a variable and fail with VAR_UNDEFINED when it is undefined. A workaround is to declare a variable whose value is the literal text you need.

markdown (.../items/markdown/1.0.0)

A prose leaf carrying a single block of Markdown.

FieldTypeNotes
sourcestringRequired, non-empty. The opaque Markdown body, stored verbatim (see Opaque strings). An absent or empty source fails with RESOLVE_CONFIG_INVALID.
{
  "$ref": "https://lattice.dev/schemas/items/markdown/1.0.0",
  "id": "intro-prose",
  "config": {
    "source": "Welcome to **${productName}**. This release brings page-like content."
  }
}

The single source field is runtime-tunable through the configurable surface, rendered as a multi-line textarea.

heading (.../items/heading/1.0.0)

A section-heading leaf.

FieldTypeNotes
textstringRequired, non-empty. The heading label. An absent or empty text fails with RESOLVE_CONFIG_INVALID.
levelintegerRequired, inclusive range 1–6 (1 = top-level section, 6 = deepest). There is no default — every heading must state its depth. A value below 1, above 6, or non-integer (e.g. 2.5) fails with RESOLVE_CONFIG_INVALID.
{
  "$ref": "https://lattice.dev/schemas/items/heading/1.0.0",
  "id": "page-heading",
  "config": { "text": "Introducing ${productName}", "level": 1 }
}

Both fields are runtime-tunable through the configurable surfacetext as a single-line text-input, level as a number-field.

image (.../items/image/1.0.0)

An image-reference leaf.

FieldTypeNotes
srcstringRequired, non-empty. The opaque image reference, stored verbatim (see Opaque strings). An absent or empty src fails with RESOLVE_CONFIG_INVALID.
altstringOptional. Alternative text for accessibility and fallback. The leaf resolves with src alone.
captionstringOptional. Caption text rendered alongside the image. The leaf resolves with src alone.
{
  "$ref": "https://lattice.dev/schemas/items/image/1.0.0",
  "id": "architecture-figure",
  "config": {
    "src": "https://assets.example.com/page-content-blocks.png",
    "alt": "Diagram of a page composed from content leaves",
    "caption": "Figure 1. A page assembled from block-wrapped content leaves."
  }
}

All three fields are runtime-tunable through the configurable surface, each rendered as a single-line text-input.

Error codes

CodeWhen
GRAMMAR_REGION_CHILD_INVALIDA bare (unwrapped) content leaf sits directly under a container or root — content must be block-wrapped.
RESOLVE_CONFIG_INVALIDA required field is missing or empty, or heading.level is outside 1–6 / not an integer.
VAR_UNDEFINEDA ${var} / $var reference in a text field names a variable not visible at the node.

Worked example

examples/page-dashboard.json is a page-like document: a single body region of block-wrapped content leaves — a heading, two markdown paragraphs, and a captioned image — that reads like an article. Two document-scope variables (productName, releaseVersion) are interpolated into the heading and prose via ${var} to show that content text is carried verbatim with variables substituted.

Forms & Widget Placement

Widgets are leaf item types that each set a single variable. This page covers the two ways to arrange them in a document:

  • A form container groups widgets and lays them out compactly, so a set of controls does not each consume a whole grid cell.
  • A standalone widget placed directly in a normal container grid cell — because a widget is an ordinary item instance, it can occupy a cell exactly like a table does.

Standalone widgets

A widget is a normal instance. Dropping one straight into a container’s grid — with its own 1-indexed placement — just works; no form wrapper is required:

{
  "$ref": "https://lattice.dev/schemas/items/textarea/1.0.0",
  "id": "note-input",
  "placement": { "colStart": 1, "rowStart": 3 },
  "config": { "label": "Note", "variable": "note" }
}

The container normalizes this widget’s placement into its layout block alongside every other child, and the widget binds its variable through the usual binding contract. Use a standalone widget when a single control sits naturally beside other panels; reach for a form when you have a cluster of controls that should pack together.

The form container

form (.../items/form/1.0.0) is, like container, structurally special: it is the other item type permitted to carry children. Unlike container, a form may only contain variable widgets — a non-widget child fails fast with LAYOUT_FORM_CHILD_INVALID, naming the offending child path. A form keeps a set of controls together as one compact unit.

A form picks one of two layout modes via the layout.mode discriminator. Flow and grid are two modes of the one form type, not two types.

flow (default)grid
ArrangementCompact label+control cells, filled row-major then wrappedA weighted grid identical in shape to container’s
layout.columnsA unitless integer count (1–12)An array of relative column-track weights
layout.rows / layout.gapNot usedRelative row-track weights / relative gap
Child placementNone — widgets carry no placementEach child carries an explicit 1-indexed placement

No CSS keywords or absolute units appear anywhere: flow columns is a plain count, and grid weights are unitless relative weights normalized to fractions summing to 1 per axis (exactly as for container).

The form instance itself still carries a placement describing where it sits in its parent container’s grid — the modes above govern only how the form arranges its own children.

Flow mode

Flow is the default — omit layout, or set layout.mode to "flow". Each child becomes a compact label+control cell; cells fill left-to-right across columns (default 1) and wrap to the next row. Widgets carry no placement.

{
  "$ref": "https://lattice.dev/schemas/items/form/1.0.0",
  "id": "general-form",
  "placement": { "colStart": 1, "colSpan": 2, "rowStart": 1 },
  "config": {
    "layout": { "mode": "flow", "columns": 2 }
  },
  "children": [
    {
      "$ref": "https://lattice.dev/schemas/items/text-input/1.0.0",
      "id": "title-input",
      "config": { "label": "Panel title", "variable": "title" }
    },
    {
      "$ref": "https://lattice.dev/schemas/items/select/1.0.0",
      "id": "region-select",
      "config": {
        "label": "Region",
        "variable": "region",
        "options": [{ "value": "us", "label": "United States" }]
      }
    },
    {
      "$ref": "https://lattice.dev/schemas/items/toggle/1.0.0",
      "id": "live-toggle",
      "config": { "label": "Live updates", "variable": "live" }
    }
  ]
}

The resolver attaches a normalized flow block to the form node: mode, the resolved columns count, and a cells list giving each child its 1-indexed column/row. With columns: 2 and three children, the third wraps to row 2:

"flow": {
  "mode": "flow",
  "columns": 2,
  "cells": [
    { "column": 1, "row": 1 },
    { "column": 2, "row": 1 },
    { "column": 1, "row": 2 }
  ]
}

A columns count outside the schema’s [1, 12] range fails config validation (RESOLVE_CONFIG_INVALID).

Grid mode

Set layout.mode to "grid" to arrange the form’s widgets on a weighted grid identical in shape to a container’s. The layout object then carries columns / rows track-weight arrays and a relative gap, and each child declares an explicit 1-indexed placement:

{
  "$ref": "https://lattice.dev/schemas/items/form/1.0.0",
  "id": "thresholds-form",
  "placement": { "colStart": 1, "colSpan": 2, "rowStart": 2 },
  "config": {
    "layout": { "mode": "grid", "columns": [1, 1], "rows": [1], "gap": 1 }
  },
  "children": [
    {
      "$ref": "https://lattice.dev/schemas/items/slider/1.0.0",
      "id": "threshold-slider",
      "placement": { "colStart": 1, "rowStart": 1 },
      "config": { "label": "Alert threshold", "variable": "threshold" }
    },
    {
      "$ref": "https://lattice.dev/schemas/items/stepper/1.0.0",
      "id": "window-stepper",
      "placement": { "colStart": 2, "rowStart": 1 },
      "config": { "label": "Window", "variable": "window" }
    }
  ]
}

Grid mode reuses the exact same grid path as container: tracks normalize to fractions summing to 1, and each child’s placement is validated against the grid bounds, so an out-of-bounds or non-positive placement fails with the same LAYOUT_PLACEMENT_OUT_OF_BOUNDS / LAYOUT_PLACEMENT_INVALID codes a container would emit. The resolver attaches a normalized layout block to the form node (the same shape container nodes carry), not a flow block.

Error codes

CodeWhen
LAYOUT_FORM_CHILD_INVALIDA form contains a non-widget child (a container, a table, …).
LAYOUT_FORM_COLUMNS_INVALIDA form’s layout field has an unexpected type (a contract backstop; the schema bounds the integer range).
LAYOUT_PLACEMENT_OUT_OF_BOUNDSA grid-mode form child’s placement falls outside the form’s grid (shared with container).
LAYOUT_PLACEMENT_INVALIDA grid-mode form child’s placement is malformed, e.g. a non-positive span (shared with container).

Worked example

examples/form-dashboard.json exercises a flow-mode form, a grid-mode form, and a standalone widget in one document, with a table consuming every bound variable through $var typed bindings and ${} string templates.

Configurable Surfaces

An item type declares which of its config fields are runtime-configurable — editable by a viewer or an external override — through a schema-level configurable keyword. Together those entries form the item type’s configurable surface: the honest, machine-readable list of knobs a configurator may expose and the override system may set, without a human hand-maintaining a parallel list.

The surface is declarative data on the item-type schema, not per-instance config. Every instance of a type shares the same surface. The resolver validates each declaration fail-fast and attaches the validated surface to the resolved instance, so downstream consumers read it without re-parsing the schema.

The configurable keyword

configurable is a top-level keyword on an item-type schema — a sibling of type and properties, not a config property. It maps each runtime-configurable config field to a descriptor:

{
  "type": "object",
  "additionalProperties": false,
  "configurable": {
    "label": {
      "type": "string",
      "label": "Label",
      "rendering": "text-input",
      "constraints": {
        "description": "The human label rendered beside the control."
      }
    },
    "disabled": {
      "type": "boolean",
      "label": "Disabled",
      "rendering": "toggle"
    }
  },
  "properties": {
    "label": { "type": "string" },
    "disabled": { "type": "boolean" }
  }
}

Each descriptor carries:

  • type (required). The field’s value type, drawn from the variable type set: string, number, integer, boolean, enum, or array. This is the type an editor or override deals in — not necessarily the raw JSON Schema type of the underlying property (see Object-shaped fields).
  • label (optional). A human label for the field, shown by a configurator.
  • constraints (optional). An opaque object the resolver passes through verbatim. Use it to carry editing hints — an enum of allowed values, a description, the shape of nested sub-fields — for downstream tools. The resolver does not interpret it.
  • rendering (optional). A preferred widget item-type to edit this field with, e.g. text-input or toggle. It must name a registered widget, so a configurator can auto-pick a control.

Validation rules

On every resolve the resolver validates the configurable declaration of each node’s item type, fail-fast, reporting CONFIGURABLE_SURFACE_INVALID (with the offending path, type, and field in the error details) when:

  • a declared field name is not a real config property of the item type (it is absent from the schema’s properties), or
  • a field’s type is not one of the variable type set, or
  • a rendering hint names a widget the catalog does not know.

A type that declares no configurable keyword simply has an empty surface — it is not an error.

Document-scope surfaces

The same descriptor shape is reused beyond item types. The reserved document scopes ($manifest, $theme, $variables, $connections, $root) declare their own configurable surfaces on the document schema, under a top-level documentScopes keyword that maps each $-keyword to the identical field → descriptor shape. A configurator pointed at a reserved scope generates its editor from that surface through the same machinery — there is no parallel system. Field names are validated against the scope’s real schema (the manifest’s properties for $manifest, the theme vocabulary’s tokens for $theme).

Constraints: top-level fields only

The mechanism validates field names against the item type’s top-level schema properties only, and field types against the variable type set (string / number / integer / boolean / enum / array — no nested object). So a surface is declared only on top-level config fields carrying those types. A nested object property cannot be surfaced as an object; instead it is surfaced under the closest variable type and its inner shape is described in constraints.

Object-shaped fields

When a meaningful editable field is itself an object — the container grid, or the form layout — it is declared with the closest variable type (array) and its sub-fields are spelled out in constraints.fields for the configurator to walk:

"configurable": {
  "layout": {
    "type": "array",
    "label": "Form layout",
    "constraints": {
      "description": "The form's layout: mode plus columns/rows/gap.",
      "fields": {
        "mode": { "type": "string", "enum": ["flow", "grid"] },
        "columns": { "description": "Flow: column count. Grid: weight array." }
      }
    }
  }
}

What declares a surface

Every shipped item type that has runtime-tunable presentation declares one:

  • All 13 widgets surface their shared label and disabled, plus family-specific fields — placeholder (string family and tag-input), min / max / step (number family), and sort (enum and option-array families).
  • The form container surfaces its layout.
  • The container surfaces its grid, the variable-box surfaces its arrangement, and the table surfaces its title, columns, and query.
  • The block wrapper surfaces its title and visibility, so those per-block concerns are tunable at runtime through the same mechanism (its id, content, and theme are not surfaced).

What reads a surface

The resolver attaches the validated surface to each resolved instance, in sorted field order, so downstream layers read it directly:

  • A configurator auto-generates an editor from the surface — one control per field, picked by the rendering hint.
  • The override system knows exactly which fields it may set at runtime.
  • A JSON Patch changeset uses the surface as its guardrail — it enumerates the only paths a patch may legally touch.

Because the surface is derived from the schema and validated on every resolve, a declared surface can never drift out of sync with the properties the item type actually accepts.

Theme & Semantic Tokens

A theme expresses presentation choices as renderer-agnostic semantic tokens. Each token is a closed, enum-constrained vocabulary of meaning — never a medium-specific value. A theme carries no pixels, hex colours, fonts, CSS, or any HTML/medium detail; a renderer maps each semantic choice onto whatever concrete styling its medium uses.

The theme vocabulary is defined once, in schemas/theme/theme.schema.json (https://lattice.dev/schemas/theme/1.0.0), and referenced wherever a theme may be expressed:

  • the document default theme, declared as the optional top-level theme member of the document, and
  • a block wrapper’s per-block theme override, declared in the wrapper’s config.theme.

Tokens are ordinary scalar fields drawn from the variable type set (each an enum over a fixed value list), so a theme is fully describable through the configurable-surface mechanism — there is no special chrome subsystem and no group tags.

The token vocabulary

The vocabulary is intentionally small and structured so it can grow without breaking existing themes: a base group of cross-cutting tokens ($defs/baseTokens) that any future per-type token group composes alongside, rather than redefining. Every token is optional; an omitted token means “inherit / renderer default”.

TokenValuesMeaning
emphasisnone · low · highHow prominent the element is relative to its surroundings.
spacingcompact · cosy · roomyRelative internal/outer breathing room.
densitycomfortable · compactHow tightly repeated/listed content is packed.
toneneutral · accent · positive · caution · criticalSemantic colour intent, by meaning — no colour value.
radiusnone · subtle · roundedHow softened the corners of a surface are.
bordernone · hairline · standardHow present a separating edge is.

Every value is enum-constrained and medium-agnostic — there are no units (no px), no colour literals (no hex), and nothing HTML/CSS-specific. A renderer is free to map, say, spacing: roomy to whatever concrete distance suits its medium.

The two layers

A theme can be expressed in exactly two places, and they are independent layers:

  • Document default theme. The optional top-level theme member of the document carries the document’s base presentation choices. Its tokens are constrained to the vocabulary by structural validation, so a consumer may treat every present token as valid.

    {
      "manifest": { ... },
      "theme": { "emphasis": "high", "spacing": "cosy" },
      "root": { ... }
    }
    
  • Per-block override. A block wrapper may carry a config.theme override — any subset of tokens. A set token overrides the corresponding document-default choice for that block; an omitted token inherits. Positional regions (container, variable-box) carry no theme — only block wrappers do, enforced by the grammar pass (GRAMMAR_REGION_THEME_FORBIDDEN).

    {
      "$ref": "https://lattice.dev/schemas/items/block/1.0.0",
      "id": "highlighted",
      "config": {
        "id": "highlighted",
        "theme": { "emphasis": "high", "tone": "accent" },
        "content": { ... }
      }
    }
    

No merge: side-by-side layers

The resolver stays dumb about themes. It validates each theme against the vocabulary and attaches it verbatim, but it performs no cascade, merge, or “effective theme” computation:

  • the document default lands on the resolved tree as defaultTheme (the default layer only), and
  • each per-block override lands on its own block node’s resolved config.theme.

The two are emitted side by side. Composing the cascade — deciding how a block’s partial override layers over the document default for a given renderer — is a downstream consumer’s job, not the resolver’s. There is no computed effective theme in the resolved output. This is what keeps the format renderer-agnostic: the resolver records intent as semantic tokens, and a builder owns mixing and rendering.

Extending the vocabulary

New tokens are added by extending $defs/baseTokens (for further cross-cutting choices) or by composing an additional per-type token group alongside it. Because each token is an independent optional enum field, adding one never invalidates a theme that omits it. Bumping the schema’s $id semver follows the usual per-schema versioning convention.

Runtime Overrides

A resolved dashboard is not frozen. At resolution time a caller may supply an override set — a flat map of address → value — that adjusts the document before the tree is assembled. Both lattice resolve (via ResolveWithValues) and lattice serve (per request) accept the same override set, so the runtime model is identical whether you re-resolve from the CLI or from the served page.

Overrides are ephemeral: they apply only to that single resolution. The document on disk is never mutated, and an empty override set yields exactly the same resolved tree as a plain resolve. The durable counterpart — a recorded edit to the document itself — is a JSON Patch changeset, gated by the same configurable surface; its application is future work.

Two override kinds, one addressable map

The override set is a single map[string]any whose key is an address. The address shape selects what the value targets:

AddressTargetsExample
name (bare)a variable named nameregion"eu"
<node-id>.<field>a node’s config fieldsummary.title"Pinned"
$scope.<field>a document scope field$theme.density"compact"

A key with no . is a variable override; a key of the form <node-id>.<field> is a config override. The two kinds share one map and are routed by address — neither the CLI nor the server has to separate them. A document-scope configurator posts the same shape with a reserved $-keyword as the node half ($theme.<token>, $manifest.<field>).

Variable overrides

A variable override replaces the effective value of a settable variable — see Runtime inputs for the full rules. In short: the value flows through the variable model (override > default), computed variables recompute against it, and dependent ${var} / $var consumers update live. An override for an undeclared name is a no-op; a value that fails the variable’s type / enum check fails fast with the usual VAR_TYPE / VAR_OPTIONS_INVALID codes.

Config overrides

A config override sets one config field of a single resolved node, addressed by <node-id>.<field>. It is applied after interpolation, so it overwrites whatever an interpolated ${var} produced for that field, and is validated against the node’s configurable surface — only a field the item type declares configurable may be overridden. Failures are fail-fast:

  • An address whose <field> is not on the node’s configurable surface fails with CONFIG_OVERRIDE_FIELD_UNKNOWN.
  • A value that violates the field’s declared type / constraints fails with CONFIG_OVERRIDE_VALUE_INVALID.

Like every override, a config override is ephemeral — it mutates only the resolved instance for that resolution, never the document.

Precedence

Within a single resolution the layers compose top-down, each later layer winning for the value it touches:

  1. Declared defaults — the variable default and the node’s authored config.
  2. Variable overrides — replace settable variables; computed variables recompute against the new values; ${var} / $var consumers see the result.
  3. Config overrides — applied last, after interpolation, so a <node-id>.<field> override wins over whatever the interpolated config produced for that field.

So for a field whose value came from a ${var} template, a variable override changes it through interpolation, while a config override on that same field pins it regardless of interpolation — the config override is the more specific, later-applied layer.

Supplying overrides through serve

lattice serve collects the unified override set from two transports and routes both kinds — variable and config — into the same map:

  • URL query parameters. Each ?address=value becomes one override. A bare name (?region=eu) is a variable override; a dotted name (?summary.title=Pinned) is a config override. Query values arrive as text and are coerced to the target’s declared type before validation.
  • The /api/resolve endpoint. The served page POSTs a JSON object of the current overrides on every re-resolve. Interactive widget controls post their bound variable as a variable override on change; a client may also include <node-id>.<field> keys to drive config overrides.

For example, serving examples/widgets-dashboard.json and requesting

GET /?region=eu&summary.title=Pinned

resolves the document with region overridden to eu (a variable override that flows through the ${region} template into the summary table) and the summary table’s title config field pinned to Pinned (a config override that wins over the interpolated "${label} — ${region}" title). Both adjustments are ephemeral to that request.

Configurators

A configurator is a leaf item type that renders an editor for another item in the same document — its target. Instead of hand-authoring a form of controls, you point a configurator at a target by that target’s stable instance id, and the resolver builds the editor for you from the target’s configurable surface. The generated controls drive config overrides that re-resolve the target ephemerally, so a viewer can retune one item live without the document ever being mutated on disk.

A configurator never has children.

Declaring a configurator

A configurator instance $refs the configurator item type and carries just two config fields:

{
  "$ref": "https://lattice.dev/schemas/items/configurator/1.0.0",
  "id": "summary-configurator",
  "config": {
    "target": "summary",
    "title": "Configure the summary table"
  }
}
  • target (required). The stable instance id of the item this configurator edits. It must reference an item declared in the same document that carries an id. Most items omit id; a configurator’s target is the first thing that makes a stable id required on the item it points at.
  • title (optional). A heading rendered above the generated editor. It is itself a configurable field, so it can be retuned at runtime like any other.

That is the entire authored surface of a configurator — there is no per-field form to write. The editor is derived purely from the target.

Target validation

On every resolve the configurator pass builds a tree-wide id index once, then validates each configurator’s target against it, fail-fast:

  • CONFIGURATOR_TARGET_MISSING_ID — the target reference is empty or whitespace-only, so there is no id to look up.
  • CONFIGURATOR_TARGET_NOT_FOUND — the target is a well-formed id but no item in the document declares it; the reference dangles.

Both errors name the offending configurator’s path in their details.

Reserved document-scope targets

A target may instead be a reserved, $-prefixed keyword that points the configurator at a document-level scope rather than an item:

TargetScope
$manifestthe document manifest
$variablesthe document variable set
$connectionsthe document connections
$themethe document default theme
$rootthe resolved root region

A $-prefixed target is always routed to a document scope and is never looked up as an item id, so a reserved keyword can never collide with — nor be shadowed by — an item that happens to share the name. Conversely, an ordinary item id (one without the $ sigil) is unaffected: an item literally named theme is still targeted as an item by "target": "theme".

  • CONFIGURATOR_TARGET_SCOPE_UNKNOWN — the target is $-prefixed but names no recognized scope; it fails fast (it is not reinterpreted as an item id). The offending configurator’s path and the unknown scope keyword are in the error details.

Document-scope surfaces

Each reserved scope exposes its own configurable surface — declared on the document schema under a top-level documentScopes keyword, mapping each $-keyword to the same field → descriptor shape an item type’s configurable keyword uses. A configurator pointed at a reserved scope generates its editor from that surface through the same form-generation path an item-targeting configurator uses (one control per surface field, bound to the $scope.<field> override address). A scope with no surface yields a present-but-empty form, exactly like a surface-less item.

The surface is the honest, machine-readable list of a scope’s runtime-tunable fields, and it doubles as the guardrail: it enumerates the legal target paths within the scope. Field names are validated against the scope’s real schema properties — $manifest against the manifest’s properties, $theme against the theme vocabulary’s tokens — so a scope surface can never drift out of sync with what the scope accepts. The shipped scopes surface:

ScopeFields
$manifesttitle, description
$themethe six theme tokens (emphasis, spacing, density, tone, radius, border)
$variables(empty for now — no top-level scalar is runtime-tunable yet)
$connections(empty for now)
$root(empty for now)

Generation is read-only: the resolver reads a scope surface and emits the editor but applies no change to the document — the authored manifest, theme, variables, connections, and root are passed through verbatim.

The auto-generated form

When the target resolves, the configurator pass reads the target’s validated configurable surface and generates one control per surface field, in surface (sorted field) order. For each field it picks:

  • the field’s preferred rendering widget when the surface declares one, else
  • the canonical widget for the field’s value type — stringtext-input, number / integernumber-field, booleantoggle, enumselect, arraymultiselect.

The controls are laid out with the same flow layout a form uses, so a renderer arranges them exactly like an authored form. The whole editor is attached to the resolved configurator node as its generated form: a target id, the list of widgets, and the flow layout. Each generated widget records the widget item type that renders it, the target field it edits, the field’s value type, a human label, and the field’s opaque constraints (option sets, min/max, nested sub-fields) passed through verbatim for the renderer to honor.

Because the form is derived from the surface and regenerated on every resolve, the editor can never drift out of sync with what the target actually accepts. A configurator that targets a surface-less item yields an empty (but present) form, so a renderer can always tell a resolved configurator from an unresolved one.

Composite fields are one control each. The configurable surface is top-level-only, so a table’s columns array or a form’s layout is a single surface entry — and the configurator generates a single control for the whole field, not one per sub-field. The field’s inner shape travels in constraints for the renderer; per-sub-field editing is out of scope.

The ephemeral mutation model

Each generated widget carries the override address it drives: <target-id>.<field>. When a viewer changes a control, the renderer posts a config override keyed by that address, and the document re-resolves with the override applied. As with every runtime override:

  • The override is ephemeral — it adjusts only the target’s resolved instance for that one resolution; the document on disk is never touched.
  • It is applied after interpolation and validated against the target’s configurable surface. A field not on the surface fails CONFIG_OVERRIDE_FIELD_UNKNOWN; a value violating the field’s declared type or constraints fails CONFIG_OVERRIDE_VALUE_INVALID.

So a configurator is the authoring-side counterpart to the override system: the configurable surface declares which fields are tunable, the configurator renders the editor for them, and a config override is what a change posts. The served page wires this loop end to end — see Supplying overrides through serve.

The override above is ephemeral — it adjusts only one resolution. The durable counterpart of the same edit is a JSON Patch changeset, gated by the same configurable surface; its application and persistence are future work.

Worked example

examples/configurator-dashboard.json places a summary table beside a configurator that targets it by id. The table surfaces title, columns, and query, so the resolver generates a three-control editor — a text-input for the title and a control for each composite field — each bound to summary.<field>. See Examples.

Changesets (JSON Patch)

Every edit to a document — at any scope — is expressed as a JSON Patch (RFC 6902) document. This is the single, universal changeset mechanism for the format: there is no second edit language, no scope-specific mutation API, and no bespoke diff shape. Whether a change retunes one item’s config, rewrites the manifest, adds a variable, swaps a connection, flips a theme token, or restructures the root region, it is the same artifact — an ordered array of JSON Patch operations against the document.

Implemented. A changeset can now be applied: lattice loads a stored document, applies a changeset to it under the configurable-surface and tree-grammar guardrails, re-resolves the result for full validation, and persists it — all atomically. The Go entry point is changeset.ApplyChangeset; the CLI is lattice patch. Two pieces remain out of scope (see What is deferred): an HTTP write endpoint (serve stays read-only) and a database storage backend.

Why one mechanism

A document is a single JSON tree: a manifest, a variable set, connections, a default theme, and a root region of nested items. JSON Patch already addresses any location in such a tree by JSON Pointer (RFC 6901) and already defines the six operations needed to mutate one — add, remove, replace, move, copy, test. Adopting it wholesale means:

  • Uniformity. The same operation vocabulary edits an item’s config field, a document scope, or the whole root. A consumer learns one changeset format, not one per scope.
  • Addressability. A JSON Pointer names exactly the node and field a change touches, which is precisely what a guardrail needs in order to decide legality.
  • Ordering and atomicity. A patch is an ordered list; test operations let a changeset assert preconditions. The format inherits this for free.

Uniform across every scope

The changeset format does not vary by what it edits. The same JSON Patch shape applies to all of these scopes:

ScopeExample pointer target
itema node’s config field
manifestthe document title / description
variablesthe document variable set
connectionsthe document connections
themea theme token
rootthe resolved root region

These line up one-for-one with the addresses the rest of the format already speaks: an item is addressed by its stable id, and the five document scopes are the reserved $-keywords ($manifest, $variables, $connections, $theme, $root) introduced for reserved document-scope targets. A changeset is just the write counterpart to those read/target addresses, spelled in JSON Pointer.

A minimal config edit and a minimal theme edit are the same kind of document:

[
  { "op": "replace", "path": "/<item>/config/title", "value": "Pinned" }
]
[
  { "op": "replace", "path": "/$theme/density", "value": "compact" }
]

The id-rooted pointer dialect

A changeset pointer’s leading segment is not a literal physical path — it is an address the apply layer resolves, exactly the way a configurator target or a config override addresses a node. The remainder after the leading segment is a literal RFC 6901 JSON Pointer.

  • Leading segment → physical base. A leading segment beginning with $ is a reserved document scope and routes to that scope’s physical member ($manifest/manifest, $variables/variables, $connections/connections, $theme/theme, $root/root); the $-prefix is recognized before any id lookup, so a reserved keyword can never collide with an item id. Any other leading segment is an item id, resolved through an index of every id-carrying node to that node’s physical pointer in the on-disk tree.
  • Block content is /config/content, not a child slot. A block wrapper’s single inner content item lives physically at the wrapper’s config.content — a config field, not a children slot. So to edit a wrapped leaf’s config you address /<wrapper-id>/config/content/config/<field> (or, more usually, address the inner item directly by its own id). The id index descends into config/content precisely so the inner item is addressable by its own id.
  • Structure is children/- and children/N. Only a container or form carries a children array. Append a child with the RFC 6901 end-of-array token — /<container-id>/children/- — and address an existing child slot positionally — /<container-id>/children/0. Remove a whole item by addressing it as a whole: /<item-id>.
  • Move uses from + path. A move (or copy) names the relocated node by from (an id-rooted pointer to the source) and its destination by path. Both ends are translated. Reordering a child within one parent is a move whose from and path are slots of the same children array.
[
  { "op": "add", "path": "/main-grid/children/-",
    "value": { "id": "kpi-block", "$ref": "block@1.0.0",
               "config": { "content": { "id": "kpi", "$ref": "metric@1.0.0", "config": {} } } } },
  { "op": "move", "from": "/old-block", "path": "/main-grid/children/0" },
  { "op": "remove", "path": "/stale-block" }
]

Gotcha — id-rooted pointers resolve against the original tree. The id index is built once, up front, from the document as loaded — it is not recomputed between operations. So a positional pointer like /<id>/children/2 always means “index 2 in the original array,” even if an earlier operation in the same changeset removed an earlier sibling. When you remove several siblings of one parent in a single changeset, author the removes in descending physical-index order (highest index first) so each removal does not shift the index of a sibling a later operation still refers to. Removing or appending by a stable item id (/<item-id>, children/-) sidesteps this entirely.

The two guardrails

A changeset is not free to touch anything. Each operation is routed to exactly one of two guardrails, decided by what it addresses:

Field and nested edits → the configurable surface

A field-level edit — anything rooted at an item’s config or at a settable document scope — may only touch a path the configurable surface actually exposes. This is the same surface that gates an ephemeral runtime override: the resolver computes it from the schema on every resolve, so the set of legal patch paths can never drift out of sync with what an item type or scope accepts.

  • For an item edit (/<id>/config/<field>), the surface is the item type’s configurable declaration. A target that resolves to a field not on that surface is rejected with CONFIG_OVERRIDE_FIELD_UNKNOWN — exactly as a config override of an unsurfaced field fails. The value is type-checked against the surface field’s declared type (and enum options), rejected with CONFIG_OVERRIDE_VALUE_INVALID.
  • For a document scope edit (/$manifest/<field>, /$theme/<token>), the surface is the scope’s entry in the document schema’s documentScopes keyword. $manifest surfaces title / description; $theme surfaces the theme tokens. A path the scope does not surface is out of bounds.

Nested config edits are supported through the surface’s nested declaration. A configurable key that itself carries a dot — e.g. "grid.gap" — declares an explicit sub-path into a nested config object. A changeset that addresses the matching nested path, /<id>/config/grid/gap, has its pointer remainder joined with “.” into the dotted surface key (grid.gap) and is checked against that nested entry. A nested edit is legal only if the surface declares the dotted key; addressing a nested path the surface does not enumerate is off-surface, the same CONFIG_OVERRIDE_FIELD_UNKNOWN. (Document scopes surface top-level fields only, so a multi-segment scope path simply never matches.)

Structural edits → tree grammar + re-resolve

A structural edit — an insert or delete in a children array, a remove by item id, or a move/copy that relocates a node — cannot be surface-gated: the $root configurable surface is intentionally empty, so there is no surface field to match against. Instead, structure is validated by re-resolving the mutated document: the full two-pass resolver runs again, so the tree grammar (root holds only positional regions, a container holds regions or block wrappers, a bare leaf must be wrapped, …), every item-type schema, referential integrity, and variable/interpolation validity are all re-checked “for free.” A mutated tree that violates any rule rejects the whole changeset.

Re-resolve catches almost everything, but it cannot catch a missing or duplicate instance id — the resolver’s id index is last-wins, so a second node reusing an id is silently shadowed rather than rejected. Because the id is the stable address every changeset pointer roots on, a structural add is checked before apply: its value must carry its own non-empty, document-unique id (CHANGESET_STRUCTURAL_ID_INVALID).

Two structural specifics:

  • Emptied regions are legal. Removing the last child of a container leaves an empty children array; the grammar permits it, so a changeset may legally empty a region.
  • Cross-parent move strips placement. A node’s placement is expressed in its immediate parent container’s grid coordinates. A move that carries a node to a different parent would carry stale coordinates that the new grid’s bounds reject. The apply layer therefore strips the placement from any cross-parent move, so the node falls back to the default first cell (which fits any grid). A same-parent reorder keeps its placement — the grid is unchanged, so the author’s explicit coordinates are still valid and are preserved.

Preconditions: test ops and the revision precondition

A changeset has two independent levers for optimistic concurrency, so a stale edit cannot clobber a document that changed underneath it.

  • RFC 6902 test ops. A test operation asserts that a given path holds an expected value; the standard applier evaluates it during apply, and a mismatch aborts the whole changeset (PATCH_APPLY_FAILED) with nothing persisted. Use a test to make a changeset’s legality depend on the current value of the very field it edits.
  • The revision precondition. A caller may supply an opaque expected revisionchangeset.WithExpectedRevision(token) in Go, --expect-revision on the CLI. The pipeline re-reads the store’s current revision immediately before the write (as close to the write as possible, to minimize the race window) and rejects with CHANGESET_REVISION_CONFLICT if it no longer matches. The conflict code is distinct so a caller can retry: reload, re-derive the changeset against the new bytes, re-apply. The revision token is opaque and compared verbatim — a git commit hash for the git backend, a content hash for the filesystem backend (both storage backends expose it). If the configured store cannot report a revision but an expected one was supplied, the apply fails with CHANGESET_REVISION_UNSUPPORTED rather than silently skipping the check. Omit the precondition for single-writer behavior.

Canonical serialization

After a changeset is applied, the mutated document is re-serialized canonically: object keys are emitted in sorted order with a fixed two-space indent. This makes the on-disk form deterministic — the same logical document always produces identical bytes.

The practical consequence is a one-time reflow. The first changeset applied to a hand-authored document may rewrite key order and indentation across the whole file (a large, noisy diff), because it brings the document into canonical form. Every subsequent changeset then produces a minimal, stable diff that touches only the bytes the edit actually changed. A no-op changeset applied to an already-canonical document round-trips to identical bytes.

Applying a changeset (lattice patch)

The lattice patch command applies a changeset to a stored document end to end:

lattice patch <id> --changeset edit.json
  • <id> is the document’s manifest id, not a filesystem path. Unlike resolve/serve, patch always operates through a storage backend.
  • --changeset <path> is the changeset file — an id-rooted JSON Patch array. The special path - reads the changeset from stdin, so a changeset can be piped in.
  • --store fs|git and --root <dir> select the backend, the same seam resolve/serve use (defaults: fs, the working directory). For the git backend, the persisting Save is a commit.
  • --expect-revision <token> is the optional optimistic-concurrency precondition described above; omit it for no precondition.
  • --schemas <dir> points at the dashboard schema and item-type catalog.

The command exits non-zero on any coded error, reported through the shared CLI error path (a {code, message, details} JSON envelope under the global --json flag), and nothing is persisted on failure.

The ApplyChangeset Go entry point

The CLI is a thin wrapper over the single reusable Go entry point, which every touchpoint shares:

func ApplyChangeset(
    store storage.Store,
    res DocumentResolver,
    id string,
    cs *changeset.Changeset,
    opts ...changeset.ApplyOption,
) (*changeset.ApplyResult, error)

It runs the whole pipeline — Store.Load(id) → resolve the current bytes (to get the surfaces the field-edit guardrail checks against) → apply under the guardrails and canonically re-marshal → re-resolve the mutated bytes (the structural/schema/referential guardrail) → check the revision precondition → Store.Save. It is atomic: on any error at any step the apply is rejected and the store is never touched, so the stored document is left byte-for-byte unchanged. The store is written exactly once, only on full success. *resolver.Resolver satisfies the DocumentResolver capability via ResolveBytesWithValues. ApplyResult returns the persisted bytes and their already-computed resolved tree.

Relationship to runtime overrides

A changeset and a runtime override share the configurable surface but differ in lifetime:

Runtime overrideJSON Patch changeset
Lifetimeephemeral — one resolutiondurable — a persisted edit
Shapeflat address → value mapordered RFC 6902 operation list
Scopevariables and config fieldsevery scope, uniformly
Field guardrailconfigurable surfaceconfigurable surface
Structural editsnot applicabletree grammar + re-resolve

An override answers “what should this one resolution see?” A changeset answers “what edit should be recorded against the document?” Field edits in both are gated by the same surface, and the changeset reuses the override pass’s coded errors so a field-level violation reads the same either way.

What is deferred

The apply pipeline is implemented. Two adjacent capabilities are still explicitly out of scope (see Out of Scope):

  • An HTTP write endpoint. The serve command stays read-only — it re-resolves and renders, but exposes no write path. A changeset is applied through the Go ApplyChangeset entry point or the lattice patch CLI, not over HTTP.
  • A database storage backend. Apply persists through the existing filesystem and git storage backends. A database-backed Store is future work — the Store contract admits one, but none ships.

When either is implemented, this page and the relevant reference chapter should be updated rather than left stale.

Connections

A connection is a document-scoped data source. Items bind to a connection by id and carry their own query. In this effort connections are declared and validated only — never dialed: there is no live fetch, no network request, no real data. The point is to validate the model — that the wiring is well-formed and the shapes line up.

Declaring connections

Connections live in the top-level connections array. Each instance has the shape { id, $ref, config?, secretRefs? }:

{
  "id": "metrics-api",
  "$ref": "https://lattice.dev/schemas/connections/http/1.0.0",
  "config": { "url": "https://api.example.com/metrics", "method": "GET" },
  "secretRefs": { "token": "vault://lattice/metrics-api#token" }
}
FieldRequiredNotes
idyesDocument-unique identifier; items bind by this id.
$refyesURI of the connection-type schema (validated against the catalog).
confignoPer-connection config; shape defined by the connection type.
secretRefsnoIndirection map from a logical secret name to an opaque reference token. Secret values are never inlined.

The resolver resolves each $ref to a connection-type schema using the same machinery as item $refs, validates the config against that schema, and rejects duplicate ids (CONNECTION_DUPLICATE_ID). An unresolvable $ref is CONNECTION_TYPE_UNRESOLVED; an invalid config is CONNECTION_CONFIG_INVALID.

The two connection types are http (a query-style endpoint) and static (inline rows embedded in config).

The direct binding model

An item draws data by naming a connection in its config:

{
  "$ref": "https://lattice.dev/schemas/items/table/1.0.0",
  "config": {
    "connectionId": "metrics-api",
    "query": {
      "region": { "$var": "region" },
      "hours":  { "$var": "window" },
      "label":  "last ${window}h"
    }
  }
}
  • connectionId names a document-scoped connection. If it matches no declared connection, resolution fails with BINDING_CONNECTION_NOT_FOUND.
  • query is an arbitrary object passed to the connection. Its parameters may reference variables using the same $var / ${} forms as any config — the interpolation pass runs over the whole item config (including the query) before binding, so by the time the binding is lifted onto the resolved node the query carries concrete, typed values, not references.
  • A query declared without a connectionId is malformed (BINDING_INVALID).

In the resolved tree, a bound item gains a binding block:

"binding": {
  "connectionId": "metrics-api",
  "query": { "region": "us-east", "hours": 24, "label": "last 24h" },
  "contract": { ... }
}

Secret handling

Credentials are never stored in a dashboard document or its resolved tree. A connection’s config may carry a secret reference of the exact shape { "$secret": "NAME" }. At resolution time the resolver:

  1. Reads NAME from the process environment (os.LookupEnv).
  2. Substitutes the value only to validate the connection config (a connection-type schema expects the concrete value, e.g. a header string).
  3. Discards the resolved value immediately afterward.

What is kept in the resolved tree is not the value:

  • The connection’s config retains the { "$secret": "NAME" } reference object, unchanged.
  • A sorted secrets list records which secret names the connection consumed — names only, never values.

So the serialized resolved tree is secret-value-free by construction. A malformed reference (empty or non-string name) fails fast with SECRET_INVALID; a reference whose NAME is absent from the environment fails fast with SECRET_MISSING.

Because a $secret must resolve from the environment, documents that use one require the variable to be set even just to resolve by hand. For example, examples/kitchen-sink-dashboard.json needs METRICS_API_TOKEN:

METRICS_API_TOKEN=xyz lattice resolve examples/kitchen-sink-dashboard.json

The token value never appears in the output.

The secretRefs map on a connection is a separate, complementary indirection: it maps a logical secret name to an opaque reference token (e.g. a vault URI) and is passed through verbatim. It, too, never carries a secret value.

Result-shape contract

A bound item type may declare what its connection’s results should look like via the expectedResult schema-level keyword. For the table type, that contract is: an array of non-empty row objects whose cells are non-null scalars (string, number, or boolean).

When an item declares a connectionId, the resolver enforces the contract:

  • The item’s type must declare an expectedResult, or resolution fails with CONTRACT_MISSING — a binding with no shape to validate against is an error.
  • The expectedResult fragment must be a well-formed draft 2020-12 schema, or resolution fails with CONTRACT_INVALID.
  • For a static connection, the inline rows are the one place a real data check is possible without a live fetch, so the resolver validates them against the contract; non-conforming data fails with RESULT_SHAPE_INVALID. (Note the contract is stricter than the static connection’s own config schema, which also permits null cells.)

The validated contract is recorded on the binding as contract ({ itemType, connectionId, expectedResult }). It is model-only: the resolver validates the declared shape (and inline static data), never live fetched data.

Examples

The examples/ directory holds hand-written, conforming dashboard documents. Each is a valid fixture you can resolve directly, and together they cover every feature of the spec. Paths below are relative to the repository root.

FileDemonstrates
examples/minimal-dashboard.jsonThe smallest real document: a root container holding a body region whose grid places two block-wrapped static tables.
examples/grids-dashboard.jsonNested grids / subgrids with explicit placement and fractional track sizing.
examples/variables-dashboard.jsonAll three variable kinds — static, runtime-settable, computed expr — feeding $var and ${} references.
examples/binding-dashboard.jsonAn item bound to an http connection by id with a variable-filled query, plus a $secret reference.
examples/dropdown-dashboard.jsonThe live runtime-input loop: a select widget sets an enum variable consumed by a table.
examples/widgets-dashboard.jsonThe typed-widget catalog: one widget per family (text-input, slider, toggle, select, multiselect, tag-input) each binding a matching variable, consumed by a table.
examples/page-dashboard.jsonA page-like document of block-wrapped content leaves — a heading, two markdown paragraphs, and a captioned image — with ${var} interpolation into the heading and prose.
examples/form-dashboard.jsonThe form container in both layout modes (flow and grid) plus a standalone widget placed directly in a container grid cell.
examples/connections-dashboard.jsonBoth connection kinds declared at document scope — static (inline) and http (query) — with secretRefs.
examples/contract-dashboard.jsonA table bound to a static connection whose inline rows conform to the table’s expectedResult contract.
examples/configurator-dashboard.jsonA configurator targeting a table by id, auto-generating an editor from the table’s configurable surface that drives ephemeral config overrides.
examples/themed-dashboard.jsonThe theme + grammar constructs side by side: a document default theme, a per-block theme override (attached verbatim, no merge), and a variable-box holding a widget directly.
examples/theme-configurator-dashboard.jsonA document-scope configurator: a configurator whose target is the reserved $theme keyword, generating a document-level editor for the six theme tokens.
examples/kitchen-sink-dashboard.jsonEvery feature in one document (see below).

Resolving an example

Most examples resolve with no setup:

lattice resolve examples/minimal-dashboard.json

The kitchen-sink example needs a secret

examples/kitchen-sink-dashboard.json exercises the whole spec in one document: nested grids with subgrids and explicit placement; static, runtime, and computed variables; $var typed bindings and ${} string templates; a select runtime input bound to an enum variable; both connection types; a $secret reference that is redacted from the resolved tree; and a result-shape contract on a bound table.

Because it includes a $secret, it requires METRICS_API_TOKEN to be set in the environment to resolve by hand:

METRICS_API_TOKEN=xyz lattice resolve examples/kitchen-sink-dashboard.json

The token value never appears in the output — see Connections — Secret handling.

Serving an example

The select and kitchen-sink examples are most interesting under serve, where the runtime-input loop is live:

lattice serve examples/dropdown-dashboard.json
# then open http://localhost:8080/?region=eu to set the initial value

Storage Backends

lattice can load and save whole dashboard documents through a storage backend. The store is deliberately simple: it reads and writes complete documents as raw bytes, addressed by each document’s manifest.id. Two backends ship today — a plain filesystem backend and a git backend that records a commit per change and exposes read-side version history.

Storage sits upstream of the resolver. A backend never validates a document, never resolves $refs, and has no JSON Patch awareness — it treats every document as an opaque blob. The bytes you save are the bytes you load back.

The Store model

Every backend satisfies one small contract: whole-document load/save plus a few metadata operations.

OperationBehavior
Load(id)Returns the stored document bytes for a manifest.id. A missing id is a STORAGE_NOT_FOUND error.
Save(document)Persists a whole document. The addressing key is read from the document’s own manifest.id — it is not a separate argument.
List()Returns the manifest.ids of all stored documents, sorted.
Exists(id)Reports whether a document with that id is stored (a cheap existence check, no read).
Delete(id)Removes the stored document. A missing id is a STORAGE_NOT_FOUND error.

Two properties define the model:

  • Whole-document (dumb blob). A backend operates on the entire document as []byte. It performs no partial edits and no schema validation. A document saved then loaded is byte-identical, which keeps git diffs clean.
  • Addressed by manifest.id. Save derives the key from the document itself; callers never choose where the bytes land. This is what makes backends interchangeable — the same call persists to the filesystem or to git depending only on which backend was constructed.

All failures are CodedErrors with STORAGE_* codes (see Error Codes), carrying structured Details such as the offending id or path.

Filesystem backend (fs)

The default backend. It maps a document’s manifest.id to <root>/<id>.json and is the one selected when you load a document by path (see CLI usage below).

  • Filename mapping. A document with manifest.id of example-minimal is stored as example-minimal.json under the configured root. List recovers ids by stripping the .json extension from the files directly under root (a missing root is treated as empty, not an error).
  • Atomic writes. Save writes to a temporary file in the root, then renames it over the destination. A crash mid-write never leaves a partially written document — you either have the old bytes or the new bytes, never a torn file.
  • Filename-safe id validation. Because the id becomes a filename stem, it is validated before any write or read. An id that is empty/whitespace-only, contains a path separator (/ or \), or is a relative path element (. or ..) is rejected with STORAGE_ID_INVALID.

The filesystem backend has no versioning — it does not implement the VersionedStore capability described below. Each Save overwrites the prior document in place.

Git backend (git)

The git backend is “filesystem write semantics, plus a commit”. Documents live on disk as plain <id>.json files in a working-tree git repository — the same byte-faithful, atomic, id-mapped writes the filesystem backend performs — and every Save or Delete records a commit.

  • Reads come from the working tree. Load, List, and Exists are served straight from the on-disk files via the embedded filesystem backend, so a git store reads identically to a filesystem store over the same root.
  • Commit on save / commit on delete. Save writes the file, stages it, and commits with a generated message (Save dashboard <id>). Delete removes the file, stages the deletion, and commits (Delete dashboard <id>).
  • Init on absent root. If the root is not already a git repository it is initialised as a non-bare, working-tree repo; if it already is one, it is opened. (go-git is used directly — there is no dependency on a system git binary.)
  • Author identity. The commit author is resolved from the repository’s git config (user.name / user.email). Whichever field is unset falls back to a fixed lattice <lattice@localhost> identity, so a freshly initialised repo with no configured identity still produces valid, attributable commits.
  • Path-scoped staging. Only the specific <id>.json is ever staged — never git add .. Unrelated untracked or modified files in the repository are left alone, so a lattice store can safely share a working tree with other files.

Edge behavior: no-op commits are rejected

A git commit needs a tree change. Re-saving byte-identical content produces no change to stage, and go-git rejects the empty commit (ErrEmptyCommit) surfaced as a STORAGE_IO error. To record a new revision, the document bytes must actually differ from the currently committed version.

Versioning (VersionedStore)

Versioning is an optional capability layered on top of the core Store contract. Only version-capable backends implement it — today, the git backend. The filesystem backend does not. Callers detect it with a capability check rather than assuming it is present.

A revision is identified by its git commit hash and carries the commit message and timestamp. The version-capable surface adds two operations:

OperationBehavior
History(id)Returns the revisions that touched <id>.json, newest-first, by walking the commit log filtered to that document’s path. An id that no commit ever touched (never saved, or unknown) returns STORAGE_NOT_FOUND — an empty history is reported as not-found, not as an empty list.
LoadAt(id, revision)Returns the document bytes as of a given revision. The revision is a commit hash — the full 40-character hash, or a short hash that resolves unambiguously to one commit. A revision that resolves to no commit, or at which the document does not exist, returns STORAGE_NOT_FOUND.

Because history is path-filtered, one document’s saves never inflate another document’s history.

CLI usage

Both resolve and serve accept the same backend-selection flags:

FlagMeaningDefault
--storeBackend kind: fs or git.fs
--rootRoot directory for the backend.. (the working directory)

How the positional argument is interpreted depends on whether you set either flag:

  • Neither --store nor --root set — direct-path loading (default). The positional argument is a filesystem path to a document, resolved directly. This is the pre-existing behavior, and every existing resolve <path> invocation continues to work unchanged. No backend is constructed.
  • --store or --root explicitly set — backend-addressed loading. The positional argument is a manifest.id loaded through the constructed backend. The backend is built lazily, only once the argument is known to be an id, so a plain path-mode invocation never incurs a backend side-effect (such as the git backend’s git init).

Examples:

# Direct path (default): resolve a document file on disk.
lattice resolve examples/minimal.json

# Backend-addressed: load the document whose manifest.id is "example-minimal"
# from the ./dashboards filesystem store.
lattice resolve --store fs --root ./dashboards example-minimal

# Same, served read-only over HTTP from a git-backed store.
lattice serve --store git --root ./dashboards example-minimal

An unrecognized --store value fails with STORAGE_BACKEND_UNKNOWN. In serve backend mode the store is re-read on every request, so editing the stored document is reflected on reload exactly as a path-mode edit is; the render stays read-only.

Out of scope

The storage layer is intentionally narrow. The following are not implemented and are deferred future work — do not assume them present:

  • The JSON Patch apply→save pipeline. The store is a dumb blob store: it saves and loads whole documents and has no JSON Patch awareness. Applying a changeset to a document and persisting the result is a separate, later effort. There is no write path in serve today.
  • A database (DB) adapter. The shipped backends are the filesystem backend and the git backend. A database-backed Store is future work; the Store contract is designed to admit one, but none ships.

When either of these is implemented, this page should be updated rather than left stale.

MCP Mode

lattice can run as an MCP (Model Context Protocol) server, exposing its read and dry-run capabilities as tools an MCP host (a coding assistant, an agent, Claude Desktop, …) can call. The host uses these tools to discover, read, and propose edits to stored dashboard documents — but the MCP server never persists. A proposed edit is committed separately, by a human, through the POST /api/patch HTTP endpoint. This split is the heart of MCP mode: the model proposes and validates; the human commits.

Security: no authentication anywhere. Neither the MCP stdio server nor the POST /api/patch write endpoint performs any authentication or authorization. MCP mode assumes a localhost / trusted deployment. Do not expose either on an untrusted network. This is a known, accepted gap — see No auth — known gap.

Running lattice mcp

The mcp subcommand runs the server over stdio — the transport an MCP host uses to launch a local server as a subprocess and talk to it over stdin/stdout.

lattice mcp --store fs --root ./dashboards --schemas schemas
FlagDefaultMeaning
--storefsBackend kind: fs or git.
--root.Root directory the backend reads documents from.
--schemas <dir>schemasDirectory holding dashboard.schema.json and the item/connection catalog.
--jsonoffEmit an invocation/transport error as a {code,message,details} JSON envelope.

Unlike serve, mcp always operates through a backend: its tools address documents by manifest.id, never by filesystem path, so the --store/--root seam (shared with resolve, serve, and patch) is always used. The fs backend maps <id> to <root>/<id>.json; the git backend adds a current-revision token per document (needed for the optimistic-concurrency handoff below).

The process prints lattice MCP server running on stdio (Ctrl-C to stop) and then blocks, serving the host until it disconnects or the process is interrupted.

Host configuration

An MCP host launches the server by running the lattice binary with the mcp subcommand. A typical host config (the shape Claude Desktop and most MCP hosts accept — a named server with a command and args) points at the built binary:

{
  "mcpServers": {
    "lattice": {
      "command": "/absolute/path/to/bin/lattice",
      "args": [
        "mcp",
        "--store", "fs",
        "--root", "/absolute/path/to/dashboards",
        "--schemas", "/absolute/path/to/schemas"
      ]
    }
  }
}

Use absolute paths — the host launches the subprocess with its own working directory, which is rarely your project root. To get commit history per edit (and the strongest revision handoff), use "--store", "git" instead.

The tool reference

The server advertises ten tools, all read or dry-run only — none writes to the store. Seven address documents directly (detailed below); three more — lattice_list_skills, lattice_get_skill, and lattice_get_manifest — serve the embedded skill pack (the LLM-facing guides) and the lattice_get_manifest bootstrap index. Every tool surfaces a failure as the lattice CodedError (code + message + structured Details) verbatim, returned as an MCP tool error so the model can read the code and self-correct.

ToolPurposeWhen to use
lattice_list_dashboardsEnumerate stored documents (id + title).First step — discover what exists.
lattice_get_outlineConfig-free skeleton of one document + revision.Navigate a document cheaply; locate a node by id.
lattice_get_nodeOne node’s stored subtree + editable field surface.Read a node you intend to edit in place.
lattice_get_documentWhole document, raw and (optionally) resolved.Escape hatch — only when a slice won’t do.
lattice_list_schemasThe grammar catalog (item types + envelope).Discover what node types you may build.
lattice_get_schemaOne type’s JSON Schema.Author a new node/document validly.
lattice_validate_patchDry-run a changeset; never persists.Check a proposed edit before a human commits it.

lattice_list_dashboards

The discover-and-enumerate entry point.

  • Input: none.
  • Output: {dashboards: [{id, title?}]} — every stored document’s manifest.id (the key every other tool accepts) plus its manifest.title when present. The title is best-effort: a document whose bytes can’t be read still appears, listed by id with no title.
  • When: the first call in any session — to learn which ids exist.

lattice_get_outline

The token-cheap navigation tool. Resolves a document server-side and returns a config-free skeleton of the tree — no config bodies, so the host can locate a target node without pulling the whole (config-laden) document through context.

  • Input: {id} — the document’s manifest id.
  • Output:
    • id — the requested id.
    • revision — the document’s current opaque revision token (omitted when the store has no revision capability).
    • document — a document-scope summary: {variables: [names], connections: [ids], theme: bool}. Names and ids only — no value bodies.
    • root — the skeleton root node, recursively. Each node carries id, type (short item-type ref), optional title (only when the node’s config declares one), container (true when it may hold children), an optional placement summary (e.g. "col 2+1, row 1+1", never the verbatim placement object), and children.
  • When: to navigate. Use the outline to find the id of the node you want, then drill in with lattice_get_nodenot lattice_get_document, whose config bodies the outline deliberately omits.

lattice_get_node

The drill-in read for editing an existing node. Given a document id and a node id (from the outline), it returns the exact stored shape a patch edits plus the set of field paths that are valid to patch.

  • Input: {id, nodeId} — the document id and the node’s stable instance id.
  • Output:
    • id, nodeId — echoed.
    • revision — the document’s current revision token (omitted when unsupported).
    • subtree — the stored JSON subtree for the node: the exact shape a raw id-rooted patch edits.
    • surface — the node’s editable field surface as a flat [{key, type}] list. key is the tail of a valid id-rooted patch path (nested keys dotted, e.g. grid.gap); type is the field’s value type.
  • Block addressing: when nodeId names a block wrapper, the subtree is the whole block (wrapper plus its config/content) and the surface is the content item’s editable fields — a block delegates its knobs to what it wraps.
  • Surface gates field edits only. The surface lists which field paths a patch may touch. Structural edits (add/remove/move children) are not surface-listed — plan those from lattice_get_outline.
  • When: to edit a node that already exists. The surface tells you which field paths lattice_validate_patch will accept. (Distinct errors make the two failure modes clear: an unknown document id is STORAGE_NOT_FOUND; an unknown node id is CHANGESET_TARGET_NOT_FOUND.)

lattice_get_document

The whole-document escape hatch. Prefer the slicing tools (lattice_get_outline + lattice_get_node) for targeted reads; reach for this only when you genuinely need the entire document.

  • Input: {id, resolved?} — the document id, and an optional flag.
  • Output: {id, document, resolved?}document is the raw stored JSON; resolved is the full resolved tree, present only when resolved: true was requested (it runs the two-pass resolver).
  • When: rarely — when no slice suffices. Pulling a whole document is the expensive path the outline/node tools exist to avoid.

lattice_list_schemas

The grammar-discovery tool: what node types may be built, independent of any existing node.

  • Input: none.
  • Output: {types: [string]} — every item-type name in the schema catalog, plus the reserved "dashboard" envelope token. Every entry is a valid lattice_get_schema input.
  • When: before building a new node or document — to discover the legal type tokens.

lattice_get_schema

The grammar-detail tool: one type’s JSON Schema, so a new node of that type (or a whole new dashboard) can be authored validly.

  • Input: {type} — an item-type name from lattice_list_schemas, or "dashboard" for the envelope.
  • Output: {type, schema} — the type’s JSON Schema (config fields, required keys, $ref form) as JSON.
  • When: when building new. The contrast with lattice_get_node is the key distinction:
    • Editing an existing node → lattice_get_node (its surface tells you which field paths are valid to patch).
    • Building a new node → lattice_get_schema (its schema tells you the full config grammar a new node must satisfy).

lattice_validate_patch

The simulate step of the propose-then-commit loop. It runs the same atomic apply → re-resolve pipeline a real write runs, under every guardrail — but stops before the store write. It never persists: there is no save path reachable through MCP at all.

  • Input: {id, ops} — the document id and the cumulative RFC 6902 JSON Patch array. Pointers are id-rooted: each op’s path leads with a node’s stable id or a $-scope keyword ($manifest, $variables, $connections, $theme, $root), and the remainder is literal RFC 6901 (see Changesets). The server is stateless — send the full cumulative patch on every call, not an incremental delta.
  • Output (success): {ok: true, preview, baseRevision?}preview is the resolved tree the patch would produce (nothing is persisted to produce it); baseRevision is the document’s current revision token, the value the eventual write passes as expectedRevision.
  • Output (failure): {ok: false} plus the pipeline’s coded error (e.g. PATCH_* for a malformed op set, a configurable-surface or structural rejection, or a re-resolution RESOLVE_*/SCHEMA_*/VAR_*) as a tool error. Correct the ops and call again.
  • When: to check a proposed edit. Iterate lattice_validate_patch until ok: true, then hand baseRevision to the human for the commit. The model stops here — it cannot and does not persist.

The propose-then-commit flow

The whole point of MCP mode is a clean split between the model (which proposes and validates an edit) and the human (who commits it). The end-to-end loop:

  1. List. lattice_list_dashboards → pick the target document id.
  2. Navigate. lattice_get_outline {id} → locate the node id to change; note the revision and the document-scope summary.
  3. Drill or discover.
    • Editing an existing node → lattice_get_node {id, nodeId} for the stored subtree and the editable surface (which field paths are patchable).
    • Building a new node → lattice_list_schemas then lattice_get_schema {type} for the config grammar a new node must satisfy.
  4. Validate (iterate). lattice_validate_patch {id, ops} with the cumulative, id-rooted RFC 6902 patch. On a coded error, correct the ops and re-validate; repeat until ok: true. Keep the returned baseRevision. Nothing has been persisted.
  5. Commit (human). A human commits the validated patch via POST /api/patch, passing the baseRevision from step 4 as expectedRevision. This is the only persistence path.

Committing the change (POST /api/patch)

The commit happens outside MCP, against the serve HTTP layer running in backend mode (lattice serve --store … --root … <id>). The endpoint:

POST /api/patch
{"id": "<id>", "ops": [<RFC 6902 id-rooted JSON Patch>], "expectedRevision": "<baseRevision from lattice_validate_patch>"}

It commits the changeset through the same atomic apply → validate → save pipeline lattice_validate_patch simulated, and returns {"revision": "<new>", "result": <resolved tree>} on success. The expectedRevision is an optimistic-concurrency precondition: if the stored document moved on since lattice_validate_patch read baseRevision, the commit is rejected with 409 (CHANGESET_REVISION_CONFLICT) — re-run the flow against the new revision. An unknown id is 404; a malformed/off-surface/invalid changeset is 422. Each error is the coded-error JSON envelope. (The field is optional; omitting it skips the precondition, but passing the baseRevision is what makes the handoff safe.)

The MCP server never reaches this endpoint. POST /api/patch is served by the serve command, not the mcp command. The model’s last step is a successful lattice_validate_patch; a human performs the commit.

No auth — known gap

There is no authentication or authorization on either MCP mode surface:

  • the lattice mcp stdio server (any host that can launch the subprocess can call every tool), and
  • the POST /api/patch write endpoint on lattice serve (any caller that can reach the port can mutate stored documents).

MCP mode assumes a localhost / trusted deployment. The read/dry-run-only MCP tools cannot persist, so the exposure there is read-only; the write endpoint, however, mutates state with no caller check. Do not expose either on an untrusted network. This is a deliberate, documented boundary for this effort, not an oversight — revisit the trust assumption before adding a remote transport.

A compile-checked walkthrough

The MCP tools are thin wrappers over the public service facade. The exact facade calls each tool makes — List, Resolve, NodeView, ListSchemas/Schema, ParseChangeset + DryRunPatch (the dry-run behind lattice_validate_patch), and Revision — are demonstrated as a runnable, compile-checked Go example in service/mcp_example_test.go. Because it is a real Example function, it cannot drift from the facade signatures the tools depend on.

Error Codes

Every failure is a CodedError with a typed Code, a message, and optional structured Details (often a path naming the offending instance or connection). Resolution is fail-fast: the first error stops the run and is returned. With the global --json flag, the error is emitted as a {code, message, details} JSON envelope on stderr.

Codes are grouped by domain.

RESOLVE_* — document resolution

CodeMeaning
RESOLVE_INVALIDInvalid invocation (e.g. missing document path argument).
RESOLVE_IOI/O failure loading the document.
RESOLVE_INTERNALUnexpected internal error.
RESOLVE_DOCUMENT_INVALIDThe document failed Pass 1 (structural validation) or is not valid JSON.
RESOLVE_CONFIG_INVALIDAn instance’s interpolated config failed item-type schema validation.
RESOLVE_CHILDREN_NOT_ALLOWEDChildren declared on a non-container item type.

SCHEMA_* — catalog & reference resolution

CodeMeaning
SCHEMA_NOT_FOUNDA referenced schema could not be located.
SCHEMA_IOI/O failure reading a schema or document.
SCHEMA_INVALIDA schema failed to parse / is malformed.
SCHEMA_REFA $ref could not be resolved.
SCHEMA_VALIDATIONA document failed JSON Schema validation.
SCHEMA_REF_UNRESOLVEDAn instance $ref matched no catalog schema, relative file, or inline fragment.
SCHEMA_VERSION_MISMATCHA $ref named a type whose pinned semver is missing/mismatched in the catalog.

VAR_* — variables

CodeMeaning
VAR_UNDEFINEDA $var / ${} reference named an undeclared or unset variable.
VAR_TYPEA variable value (default, computed result, or override) did not match its declared type.
VAR_EXPRA computed-variable expression failed to compile or evaluate.
VAR_DECLARATION_INVALIDMalformed declaration: missing name, unknown type, both default and expr, or a duplicate name in one scope.
VAR_OPTIONS_INVALIDEnum options missing/malformed, options on a non-enum, or a value outside the enum set.
VAR_CYCLEComputed variables form a dependency cycle.

CONNECTION_*, SECRET_*, BINDING_*, CONTRACT_* — data

CodeMeaning
CONNECTION_NOT_FOUNDA referenced connection was not declared.
CONNECTION_INVALIDA connection declaration is malformed (e.g. missing $ref).
CONNECTION_DUPLICATE_IDTwo connections share an id.
CONNECTION_TYPE_UNRESOLVEDA connection’s $ref matched no connection-type schema.
CONNECTION_CONFIG_INVALIDA connection’s config failed its connection-type schema.
SECRET_INVALIDA { "$secret": "name" } reference has an empty or non-string name.
SECRET_MISSINGA referenced secret is not set in the environment.
BINDING_INVALIDA query without a connectionId, or a malformed connectionId/query.
BINDING_CONNECTION_NOT_FOUNDAn item’s connectionId matched no declared connection.
CONTRACT_MISSINGA bound item’s type declares no expectedResult contract.
CONTRACT_INVALIDA bound item type’s expectedResult is not a well-formed schema fragment.
RESULT_SHAPE_INVALIDA static connection’s inline data violates the consuming item’s contract.

LAYOUT_* — container grids

CodeMeaning
LAYOUT_PLACEMENT_INVALIDA child placement carried a non-positive span or start.
LAYOUT_PLACEMENT_OUT_OF_BOUNDSA child placement extends beyond the parent grid bounds.
LAYOUT_FORM_COLUMNS_INVALIDA form’s flow-layout column count is out of range.
LAYOUT_FORM_CHILD_INVALIDA form holds a child that is not a variable widget.

CONFIGURABLE_*, CONFIG_OVERRIDE_* — surfaces & overrides

See Configurable Surfaces and Runtime Overrides.

CodeMeaning
CONFIGURABLE_SURFACE_INVALIDAn item type’s configurable declaration is malformed: it names a non-existent config field, gives a field an unknown value type, or sets a rendering hint naming a widget the catalog does not know.
CONFIG_OVERRIDE_FIELD_UNKNOWNA <node-id>.<field> config override addressed a field not on the target’s configurable surface (or a dotted sub-path).
CONFIG_OVERRIDE_VALUE_INVALIDA config-override value violates the target surface field’s declared type or the item type’s config-schema constraints.

WRAPPER_* — the block wrapper

See Blocks & the Tree Grammar.

CodeMeaning
WRAPPER_ID_MISSINGA block wrapper is missing its required stable id (absent or whitespace-only).
WRAPPER_CHILD_COUNT_INVALIDA block wrapper does not wrap exactly one inner content item (content absent, null, or not a single instance object).

GRAMMAR_* — the dashboard tree grammar

See Blocks & the Tree Grammar.

CodeMeaning
GRAMMAR_ROOT_CHILD_INVALIDA node directly under root is not a positional region (the only legal root children are positional-marked types, e.g. container, variable-box).
GRAMMAR_REGION_CHILD_INVALIDA container region holds an illegal child — a bare (unwrapped) content leaf, which must be block-wrapped.
GRAMMAR_VARIABLE_BOX_CHILD_INVALIDA variable-box holds a child that is not a variable widget held directly.
GRAMMAR_WRAPPER_NESTEDA block wrapper’s single inner content is itself a block wrapper — wrappers do not recurse.
GRAMMAR_REGION_THEME_FORBIDDENA positional region carries a theme — regions are layout-only; only block wrappers carry chrome.

CONFIGURATOR_* — configurators

See Configurators.

CodeMeaning
CONFIGURATOR_TARGET_NOT_FOUNDA configurator’s target named an item id that no node in the tree declares.
CONFIGURATOR_TARGET_MISSING_IDA configurator’s target is empty/whitespace-only, so it names no resolvable id.
CONFIGURATOR_TARGET_SCOPE_UNKNOWNA configurator’s target is a $-prefixed keyword naming no known document scope (the recognized scopes are $manifest, $variables, $connections, $theme, $root).

CHANGESET_*, PATCH_* — the JSON Patch write pipeline

See Changesets (JSON Patch). A field-edit guardrail violation reuses the CONFIG_OVERRIDE_* codes (the changeset and runtime-override guardrails share the configurable surface); an unknown $-scope in a changeset pointer reuses CONFIGURATOR_TARGET_SCOPE_UNKNOWN. The codes below are specific to applying a changeset.

CodeMeaning
CHANGESET_INVALIDA changeset document is malformed: not a JSON array of operation objects, or an operation has a missing/wrong-typed required member (a non-string/absent op or path, an unknown op, a value-requiring op without value, or a from-requiring op without from).
CHANGESET_POINTER_INVALIDAn id-rooted changeset pointer is not well-formed for translation: empty, not rooted at /, or with an empty leading id/scope segment.
CHANGESET_TARGET_NOT_FOUNDA changeset pointer’s leading segment names an item id that no node in the document declares, so there is nothing to address. (An unknown $-scope reuses CONFIGURATOR_TARGET_SCOPE_UNKNOWN.)
CHANGESET_STRUCTURAL_ID_INVALIDA structural add op inserting an item into a children array does not carry a valid, document-unique id: the value is not an object, its id is missing/blank/non-string, or it collides with an id already in the document. (Re-resolve cannot catch this — the resolver’s id index is last-wins.)
CHANGESET_REVISION_CONFLICTThe optimistic-concurrency precondition failed: an expected revision was supplied, but the store’s current revision — re-read immediately before write — no longer matches, so the document changed since it was loaded. Distinct so callers can reload and retry; nothing is persisted.
CHANGESET_REVISION_UNSUPPORTEDAn expected revision was supplied but the configured store does not implement the RevisionedStore capability, so the precondition cannot be enforced. (Both the fs and git backends implement it; this guards a custom/stub store.)
PATCH_APPLY_FAILEDA translated changeset could not be applied by the RFC 6902 applier: the patch did not decode, or an operation failed at apply time (a test precondition mismatch, a remove/replace of a missing member, or an out-of-range array index). The whole changeset is rejected.
PATCH_INVALIDAn invalid lattice patch invocation: a missing manifest id argument, a missing/unreadable --changeset file, or a stdin read failure.

SERVE_* — the HTTP layer

CodeMeaning
SERVE_INVALIDInvalid serve invocation (missing document, out-of-range port).
SERVE_RESOLVEThe served document failed to resolve (wraps the underlying resolver error; rendered on the HTML error page).
SERVE_INTERNALUnexpected error in the web layer.

STORAGE_* — whole-document persistence

See Storage Backends.

CodeMeaning
STORAGE_ID_INVALIDA document’s manifest.id is not usable as a filename-safe addressing key: absent, empty/whitespace-only, containing a path separator, or a relative path element (., ..).
STORAGE_NOT_FOUNDA Load/Delete/History/LoadAt addressed an id (or revision) that no stored document/commit matches.
STORAGE_IOAn I/O failure reading or writing a document (open, write, rename, stat, remove), or a git operation failure (including the empty/no-op commit rejected when re-saving byte-identical content).
STORAGE_INVALIDA document could not be parsed far enough during Save to extract its manifest.id (malformed JSON or a missing manifest object).
STORAGE_INTERNALAn unexpected error in a storage backend.
STORAGE_BACKEND_UNKNOWNThe --store value names no known backend (the recognized kinds are fs and git).

Out of Scope

This effort delivers the dashboard format, the schema catalog, and the lattice resolver. Several capabilities are deliberately not part of it. They are listed here so future contributors know the boundaries of what the shipped binary actually does — and do not assume a behavior the spec does not promise.

Not implemented in this effort

  • Live data fetch. Connections are declared and validated only. lattice never dials an http connection, opens a socket, or makes a network request. The only real data check is validating a static connection’s inline rows against the result-shape contract.
  • Visual rendering. There is no chart, table, or widget rendering engine. The serve command produces a minimal structural sketch plus a JSON resolved-tree endpoint — enough to inspect structure, not to display a finished dashboard.
  • Styling. No CSS, themes, colors, fonts, or visual design. The container grid is expressed purely in relative, unitless track weights; mapping that to pixels or CSS is a renderer’s job, not the spec’s.
  • Refresh / polling. Nothing re-fetches or auto-updates data on a timer. The only update mechanism is serve re-resolving the whole document on each request.
  • Partial / incremental re-resolution. Every resolve processes the entire document. The resolved tree records each variable’s declaredAt so a future dependency tracker could scope re-resolution to affected nodes, but that optimization is deferred — today resolution is always whole-document.
  • A pinned expr-lang syntax. Computed variables use the expr-lang/expr engine, but the exact supported subset is intentionally left generic in this spec. Pinning a normative grammar (which operators, functions, and forms are guaranteed) is deferred future work; treat the expression examples as illustrative, not as a stable contract.
  • An HTTP write endpoint. The JSON Patch apply→save pipeline is implemented and reachable through the Go changeset.ApplyChangeset entry point and the lattice patch CLI. But serve stays read-only — it re-resolves and renders, and exposes no write path over HTTP. A network-facing apply endpoint is future work.
  • A database storage adapter. Apply persists through the filesystem and git storage backends. A database-backed Store is future work — the Store contract is designed to admit one, but none ships.

What downstream consumers can rely on

  • The resolved-tree shape is a stable, JSON-tagged contract; changes are additive and backward-compatible.
  • A resolved tree is fully validated (both passes passed), so consumers may assume every node is structurally valid and type-checked.
  • The resolved tree is secret-value-free by construction.

When any of the deferred items above is implemented, this page (and the relevant format chapter) should be updated rather than left stale.