A corporate action rarely arrives as one perfect row. A preliminary vendor message may name one date, an exchange may confirm another, and an issuer may later replace, cancel, or withdraw the event. If a data pipeline simply keeps “the latest record,” it can erase what was knowable earlier and apply an event on the wrong date.
This tutorial builds a point-in-time reconciliation engine. It chooses only
messages available by an as_of timestamp, keeps the latest version within
each source, applies an explicit authority policy across sources, and
retains every disagreement as an audit diagnostic.
Separate the identities
The event reference identifies the corporate action. The security ID identifies the affected instrument. A message ID identifies one communication. A provider or account-servicer reference is not necessarily the official market reference.
ISO's MT 564 field dictionary makes these distinctions explicit. Its scope also allows preliminary advice followed by replacement and supports cancellation. Field 23G distinguishes new, replacement, cancellation, and withdrawal functions. These facts motivate the contract, but the tutorial does not claim to implement all of MT 564.
Keep dates in their own roles
Announcement, effective, ex, record, and payment dates answer different questions. ISO defines announcement and effective dates separately. FINRA Rule 11140 illustrates why ex-date designation depends on definitive information, event type, size, and timing. A generic reconciliation engine must not invent a universal ex-date formula.
The point-in-time selection rule
First discard every message whose available_at is later than as_of.
Within each source, choose the maximum tuple:
(available_at, version, message_id).
Then rank the source heads using a governed source_rank. Higher rank wins.
Recency breaks ties only after rank. Lower-ranked disagreements remain in
the output; they are not overwritten.
Refuse ambiguous authority
If equally ranked sources disagree on status or effective date, the output
is unresolved-authority-tie. The engine does not choose whichever message
arrived last. Source rank is a deployment policy and should be governed by
market, event type, and jurisdiction.
A selected cancellation or withdrawal is terminal and never applicable. A
confirmed event with no effective date is incomplete. A confirmed or
completed event becomes applicable only when its effective date is present
and no later than the calendar date of as_of. Equality belongs to the
effective side in this package.
Work the synthetic event
Event CA-77 has three current source heads. The issuer, rank three, confirms July 16. The exchange, rank two, agrees. The provider, rank one, remains preliminary and says July 15.
At July 5, issuer replacement message I-2 is selected. The engine emits two
conflicts against the provider: one for status and one for effective date.
Because July 16 is still future, the result is
reconciled-with-conflicts and is not applicable.
The provider record is not deleted. It remains evidence of the state a lower-authority consumer may still be seeing.
Replacements, cancellations, and withdrawals
A replacement must name the message it supersedes. The earlier message
stays in storage and remains visible for earlier as_of queries. A later
cancellation or withdrawal becomes the source head only after its
availability timestamp and blocks application without rewriting the past.
This is the difference between a versioned event ledger and a mutable security-master row.
Seven lifecycle scenarios
The playground steps through 61 knowledge times for each of seven cases: authority conflict, clean consensus, cancellation after confirmation, withdrawal, equal-authority disagreement, missing effective date, and future-only evidence. The same event can move from not known to preliminary, scheduled, conflicted, effective, or terminal as knowledge changes.
Implementation and parity
Python and TypeScript validate the same normalized vocabulary, unique message IDs, identity alignment, timezone-aware availability, replacement linkage, and function/status consistency. Both return the selected record, state, application flag, conflicts, eligible message IDs, and future message IDs.
The algorithm is small. The hard production work is upstream: normalize raw codes without losing them, preserve payloads and adapter versions, govern source rank, and maintain stable event/security identity.
Misuse boundaries
This engine does not calculate an entitlement, determine a legal ex-date, validate a SWIFT message, or establish that a source is truthful. A high authority rank is a policy, not a fact. Provider codes must not be guessed into ISO statuses. Market-specific rules still govern actual event application.
Evidence decision
The example is synthetic. A named case is deferred until the complete message lineage, original timestamps, official references, governing rule, adapter mapping, and redistribution rights are available. A latest-state corporate-action webpage is not enough for a point-in-time reconstruction.
Next step
The reconciled event can feed Provider Adjustment-Basis Drift Detector to explain legitimate historical restatements. Corporate-Action Divisor Bridge applies validated event economics to index continuity.
Full source roles and limitations are in REFERENCES.md.
Rendered from the canonical Mermaid sources linked by this article.
Corporate-action reconciliation flow
The audit node states the invariant that must survive every derivative.
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.
S1 — ISO 15022 MT 564 scope
- Organization: ISO 20022
- Source type: Official standards documentation
- URL: https://www.iso20022.org/15022/uhb/finmt564.htm
- Accessed: 2026-07-30
- Supports: Preliminary advice, replacement, cancellation, resending, and event details.
- Limitations: The package is not a complete MT 564 validator.
S2 — ISO 15022 MT 564 field 23G
- Organization: ISO 20022
- Source type: Official data-field dictionary
- URL: https://www.iso20022.org/15022/uhb/mt564-4-field-23g.htm
- Accessed: 2026-07-30
- Supports: New, replacement, cancellation, and withdrawal message functions.
- Limitations: The package uses normalized words, not raw SWIFT codes.
S3 — ISO 15022 MT 564 field 20C
- Organization: ISO 20022
- Source type: Official data-field dictionary
- URL: https://www.iso20022.org/15022/uhb/mt564-3-field-20c.htm
- Accessed: 2026-07-30
- Supports: Distinct official event, account-servicer event, and sender-message references.
- Limitations: Identifier matching and deduplication remain upstream.
S4 — ISO 15022 MT 564 field 98a
- Organization: ISO 20022
- Source type: Official data-field dictionary
- URL: https://www.iso20022.org/15022/uhb/mt564-47-field-98a.htm
- Accessed: 2026-07-30
- Supports: Separate announcement, effective, payment, and other date meanings.
- Limitations: Presence of a date does not determine jurisdictional applicability.
S5 — FINRA Rule 11140
- Organization: FINRA
- Source type: Current rulebook
- Effective version noted on page: amended effective 2024-05-28
- URL: https://www.finra.org/rules-guidance/rulebooks/finra-rules/11140
- Accessed: 2026-07-30
- Jurisdiction: FINRA-covered transactions
- Supports: Ex-date designation depends on definitive information, event type, size, and timing.
- Limitations: The canonical engine does not calculate a FINRA ex-date.
Publication decision
Standards and rule pages are linked, not reproduced. Event CA-77 and all records are synthetic. No live corporate-action feed is redistributed.
Full dependency-light reference implementations in both supported languages.
export type CorporateActionStatus =
| "preliminary"
| "confirmed"
| "completed"
| "cancelled"
| "withdrawn"
| "replaced";
export type MessageFunction = "new" | "replacement" | "cancellation" | "withdrawal";
export interface CorporateActionRecord {
message_id: string;
event_reference: string;
security_id: string;
event_type: string;
source: string;
source_rank: number;
version: number;
available_at: string;
announcement_date: string | null;
effective_date: string | null;
status: CorporateActionStatus;
function: MessageFunction;
supersedes: string | null;
}
export interface ReconciliationInput {
event_reference: string;
security_id: string;
event_type: string;
as_of: string;
records: CorporateActionRecord[];
}
export interface ReconciliationConflict {
type:
| "status-conflict"
| "effective-date-conflict"
| "missing-effective-date"
| "effective-before-announcement";
selected: string | null;
other: string | null;
other_source: string;
}
export interface ReconciliationOutput {
event_reference: string;
security_id: string;
event_type: string;
as_of: string;
state: string;
selected: Omit<CorporateActionRecord, "event_reference" | "security_id" | "event_type"> | null;
applicable: boolean;
conflicts: ReconciliationConflict[];
eligible_message_ids: string[];
future_message_ids: string[];
}
const STATUSES = new Set([
"preliminary",
"confirmed",
"completed",
"cancelled",
"withdrawn",
"replaced",
]);
const FUNCTIONS = new Set(["new", "replacement", "cancellation", "withdrawal"]);
const ACTIVE_STATUSES = new Set(["confirmed", "completed"]);
const TERMINAL_STATUSES = new Set(["cancelled", "withdrawn"]);
function nonempty(value: unknown, field: string): string {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`${field} must be a non-empty string`);
}
return value;
}
function timestamp(value: unknown, field: string): number {
const text = nonempty(value, field);
if (!/(Z|[+-]\d{2}:\d{2})$/.test(text)) {
throw new Error(`${field} must include a timezone`);
}
const parsed = Date.parse(text);
if (!Number.isFinite(parsed)) {
throw new Error(`${field} must be a valid ISO 8601 timestamp`);
}
return parsed;
}
function optionalDate(value: unknown, field: string): string | null {
if (value === null) return null;
if (
typeof value !== "string" ||
!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
Number.isNaN(Date.parse(`${value}T00:00:00Z`))
) {
throw new Error(`${field} must be YYYY-MM-DD or null`);
}
return value;
}
function integer(value: unknown, field: string): number {
if (!Number.isInteger(value) || (value as number) < 0) {
throw new Error(`${field} must be a nonnegative integer`);
}
return value as number;
}
function parseRecord(value: unknown, index: number) {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new TypeError(`records[${index}] must be an object`);
}
const record = value as Record<string, unknown>;
const prefix = `records[${index}]`;
const status = nonempty(record.status, `${prefix}.status`);
if (!STATUSES.has(status)) throw new Error(`${prefix}.status is unsupported`);
const fn = nonempty(record.function, `${prefix}.function`);
if (!FUNCTIONS.has(fn)) throw new Error(`${prefix}.function is unsupported`);
if (fn === "replacement" && !record.supersedes) {
throw new Error(`${prefix}.supersedes is required for a replacement`);
}
if (fn === "cancellation" && status !== "cancelled") {
throw new Error(`${prefix}: cancellation function requires cancelled status`);
}
if (fn === "withdrawal" && status !== "withdrawn") {
throw new Error(`${prefix}: withdrawal function requires withdrawn status`);
}
const availableText = nonempty(record.available_at, `${prefix}.available_at`);
return {
message_id: nonempty(record.message_id, `${prefix}.message_id`),
event_reference: nonempty(record.event_reference, `${prefix}.event_reference`),
security_id: nonempty(record.security_id, `${prefix}.security_id`),
event_type: nonempty(record.event_type, `${prefix}.event_type`),
source: nonempty(record.source, `${prefix}.source`),
source_rank: integer(record.source_rank, `${prefix}.source_rank`),
version: integer(record.version, `${prefix}.version`),
available_at: availableText,
availableTime: timestamp(availableText, `${prefix}.available_at`),
announcement_date: optionalDate(
record.announcement_date,
`${prefix}.announcement_date`,
),
effective_date: optionalDate(record.effective_date, `${prefix}.effective_date`),
status: status as CorporateActionStatus,
function: fn as MessageFunction,
supersedes:
record.supersedes === null
? null
: nonempty(record.supersedes, `${prefix}.supersedes`),
};
}
export function reconcileCorporateAction(input: ReconciliationInput): ReconciliationOutput {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
throw new TypeError("input must be an object");
}
const eventReference = nonempty(input.event_reference, "event_reference");
const securityId = nonempty(input.security_id, "security_id");
const eventType = nonempty(input.event_type, "event_type");
const asOfText = nonempty(input.as_of, "as_of");
const asOf = timestamp(asOfText, "as_of");
if (!Array.isArray(input.records)) throw new TypeError("records must be a list");
const parsed = input.records.map(parseRecord);
const ids = parsed.map((record) => record.message_id);
if (new Set(ids).size !== ids.length) {
throw new Error("message_id values must be unique");
}
parsed.forEach((record) => {
if (
record.event_reference !== eventReference ||
record.security_id !== securityId ||
record.event_type !== eventType
) {
throw new Error("every record must match the requested event identity");
}
});
const eligible = parsed.filter((record) => record.availableTime <= asOf);
const futureMessageIds = parsed
.filter((record) => record.availableTime > asOf)
.map((record) => record.message_id)
.sort();
if (eligible.length === 0) {
return {
event_reference: eventReference,
security_id: securityId,
event_type: eventType,
as_of: asOfText,
state: "not-yet-known",
selected: null,
applicable: false,
conflicts: [],
eligible_message_ids: [],
future_message_ids: futureMessageIds,
};
}
const latestBySource = new Map<string, ReturnType<typeof parseRecord>>();
eligible.forEach((record) => {
const current = latestBySource.get(record.source);
if (
!current ||
record.availableTime > current.availableTime ||
(record.availableTime === current.availableTime &&
(record.version > current.version ||
(record.version === current.version &&
record.message_id > current.message_id)))
) {
latestBySource.set(record.source, record);
}
});
const latest = [...latestBySource.values()].sort(
(left, right) =>
right.source_rank - left.source_rank ||
right.availableTime - left.availableTime ||
right.version - left.version ||
left.source.localeCompare(right.source),
);
const selected = latest[0];
const topRecords = latest.filter(
(record) => record.source_rank === selected.source_rank,
);
const topSignatures = new Set(
topRecords.map((record) => `${record.status}|${record.effective_date}`),
);
const authorityTie = topSignatures.size > 1;
const conflicts: ReconciliationConflict[] = [];
latest.slice(1).forEach((record) => {
if (record.status !== selected.status) {
conflicts.push({
type: "status-conflict",
selected: selected.status,
other: record.status,
other_source: record.source,
});
}
if (record.effective_date !== selected.effective_date) {
conflicts.push({
type: "effective-date-conflict",
selected: selected.effective_date,
other: record.effective_date,
other_source: record.source,
});
}
});
if (ACTIVE_STATUSES.has(selected.status) && selected.effective_date === null) {
conflicts.push({
type: "missing-effective-date",
selected: null,
other: null,
other_source: selected.source,
});
}
if (
selected.announcement_date !== null &&
selected.effective_date !== null &&
selected.effective_date < selected.announcement_date
) {
conflicts.push({
type: "effective-before-announcement",
selected: selected.effective_date,
other: selected.announcement_date,
other_source: selected.source,
});
}
let state: string;
let applicable = false;
if (authorityTie) {
state = "unresolved-authority-tie";
} else if (TERMINAL_STATUSES.has(selected.status)) {
state = selected.status;
} else if (selected.status === "preliminary") {
state = "preliminary";
} else if (selected.effective_date === null) {
state = "incomplete";
} else {
const effectiveNow =
selected.effective_date <= new Date(asOf).toISOString().slice(0, 10);
applicable = ACTIVE_STATUSES.has(selected.status) && effectiveNow;
if (selected.status === "completed") {
state = conflicts.length ? "completed-with-conflicts" : "completed";
} else if (effectiveNow) {
state = conflicts.length ? "effective-with-conflicts" : "effective";
} else {
state = conflicts.length ? "reconciled-with-conflicts" : "scheduled";
}
}
const selectedOutput = {
message_id: selected.message_id,
source: selected.source,
source_rank: selected.source_rank,
version: selected.version,
available_at: selected.available_at,
announcement_date: selected.announcement_date,
effective_date: selected.effective_date,
status: selected.status,
function: selected.function,
supersedes: selected.supersedes,
};
return {
event_reference: eventReference,
security_id: securityId,
event_type: eventType,
as_of: asOfText,
state,
selected: selectedOutput,
applicable,
conflicts,
eligible_message_ids: eligible.map((record) => record.message_id).sort(),
future_message_ids: futureMessageIds,
};
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.