Validating Authoring Before Deploy
Three guardrails you adopt in your own deployment to catch agent-UX violations before they reach an agent. Each catches a different bug shape; together they make missing-envelope bugs effectively unshippable.
The three guardrails
| Guardrail | When it fires | What it catches |
|-----------|--------------|-----------------|
| Lint sweep | Edit time | Anti-patterns in code (hand-rolled nextSteps, type casts that hide missing fields). |
| Static schema sweep | CI test run | Mutation response schemas that do not declare nextSteps. |
| Runtime preSerialization hook | Live request handling | Mutation handlers that declared nextSteps but forgot to populate it. |
The lint sweep
A pattern you adopt in your ESLint config (or equivalent for Python, Go, etc.). Three rule shapes worth catching:
Inline nextSteps: [{...}] in a handler
// Bad — the static sweep cannot see this; the helper enumeration test cannot reach it.
return reply.send({
data: record,
nextSteps: [
{ action: 'Submit receipt', method: 'POST', href: `/v1/records/${id}/receipts`, description: '...' },
],
});
// Good — central helper, enumerable, testable.
return reply.send({ data: record, nextSteps: recordNextSteps(record.id, record.status) });
Centralized helpers let the static sweep walk every emitted href and assert it resolves to a registered route. Inline objects bypass the sweep entirely.
as { foo?: T } casts paired with ?? default
// Bad — the cast hides whether `nextSteps` exists. If the response body
// changes shape upstream, this silently returns the default.
const steps = (resp as { nextSteps?: NextStep[] }).nextSteps ?? [];
// Good — narrow with a runtime check, surface the absence.
if (!Array.isArray(resp.nextSteps)) throw new Error('Response missing nextSteps');
const steps = resp.nextSteps;
The cast is a shape claim the compiler cannot verify. A runtime check converts a silent miss into a loud failure.
Direct pool.query() with transaction-scoped SQL
A different concern from agent-UX, but bundled with these guardrails because both are "shape correctness" rules: SQL that uses transaction-scoped state (advisory locks, SET LOCAL, savepoints) must run on the held client, not via a fresh pool acquisition. A lint rule that flags pool.query() adjacent to a BEGIN / COMMIT block is cheap and catches a high-cost bug.
The static schema sweep
The single most important guardrail. The shape, lifted from src/test/agent-ux-mutation-sweep.test.ts:
// For every mutation operation in the OpenAPI spec, assert the 2xx response
// schema declares `nextSteps`.
it('every mutation declares nextSteps on 2xx responses', () => {
const violations = [];
for (const [path, ops] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries(ops)) {
if (!MUTATION_METHODS.has(method)) continue;
if (NEXT_STEPS_EXEMPT[op.operationId]) continue;
for (const [code, response] of Object.entries(op.responses)) {
if (!is2xx(code)) continue;
const schema = responseJsonSchema(spec, response);
if (!schema || schema.type === 'null') continue; // 204 etc.
const props = schemaMergedProperties(spec, schema);
if (!('nextSteps' in props)) {
violations.push({ method, path, code, opId: op.operationId });
}
}
}
}
if (violations.length > 0) expect.fail(/* itemized violation list */);
});
Three properties to preserve when adapting this:
- Run on the live spec, not a hand-maintained list.
spec.pathsenumerates whatever the route table actually registered. You cannot forget to add a route to the test. - Assert on the response schema, not handler output. Response serializers strip undeclared fields silently. If
nextStepsis not on the schema, the handler's value disappears at the wire — the schema is the contract. - Itemize violations, do not bail on first. A single missing field is rarely the only bug; ship a complete punch list per CI run.
The companion test asserts every mutation declares an RFC 9457-shaped error response (4xx / 5xx references ErrorResponse or carries type / title / detail inline). And a third sweep walks every href returned by the helpers in src/shared/next-steps.ts and asserts each resolves to a registered route — that is how a step pointing at a 404 gets caught before deploy.
The exemption list
Some mutation responses legitimately do not carry nextSteps. Track them in a typed map with a one-line reason per entry:
const NEXT_STEPS_EXEMPT: Record<string, string> = {
// JSON-RPC 2.0 envelope `{ jsonrpc, id, result|error }` is fixed by spec —
// extra envelope fields break wire format. Agent-UX hooks live inside
// `result.task.metadata` per A2A v1.0, not at the HTTP envelope level.
a2aJsonRpc: 'JSON-RPC 2.0 envelope; hooks live inside result.task per A2A v1.0',
};
Default answer to a sweep failure is "fix the schema," not "add to the allowlist." Allowlist entries are for routes where the contract genuinely does not apply, with a justification that survives review.
The runtime preSerialization hook
The static sweep catches a missing declaration. The runtime hook catches a missing value — when the schema declared nextSteps as optional and the handler forgot to populate it.
The shape, from src/plugins/agent-ux-envelope.ts:
const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const ENFORCED_PREFIXES = ['/v1/', '/admin/'];
const EXEMPT_PREFIXES = ['/a2a'];
app.addHook('preSerialization', async (request, reply, payload) => {
const status = reply.statusCode;
if (status < 200 || status >= 300) return payload;
if (!MUTATION_METHODS.has(request.method)) return payload;
const url = request.url;
if (!ENFORCED_PREFIXES.some((p) => url.startsWith(p))) return payload;
if (EXEMPT_PREFIXES.some((p) => url.startsWith(p))) return payload;
const ct = reply.getHeader('content-type');
if (typeof ct === 'string' && !ct.includes('json')) return payload;
if (hasNextSteps(payload)) return payload;
// Strict mode (tests): throw and name the route.
// Observe mode (production): log + counter increment.
if (strict) {
throw new Error(
`Agent-UX envelope violation: ${request.method} ${url} returned ${status} without nextSteps.`,
);
}
agentUxMissingNextSteps.inc({ route: request.routeOptions?.url ?? url, method: request.method });
logger.warn({ method: request.method, url, status }, 'Agent-UX envelope: missing nextSteps');
return payload;
});
Mode selection:
- Strict (throw) when
AGLEDGER_ENFORCE_AGENT_UX=strict,NODE_ENV=test, orVITEST=true. A missingnextStepscrashes the test, naming the exact mutation that is incomplete. - Observe (log + counter) otherwise. Production runs with a counter
agledger_agent_ux_missing_next_steps_total{route, method}so a missed case is measurable rather than silent.
Pair the counter with a Grafana alert: any non-zero increment is a regression worth investigating.
Why all three
Each guardrail catches a different bug shape:
- The lint sweep catches anti-patterns at edit time — bypassed helpers, hidden type assertions. Fastest feedback, cheapest fix.
- The static schema sweep catches missing declarations in the response schema — where serializers like
fast-json-stringifywould silently strip the field at the wire. Catches the bug before any handler runs. - The runtime hook catches missing values in the response handler — where the schema declared the field as optional and the handler forgot to populate it. Last line of defense.
A bug that slips past one is caught by the next. None of them on its own is sufficient.
Customer adoption pattern
Concrete steps to adopt this in your deployment:
- Drop the lint patterns into your
eslint.config. The three rule shapes above generalize; implement equivalents in your repo's existing lint stack. - Copy the static sweep test into your test suite. Run it on every CI build. Allowlist entries require justification.
- Register the runtime hook in your bootstrap. Strict in tests, observe in production. Add the counter to your Prometheus scrape config.
These are one-time-cost setups, not per-route-cost. Once they are in place, every new mutation route inherits all three guardrails automatically.
See also
- The agent-UX envelope — what each guardrail enforces.
- Discovery surface projection — what registered schemas project to agents.
Validated against the live API (v0.22.18) on 2026-05-02.