Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3592,13 +3592,22 @@
"type": "string"
},
"importance": {
"type": "number"
"type": [
"number",
"null"
]
},
"score": {
"type": "number"
"type": [
"number",
"null"
]
},
"similarity": {
"type": "number"
"type": [
"number",
"null"
]
},
"source_site": {
"type": "string"
Expand Down Expand Up @@ -4077,13 +4086,22 @@
"type": "string"
},
"importance": {
"type": "number"
"type": [
"number",
"null"
]
},
"score": {
"type": "number"
"type": [
"number",
"null"
]
},
"similarity": {
"type": "number"
"type": [
"number",
"null"
]
},
"source_site": {
"type": "string"
Expand Down
24 changes: 18 additions & 6 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2425,11 +2425,17 @@ paths:
id:
type: string
importance:
type: number
type:
- number
- "null"
score:
type: number
type:
- number
- "null"
similarity:
type: number
type:
- number
- "null"
source_site:
type: string
required:
Expand Down Expand Up @@ -2758,11 +2764,17 @@ paths:
id:
type: string
importance:
type: number
type:
- number
- "null"
score:
type: number
type:
- number
- "null"
similarity:
type: number
type:
- number
- "null"
source_site:
type: string
required:
Expand Down
63 changes: 63 additions & 0 deletions src/middleware/validate-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* @file Dev/test-mode response validator.
*
* Wraps `res.json()` to parse the outgoing body against a route→schema
* map declared alongside the OpenAPI response schemas. Catches the
* formatter↔schema drift that `check:openapi` alone can't see: if a
* formatter in `routes/memory-response-formatters.ts` changes a field
* name or shape without the corresponding update in
* `schemas/responses.ts`, tests fail loudly at the point of emission.
*
* No-op in production. Enabled by default everywhere else (tests,
* `NODE_ENV=development`) so the check is on in the only environments
* that actually run tests or local dev.
*
* Route keys follow Express's router-relative `${method} ${route.path}`
* format (e.g. `post /ingest`, `get /:id`). `req.route.path` is set by
* the time `res.json()` is called inside the handler, so the lookup
* resolves correctly even for parameterized paths.
*
* Error-path responses (4xx/5xx) are not validated — each route emits
* its own error envelope that's already schema-checked by the
* `validateBody` middleware on the way in, and runtime error shapes
* are intentionally less strict than success bodies.
*/

import type { RequestHandler } from 'express';
import type { z } from 'zod';

export type ResponseSchemaMap = Record<string, z.ZodTypeAny>;

/**
* Build a middleware that validates 2xx JSON response bodies against
* the supplied schema map. Returns a pass-through no-op when
* NODE_ENV is `production`, so prod has zero per-request cost.
*/
export function validateResponse(schemaMap: ResponseSchemaMap): RequestHandler {
if (process.env.NODE_ENV === 'production') {
return (_req, _res, next) => next();
}
return (req, res, next) => {
const originalJson = res.json.bind(res);
res.json = function patchedJson(body: unknown) {
if (res.statusCode >= 200 && res.statusCode < 300 && req.route?.path) {
const key = `${req.method.toLowerCase()} ${req.route.path}`;
const schema = schemaMap[key];
if (schema) {
const result = schema.safeParse(body);
if (!result.success) {
throw new Error(
`Response body for ${key} violates declared schema.\n`
+ `Formatter output does not match ${key}'s OpenAPI response schema. `
+ `Either update the formatter, or if the change is intentional, update `
+ `src/schemas/responses.ts and regenerate the OpenAPI spec.\n`
+ `Zod issues:\n${JSON.stringify(result.error.issues, null, 2)}`,
);
}
}
}
return originalJson(body);
};
next();
};
}
5 changes: 5 additions & 0 deletions src/routes/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { Router, type Request, type Response } from 'express';
import { AgentTrustRepository } from '../db/agent-trust-repository.js';
import { handleRouteError } from './route-errors.js';
import { validateBody, validateQuery, validateParams } from '../middleware/validate.js';
import { validateResponse } from '../middleware/validate-response.js';
import { AGENT_RESPONSE_SCHEMAS } from './response-schema-map.js';
import {
SetTrustBodySchema,
GetTrustQuerySchema,
Expand All @@ -24,6 +26,9 @@ import {

export function createAgentRouter(trustRepo: AgentTrustRepository): Router {
const router = Router();
// Dev/test-mode response validator: no-op in production, throws loudly
// if any 2xx body violates the schema declared in responses.ts.
router.use(validateResponse(AGENT_RESPONSE_SCHEMAS));
registerSetTrustRoute(router, trustRepo);
registerGetTrustRoute(router, trustRepo);
registerListConflictsRoute(router, trustRepo);
Expand Down
5 changes: 5 additions & 0 deletions src/routes/memories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
import type { AgentScope, WorkspaceContext } from '../db/repository-types.js';
import { handleRouteError } from './route-errors.js';
import { validateBody, validateQuery, validateParams } from '../middleware/validate.js';
import { validateResponse } from '../middleware/validate-response.js';
import { MEMORY_RESPONSE_SCHEMAS } from './response-schema-map.js';
import {
IngestBodySchema,
type IngestBody,
Expand Down Expand Up @@ -106,6 +108,9 @@ export function createMemoryRouter(
): Router {
const router = Router();
registerCors(router);
// Dev/test-mode response validator: no-op in production, throws loudly
// if any 2xx body violates the schema declared in responses.ts.
router.use(validateResponse(MEMORY_RESPONSE_SCHEMAS));
registerIngestRoute(router, service);
registerQuickIngestRoute(router, service);
registerSearchRoute(router, service, configRouteAdapter);
Expand Down
73 changes: 73 additions & 0 deletions src/routes/response-schema-map.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* @file Route→schema maps consumed by `validate-response` middleware.
*
* Keyed by Express's router-relative `${method} ${route.path}` format
* (method lowercase, path matches what Express stores in `req.route.path`).
* When a new route is added, add a corresponding entry here; the
* validator is a no-op for routes not in the map (so new routes
* ship without a hard dependency on a schema existing yet).
*/

import {
IngestResponseSchema,
SearchResponseSchema,
ExpandResponseSchema,
ListResponseSchema,
GetMemoryResponseSchema,
StatsResponseSchema,
HealthResponseSchema,
ConfigUpdateResponseSchema,
ConsolidateResponseSchema,
DecayResponseSchema,
CapResponseSchema,
LessonsListResponseSchema,
LessonStatsResponseSchema,
LessonReportResponseSchema,
ReconciliationResponseSchema,
ReconcileStatusResponseSchema,
ResetSourceResponseSchema,
SuccessResponseSchema,
MutationSummaryResponseSchema,
AuditRecentResponseSchema,
AuditTrailResponseSchema,
TrustResponseSchema,
ConflictsListResponseSchema,
ResolveConflictResponseSchema,
AutoResolveConflictsResponseSchema,
} from '../schemas/responses.js';
import type { ResponseSchemaMap } from '../middleware/validate-response.js';

export const MEMORY_RESPONSE_SCHEMAS: ResponseSchemaMap = {
'post /ingest': IngestResponseSchema,
'post /ingest/quick': IngestResponseSchema,
'post /search': SearchResponseSchema,
'post /search/fast': SearchResponseSchema,
'post /expand': ExpandResponseSchema,
'get /list': ListResponseSchema,
'get /stats': StatsResponseSchema,
'get /health': HealthResponseSchema,
'put /config': ConfigUpdateResponseSchema,
'post /consolidate': ConsolidateResponseSchema,
'post /decay': DecayResponseSchema,
'get /cap': CapResponseSchema,
'get /lessons': LessonsListResponseSchema,
'get /lessons/stats': LessonStatsResponseSchema,
'post /lessons/report': LessonReportResponseSchema,
'delete /lessons/:id': SuccessResponseSchema,
'post /reconcile': ReconciliationResponseSchema,
'get /reconcile/status': ReconcileStatusResponseSchema,
'post /reset-source': ResetSourceResponseSchema,
'get /:id': GetMemoryResponseSchema,
'delete /:id': SuccessResponseSchema,
'get /audit/summary': MutationSummaryResponseSchema,
'get /audit/recent': AuditRecentResponseSchema,
'get /:id/audit': AuditTrailResponseSchema,
};

export const AGENT_RESPONSE_SCHEMAS: ResponseSchemaMap = {
'put /trust': TrustResponseSchema,
'get /trust': TrustResponseSchema,
'get /conflicts': ConflictsListResponseSchema,
'put /conflicts/:id/resolve': ResolveConflictResponseSchema,
'post /conflicts/auto-resolve': AutoResolveConflictsResponseSchema,
};
64 changes: 46 additions & 18 deletions src/schemas/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,34 @@

import { z } from './zod-setup';

/**
* ISO date-time string on the wire. Accepts `Date` at validation time
* so `validateResponse` middleware can run before Express serializes
* the body — Express's JSON.stringify converts Date → ISO string,
* matching the outer `z.string()` the OpenAPI spec documents.
*/
const IsoDateString = z.preprocess(
(v) => (v instanceof Date ? v.toISOString() : v),
z.string(),
);

/** Same pattern but nullable (schema exports nullable string). */
const IsoDateStringOrNull = z.preprocess(
(v) => (v instanceof Date ? v.toISOString() : v),
z.string().nullable(),
);

/**
* Float that may be NaN on the JS side. `JSON.stringify(NaN)` emits
* `null`, so the wire shape is `number | null` — the schema reflects
* that, and the preprocess converts NaN so the validator (which runs
* before serialization) matches.
*/
const NumberOrNaN = z.preprocess(
(v) => (typeof v === 'number' && Number.isNaN(v) ? null : v),
z.number().nullable(),
);

// ---------------------------------------------------------------------------
// Shared sub-schemas
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -46,12 +74,12 @@ const MemoryRowSchema = z.object({
summary: z.string(),
overview: z.string(),
trust_score: z.number(),
observed_at: z.string(),
created_at: z.string(),
last_accessed_at: z.string(),
observed_at: IsoDateString,
created_at: IsoDateString,
last_accessed_at: IsoDateString,
access_count: z.number(),
expired_at: z.string().nullable().optional(),
deleted_at: z.string().nullable().optional(),
expired_at: IsoDateStringOrNull.optional(),
deleted_at: IsoDateStringOrNull.optional(),
network: z.unknown(),
opinion_confidence: z.number().nullable().optional(),
observation_subject: z.string().nullable().optional(),
Expand All @@ -63,11 +91,11 @@ const MemoryRowSchema = z.object({
const SearchMemoryItemSchema = z.object({
id: z.string(),
content: z.string(),
similarity: z.number().optional(),
score: z.number().optional(),
importance: z.number().optional(),
similarity: NumberOrNaN.optional(),
score: NumberOrNaN.optional(),
importance: NumberOrNaN.optional(),
source_site: z.string().optional(),
created_at: z.string().optional(),
created_at: IsoDateString.optional(),
}).openapi({ description: 'Projected memory record in a search result.' });

const TierAssignmentSchema = z.object({
Expand Down Expand Up @@ -157,7 +185,7 @@ const LessonRowSchema = z.object({
severity: z.enum(['low', 'medium', 'high', 'critical']),
active: z.boolean(),
metadata: z.record(z.string(), z.unknown()),
created_at: z.string(),
created_at: IsoDateString,
}).passthrough().openapi({ description: 'Lesson row from the repository.' });

const ClaimVersionRowSchema = z.object({
Expand All @@ -171,15 +199,15 @@ const ClaimVersionRowSchema = z.object({
source_site: z.string(),
source_url: z.string(),
episode_id: z.string().nullable().optional(),
valid_from: z.string(),
valid_to: z.string().nullable().optional(),
valid_from: IsoDateString,
valid_to: IsoDateStringOrNull.optional(),
superseded_by_version_id: z.string().nullable().optional(),
mutation_type: z.enum(['add', 'update', 'supersede', 'delete', 'clarify']).nullable().optional(),
mutation_reason: z.string().nullable().optional(),
previous_version_id: z.string().nullable().optional(),
actor_model: z.string().nullable().optional(),
contradiction_confidence: z.number().nullable().optional(),
created_at: z.string(),
created_at: IsoDateString,
}).passthrough().openapi({ description: 'Claim-version row (one snapshot in a memory\'s history).' });

const MemoryConflictRowSchema = z.object({
Expand All @@ -195,9 +223,9 @@ const MemoryConflictRowSchema = z.object({
clarification_note: z.string().nullable(),
status: z.enum(['open', 'resolved_new', 'resolved_existing', 'resolved_both', 'auto_resolved']),
resolution_policy: z.string().nullable(),
resolved_at: z.string().nullable(),
created_at: z.string(),
auto_resolve_after: z.string().nullable(),
resolved_at: IsoDateStringOrNull,
created_at: IsoDateString,
auto_resolve_after: IsoDateStringOrNull,
}).passthrough().openapi({ description: 'Memory conflict row from the repository.' });

const HealthConfigResponseSchema = z.object({
Expand Down Expand Up @@ -374,8 +402,8 @@ const AuditTrailEntryResponseSchema = z.object({
contradiction_confidence: z.number().nullable(),
previous_version_id: z.string().nullable(),
superseded_by_version_id: z.string().nullable(),
valid_from: z.string(),
valid_to: z.string().nullable(),
valid_from: IsoDateString,
valid_to: IsoDateStringOrNull,
memory_id: z.string().nullable(),
}).openapi({ description: 'Single entry in a memory\'s audit trail.' });

Expand Down
Loading