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
- New to Lattice? Read the Overview.
- Want to run the tool? See Building & Running.
- Authoring a document? Start with Document Structure.
- Looking for working files? See Examples.
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:
- A JSON document format for dashboards (
schemas/dashboard.schema.json). - A catalog of typed schemas for item types and connection types
(
schemas/items/,schemas/connections/). - A resolver, shipped as the
latticebinary, 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.
- 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). - Reference linking. Every instance
$refis resolved to an item-type schema in the catalog (SCHEMA_*on failure). - Variable model. The tree-scoped variable environment is built: document
and per-container declarations are layered with inner-shadows-outer
semantics, computed
exprvariables are evaluated, and any runtime overrides are applied. - 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.
- Connections. Each document-scoped connection’s
$refis resolved and its config validated against the connection-type schema;$secretreferences are resolved from the environment for validation and then discarded. Duplicate connection ids fail fast. - Bindings & contracts. Items that declare a
connectionIdare 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
| Command | Purpose |
|---|---|
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 Go —
CGO_ENABLED=0is 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
| Flag | Default | Meaning |
|---|---|---|
--schemas <dir> | schemas | Directory holding dashboard.schema.json and the item/connection catalog. |
--json | off | Emit 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
| Flag | Default | Meaning |
|---|---|---|
--schemas <dir> | schemas | Catalog directory (as for resolve). |
--port <n> | 8080 | TCP listen port (1–65535). |
--json | off | JSON 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.
| Constructor | Use 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:
- Read —
Resolve(id, overrides)andResolveBytes(b, src, overrides)run the two-pass resolver (store-addressed vs. in-memory);Load(id),List(),Exists(id)are byte-level store reads. - Write —
ParseChangeset(b)→ opaque*Changeset;Patch(id, cs, opts...)applies it atomically (passWithExpectedRevision(rev)for an optimistic-concurrency precondition);Save(document)writes unvalidated whole bytes;Delete(id)removes a document. - History —
History(id)andLoadAt(id, rev)read version history (git backend only);Revision(id)returns the current token to pair withWithExpectedRevision. These are capability-gated: a backend that lacks the capability is rejected withSTORAGE_CAPABILITY_UNSUPPORTEDrather 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 likeRevision.HashorResolvedTree.Manifest. - You cannot construct the ones carrying unexported fields (
Resolver, theStoreimplementations,Serviceitself) 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 |
|---|---|
*Service | Open(Options{...}) or New(store, res) |
*Resolver | NewResolver(schemas) |
Store | NewStore(backend, fs, root) |
*Changeset | svc.ParseChangeset(b) |
ResolvedTree | svc.Resolve(...) / svc.ResolveBytes(...) |
ApplyResult | svc.Patch(...) |
ApplyOption | WithExpectedRevision(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
| Method | Capability needed | BackendFS | BackendGit |
|---|---|---|---|
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=0is exported globally by the Makefile, making the no-C-toolchain rule a build contract rather than a convention. - The binary is built to
bin/latticeviamake build(-trimpath, stripped).
Tooling
- Lint is
go vetplus staticcheck; there is no golangci-lint.make lintruns both. - Tests use the standard library
testingpackage only — no testify or other assertion libraries. Tests arefunc TestXxx(t *testing.T)usingt.Error*/t.Fatal*, table-driven where it helps. Benchmarks live in*_bench_test.goand run viamake bench(not part ofmake 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 stringwithconst 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 basehttps://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.
| Field | Required | Notes |
|---|---|---|
formatVersion | yes | Semver of the document format, e.g. "1.0.0" (pattern ^\d+\.\d+\.\d+$). |
id | yes | Stable machine-readable identifier for the dashboard. |
title | yes | Human-readable title. |
description | no | Longer-form description. |
author | no | Author 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": [ ... ]
}
| Field | Required | Notes |
|---|---|---|
$ref | yes | URI of the item-type schema this node instantiates. |
id | no | Instance-local identifier, unique within the document. |
config | no | Per-instance configuration; its shape is defined by the referenced item type and is opaque at the document level. |
placement | no | Layout hints interpreted by the parent container’s grid. |
variables | no | Variable declarations scoped to this node and its descendants (meaningful on containers). |
children | no | Child 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:
| Field | Meaning |
|---|---|
id | Copied from the source instance (omitted if none). |
type | The resolved type identity: the raw ref, the canonical id, and the parsed name/version. |
container | true when the resolved type is a container. Surfaced so consumers need not re-derive it. |
config | The interpolated, schema-validated config (variable references already substituted). |
placement | Verbatim placement hints. |
layout | For containers only: the normalized grid (fractional track sizes + each child’s validated placement). |
children | Resolved child instances, in document order. |
varEnv | The variable environment visible at this node (see Variables). |
binding | For 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-levelpositionalkeyword, 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:
| Field | Required | Meaning |
|---|---|---|
id | yes | Stable, 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. |
content | yes | The single inner content item the block wraps — one instance node referencing an item-type schema via $ref. Exactly one. |
title | no | Optional human heading/label rendered with the content. Tunable at runtime (it is on the block’s configurable surface). |
visibility | no | Whether the block (and its content) is shown; defaults to true. Tunable at runtime. |
theme | no | An 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
contentfield 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’sidis absent or whitespace-only.WRAPPER_CHILD_COUNT_INVALID— the block does not wrap exactly one content item (itscontentis 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-onlyarrangement(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:
| Rule | Violation 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/andschemas/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
$refand supplying aconfigthat 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 absolutehttps://lattice.dev/schemas/...URI that matches a schema’s$idin the loaded catalog (the normal case). - A relative file — a path looked up under configured relative roots.
- An inline
$defsfragment — 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 wrapper — distinct 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 leaves — markdown, 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:
| Family | Binds | Widgets |
|---|---|---|
| String | string | text-input, textarea |
| Number | number / integer | number-field, slider, stepper |
| Boolean | boolean | toggle, checkbox |
| Enum | enum | select, radio-group, segmented |
| Array | array | multiselect, 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.containerandvariable-boxsetpositional: 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 }
| Field | Notes |
|---|---|
name | Identifier, unique within its declaring scope. |
type | One of string, number, integer, boolean, enum, array. |
default | Optional literal value, validated against type. Mutually exclusive with expr. |
expr | Optional computed expression. Mutually exclusive with default. |
options | The 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/exprengine, which supports arithmetic, string concatenation, comparisons, and built-in conversion functions such asstring(...). 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
variableconfig 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 reportsWIDGET_TYPE_MISMATCH. Changing the control sets that variable’s runtime override and re-resolves the document, so dependent${var}/$varconsumers update live. The widget only declares the binding and its presentation; the variable itself is declared in the document/containervariablesand supplies the effective default (override > default).selectis the canonical single-choice control, replacing the retireddropdownitem. - URL query parameters.
servereads?name=valueparameters 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 sameVAR_TYPE/VAR_OPTIONS_INVALIDcodes 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
| Family | Binds variable type | Widgets |
|---|---|---|
| String | string | text-input, textarea |
| Number | number or integer | number-field, slider, stepper |
| Boolean | boolean | toggle, checkbox |
| Enum | enum | select, radio-group, segmented |
| Array | array | multiselect, 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 withVAR_UNDEFINED.- Type compatibility. The bound variable’s declared
typemust be one the widget’s family permits (see the table above). Otherwise the resolver reportsWIDGET_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
variablesarray 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:
| Field | Type | Notes |
|---|---|---|
label | string | Optional label rendered with the control. |
description | string | Optional help text rendered beside or beneath the control. |
disabled | boolean | When 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:
| Field | Type | Notes |
|---|---|---|
placeholder | string | Optional 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 frommin/maxto bound it).stepper— increment / decrement buttons around a value.
All three accept the shared config plus an optional range:
| Field | Type | Notes |
|---|---|---|
min | number | Optional inclusive lower bound. Must not exceed max. |
max | number | Optional inclusive upper bound. Must not be less than min. |
step | number | Optional 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 retireddropdownitem).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:
| Field | Type | Notes |
|---|---|---|
options | array of { value, label? } | Required. The selectable options in display order. At least one. label defaults to value when omitted. |
sort | declared | label | value | Optional 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:
| Field | Type | Notes |
|---|---|---|
options | array of { value, label? } | The values the bound array may contain, in display order. An absent or empty set fails with RESOLVE_CONFIG_INVALID. |
sort | declared | label | value | Optional 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:
| Field | Type | Notes |
|---|---|---|
placeholder | string | Optional 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
| Code | When |
|---|---|
WIDGET_TYPE_MISMATCH | The bound variable’s declared type is not one the widget’s family permits. |
VAR_UNDEFINED | The widget binds a name not visible in its scope. |
RESOLVE_CONFIG_INVALID | A 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:
| Type | Carries | Required fields | Optional fields |
|---|---|---|---|
markdown | A block of prose | source | — |
heading | A section heading | text, level | — |
image | An image reference | src | alt, 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.sourcecarries Markdown prose verbatim. The schema imposes no Markdown grammar, flavor, or sanitization — it is a pass-through string a downstream renderer interprets.image.srccarries an image reference verbatim. The schema imposes noformat: 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 resolvexas a variable and fail withVAR_UNDEFINEDwhen 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.
| Field | Type | Notes |
|---|---|---|
source | string | Required, 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.
| Field | Type | Notes |
|---|---|---|
text | string | Required, non-empty. The heading label. An absent or empty text fails with RESOLVE_CONFIG_INVALID. |
level | integer | Required, 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 surface — text as a single-line text-input,
level as a number-field.
image (.../items/image/1.0.0)
An image-reference leaf.
| Field | Type | Notes |
|---|---|---|
src | string | Required, non-empty. The opaque image reference, stored verbatim (see Opaque strings). An absent or empty src fails with RESOLVE_CONFIG_INVALID. |
alt | string | Optional. Alternative text for accessibility and fallback. The leaf resolves with src alone. |
caption | string | Optional. 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
| Code | When |
|---|---|
GRAMMAR_REGION_CHILD_INVALID | A bare (unwrapped) content leaf sits directly under a container or root — content must be block-wrapped. |
RESOLVE_CONFIG_INVALID | A required field is missing or empty, or heading.level is outside 1–6 / not an integer. |
VAR_UNDEFINED | A ${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
formcontainer 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
containergrid 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 | |
|---|---|---|
| Arrangement | Compact label+control cells, filled row-major then wrapped | A weighted grid identical in shape to container’s |
layout.columns | A unitless integer count (1–12) | An array of relative column-track weights |
layout.rows / layout.gap | Not used | Relative row-track weights / relative gap |
Child placement | None — widgets carry no placement | Each 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
| Code | When |
|---|---|
LAYOUT_FORM_CHILD_INVALID | A form contains a non-widget child (a container, a table, …). |
LAYOUT_FORM_COLUMNS_INVALID | A form’s layout field has an unexpected type (a contract backstop; the schema bounds the integer range). |
LAYOUT_PLACEMENT_OUT_OF_BOUNDS | A grid-mode form child’s placement falls outside the form’s grid (shared with container). |
LAYOUT_PLACEMENT_INVALID | A 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, orarray. 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 — anenumof allowed values, adescription, 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-inputortoggle. 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
typeis not one of the variable type set, or - a
renderinghint 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
labelanddisabled, plus family-specific fields —placeholder(string family andtag-input),min/max/step(number family), andsort(enum and option-array families). - The
formcontainer surfaces itslayout. - The
containersurfaces itsgrid, thevariable-boxsurfaces itsarrangement, and thetablesurfaces itstitle,columns, andquery. - The
blockwrapper surfaces itstitleandvisibility, so those per-block concerns are tunable at runtime through the same mechanism (itsid,content, andthemeare 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
renderinghint. - 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
thememember of the document, and - a block wrapper’s per-block
themeoverride, declared in the wrapper’sconfig.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”.
| Token | Values | Meaning |
|---|---|---|
emphasis | none · low · high | How prominent the element is relative to its surroundings. |
spacing | compact · cosy · roomy | Relative internal/outer breathing room. |
density | comfortable · compact | How tightly repeated/listed content is packed. |
tone | neutral · accent · positive · caution · critical | Semantic colour intent, by meaning — no colour value. |
radius | none · subtle · rounded | How softened the corners of a surface are. |
border | none · hairline · standard | How 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
thememember 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.themeoverride — 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:
| Address | Targets | Example |
|---|---|---|
name (bare) | a variable named name | region → "eu" |
<node-id>.<field> | a node’s config field | summary.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 withCONFIG_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:
- Declared defaults — the variable
defaultand the node’s authoredconfig. - Variable overrides — replace settable variables; computed variables
recompute against the new values;
${var}/$varconsumers see the result. - 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=valuebecomes 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/resolveendpoint. 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 instanceidof the item this configurator edits. It must reference an item declared in the same document that carries anid. Most items omitid; a configurator’s target is the first thing that makes a stableidrequired 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— thetargetreference is empty or whitespace-only, so there is no id to look up.CONFIGURATOR_TARGET_NOT_FOUND— thetargetis 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:
| Target | Scope |
|---|---|
$manifest | the document manifest |
$variables | the document variable set |
$connections | the document connections |
$theme | the document default theme |
$root | the 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— thetargetis$-prefixed but names no recognized scope; it fails fast (it is not reinterpreted as an item id). The offending configurator’spathand 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:
| Scope | Fields |
|---|---|
$manifest | title, description |
$theme | the 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
renderingwidget when the surface declares one, else - the canonical widget for the field’s value type —
string→text-input,number/integer→number-field,boolean→toggle,enum→select,array→multiselect.
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
columnsarray or a form’slayoutis 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 inconstraintsfor 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 failsCONFIG_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:
latticeloads 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 ischangeset.ApplyChangeset; the CLI islattice patch. Two pieces remain out of scope (see What is deferred): an HTTP write endpoint (servestays 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;
testoperations 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:
| Scope | Example pointer target |
|---|---|
| item | a node’s config field |
| manifest | the document title / description |
| variables | the document variable set |
| connections | the document connections |
| theme | a theme token |
| root | the 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 itemid, 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’sconfig.content— a config field, not achildrenslot. 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 intoconfig/contentprecisely so the inner item is addressable by its own id. - Structure is
children/-andchildren/N. Only a container or form carries achildrenarray. 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. Amove(orcopy) names the relocated node byfrom(an id-rooted pointer to the source) and its destination bypath. Both ends are translated. Reordering a child within one parent is amovewhosefromandpathare slots of the samechildrenarray.
[
{ "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/2always 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 itemid(/<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’sconfigurabledeclaration. A target that resolves to a field not on that surface is rejected withCONFIG_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 withCONFIG_OVERRIDE_VALUE_INVALID. - For a document scope edit (
/$manifest/<field>,/$theme/<token>), the surface is the scope’s entry in the document schema’sdocumentScopeskeyword.$manifestsurfacestitle/description;$themesurfaces 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
childrenarray; the grammar permits it, so a changeset may legally empty a region. - Cross-parent move strips placement. A node’s
placementis expressed in its immediate parent container’s grid coordinates. Amovethat carries a node to a different parent would carry stale coordinates that the new grid’s bounds reject. The apply layer therefore strips theplacementfrom 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
testops. Atestoperation 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 atestto 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
revision —
changeset.WithExpectedRevision(token)in Go,--expect-revisionon 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 withCHANGESET_REVISION_CONFLICTif 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 withCHANGESET_REVISION_UNSUPPORTEDrather 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. Unlikeresolve/serve,patchalways 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|gitand--root <dir>select the backend, the same seamresolve/serveuse (defaults:fs, the working directory). For the git backend, the persistingSaveis 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 override | JSON Patch changeset | |
|---|---|---|
| Lifetime | ephemeral — one resolution | durable — a persisted edit |
| Shape | flat address → value map | ordered RFC 6902 operation list |
| Scope | variables and config fields | every scope, uniformly |
| Field guardrail | configurable surface | configurable surface |
| Structural edits | not applicable | tree 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
servecommand stays read-only — it re-resolves and renders, but exposes no write path. A changeset is applied through the GoApplyChangesetentry point or thelattice patchCLI, not over HTTP. - A database storage backend. Apply persists through the existing filesystem
and git storage backends. A database-backed
Storeis future work — theStorecontract 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" }
}
| Field | Required | Notes |
|---|---|---|
id | yes | Document-unique identifier; items bind by this id. |
$ref | yes | URI of the connection-type schema (validated against the catalog). |
config | no | Per-connection config; shape defined by the connection type. |
secretRefs | no | Indirection 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"
}
}
}
connectionIdnames a document-scoped connection. If it matches no declared connection, resolution fails withBINDING_CONNECTION_NOT_FOUND.queryis 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
querydeclared without aconnectionIdis 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:
- Reads
NAMEfrom the process environment (os.LookupEnv). - Substitutes the value only to validate the connection config (a connection-type schema expects the concrete value, e.g. a header string).
- Discards the resolved value immediately afterward.
What is kept in the resolved tree is not the value:
- The connection’s
configretains the{ "$secret": "NAME" }reference object, unchanged. - A sorted
secretslist 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
$secretmust resolve from the environment, documents that use one require the variable to be set even just toresolveby hand. For example,examples/kitchen-sink-dashboard.jsonneedsMETRICS_API_TOKEN:METRICS_API_TOKEN=xyz lattice resolve examples/kitchen-sink-dashboard.jsonThe 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 withCONTRACT_MISSING— a binding with no shape to validate against is an error. - The
expectedResultfragment must be a well-formed draft 2020-12 schema, or resolution fails withCONTRACT_INVALID. - For a
staticconnection, the inlinerowsare 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 withRESULT_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.
| File | Demonstrates |
|---|---|
examples/minimal-dashboard.json | The smallest real document: a root container holding a body region whose grid places two block-wrapped static tables. |
examples/grids-dashboard.json | Nested grids / subgrids with explicit placement and fractional track sizing. |
examples/variables-dashboard.json | All three variable kinds — static, runtime-settable, computed expr — feeding $var and ${} references. |
examples/binding-dashboard.json | An item bound to an http connection by id with a variable-filled query, plus a $secret reference. |
examples/dropdown-dashboard.json | The live runtime-input loop: a select widget sets an enum variable consumed by a table. |
examples/widgets-dashboard.json | The 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.json | A 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.json | The form container in both layout modes (flow and grid) plus a standalone widget placed directly in a container grid cell. |
examples/connections-dashboard.json | Both connection kinds declared at document scope — static (inline) and http (query) — with secretRefs. |
examples/contract-dashboard.json | A table bound to a static connection whose inline rows conform to the table’s expectedResult contract. |
examples/configurator-dashboard.json | A configurator targeting a table by id, auto-generating an editor from the table’s configurable surface that drives ephemeral config overrides. |
examples/themed-dashboard.json | The 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.json | A 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.json | Every 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.
| Operation | Behavior |
|---|---|
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.Savederives 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.idofexample-minimalis stored asexample-minimal.jsonunder the configured root.Listrecovers ids by stripping the.jsonextension from the files directly under root (a missing root is treated as empty, not an error). - Atomic writes.
Savewrites 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 withSTORAGE_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, andExistsare 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.
Savewrites the file, stages it, and commits with a generated message (Save dashboard <id>).Deleteremoves 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
gitbinary.) - 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 fixedlattice <lattice@localhost>identity, so a freshly initialised repo with no configured identity still produces valid, attributable commits. - Path-scoped staging. Only the specific
<id>.jsonis ever staged — nevergit 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:
| Operation | Behavior |
|---|---|
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:
| Flag | Meaning | Default |
|---|---|---|
--store | Backend kind: fs or git. | fs |
--root | Root directory for the backend. | . (the working directory) |
How the positional argument is interpreted depends on whether you set either flag:
- Neither
--storenor--rootset — direct-path loading (default). The positional argument is a filesystem path to a document, resolved directly. This is the pre-existing behavior, and every existingresolve <path>invocation continues to work unchanged. No backend is constructed. --storeor--rootexplicitly set — backend-addressed loading. The positional argument is amanifest.idloaded 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’sgit 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
servetoday. - A database (DB) adapter. The shipped backends are the filesystem backend
and the git backend. A database-backed
Storeis future work; theStorecontract 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/patchwrite 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
| Flag | Default | Meaning |
|---|---|---|
--store | fs | Backend kind: fs or git. |
--root | . | Root directory the backend reads documents from. |
--schemas <dir> | schemas | Directory holding dashboard.schema.json and the item/connection catalog. |
--json | off | Emit 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.
| Tool | Purpose | When to use |
|---|---|---|
lattice_list_dashboards | Enumerate stored documents (id + title). | First step — discover what exists. |
lattice_get_outline | Config-free skeleton of one document + revision. | Navigate a document cheaply; locate a node by id. |
lattice_get_node | One node’s stored subtree + editable field surface. | Read a node you intend to edit in place. |
lattice_get_document | Whole document, raw and (optionally) resolved. | Escape hatch — only when a slice won’t do. |
lattice_list_schemas | The grammar catalog (item types + envelope). | Discover what node types you may build. |
lattice_get_schema | One type’s JSON Schema. | Author a new node/document validly. |
lattice_validate_patch | Dry-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’smanifest.id(the key every other tool accepts) plus itsmanifest.titlewhen 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 carriesid,type(short item-type ref), optionaltitle(only when the node’s config declares one),container(true when it may hold children), an optionalplacementsummary (e.g."col 2+1, row 1+1", never the verbatim placement object), andchildren.
- When: to navigate. Use the outline to find the
idof the node you want, then drill in withlattice_get_node— notlattice_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.keyis the tail of a valid id-rooted patch path (nested keys dotted, e.g.grid.gap);typeis the field’s value type.
- Block addressing: when
nodeIdnames a block wrapper, thesubtreeis the whole block (wrapper plus its config/content) and thesurfaceis the content item’s editable fields — a block delegates its knobs to what it wraps. - Surface gates field edits only. The
surfacelists which field paths a patch may touch. Structural edits (add/remove/move children) are not surface-listed — plan those fromlattice_get_outline. - When: to edit a node that already exists. The
surfacetells you which field pathslattice_validate_patchwill accept. (Distinct errors make the two failure modes clear: an unknown document id isSTORAGE_NOT_FOUND; an unknown node id isCHANGESET_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?}—documentis the raw stored JSON;resolvedis the full resolved tree, present only whenresolved: truewas 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 validlattice_get_schemainput. - 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 fromlattice_list_schemas, or"dashboard"for the envelope. - Output:
{type, schema}— the type’s JSON Schema (config fields, required keys,$refform) as JSON. - When: when building new. The contrast with
lattice_get_nodeis the key distinction:- Editing an existing node →
lattice_get_node(itssurfacetells 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).
- Editing an existing node →
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’spathleads 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?}—previewis the resolved tree the patch would produce (nothing is persisted to produce it);baseRevisionis the document’s current revision token, the value the eventual write passes asexpectedRevision. - 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-resolutionRESOLVE_*/SCHEMA_*/VAR_*) as a tool error. Correct the ops and call again. - When: to check a proposed edit. Iterate
lattice_validate_patchuntilok: true, then handbaseRevisionto 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:
- List.
lattice_list_dashboards→ pick the target documentid. - Navigate.
lattice_get_outline {id}→ locate the nodeidto change; note therevisionand the document-scope summary. - Drill or discover.
- Editing an existing node →
lattice_get_node {id, nodeId}for the storedsubtreeand the editablesurface(which field paths are patchable). - Building a new node →
lattice_list_schemasthenlattice_get_schema {type}for the config grammar a new node must satisfy.
- Editing an existing node →
- 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 untilok: true. Keep the returnedbaseRevision. Nothing has been persisted. - Commit (human). A human commits the validated patch via
POST /api/patch, passing thebaseRevisionfrom step 4 asexpectedRevision. 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/patchis served by theservecommand, not themcpcommand. The model’s last step is a successfullattice_validate_patch; a human performs the commit.
No auth — known gap
There is no authentication or authorization on either MCP mode surface:
- the
lattice mcpstdio server (any host that can launch the subprocess can call every tool), and - the
POST /api/patchwrite endpoint onlattice 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
| Code | Meaning |
|---|---|
RESOLVE_INVALID | Invalid invocation (e.g. missing document path argument). |
RESOLVE_IO | I/O failure loading the document. |
RESOLVE_INTERNAL | Unexpected internal error. |
RESOLVE_DOCUMENT_INVALID | The document failed Pass 1 (structural validation) or is not valid JSON. |
RESOLVE_CONFIG_INVALID | An instance’s interpolated config failed item-type schema validation. |
RESOLVE_CHILDREN_NOT_ALLOWED | Children declared on a non-container item type. |
SCHEMA_* — catalog & reference resolution
| Code | Meaning |
|---|---|
SCHEMA_NOT_FOUND | A referenced schema could not be located. |
SCHEMA_IO | I/O failure reading a schema or document. |
SCHEMA_INVALID | A schema failed to parse / is malformed. |
SCHEMA_REF | A $ref could not be resolved. |
SCHEMA_VALIDATION | A document failed JSON Schema validation. |
SCHEMA_REF_UNRESOLVED | An instance $ref matched no catalog schema, relative file, or inline fragment. |
SCHEMA_VERSION_MISMATCH | A $ref named a type whose pinned semver is missing/mismatched in the catalog. |
VAR_* — variables
| Code | Meaning |
|---|---|
VAR_UNDEFINED | A $var / ${} reference named an undeclared or unset variable. |
VAR_TYPE | A variable value (default, computed result, or override) did not match its declared type. |
VAR_EXPR | A computed-variable expression failed to compile or evaluate. |
VAR_DECLARATION_INVALID | Malformed declaration: missing name, unknown type, both default and expr, or a duplicate name in one scope. |
VAR_OPTIONS_INVALID | Enum options missing/malformed, options on a non-enum, or a value outside the enum set. |
VAR_CYCLE | Computed variables form a dependency cycle. |
CONNECTION_*, SECRET_*, BINDING_*, CONTRACT_* — data
| Code | Meaning |
|---|---|
CONNECTION_NOT_FOUND | A referenced connection was not declared. |
CONNECTION_INVALID | A connection declaration is malformed (e.g. missing $ref). |
CONNECTION_DUPLICATE_ID | Two connections share an id. |
CONNECTION_TYPE_UNRESOLVED | A connection’s $ref matched no connection-type schema. |
CONNECTION_CONFIG_INVALID | A connection’s config failed its connection-type schema. |
SECRET_INVALID | A { "$secret": "name" } reference has an empty or non-string name. |
SECRET_MISSING | A referenced secret is not set in the environment. |
BINDING_INVALID | A query without a connectionId, or a malformed connectionId/query. |
BINDING_CONNECTION_NOT_FOUND | An item’s connectionId matched no declared connection. |
CONTRACT_MISSING | A bound item’s type declares no expectedResult contract. |
CONTRACT_INVALID | A bound item type’s expectedResult is not a well-formed schema fragment. |
RESULT_SHAPE_INVALID | A static connection’s inline data violates the consuming item’s contract. |
LAYOUT_* — container grids
| Code | Meaning |
|---|---|
LAYOUT_PLACEMENT_INVALID | A child placement carried a non-positive span or start. |
LAYOUT_PLACEMENT_OUT_OF_BOUNDS | A child placement extends beyond the parent grid bounds. |
LAYOUT_FORM_COLUMNS_INVALID | A form’s flow-layout column count is out of range. |
LAYOUT_FORM_CHILD_INVALID | A form holds a child that is not a variable widget. |
CONFIGURABLE_*, CONFIG_OVERRIDE_* — surfaces & overrides
See Configurable Surfaces and Runtime Overrides.
| Code | Meaning |
|---|---|
CONFIGURABLE_SURFACE_INVALID | An 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_UNKNOWN | A <node-id>.<field> config override addressed a field not on the target’s configurable surface (or a dotted sub-path). |
CONFIG_OVERRIDE_VALUE_INVALID | A 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.
| Code | Meaning |
|---|---|
WRAPPER_ID_MISSING | A block wrapper is missing its required stable id (absent or whitespace-only). |
WRAPPER_CHILD_COUNT_INVALID | A 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.
| Code | Meaning |
|---|---|
GRAMMAR_ROOT_CHILD_INVALID | A 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_INVALID | A container region holds an illegal child — a bare (unwrapped) content leaf, which must be block-wrapped. |
GRAMMAR_VARIABLE_BOX_CHILD_INVALID | A variable-box holds a child that is not a variable widget held directly. |
GRAMMAR_WRAPPER_NESTED | A block wrapper’s single inner content is itself a block wrapper — wrappers do not recurse. |
GRAMMAR_REGION_THEME_FORBIDDEN | A positional region carries a theme — regions are layout-only; only block wrappers carry chrome. |
CONFIGURATOR_* — configurators
See Configurators.
| Code | Meaning |
|---|---|
CONFIGURATOR_TARGET_NOT_FOUND | A configurator’s target named an item id that no node in the tree declares. |
CONFIGURATOR_TARGET_MISSING_ID | A configurator’s target is empty/whitespace-only, so it names no resolvable id. |
CONFIGURATOR_TARGET_SCOPE_UNKNOWN | A 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.
| Code | Meaning |
|---|---|
CHANGESET_INVALID | A 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_INVALID | An 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_FOUND | A 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_INVALID | A 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_CONFLICT | The 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_UNSUPPORTED | An 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_FAILED | A 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_INVALID | An invalid lattice patch invocation: a missing manifest id argument, a missing/unreadable --changeset file, or a stdin read failure. |
SERVE_* — the HTTP layer
| Code | Meaning |
|---|---|
SERVE_INVALID | Invalid serve invocation (missing document, out-of-range port). |
SERVE_RESOLVE | The served document failed to resolve (wraps the underlying resolver error; rendered on the HTML error page). |
SERVE_INTERNAL | Unexpected error in the web layer. |
STORAGE_* — whole-document persistence
See Storage Backends.
| Code | Meaning |
|---|---|
STORAGE_ID_INVALID | A 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_FOUND | A Load/Delete/History/LoadAt addressed an id (or revision) that no stored document/commit matches. |
STORAGE_IO | An 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_INVALID | A document could not be parsed far enough during Save to extract its manifest.id (malformed JSON or a missing manifest object). |
STORAGE_INTERNAL | An unexpected error in a storage backend. |
STORAGE_BACKEND_UNKNOWN | The --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.
latticenever dials anhttpconnection, opens a socket, or makes a network request. The only real data check is validating astaticconnection’s inline rows against the result-shape contract. - Visual rendering. There is no chart, table, or widget rendering engine.
The
servecommand 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
servere-resolving the whole document on each request. - Partial / incremental re-resolution. Every resolve processes the entire
document. The resolved tree records each variable’s
declaredAtso 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-langsyntax. Computed variables use theexpr-lang/exprengine, 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.ApplyChangesetentry point and thelattice patchCLI. Butservestays 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
Storeis future work — theStorecontract 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.