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
3 changes: 1 addition & 2 deletions packages/contract/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7896,8 +7896,7 @@ export interface components {
}[];
/** @constant */
schema_version: 1;
/** @enum {string} */
source: "endpoint-resource-probes" | "rpc-endpoint-probes";
source: ("endpoint-resource-probes" | "rpc-endpoint-probes") | null;
} & {
[key: string]: unknown;
};
Expand Down
17 changes: 12 additions & 5 deletions public/metagraph/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -29939,11 +29939,18 @@
"type": "number"
},
"source": {
"enum": [
"endpoint-resource-probes",
"rpc-endpoint-probes"
],
"type": "string"
"anyOf": [
{
"enum": [
"endpoint-resource-probes",
"rpc-endpoint-probes"
],
"type": "string"
},
{
"type": "null"
}
]
}
},
"required": [
Expand Down
3 changes: 1 addition & 2 deletions public/metagraph/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7896,8 +7896,7 @@ export interface components {
}[];
/** @constant */
schema_version: 1;
/** @enum {string} */
source: "endpoint-resource-probes" | "rpc-endpoint-probes";
source: ("endpoint-resource-probes" | "rpc-endpoint-probes") | null;
} & {
[key: string]: unknown;
};
Expand Down
12 changes: 12 additions & 0 deletions schemas-src/openapi-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,8 +516,20 @@ import {

export const openApiComponentRegistry = z.registry<{ id: string }>();

/**
* Every registered component, by the id it publishes under.
*
* The Zod registry answers "what id does this schema have"; this answers the
* reverse, which is what a caller holding only a NAME needs -- the response
* tripwire resolves a route's artifact contract to a component id and has to
* get back the schema to parse against. Populated by `register` itself, so it
* cannot fall behind the registry it mirrors (#10214).
*/
export const COMPONENT_SCHEMAS_BY_ID = new Map<string, z.ZodType>();

const register = (schema: z.ZodType, id: string) => {
openApiComponentRegistry.add(schema, { id });
COMPONENT_SCHEMAS_BY_ID.set(id, schema);
};

register(SubnetsArtifactSchema, "SubnetsArtifact");
Expand Down
10 changes: 9 additions & 1 deletion schemas-src/routes/endpoints-pools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,15 @@ export type EndpointIncidentsArtifact = z.infer<
// buildEndpointPoolArtifact() is the one function that produces both.

export const EndpointPoolsArtifactSchema = ArtifactBaseSchema.extend({
source: z.enum(["endpoint-resource-probes", "rpc-endpoint-probes"]),
// NULLABLE because the producer answers null, verified against production:
// `endpoint_pools(limit: 3) { source }` returns `"source": null` today. The
// enum was declared required, which is a claim the route does not keep --
// and the direction that matters, because GraphQL enforces non-null at
// execution and the generated schema would have published `String!` here,
// nulling the whole answer on every request (#10214).
source: z
.enum(["endpoint-resource-probes", "rpc-endpoint-probes"])
.nullable(),
disabled_proxy_contract: DisabledProxyContractSchema.optional(),
eligibility_policy: EndpointEligibilityPolicySchema.optional(),
provider_scores: z.array(EndpointProviderScoreSchema).optional(),
Expand Down
2 changes: 1 addition & 1 deletion src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7215,7 +7215,7 @@ function normalizeQueryParameters(queryParameters: QueryParametersInput) {
};
}

function schemaRefForArtifactPath(artifactPath: string) {
export function schemaRefForArtifactPath(artifactPath: string) {
const contract = PUBLIC_ARTIFACTS.find((entry) =>
pathTemplatesMatch(entry.path, artifactPath),
);
Expand Down
158 changes: 115 additions & 43 deletions src/response-validation-tripwire.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,128 @@
// Staging drift tripwire (types-epic B, #7860 requirement 6): when
// METAGRAPH_VALIDATE_RESPONSES="true", parse a covered route's outgoing
// envelope against its schemas-src/ Zod schema and log (never throw) on
// mismatch. Default OFF, zero-cost when unset: the caller must check the
// env flag BEFORE calling this function at all (see workers/api.ts's call
// site) so the flag check itself never even reaches this module -- and the
// schema import below is dynamic so it's only evaluated once the flag is
// actually on, not on every request.
// Parse every outgoing REST envelope against the Zod schema that defines it.
//
// Only wired for the routes schemas-src/ currently covers (types-epic A's
// 5 pilots) -- add an entry here as later types-epic B batches convert more
// routes (see .claude/skills/metagraphed/reference.md's Zod-owned-components
// note).
const SCHEMA_LOADERS: Record<
string,
() => Promise<{
safeParse: (value: unknown) => { success: boolean; error?: unknown };
}>
> = {
subnets: async () =>
(await import("../schemas-src/routes/subnets.ts")).SubnetsResponseSchema,
"subnet-detail": async () =>
(await import("../schemas-src/routes/subnet-detail.ts"))
.SubnetDetailResponseSchema,
health: async () =>
(await import("../schemas-src/routes/health.ts")).HealthResponseSchema,
economics: async () =>
(await import("../schemas-src/routes/economics.ts"))
.EconomicsResponseSchema,
"subnet-stake-quote": async () =>
(await import("../schemas-src/routes/stake-quote.ts"))
.StakeQuoteResponseSchema,
};
// This shipped in #7860 as a five-route pilot: `SCHEMA_LOADERS` hand-listed
// `subnets`, `subnet-detail`, `health`, `economics` and `subnet-stake-quote`,
// with a comment saying to add an entry as later batches converted more routes.
// The batches landed and the entries did not, so 156 of 161 routes served
// unchecked -- and the flag was `"false"` in wrangler.jsonc, so the five did
// too.
//
// It is DERIVED now, and covers everything by construction. A route names its
// artifact; `schemaRefForArtifactPath` maps that to the component id the
// OpenAPI document publishes; `COMPONENT_SCHEMAS_BY_ID` gives back the Zod node
// `register()` recorded under that id. There is no list to fall behind: a route
// converted tomorrow is covered the moment its component is registered, which
// is the same moment it appears in `openapi.json`.
//
// WHY IT MATTERS BEYOND DRIFT. The published GraphQL schema takes its
// nullability from these components, and `graphql-js` enforces non-null at
// EXECUTION -- one null where a component says non-null and the whole
// surrounding object nulls with an error attached (#10215). Until this ran,
// "the Zod says non-null" was a claim about the schema with nothing checking it
// against the producer. A sweep of the served surface found exactly one
// disagreement (`endpoint_pools.source`, fixed at the Zod); this is what keeps
// it at one.
//
// It THROWS. A drifted response is a response the published contract does not
// describe, and serving it anyway is how a consumer ends up trusting a shape
// nothing guarantees -- which is the entire failure this epic exists to close.
// That means it CANNOT run under `waitUntil`: the response is already built by
// then and a throw would only surface as an unhandled rejection. It is awaited
// in the response path, and the caller turns a drift into a 500 rather than
// serving the body.
//
// The cost is real and deliberate: when the flag is on, every response is
// parsed before it is sent, and a schema bug fails the route instead of
// quietly shipping. That is the trade the flag exists to make.
import { successEnvelopeSchema } from "../schemas-src/envelope.ts";
import { registerModuleStateReset } from "./module-state-registry.ts";

/** A response that does not match the schema its route publishes. */
export class ResponseSchemaDriftError extends Error {
readonly routeId: string;
readonly detail: unknown;
constructor(routeId: string, detail: unknown) {
super(`${routeId} response drifted from its Zod schema`);
this.name = "ResponseSchemaDriftError";
this.routeId = routeId;
this.detail = detail;
}
}

/** Resolved schemas, so a hot route pays the lookup once per isolate. */
const cache = new Map<string, { safeParse: (value: unknown) => unknown }>();

/** Artifact paths with no component -- reported once, never re-resolved. */
const unresolved = new Set<string>();

// Both caches are per-isolate memoization, and `unresolved` also gates a
// warn-once. Under `isolate: false` that would carry across test files, so a
// suite that asserted the warning would pass or fail on which file ran first.
registerModuleStateReset("src/response-validation-tripwire.ts", () => {
cache.clear();
unresolved.clear();
});

async function schemaForArtifact(artifactPath: string) {
const cached = cache.get(artifactPath);
if (cached) return cached;
if (unresolved.has(artifactPath)) return null;
const [{ schemaRefForArtifactPath }, { COMPONENT_SCHEMAS_BY_ID }] =
await Promise.all([
import("./contracts.ts"),
import("../schemas-src/openapi-registry.ts"),
]);
let componentId: string;
try {
componentId = schemaRefForArtifactPath(artifactPath);
} catch {
// A route whose artifact has no contract entry. Not this module's problem
// to fail on -- validate:openapi already owns that invariant.
unresolved.add(artifactPath);
return null;
}
const component = COMPONENT_SCHEMAS_BY_ID.get(componentId);
if (!component) {
unresolved.add(artifactPath);
console.warn(
`[METAGRAPH_VALIDATE_RESPONSES] ${artifactPath} maps to component ` +
`${componentId}, which nothing registers -- not validated`,
);
return null;
}
const schema = successEnvelopeSchema(component) as unknown as {
safeParse: (value: unknown) => unknown;
};
cache.set(artifactPath, schema);
return schema;
}

// Called ONLY when the caller has already confirmed
// env.METAGRAPH_VALIDATE_RESPONSES === "true" -- see this file's own header.
/**
* Called ONLY when the caller has already confirmed
* `env.METAGRAPH_VALIDATE_RESPONSES === "true"` -- see this file's own header
* and the call sites in workers/api.ts and workers/request-handlers/entities.ts.
*/
export async function validateResponseTripwire(
routeId: string,
envelope: unknown,
artifactPath?: string,
): Promise<void> {
const loadSchema = SCHEMA_LOADERS[routeId];
if (!loadSchema) return; // Route not yet covered by a schemas-src/ schema.
if (!artifactPath) return;
try {
const schema = await loadSchema();
const result = schema.safeParse(envelope);
const schema = await schemaForArtifact(artifactPath);
if (!schema) return;
const result = schema.safeParse(envelope) as {
success: boolean;
error?: unknown;
};
if (!result.success) {
console.warn(
`[METAGRAPH_VALIDATE_RESPONSES] ${routeId} response drifted from its Zod schema:`,
result.error,
);
throw new ResponseSchemaDriftError(routeId, result.error);
}
} catch (err) {
// The tripwire itself must never break a real response.
// A DRIFT propagates -- that is the point. Anything else (a failed import,
// a bad contract entry) is the tripwire's own fault and must not take a
// route down with it.
if (err instanceof ResponseSchemaDriftError) throw err;
console.warn(
`[METAGRAPH_VALIDATE_RESPONSES] ${routeId} tripwire failed to run:`,
err,
Expand Down
Loading
Loading