Skip to content

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.

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.

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.

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.

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.

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.
  1. Make sure every submitter sends a complete payload. The check_resolution MCP tool returns the form (fields, types, x-lt-bind paths) an agent needs to build one.

  2. Set enforce_schema: true on the role. check_resolution then reports schema_enforced: true on its pending rows.

  3. 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.

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.

src/forms/intake.ts
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.