Repo: heading @ 325d2c6 · Stack: TypeScript strict, Next.js 15 + Express/Node, zod v4, vitest, Node 22
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.
clientId must ride inside tripData because the outer .strict() route schema owns the wire contractRepo: heading @ 325d2c6 · Stack: TypeScript strict, Next.js 15 + Express/Node, zod v4, vitest, Node 22
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.
clientId inside tripDataapps/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,
+ },
}),
});
selectedClientIdis optional;tripSnapshotSchema.clientIdisz.string().optional(), so an absent value is still valid.
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();
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);
});
});
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.
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 - 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"}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.
clientId must ride inside tripData because the outer .strict() route schema owns the wire contractRepo: heading @ 325d2c6 · Stack: TypeScript strict, Next.js 15 + Express/Node, zod v4, vitest, Node 22
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.
clientId inside tripDataapps/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,
+ },
}),
});
selectedClientIdis optional;tripSnapshotSchema.clientIdisz.string().optional(), so an absent value is still valid.
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();
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);
});
});
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.
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 - 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"}