The governed definitions this build depends on. Read them first if a term is unfamiliar.
- PrimarySchema DriftSchema drift is a change in data structure or declared field semantics across versions or deliveries.
- PrerequisiteSeries IdentitySeries identity is the complete key and semantic contract that states what a sequence of observations represents.
- ImportantData LineageData lineage records where data came from, how it was transformed, and which entities, activities, and agents affected it.
- ImportantMarket Data FeedA market data feed is a source-defined stream or delivery product carrying market events, reference data, or aggregates.
- ImportantMeasurement UnitA measurement unit states the quantity scale represented by a numeric value, such as currency, shares, contracts, seconds, or percent.
- ImportantPrecisionPrecision describes the fineness or number of digits with which a value or timestamp is represented or computed.
- ImportantRevision LineageAn ordered chain connecting each correction to the exact earlier record it supersedes.
Practical promise. Build a release gate that catches field, type, requiredness, nullability, enumeration, unit, and meaning changes—without pretending an incomplete specification is complete.
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:
- Syntactic structure: fields, physical types, requiredness, and nullability.
- 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 change | Default result | Existing-consumer reasoning |
|---|---|---|
| Add a field | non-breaking | Unknown fields are assumed ignored. |
| Remove a required field | breaking | The baseline consumer requires it. |
| Required to optional | breaking | Candidate records may now omit it. |
| Non-nullable to nullable | breaking | Null is newly possible. |
| Add an enum value | breaking | An exhaustive consumer may reject it. |
| Change unit or meaning | breaking | The 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
versionandparent_version; completeness:complete,partial, orunknown;- 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: nullmeans 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
The detector compares every known attribute under policy (P):
It does not stop after the first breaking item. A release reviewer needs the whole diff to plan decoder, documentation, and data-migration work.
The aggregate precedence is:
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.
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
| Field | Stable physical contract | New v3.3c value |
|---|---|---|
master.security_type | Text(3) | ETS |
quotes.sip_generated_message_identifier | Text(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:
- Choose a scenario.
- Step through lineage, evidence, field presence, attributes, and aggregation.
- Compare the change list with the release status.
- 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, andparent_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
indeterminateto 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 notesExpand the source trail, evidence role, and limitations behind the engineering choices.
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
Textwith maximum size 3 and lists noETSSecurity Type value; Quotes field 18 lists blank andEvalues. - 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 addsSfor CTA-originated messages. Document history states theETSchange was effective 2022-10-31. - Limitations: The document history records specification changes, not evidence that a production consumer failed. The package’s
breakingresult 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, andrequiredare explicit validation concepts. Theenumvocabulary 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:
- Quoted facts: version dates, field sizes/types, listed values, and the stated effective date.
- Extracted diff:
ETSandSare new allowed values; the field names and physical text widths remain stable in the extraction. - Package derivation: two
enum_changedresults. - Interpretation:
breakingunderstrict_existing_consumer_v1because that policy assumes exhaustive baseline enum consumers. A tolerant decoder may continue to parse the text; no actual outage is claimed.
Full dependency-light reference implementations in both supported languages.
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,
};
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.