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:

  1. Run on the live spec, not a hand-maintained list. spec.paths enumerates whatever the route table actually registered. You cannot forget to add a route to the test.
  2. Assert on the response schema, not handler output. Response serializers strip undeclared fields silently. If nextSteps is not on the schema, the handler's value disappears at the wire — the schema is the contract.
  3. 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:

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:

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:

  1. Drop the lint patterns into your eslint.config. The three rule shapes above generalize; implement equivalents in your repo's existing lint stack.
  2. Copy the static sweep test into your test suite. Run it on every CI build. Allowlist entries require justification.
  3. 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


Validated against the live API (v0.22.18) on 2026-05-02.