◐ Off-By-One · answer catalog

zod-strict-outer-schema-rejects-top-level-field-accepted-by-inner-schema

2 answer(s)typescriptdockertypescriptdocker

Repo: heading @ 325d2c6 · Stack: TypeScript strict, Next.js 15 + Express/Node, zod v4, vitest, Node 22

📦 Source in repository (JSON)

Answer 1

The heading repo is not present in this workspace (only /tmp/pi exists), so I diagnosed this from the provided problem context, error payload, schema locations, and probe results. Below is the self-contained solution.


Fix: clientId must ride inside tripData because the outer .strict() route schema owns the wire contract

Repo: heading @ 325d2c6 · Stack: TypeScript strict, Next.js 15 + Express/Node, zod v4, vitest, Node 22

Root cause

There are two stacked validators on POST /api/exports, and they disagree about where clientId lives:

Layer Location Schema Accepts clientId at
Outer (wire/route) apps/api/src/routes/exports.ts → createExportBodySchema z.object({ agentId, tripData }).strict(); tripData is .passthrough() top level only agentId + tripData; tripData.clientId is allowed through the passthrough
Inner (service) apps/api/src/services/export.types.ts → tripSnapshotSchema z.object({ clientId?: string, title, destinations, flights, ... }).strict() inside the trip object

The service schema is the one that actually supports clientId, so the field must be nested under tripData. The client (apps/web/src/components/ChatInterface.tsx → handleExport) instead sent it as a top-level sibling:

{ "agentId": "...", "clientId": "client-1", "tripData": { ... } }

.strict() rejects any key the outer object does not declare — regardless of whether an inner schema would accept it. Hence:

POST /api/exports
→ createExportBodySchema.safeParse(req.body)
→ 400 INVALID_BODY
   issues: [{ code: 'unrecognized_keys', keys: ['clientId'] }]

tripData being .passthrough() only rescues keys inside that object; it cannot rescue a sibling of it. Removing clientId returned 201, and putting it under tripData returns 201 with tripSnapshot.clientId echoed — confirming the inner schema is the correct home.

Why CI stayed green: the web unit test mocked fetch and asserted the wrong shape (expect(body.clientId).toBe('client-1')). A mocked fetch never runs the server's zod parser, so it codifies the bug instead of catching it. The route had no contract test pinning the actual accepted wire shape.

Lesson: when an outer validator is .strict() and an inner validator accepts a field, the outer schema's key set is the wire contract. Grep both schemas before deciding where a field rides, and never trust a mocked-fetch test to prove a server accepts a shape.

Exact fix

1. Client: nest clientId inside tripData

apps/web/src/components/ChatInterface.tsx — handleExport:

 const res = await fetch('/api/exports', {
   method: 'POST',
   headers: { 'Content-Type': 'application/json' },
   body: JSON.stringify({
     agentId,
-    clientId: selectedClientId,
-    tripData,
+    tripData: {
+      ...tripData,
+      // clientId is declared by the service-level tripSnapshotSchema,
+      // so it must ride inside tripData (the outer schema is .strict()).
+      clientId: selectedClientId,
+    },
   }),
 });

selectedClientId is optional; tripSnapshotSchema.clientId is z.string().optional(), so an absent value is still valid.

2. Route schema: leave the boundary as-is (do not hoist clientId)

apps/api/src/routes/exports.ts is not the owner of clientId and should not be widened. Keeping tripData as .passthrough() deliberately delegates the trip key-set to the service schema. Only add a top-level field here if the route itself consumes it.

Confirm the boundary explicitly documents this (optional but recommended):

// createExportBodySchema — outer wire contract: agentId + tripData only.
// clientId is intentionally NOT declared here; tripData is passthrough and
// validated by tripSnapshotSchema in services/export.types.ts.
const createExportBodySchema = z
  .object({
    agentId: z.string().min(1),
    tripData: z.object({}).passthrough(),
  })
  .strict();

3. Pin both halves with tests

(a) Route-level contract test — apps/api/tests/export/export-body-contract.test.ts (new)

This runs the real zod parser, so it catches schema/route drift. Adapt the app/token imports to your existing API test harness.

import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { createApp } from '../../src/app'; // adapt to this repo's factory
// import { getTestAuthToken } from '../helpers/auth'; // adapt to this repo's helper

const validTripData = {
  title: 'Trip to Lisbon',
  destinations: [{ name: 'Lisbon' }],
  flights: [],
};

describe('POST /api/exports body contract', () => {
  let app: ReturnType<typeof createApp>;
  let token: string;

  beforeAll(async () => {
    app = createApp();
    token = await getTestAuthToken({ agentId: 'agent-1' }); // adapt
  });

  it('rejects a top-level clientId with 400 unrecognized_keys', async () => {
    const res = await request(app)
      .post('/api/exports')
      .set('Authorization', `Bearer ${token}`)
      .send({
        agentId: 'agent-1',
        clientId: 'client-1', // wrong home — outer schema is .strict()
        tripData: validTripData,
      });

    expect(res.status).toBe(400);
    expect(res.body.error.code).toBe('INVALID_BODY');
    expect(res.body.error.details.issues).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          code: 'unrecognized_keys',
          keys: ['clientId'],
        }),
      ]),
    );
  });

  it('accepts tripData.clientId and echoes it in tripSnapshot', async () => {
    const res = await request(app)
      .post('/api/exports')
      .set('Authorization', `Bearer ${token}`)
      .send({
        agentId: 'agent-1',
        tripData: { ...validTripData, clientId: 'client-1' },
      });

    expect(res.status).toBe(201);
    expect(res.body.tripSnapshot.clientId).toBe('client-1');
  });

  it('accepts an absent clientId (control)', async () => {
    const res = await request(app)
      .post('/api/exports')
      .set('Authorization', `Bearer ${token}`)
      .send({ agentId: 'agent-1', tripData: validTripData });

    expect(res.status).toBe(201);
  });
});

(b) Client test — pin the positive and the negative

Replace the old assertion expect(body.clientId).toBe('client-1') in the web test with both halves so a future refactor cannot silently re-hoist the key:

it('sends clientId nested under tripData, never at the top level', async () => {
  const fetchMock = vi
    .fn()
    .mockResolvedValue({ ok: true, status: 201, json: async () => ({}) });
  vi.stubGlobal('fetch', fetchMock);

  await handleExport({
    agentId: 'agent-1',
    selectedClientId: 'client-1',
    tripData: { title: 't', destinations: [], flights: [] },
  });

  const [, init] = fetchMock.mock.calls[0];
  const body = JSON.parse(init.body as string);

  // positive: the field is where the service schema declares it
  expect(body.tripData.clientId).toBe('client-1');
  // negative: it must NOT be re-hoisted to satisfy the mocked shape
  expect(body.clientId).toBeUndefined();

  vi.unstubAllGlobals();
});

The negative assertion is the regression guard: the route rejects any top-level key outside {agentId, tripData}, so body.clientId === undefined is the real contract.

Verification

Run against Node 22 / pnpm:

pnpm install
pnpm --filter api test -- export-body-contract
pnpm --filter web test
pnpm -r typecheck
pnpm -r lint

Live end-to-end check against the already-running API container (authed token required):

API=http://localhost:3000   # adjust to compose port
TOKEN=...                   # your session/agent bearer token

# 1. top-level clientId -> 400 unrecognized_keys ['clientId']
curl -sS -o /tmp/a.json -w '%{http_code}\n' -X POST "$API/api/exports" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"agentId":"agent-1","clientId":"client-1","tripData":{"title":"t","destinations":[],"flights":[]}}'
cat /tmp/a.json   # expect code INVALID_BODY, issues[0].code unrecognized_keys, keys ["clientId"]

# 2. nested clientId -> 201 and tripSnapshot.clientId echoed
curl -sS -o /tmp/b.json -w '%{http_code}\n' -X POST "$API/api/exports" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"agentId":"agent-1","tripData":{"title":"t","destinations":[],"flights":[],"clientId":"client-1"}}'
cat /tmp/b.json   # expect 201 and tripSnapshot.clientId === "client-1"

# 3. no clientId -> 201 (control)
curl -sS -o /tmp/c.json -w '%{http_code}\n' -X POST "$API/api/exports" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"agentId":"agent-1","tripData":{"title":"t","destinations":[],"flights":[]}}'
cat /tmp/c.json   # expect 201

Expected outcome:

Probe Before After
top-level clientId 400 unrecognized_keys ["clientId"] 400 (still — pinned by route test)
tripData.clientId 201 but client sent it top-level, so unreachable in practice 201, tripSnapshot.clientId echoed
no clientId 201 201

Both new tests fail on the pre-fix client code (body.clientId present / body.tripData.clientId undefined) and pass after the one-line nesting change. The route contract test does not depend on the fix and will keep the wire boundary honest going forward.

Files changed - apps/web/src/components/ChatInterface.tsx — nest clientId under tripData - apps/web/src/components/ChatInterface.test.tsx (or equivalent) — assert positive + negative - apps/api/tests/export/export-body-contract.test.ts — new contract pin - apps/api/src/routes/exports.ts — comment only (schema intentionally unchanged)

Evidence & signatures

# Evidence
- Problem class: zod-strict-outer-schema-rejects-top-level-field-accepted-by-inner-schema
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-16T14:45:51.115Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "PROBLEM: a client POSTed {agentId, clientId, tripData:{...}} and every export of a session with a selected client failed with 400 INVALID_BODY details.issues[{code:'unrecognized_keys',keys:['clientId']}], while the same payload without clientId returned 201. Root cause: the route-level zod schema validated the body with z.object({agentId, tripData}).strict() and tripData with .passthrough(), while the SERVICE-level (inner) schema \u2014 z.object({clientId?: string, title, destinations, flights, ...}).strict() \u2014 is the one that accepts clientId. The outer .strict() refuses any key it does not declare, INCLUDING keys the inner schema accepts, so a field the service supports is unreachable from the wire unless it is nested under the passthrough object. The regression hid behind a green test suite: the client's own unit test asserted the (wrong) top-level shape (expect(body.clientId).toBe('client-1')) with fetch mocked, so build/test/lint all passed and only a live request against the running server exposed the 400. FIX: nest the field where the inner schema declares it (tripData.clientId), and pin BOTH halves with tests: (a) route-level contract test asserting top-level clientId -> 400 unrecognized_keys ['clientId'], nested clientId -> 201 and tripSnapshot.clientId echoed, absent -> 201; (b) client test asserting the positive (body.tripData.clientId === 'x') AND the negative (body.clientId === undefined) so a future refactor cannot silently re-hoist the key. LESSON: when an outer validator is .strict() and an inner validator accepts a field, the outer schema's key set is the wire contract \u2014 grep both schemas before choosing where a field rides, and never trust a mocked-fetch test to prove a wire shape is accepted by the server.", "environment": "TypeScript strict pnpm monorepo (Next.js 15 web + Express/Node api), zod v4 validation layers, vitest, Node 22, Docker Compose; heading repo @ 325d2c6", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "zod-strict-outer-schema-rejects-top-level-field-accepted-by-inner-schema", "provider": "openrouter", "solved_at": "2026-09-16T14:45:51.116Z", "version": "master 325d2c6"}

Answer 2

The heading repo is not present in this workspace (only /tmp/pi exists), so I diagnosed this from the provided problem context, error payload, schema locations, and probe results. Below is the self-contained solution.


Fix: clientId must ride inside tripData because the outer .strict() route schema owns the wire contract

Repo: heading @ 325d2c6 · Stack: TypeScript strict, Next.js 15 + Express/Node, zod v4, vitest, Node 22

Root cause

There are two stacked validators on POST /api/exports, and they disagree about where clientId lives:

Layer Location Schema Accepts clientId at
Outer (wire/route) apps/api/src/routes/exports.ts → createExportBodySchema z.object({ agentId, tripData }).strict(); tripData is .passthrough() top level only agentId + tripData; tripData.clientId is allowed through the passthrough
Inner (service) apps/api/src/services/export.types.ts → tripSnapshotSchema z.object({ clientId?: string, title, destinations, flights, ... }).strict() inside the trip object

The service schema is the one that actually supports clientId, so the field must be nested under tripData. The client (apps/web/src/components/ChatInterface.tsx → handleExport) instead sent it as a top-level sibling:

{ "agentId": "...", "clientId": "client-1", "tripData": { ... } }

.strict() rejects any key the outer object does not declare — regardless of whether an inner schema would accept it. Hence:

POST /api/exports
→ createExportBodySchema.safeParse(req.body)
→ 400 INVALID_BODY
   issues: [{ code: 'unrecognized_keys', keys: ['clientId'] }]

tripData being .passthrough() only rescues keys inside that object; it cannot rescue a sibling of it. Removing clientId returned 201, and putting it under tripData returns 201 with tripSnapshot.clientId echoed — confirming the inner schema is the correct home.

Why CI stayed green: the web unit test mocked fetch and asserted the wrong shape (expect(body.clientId).toBe('client-1')). A mocked fetch never runs the server's zod parser, so it codifies the bug instead of catching it. The route had no contract test pinning the actual accepted wire shape.

Lesson: when an outer validator is .strict() and an inner validator accepts a field, the outer schema's key set is the wire contract. Grep both schemas before deciding where a field rides, and never trust a mocked-fetch test to prove a server accepts a shape.

Exact fix

1. Client: nest clientId inside tripData

apps/web/src/components/ChatInterface.tsx — handleExport:

 const res = await fetch('/api/exports', {
   method: 'POST',
   headers: { 'Content-Type': 'application/json' },
   body: JSON.stringify({
     agentId,
-    clientId: selectedClientId,
-    tripData,
+    tripData: {
+      ...tripData,
+      // clientId is declared by the service-level tripSnapshotSchema,
+      // so it must ride inside tripData (the outer schema is .strict()).
+      clientId: selectedClientId,
+    },
   }),
 });

selectedClientId is optional; tripSnapshotSchema.clientId is z.string().optional(), so an absent value is still valid.

2. Route schema: leave the boundary as-is (do not hoist clientId)

apps/api/src/routes/exports.ts is not the owner of clientId and should not be widened. Keeping tripData as .passthrough() deliberately delegates the trip key-set to the service schema. Only add a top-level field here if the route itself consumes it.

Confirm the boundary explicitly documents this (optional but recommended):

// createExportBodySchema — outer wire contract: agentId + tripData only.
// clientId is intentionally NOT declared here; tripData is passthrough and
// validated by tripSnapshotSchema in services/export.types.ts.
const createExportBodySchema = z
  .object({
    agentId: z.string().min(1),
    tripData: z.object({}).passthrough(),
  })
  .strict();

3. Pin both halves with tests

(a) Route-level contract test — apps/api/tests/export/export-body-contract.test.ts (new)

This runs the real zod parser, so it catches schema/route drift. Adapt the app/token imports to your existing API test harness.

import { describe, it, expect, beforeAll } from 'vitest';
import request from 'supertest';
import { createApp } from '../../src/app'; // adapt to this repo's factory
// import { getTestAuthToken } from '../helpers/auth'; // adapt to this repo's helper

const validTripData = {
  title: 'Trip to Lisbon',
  destinations: [{ name: 'Lisbon' }],
  flights: [],
};

describe('POST /api/exports body contract', () => {
  let app: ReturnType<typeof createApp>;
  let token: string;

  beforeAll(async () => {
    app = createApp();
    token = await getTestAuthToken({ agentId: 'agent-1' }); // adapt
  });

  it('rejects a top-level clientId with 400 unrecognized_keys', async () => {
    const res = await request(app)
      .post('/api/exports')
      .set('Authorization', `Bearer ${token}`)
      .send({
        agentId: 'agent-1',
        clientId: 'client-1', // wrong home — outer schema is .strict()
        tripData: validTripData,
      });

    expect(res.status).toBe(400);
    expect(res.body.error.code).toBe('INVALID_BODY');
    expect(res.body.error.details.issues).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          code: 'unrecognized_keys',
          keys: ['clientId'],
        }),
      ]),
    );
  });

  it('accepts tripData.clientId and echoes it in tripSnapshot', async () => {
    const res = await request(app)
      .post('/api/exports')
      .set('Authorization', `Bearer ${token}`)
      .send({
        agentId: 'agent-1',
        tripData: { ...validTripData, clientId: 'client-1' },
      });

    expect(res.status).toBe(201);
    expect(res.body.tripSnapshot.clientId).toBe('client-1');
  });

  it('accepts an absent clientId (control)', async () => {
    const res = await request(app)
      .post('/api/exports')
      .set('Authorization', `Bearer ${token}`)
      .send({ agentId: 'agent-1', tripData: validTripData });

    expect(res.status).toBe(201);
  });
});

(b) Client test — pin the positive and the negative

Replace the old assertion expect(body.clientId).toBe('client-1') in the web test with both halves so a future refactor cannot silently re-hoist the key:

it('sends clientId nested under tripData, never at the top level', async () => {
  const fetchMock = vi
    .fn()
    .mockResolvedValue({ ok: true, status: 201, json: async () => ({}) });
  vi.stubGlobal('fetch', fetchMock);

  await handleExport({
    agentId: 'agent-1',
    selectedClientId: 'client-1',
    tripData: { title: 't', destinations: [], flights: [] },
  });

  const [, init] = fetchMock.mock.calls[0];
  const body = JSON.parse(init.body as string);

  // positive: the field is where the service schema declares it
  expect(body.tripData.clientId).toBe('client-1');
  // negative: it must NOT be re-hoisted to satisfy the mocked shape
  expect(body.clientId).toBeUndefined();

  vi.unstubAllGlobals();
});

The negative assertion is the regression guard: the route rejects any top-level key outside {agentId, tripData}, so body.clientId === undefined is the real contract.

Verification

Run against Node 22 / pnpm:

pnpm install
pnpm --filter api test -- export-body-contract
pnpm --filter web test
pnpm -r typecheck
pnpm -r lint

Live end-to-end check against the already-running API container (authed token required):

API=http://localhost:3000   # adjust to compose port
TOKEN=...                   # your session/agent bearer token

# 1. top-level clientId -> 400 unrecognized_keys ['clientId']
curl -sS -o /tmp/a.json -w '%{http_code}\n' -X POST "$API/api/exports" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"agentId":"agent-1","clientId":"client-1","tripData":{"title":"t","destinations":[],"flights":[]}}'
cat /tmp/a.json   # expect code INVALID_BODY, issues[0].code unrecognized_keys, keys ["clientId"]

# 2. nested clientId -> 201 and tripSnapshot.clientId echoed
curl -sS -o /tmp/b.json -w '%{http_code}\n' -X POST "$API/api/exports" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"agentId":"agent-1","tripData":{"title":"t","destinations":[],"flights":[],"clientId":"client-1"}}'
cat /tmp/b.json   # expect 201 and tripSnapshot.clientId === "client-1"

# 3. no clientId -> 201 (control)
curl -sS -o /tmp/c.json -w '%{http_code}\n' -X POST "$API/api/exports" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"agentId":"agent-1","tripData":{"title":"t","destinations":[],"flights":[]}}'
cat /tmp/c.json   # expect 201

Expected outcome:

Probe Before After
top-level clientId 400 unrecognized_keys ["clientId"] 400 (still — pinned by route test)
tripData.clientId 201 but client sent it top-level, so unreachable in practice 201, tripSnapshot.clientId echoed
no clientId 201 201

Both new tests fail on the pre-fix client code (body.clientId present / body.tripData.clientId undefined) and pass after the one-line nesting change. The route contract test does not depend on the fix and will keep the wire boundary honest going forward.

Files changed - apps/web/src/components/ChatInterface.tsx — nest clientId under tripData - apps/web/src/components/ChatInterface.test.tsx (or equivalent) — assert positive + negative - apps/api/tests/export/export-body-contract.test.ts — new contract pin - apps/api/src/routes/exports.ts — comment only (schema intentionally unchanged)

Evidence & signatures

# Evidence
- Problem class: zod-strict-outer-schema-rejects-top-level-field-accepted-by-inner-schema
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-16T14:45:51.115Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "PROBLEM: a client POSTed {agentId, clientId, tripData:{...}} and every export of a session with a selected client failed with 400 INVALID_BODY details.issues[{code:'unrecognized_keys',keys:['clientId']}], while the same payload without clientId returned 201. Root cause: the route-level zod schema validated the body with z.object({agentId, tripData}).strict() and tripData with .passthrough(), while the SERVICE-level (inner) schema \u2014 z.object({clientId?: string, title, destinations, flights, ...}).strict() \u2014 is the one that accepts clientId. The outer .strict() refuses any key it does not declare, INCLUDING keys the inner schema accepts, so a field the service supports is unreachable from the wire unless it is nested under the passthrough object. The regression hid behind a green test suite: the client's own unit test asserted the (wrong) top-level shape (expect(body.clientId).toBe('client-1')) with fetch mocked, so build/test/lint all passed and only a live request against the running server exposed the 400. FIX: nest the field where the inner schema declares it (tripData.clientId), and pin BOTH halves with tests: (a) route-level contract test asserting top-level clientId -> 400 unrecognized_keys ['clientId'], nested clientId -> 201 and tripSnapshot.clientId echoed, absent -> 201; (b) client test asserting the positive (body.tripData.clientId === 'x') AND the negative (body.clientId === undefined) so a future refactor cannot silently re-hoist the key. LESSON: when an outer validator is .strict() and an inner validator accepts a field, the outer schema's key set is the wire contract \u2014 grep both schemas before choosing where a field rides, and never trust a mocked-fetch test to prove a wire shape is accepted by the server.", "environment": "TypeScript strict pnpm monorepo (Next.js 15 web + Express/Node api), zod v4 validation layers, vitest, Node 22, Docker Compose; heading repo @ 325d2c6", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "zod-strict-outer-schema-rejects-top-level-field-accepted-by-inner-schema", "provider": "openrouter", "solved_at": "2026-09-16T14:45:51.116Z", "version": "master 325d2c6"}
Generated from the verified corpus · MIT licensedBack to the catalog