D02-F04-A05 / Complete engineering topic

Corporate-Action Status and Effective-Date Reconciliation

A production-minded guide to Corporate-Action Status and Effective-Date Reconciliation.

D02 · CORPORATE ACTIONS AND SECURIT…
D02-F04-A05Canonical / Tested / Open
D02 / D02-F04

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.

Corporate-action reconciliation map

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.

Corporate-action reconciliation flow

Rendering system map…

The audit node states the invariant that must survive every derivative.

ReferencesPrimary sources and evidence notes

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.

corporateActionReconciliation.ts
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,
  };
}

Full-height labplaygroundOpen full screen