Schema enforcement and versioning (Orleans.Lattice.Schema)
This page documents Orleans.Lattice.Schema 9.9.0, in the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at README.md, and llms.txt lists every page.
Orleans.Lattice stores every value as an opaque byte[]: the silo attaches no
schema to a value, and typed access is a client-side convenience. That keeps the
core fast and format-agnostic, but it also means the cluster cannot, on its own,
stop a caller writing a malformed value or tell a v1 value from a v2 one.
The companion Orleans.Lattice.Schema package closes that gap with two
independent, composable, strictly opt-in capabilities:
- Schema enforcement - per-tree, server-side validation of the values a tree's write operations carry, against a declarative policy (JSON well-formedness, UTF-8, a maximum byte length, a regex, or a structured predicate over a JSON document). A rejected local write fails fast; in strict mode a rejected ingested item that reaches the check (a replicated typed-CRDT entry, or an entry of a replicated atomic batch) is dead-lettered rather than dropped, so ingest never blocks. A plain (non-atomic) last-writer-wins replication apply, a backup restore and a tree merge write below the check, so their values are not validated (see strict-mode ingest). Existing data can be brought into compliance by a background, crash-safe shadow-build-and-cutover remediation.
- Schema versioning - a self-describing, per-value version tag (schema id + version) that lets a tree evolve its value shape over time. Stale values are upcast to the tree's target version at read time; the target version advances monotonically as an admin action.
Both features share one serializable value-transform primitive
(LatticeValueTransform) and the same dead-letter queue,
which is surfaced read-only through the State API and the Explorer UI.
Zero overhead when off
Neither feature costs anything until a tree opts in. With the package unregistered, the core write interceptor and value decoder are null implementations and the read/write path is byte-for-byte identical to a plain lattice. Even with the package registered, a tree with no policy and no version config pays only one cached lookup per registered feature on write and a single leading-byte check on read, and its stored bytes keep their exact steady-state shape.
Getting started
Register the feature(s) you want on the silo, after AddLattice:
using Orleans.Lattice.Schema;
// Enforcement: per-tree policies are installed afterwards through
// ILatticeSchemaAdmin; StrictIngest is the global half of strict-mode ingest.
siloBuilder.AddLatticeSchemaEnforcement(options =>
{
options.StrictIngest = true;
});
// Versioning: declare the schema family and its upcasters.
siloBuilder.AddLatticeSchemaVersioning(registry =>
{
registry.AddSchema(schemaId: 1, version: 1, name: "order");
registry.AddSchema(schemaId: 1, version: 2, name: "order");
registry.AddUpcaster(
schemaId: 1,
fromVersion: 1,
toVersion: 2,
transform: LatticeValueTransform.Passthrough(
LatticeValueTransform.SetMember(
"status", LatticeValueTransform.Const(LatticeConstant.Text("open")))));
});
When both features are used, call
AddLatticeSchemaEnforcementbeforeAddLatticeSchemaVersioningso the enforcement validation stage is composed ahead of the versioning envelope stage on the write path. The order is not checked at registration: callingAddLatticeSchemaEnforcementsecond replaces the composed write interceptor with the enforcement stage alone, so new writes are no longer stamped with a version envelope.
To layer further option delegates after registration, use
ConfigureLatticeSchemaEnforcement(Action<LatticeSchemaEnforcementOptions>) and
ConfigureLatticeSchemaVersioning(Action<LatticeSchemaVersioningOptions>).
Previewing a policy
LatticeSchemaPolicyValidator checks values against a LatticeSchemaPolicy
exactly as enforcement does, without writing anything. It compiles the rules once,
with the same checks setting the policy runs, so a rule that could not be set (a
structurally invalid rule, a negative maximum byte length, or a pattern that does
not compile) throws ArgumentException from its constructor. Validate then
judges a value against every rule in order and returns null when it complies, or
the first failing rule's reason. ValidateRule judges it against one rule by its
zero-based position, and throws ArgumentOutOfRangeException for a position
outside the policy; RuleCount and Policy report what the validator was built
from. A console or a tool uses it to preview a draft policy against sample
values before setting it; the Explorer's schema rule builder does exactly that.
using Orleans.Lattice.Schema;
var policy = new LatticeSchemaPolicy(
[
LatticeSchemaRule.Json(),
LatticeSchemaRule.Structured(
LatticePredicateNode.TypeOf("id", LatticeValueKind.Present),
description: "id is required"),
]);
var validator = new LatticeSchemaPolicyValidator(policy);
// null when the value complies; otherwise the first failing rule's reason.
string? reason = validator.Validate(Encoding.UTF8.GetBytes("""{"name":"widget"}"""));
// Judge one rule on its own, by its position in policy.Rules.
string? idRule = validator.ValidateRule(1, Encoding.UTF8.GetBytes("""{"id":"a1"}"""));
The validator judges the bytes it is given. For a tree that also uses schema
versioning, strip the version envelope from a stored value that carries one first
(LatticeSchemaEnvelope.IsEnveloped, then LatticeSchemaEnvelope.StripToBody,
which removes the header length without checking for it), because a policy judges
the body.
Documents
| Document | What it covers |
|---|---|
| Schema enforcement | Per-tree policies, rule kinds, strict-mode ingest, background remediation. |
| Schema versioning | The per-value version envelope, read-time upcasting, monotonic target-version advance. |
| Value transforms | The shared LatticeValueTransform IR used by remediation and upcasters. |
| Dead-letter queue | Strict-mode dead-lettering and how to inspect it via the State API and Explorer. |
| Wire format | The frozen per-value version envelope header layout. |
Capability gate
The in-process admin services this package registers (ILatticeSchemaAdmin,
ILatticeSchemaVersionAdmin, and ILatticeSchemaRemediationAdmin) are trusted,
host-side surfaces: they perform no authorization of their own, and they read
and write the package's reserved sys-schema-* trees (and, for a remediation or
a migration, the governed tree itself) as system origin, so any code holding the
service can change a tree's schema. LatticeSchemaReservedTrees names those trees
(PolicyTreeId, DeadLetterTreeId, VersionConfigTreeId) and their Prefix,
and lets an application check its own tree ids against the reserved namespace
(IsReserved, ThrowIfReserved). The LatticeOperation.SchemaAdmin capability
is enforced by the remote schema control facade,
Orleans.Lattice.Api.Schema, which authorizes
every call fail-closed before it touches these services: SchemaAdmin for
mutations (setting or clearing a policy, changing or advancing a version config,
migrating, remediating) and ordinary Read for inspection. With the
security layer enabled, schema control-plane actions
reached through that facade can therefore be granted independently of ordinary
data-plane read/write rights. The compliance audit
(ILatticeSchemaComplianceAdmin) reads the tree through the ordinary data plane,
so its scan is subject to the caller's Read authority.