D01-F04-A04 / Complete engineering topic

Schema-Drift Detector: Catch Structural and Semantic Contract Changes

A production-minded guide to Schema-Drift Detector: Catch Structural and Semantic Contract Changes.

D01 · MARKET DATA ENGINEERING
D01-F04-A04Canonical / Tested / Open
D01 / D01-F04
Key concepts

The governed definitions this build depends on. Read them first if a term is unfamiliar.

Practical promise. Build a release gate that catches field, type, requiredness, nullability, enumeration, unit, and meaning changes—without pretending an incomplete specification is complete.

Two NYSE Daily TAQ specification versions keep the same text width while adding new enumeration values, demonstrating semantic drift without a structural field change

A market-data file can keep the same columns, delimiter, and field widths while becoming unsafe for existing consumers. That is not a theoretical edge case. An exchange may add a valid code to an existing field: the bytes still parse, but a decoder with an exhaustive switch can reject or misclassify the new value.

Schema-drift detection therefore needs two lenses:

  1. Syntactic structure: fields, physical types, requiredness, and nullability.
  2. Contract meaning: enumerations, units, and human-defined semantics.

It also needs an evidence rule. If a candidate document is partial, an absent field is not proof that the field was removed. The correct result is “presence unknown,” not a confident breaking change.

First declare the direction

“Backward compatible” is incomplete language unless we name the writer and reader. The package's default policy asks one specific operational question:

Can records produced under the candidate contract be released to consumers built against the baseline contract?

That direction is stored as candidate_producer_to_baseline_consumer. The policy assumes an existing consumer ignores added fields but may enumerate all allowed codes. It is deliberately conservative:

Candidate changeDefault resultExisting-consumer reasoning
Add a fieldnon-breakingUnknown fields are assumed ignored.
Remove a required fieldbreakingThe baseline consumer requires it.
Required to optionalbreakingCandidate records may now omit it.
Non-nullable to nullablebreakingNull is newly possible.
Add an enum valuebreakingAn exhaustive consumer may reject it.
Change unit or meaningbreakingThe value may parse but mean something else.

Apache Avro's official schema-resolution section makes this directionality concrete: it distinguishes writer and reader schemas and specifies what happens when a reader encounters missing fields or an enum symbol it does not define. The detector here is format-neutral and does not claim to implement Avro, but it keeps the same discipline: compatibility is a relationship, not a property of one schema.

A contract that can say “unknown”

Each schema document carries:

  • a stable schema_id;
  • an exact version and parent_version;
  • completeness: complete, partial, or unknown;
  • a field map.

Each field declares type, required, nullable, enum, unit, and meaning. For a partial specification, unknown_attributes marks properties the reviewer could not establish.

There is an important difference between two values:

  • enum: null means the evidence says there is no declared closed enumeration;
  • unknown_attributes: ["enum"] means the evidence does not establish the enum contract.

Conflating those states turns incomplete documentation into false certainty.

The detector as a complete change set

For common field (f), define the contract vector

A(f)=(type,required,nullable,enum,unit,meaning).A(f) = (type, required, nullable, enum, unit, meaning).

The detector compares every known attribute under policy (P):

Δ(B,C,P)=sort(fcompare(AB(f),AC(f),P)).\Delta(B,C,P) = \operatorname{sort}\left(\bigcup_f compare(A_B(f), A_C(f), P)\right).

It does not stop after the first breaking item. A release reviewer needs the whole diff to plan decoder, documentation, and data-migration work.

Rendering system map…

The aggregate precedence is:

breaking>indeterminate>review_required>non_breaking>unchanged.breaking > indeterminate > review\_required > non\_breaking > unchanged.

A proved breaking change can block immediately even if another attribute remains unknown. coverage_complete stays false so nobody mistakes a sufficient block for an exhaustive review.

The easy-to-miss failures

Unit drift

Suppose size = 5 remains an integer. Under the baseline it means five shares; under the candidate it means five round lots. The structural diff is empty, yet notional calculations can differ by the round-lot multiplier. The detector emits unit_changed and blocks under the default policy.

Meaning drift

Suppose numeric price is redefined from executed price to executed notional. Every row can remain syntactically valid. A meaning-aware contract emits meaning_changed; a type-only schema checker sees nothing.

Enumeration drift

A one-character text field can gain a new one-character code. Width and type stay constant. A permissive parser may accept it; an exhaustive business decoder may not. The release policy must state which consumer behavior it assumes.

Incomplete evidence

If a partial candidate document lists only price, the detector does not emit three removals for the other baseline fields. It emits field_presence_unknown and returns indeterminate unless another known break already requires a block.

A light matrix separates structural, semantic, and evidence-quality changes and maps them to non-breaking, review, breaking, or indeterminate outcomes

Historical specification example: NYSE Daily TAQ

This is a researched specification diff, not a claim about a production outage.

What the official documents establish

The official NYSE document index lists Daily TAQ v3.3b dated November 1, 2021 and v3.3c dated October 3, 2022.

In the v3.3b specification, Master field 4, Security Type, is Text with maximum size 3. Its listed values do not include ETS. In the v3.3c specification, the same field adds ETS = Single-Security ETF. The newer document's history states that this value became effective October 31, 2022.

The Quotes file contains a second example. Field 18 remains one-character text. V3.3b lists blank and E; v3.3c also lists S for a CTA-originated message.

The extracted diff

FieldStable physical contractNew v3.3c value
master.security_typeText(3)ETS
quotes.sip_generated_message_identifierText(1)S

The package stores only these small factual extracts and links to the official PDFs. It does not redistribute either document or any NYSE market data.

What the package derives

The implementation emits two enum_changed items. Under strict_existing_consumer_v1, both are breaking because an exhaustive consumer written from v3.3b may not recognize the new value.

That status is a policy result, not a historical fact. A tolerant consumer that treats unknown codes as reviewable text may keep parsing. The exchange documents describe specification changes; they do not prove that any consumer actually failed. This separation between source fact, extracted diff, package derivation, and engineering interpretation is essential.

Try the guided lab

Open the Schema-Drift Detector playground. It contains 13 deterministic synthetic transitions plus the historical two-enum extract: clean, edge, breaking, semantic-only, and incomplete-evidence cases.

Use the controls in this order:

  1. Choose a scenario.
  2. Step through lineage, evidence, field presence, attributes, and aggregation.
  3. Compare the change list with the release status.
  4. Reset and replay at another speed.

Back, Step, Play/Pause, and Reset use one transition function. Reduced-motion mode removes animated movement without removing any state. The initial frame is informative even before the first click.

Implementation notes

The Python and TypeScript implementations:

  • validate both schema documents before comparing;
  • require all six field attributes;
  • reject duplicate enum values;
  • reject hidden unknown attributes in a document declared complete;
  • preserve inputs without mutation;
  • sort field paths and attributes deterministically;
  • retain every change and rationale;
  • expose the complete policy in each result.

The shared fixture is the parity contract. Both languages pass the same 13 synthetic transitions and the same two-change NYSE extraction. Passing those tests proves definition-level consistency, not the completeness of a vendor specification or the absence of downstream business assumptions.

What this detector does not prove

  • It does not validate data instances.
  • It does not discover semantic meaning from samples.
  • It does not infer an exchange's deployment outcome from a document date.
  • It does not implement nested paths, aliases, defaults, unions, numeric bounds, or protocol-specific resolution.
  • Exact meaning-string comparison identifies documented text changes; a domain owner must still decide whether two phrasings are economically equivalent.

Production checklist

  • Pin both source documents and their retrieval date.
  • Require schema_id, version, and parent_version.
  • Declare whether each specification is complete.
  • Keep enum, unit, and meaning in the contract registry.
  • Name the producer/consumer direction and enum policy.
  • Route indeterminate to evidence collection, never auto-approval.
  • Keep the full change list even when one break is enough to block.
  • Run format-native compatibility checks alongside this semantic gate.
  • Test actual consumers with representative new codes before release.

Summary

A trustworthy schema-drift detector asks more than “did a field or type change?” It asks what each value means, whether enumerations expanded, whether the evidence is complete, and which reader/producer direction the release must protect.

The NYSE v3.3b to v3.3c example makes the value visible: two fields retain their physical text widths while their allowed values expand. The file can still parse, yet an exhaustive consumer may need a code change. That is precisely the gap between syntactic validity and contract compatibility.

Continue with Filing-Revision Versioning to deepen the version-lineage and correction model used by this detector.

ReferencesPrimary sources and evidence notes

Expand the source trail, evidence role, and limitations behind the engineering choices.

NYSE-TAQ-33B — Daily TAQ Client Specification v3.3b

  • Organization: New York Stock Exchange / Intercontinental Exchange
  • Source type: Official exchange data-product specification
  • Publication date: 2021-11-01
  • Version: 3.3b
  • URL: https://www.nyse.com/publicdocs/nyse/data/Daily_TAQ_Client_Spec_v3.3b.pdf
  • Accessed: 2026-07-22
  • Jurisdiction: United States
  • Supports: Old side of the historical comparison. Master field 4 is Text with maximum size 3 and lists no ETS Security Type value; Quotes field 18 lists blank and E values.
  • Limitations: Copyrighted specification. This package links to it and records only a small factual extraction; it does not redistribute the PDF or market data.

NYSE-TAQ-33C — Daily TAQ Client Specification v3.3c

  • Organization: New York Stock Exchange / Intercontinental Exchange
  • Source type: Official exchange data-product specification
  • Publication date: 2022-10-03
  • Version: 3.3c
  • URL: https://www.nyse.com/publicdocs/nyse/data/Daily_TAQ_Client_Spec_v3.3c.pdf
  • Accessed: 2026-07-22
  • Jurisdiction: United States
  • Supports: New side of the historical comparison. Master field 4 adds ETS = Single-Security ETF; Quotes field 18 adds S for CTA-originated messages. Document history states the ETS change was effective 2022-10-31.
  • Limitations: The document history records specification changes, not evidence that a production consumer failed. The package’s breaking result is a derivation under an explicitly strict policy.

NYSE-DOCUMENT-INDEX — Market Data Documents

  • Organization: New York Stock Exchange / Intercontinental Exchange
  • Source type: Official exchange document index
  • Publication date: Continuously maintained
  • Version: Accessed state
  • URL: https://www.nyse.com/market-data/documents
  • Accessed: 2026-07-22
  • Jurisdiction: United States
  • Supports: Independent version lineage for Daily TAQ v3.3b dated 2021-11-01 and v3.3c dated 2022-10-03.
  • Limitations: The index establishes document availability and dates; field-level facts come from the versioned PDFs.

JSON-CORE — JSON Schema Core 2020-12

  • Authors: Austin Wright, Henry Andrews, Ben Hutton, Greg Dennis
  • Source type: Official JSON Schema specification
  • Publication date: 2022-06-16
  • Version: Draft 2020-12
  • URL: https://json-schema.org/draft/2020-12/json-schema-core
  • Accessed: 2026-07-22
  • Jurisdiction: Global technical standard
  • Supports: A schema describes instance structure and may contain assertion and annotation vocabularies.
  • Limitations: This package is a small, format-neutral comparison kernel, not a JSON Schema validator.

JSON-VALIDATION — JSON Schema Validation 2020-12

  • Authors: JSON Schema project editors
  • Source type: Official JSON Schema specification
  • Publication date: 2022-06-16
  • Version: Draft 2020-12
  • URL: https://json-schema.org/draft/2020-12/json-schema-validation
  • Accessed: 2026-07-22
  • Jurisdiction: Global technical standard
  • Supports: type, enum, and required are explicit validation concepts. The enum vocabulary constrains an instance to listed values.
  • Limitations: The specification defines instance validity, not the release severities used here.

AVRO-RESOLUTION — Apache Avro Specification

  • Organization: Apache Software Foundation
  • Source type: Official serialization specification
  • Publication date: Versioned documentation
  • Version: 1.12.0
  • URL: https://avro.apache.org/docs/1.12.0/specification/#schema-resolution
  • Accessed: 2026-07-22
  • Jurisdiction: Global technical standard
  • Supports: Compatibility depends on writer/reader direction. Avro specifies field resolution and says an unknown writer enum symbol causes an error unless the reader enum defines a default.
  • Limitations: Avro aliases, defaults, promotions, unions, and binary encoding are outside this teaching implementation.

Evidence decision

The historical example was accepted because both adjacent official versions remain directly accessible and the official index confirms their lineage. The package stores only the two affected field contracts and source links. It separates:

  1. Quoted facts: version dates, field sizes/types, listed values, and the stated effective date.
  2. Extracted diff: ETS and S are new allowed values; the field names and physical text widths remain stable in the extraction.
  3. Package derivation: two enum_changed results.
  4. Interpretation: breaking under strict_existing_consumer_v1 because that policy assumes exhaustive baseline enum consumers. A tolerant decoder may continue to parse the text; no actual outage is claimed.
schemaDriftDetector.ts
export type AttributeName = "type" | "required" | "nullable" | "enum" | "unit" | "meaning";
export type Severity = "non_breaking" | "review" | "breaking" | "unknown";
export type Status = "unchanged" | "non_breaking" | "review_required" | "breaking" | "indeterminate";

export interface FieldSchema {
  type: string | null;
  required: boolean | null;
  nullable: boolean | null;
  enum: string[] | null;
  unit: string | null;
  meaning: string | null;
  unknown_attributes?: AttributeName[];
}

export interface SchemaDocument {
  schema_id: string;
  version: string;
  parent_version: string | null;
  completeness: "complete" | "partial" | "unknown";
  fields: Record<string, FieldSchema>;
}

export interface ReleasePolicy {
  name: string;
  direction: string;
  added_field: Severity;
  removed_optional: Severity;
  removed_required: Severity;
  type_changed: Severity;
  optional_to_required: Severity;
  required_to_optional: Severity;
  nonnullable_to_nullable: Severity;
  nullable_to_nonnullable: Severity;
  enum_values_added: Severity;
  enum_values_removed: Severity;
  unit_changed: Severity;
  meaning_changed: Severity;
}

export interface DriftChange {
  field: string;
  kind: string;
  severity: Severity;
  before: unknown;
  after: unknown;
  rationale: string;
}

const ATTRIBUTES: AttributeName[] = ["type", "required", "nullable", "enum", "unit", "meaning"];
const SEVERITY_RANK: Record<Severity, number> = { non_breaking: 0, review: 1, breaking: 2, unknown: -1 };

export const STRICT_EXISTING_CONSUMER_POLICY: ReleasePolicy = {
  name: "strict_existing_consumer_v1",
  direction: "candidate_producer_to_baseline_consumer",
  added_field: "non_breaking",
  removed_optional: "review",
  removed_required: "breaking",
  type_changed: "breaking",
  optional_to_required: "non_breaking",
  required_to_optional: "breaking",
  nonnullable_to_nullable: "breaking",
  nullable_to_nonnullable: "non_breaking",
  enum_values_added: "breaking",
  enum_values_removed: "review",
  unit_changed: "breaking",
  meaning_changed: "breaking",
};

function validateDocument(document: SchemaDocument, label: string): void {
  if (!document || typeof document !== "object") throw new TypeError(`${label} must be an object`);
  for (const key of ["schema_id", "version", "parent_version", "completeness", "fields"] as const) {
    if (!(key in document)) throw new Error(`${label} is missing key: ${key}`);
  }
  if (!["complete", "partial", "unknown"].includes(document.completeness)) {
    throw new Error(`${label}.completeness must be complete, partial, or unknown`);
  }
  if (!document.fields || typeof document.fields !== "object" || Array.isArray(document.fields)) {
    throw new TypeError(`${label}.fields must be an object`);
  }
  for (const [fieldName, field] of Object.entries(document.fields)) {
    if (!fieldName) throw new Error(`${label} field names must be non-empty strings`);
    const unknown = field.unknown_attributes ?? [];
    if (!Array.isArray(unknown) || unknown.some((item) => !ATTRIBUTES.includes(item))) {
      throw new Error(`${label}.${fieldName}.unknown_attributes is invalid`);
    }
    if (document.completeness === "complete" && unknown.length) {
      throw new Error(`${label}.${fieldName} cannot be unknown in a complete document`);
    }
    for (const attribute of ATTRIBUTES) {
      if (!(attribute in field)) throw new Error(`${label}.${fieldName} is missing ${attribute}`);
    }
    if (field.enum !== null) {
      if (!Array.isArray(field.enum) || field.enum.some((value) => typeof value !== "string")) {
        throw new TypeError(`${label}.${fieldName}.enum must be null or a string array`);
      }
      if (new Set(field.enum).size !== field.enum.length) throw new Error(`${label}.${fieldName}.enum contains duplicates`);
    }
  }
}

function change(field: string, kind: string, severity: Severity, before: unknown, after: unknown, rationale: string): DriftChange {
  return { field, kind, severity, before: structuredClone(before), after: structuredClone(after), rationale };
}

export function detectSchemaDrift(
  baseline: SchemaDocument,
  candidate: SchemaDocument,
  policy: ReleasePolicy = STRICT_EXISTING_CONSUMER_POLICY,
) {
  validateDocument(baseline, "baseline");
  validateDocument(candidate, "candidate");
  const active = structuredClone(policy);
  const changes: DriftChange[] = [];

  if (baseline.schema_id !== candidate.schema_id) {
    changes.push(change("$schema", "lineage_mismatch", "unknown", baseline.schema_id, candidate.schema_id, "Different schema identities cannot be compared as one lineage."));
  }
  if (candidate.parent_version !== baseline.version) {
    changes.push(change("$schema", "lineage_mismatch", "unknown", baseline.version, candidate.parent_version, "Candidate parent_version does not identify the supplied baseline."));
  }
  for (const [label, document] of [["baseline", baseline], ["candidate", candidate]] as const) {
    if (document.completeness !== "complete") {
      changes.push(change("$schema", "spec_incomplete", "unknown", null, document.completeness, `The ${label} specification is not declared complete.`));
    }
  }

  const fields = [...new Set([...Object.keys(baseline.fields), ...Object.keys(candidate.fields)])].sort();
  for (const fieldName of fields) {
    const before = baseline.fields[fieldName];
    const after = candidate.fields[fieldName];
    if (!before || !after) {
      const missingDocument = !before ? baseline : candidate;
      if (missingDocument.completeness !== "complete") {
        changes.push(change(fieldName, "field_presence_unknown", "unknown", before ?? null, after ?? null, "An absent field is not evidence of addition or removal in an incomplete specification."));
      } else if (!before) {
        changes.push(change(fieldName, "field_added", active.added_field, null, after, "The strict policy assumes baseline consumers ignore unknown fields."));
      } else {
        const severity = before.required === true ? active.removed_required : active.removed_optional;
        changes.push(change(fieldName, "field_removed", severity, before, null, "Removal is breaking when a baseline consumer requires the field; optional dependencies require review."));
      }
      continue;
    }

    const beforeUnknown = new Set(before.unknown_attributes ?? []);
    const afterUnknown = new Set(after.unknown_attributes ?? []);
    for (const attribute of ATTRIBUTES) {
      if (beforeUnknown.has(attribute) || afterUnknown.has(attribute)) {
        changes.push(change(fieldName, "attribute_unknown", "unknown", before[attribute], after[attribute], `${attribute} is not known in both specification versions.`));
        continue;
      }
      const valuesEqual = attribute === "enum"
        ? (() => {
            const beforeValues = new Set(before.enum ?? []);
            const afterValues = new Set(after.enum ?? []);
            return beforeValues.size === afterValues.size && [...beforeValues].every((value) => afterValues.has(value));
          })()
        : JSON.stringify(before[attribute]) === JSON.stringify(after[attribute]);
      if (valuesEqual) continue;
      if (attribute === "type") {
        changes.push(change(fieldName, "type_changed", active.type_changed, before.type, after.type, "A baseline parser may not accept the candidate representation."));
      } else if (attribute === "required") {
        const severity = after.required ? active.optional_to_required : active.required_to_optional;
        changes.push(change(fieldName, "requiredness_changed", severity, before.required, after.required, "Under this direction, making a baseline-required field optional can create missing values for existing consumers."));
      } else if (attribute === "nullable") {
        const severity = after.nullable ? active.nonnullable_to_nullable : active.nullable_to_nonnullable;
        changes.push(change(fieldName, "nullability_changed", severity, before.nullable, after.nullable, "Existing consumers may fail when null becomes newly valid."));
      } else if (attribute === "enum") {
        const beforeValues = new Set(before.enum ?? []);
        const afterValues = new Set(after.enum ?? []);
        const added = [...afterValues].filter((value) => !beforeValues.has(value)).sort();
        const removed = [...beforeValues].filter((value) => !afterValues.has(value)).sort();
        const severities: Severity[] = [];
        if (added.length) severities.push(active.enum_values_added);
        if (removed.length) severities.push(active.enum_values_removed);
        const severity = severities.sort((a, b) => SEVERITY_RANK[b] - SEVERITY_RANK[a])[0];
        changes.push(change(fieldName, "enum_changed", severity, before.enum, after.enum, `Added values ${added.length ? added.join(", ") : "none"}; removed values ${removed.length ? removed.join(", ") : "none"}. Exhaustive baseline consumers may reject new codes.`));
      } else if (attribute === "unit") {
        changes.push(change(fieldName, "unit_changed", active.unit_changed, before.unit, after.unit, "The representation may still parse while numerical scale or economic unit changes."));
      } else {
        changes.push(change(fieldName, "meaning_changed", active.meaning_changed, before.meaning, after.meaning, "A semantic contract change can alter interpretation without changing the physical type."));
      }
    }
  }

  const hasBreaking = changes.some((item) => item.severity === "breaking");
  const hasUnknown = changes.some((item) => item.severity === "unknown");
  const hasReview = changes.some((item) => item.severity === "review");
  let status: Status;
  if (hasBreaking) status = "breaking";
  else if (hasUnknown) status = "indeterminate";
  else if (hasReview) status = "review_required";
  else if (changes.length) status = "non_breaking";
  else status = "unchanged";

  return {
    baseline_version: baseline.version,
    candidate_version: candidate.version,
    policy: active,
    status,
    coverage_complete: !hasUnknown,
    changes,
  };
}
Full-height labschema drift playgroundOpen full screen