Schema enforcement
Turn a role’s form into a contract the server enforces. Rows resolve from the dashboard, the SDK, the CLI, webhooks, MCP agents and machines, and each submits a payload shaped by the role’s form. With enforce_schema: true, every one of those surfaces validates the payload before any state changes and refuses violations with the same 422 body.
await start({ database: { connectionString: process.env.DATABASE_URL }, roles: [{ role: 'intake-reviewer', form_schema: INTAKE_FORM, enforce_schema: true }],});At runtime, set it with PATCH /api/roles/:role, the update_role MCP tool or the dashboard role editor.
What the contract checks
Section titled “What the contract checks”| Check | From the schema |
|---|---|
| Presence | required, honoring x-lt-showIf. A field the submitter cannot see never blocks submission |
| Type | The declared type. A number field takes 1; "1" is a violation |
| Choices | enum, x-lt-options, and lookup editions in force at submit |
| Bounds and patterns | minimum, maximum, lengths, pattern, format: email, format: uri, and the x-lt-* dynamic bounds read from the row |
| Checklists | x-lt-require-all against the items resolved from the row |
| Guards | x-lt-submit-guard and roster guards |
The schema validated against is resolved per row: metadata.form_schema on the row, else the snapshot pinned by metadata.schema_version, else the role’s latest form. A role that enforces but has no schema resolves unchanged.
One module, both sides
Section titled “One module, both sides”The checks live in one shared module. The dashboard runs it before submit on the flat form values. The API runs it on the submitted nested payload, after reversing each field’s x-lt-bind path. A payload that passes the dashboard’s panel passes the server, and a refusal lists exactly the errors the panel would show.
The canonical 422
Section titled “The canonical 422”Every surface reports violations with one body:
{ "error": "resolverPayload failed schema validation (2 violations)", "code": "schema_validation", "violations": [ { "field": "contact_email", "message": "Enter a valid email address" }, { "field": "tier", "message": "Must be one of: free, starter, professional, enterprise" } ], "role": "intake-reviewer", "schemaVersion": 3}| Surface | How it arrives |
|---|---|
| HTTP | The 422 response body |
| SDK | result.status === 422, result.code === 'schema_validation', the body in result.data |
| MCP | resolve_escalation and claim_and_resolve return the same JSON with isError: true |
| CLI | Each violation printed under the error line |
| Dashboard | The violations fill the errors panel |
Bulk surfaces (resolve-by-ids, resolve-all-or-none) tag each violation with its escalationId. All-or-none extends to validation: one failing item blocks the batch before anything resolves.
Where the gate runs
Section titled “Where the gate runs”Every resolve surface validates before any side effect:
| Surface | Gate |
|---|---|
POST /api/escalations/:id/resolve |
After RBAC and the claim check |
POST /api/escalations/resolve-by-signal-key |
After the write-scope check |
POST /api/escalations/resolve-by-ids |
After the write-scope check. Only rows in enforcing roles load in full |
POST /api/escalations/resolve-all-or-none |
Before anything resolves |
POST /api/escalations/resolve-by-metadata |
Inside the atomic statement: an enforcing target writes nothing until the validated payload resolves it by id, and the second pass checks pending again |
MCP resolve_escalation, claim_and_resolve |
Before the resolve. claim_and_resolve validates before claiming, so a refused payload never strands a claim |
Batch fills and accumulator adds validate each item against the same versioned form.
Cost on the hot path
Section titled “Cost on the hot path”Role schemas change on admin timescales and resolves happen on work timescales, so the gate is cached:
- The set of enforcing roles is cached for
ROLE_ENFORCEMENT_CACHE_TTL_MS(30 seconds by default). A resolve that touches no enforcing role adds no SQL. - Pinned snapshots are immutable and cached indefinitely (LRU-bounded). Latest schemas share the TTL.
- A role write in the same process invalidates the cache at once. Across a fleet, staleness is bounded by one TTL. Pinned rows are immune to latest-schema staleness.
Roll it out by watching 422s
Section titled “Roll it out by watching 422s”-
Make sure every submitter sends a complete payload. The
check_resolutionMCP tool returns the form (fields, types,x-lt-bindpaths) an agent needs to build one. -
Set
enforce_schema: trueon the role.check_resolutionthen reportsschema_enforced: trueon its pending rows. -
Watch for
422s. Each one names a submitter with a payload gap and the exact fields. The dashboard is unaffected, since its pre-submit pass is the same code.
Typed consumption in the workflow
Section titled “Typed consumption in the workflow”Enforcement guarantees what entered the system. parseResolverPayload gives workflow code the consuming half: declare the payload once as a zod schema, derive the type from it and parse the resolution at the boundary.
import { z } from 'zod';import { parseResolverPayload } from '@hotmeshio/long-tail/build/sdk/index.js';
export const IntakeResolverV1Schema = z.object({ customer: z.object({ name: z.string(), email: z.string() }), contract: z.object({ tier: z.enum(['starter', 'professional']), approved: z.boolean() }),});export type IntakeResolverV1 = z.infer<typeof IntakeResolverV1Schema>;
export function readIntake(response: unknown): IntakeResolverV1 { return parseResolverPayload(IntakeResolverV1Schema, response);}A payload that does not conform throws ResolverPayloadTypeError, carrying the same { field, message } violation list with the ZodError as cause. Parse inside an activity so a drifted payload fails loudly in one place instead of spreading any through business logic.
