D02-F03-A01 / Complete engineering topic

Permanent Security Identifier Mapping: Scoped, Effective-Dated Crosswalks

A production-minded guide to Permanent Security Identifier Mapping: Scoped, Effective-Dated Crosswalks.

D02 · CORPORATE ACTIONS AND SECURIT…
D02-F03-A01Canonical / Tested / Open
D02 / D02-F03
Key concepts

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

A price table arrives with ticker ALP. Which security does that row describe?

There is no safe answer until the key includes its assignment scheme, identity layer, venue context, business-validity time, and evidence cutoff. A ticker is not a company. A Legal Entity Identifier (LEI) is not a security. An International Securities Identification Number (ISIN) is generally not a venue listing. Even an internal “permanent” key is only durable if its owners never recycle it or silently move it to another entity.

This tutorial builds a crosswalk that makes those boundaries visible. It does not promise one magical universal identifier. It gives you an auditable answer—or an explicit unmapped or ambiguous state.

The useful outcome

At the end, you can resolve this question:

Given source key K, economic instant t, and knowledge cutoff k, which same-layer canonical entity was supported by the evidence available then?

That distinction matters in holdings, price history, filings, corporate actions, tax lots, indices, and backtests. A wrong identity join can fabricate a return, duplicate a position, attach a filing to the wrong issuer, or let a later correction leak into an earlier simulation.

A naked ticker collides while a scoped key resolves

The visual’s central lesson is simple: ALP alone is not a key. TICKER / ALP / XNAS / trading_line, with two timestamps, is a resolvable question.

“Permanent” needs a boundary

The catalog title uses “Permanent Security Identifier Mapping.” Keep the title as a learning signpost, but read it precisely:

  • Permanent is not universal. An identifier’s persistence comes from its assignment authority and lifecycle rules.
  • Security is one layer. Issuers, legal entities, listings, trading lines, venues, and symbols are distinct.
  • Mapping is historical evidence. It can be revised or cancelled, and the correction itself has an availability time.

The package-owned canonical ID is intended to be stable within one declared layer. It is not evidence that two different instruments are economically continuous.

Build the identity ladder first

The most common symbology error is not a bad string. It is joining two correct strings that name different kinds of thing.

Rendering system map…

This is a relationship graph, not a chain of aliases.

Legal entity

A legal entity is an organization recognized in a legal jurisdiction. GLEIF’s official FAQ describes an LEI as a 20-character code linked to reference data for identifying legal entities in financial transactions. It does not identify every security that entity issues.

Issuer

Issuer is a role in an issuance. An obligor, guarantor, parent, depositor, trust, or fund sponsor may be related but not identical. This package keeps issuer as its own internal layer; it does not force an LEI-to-instrument shortcut.

Instrument or security

An instrument is a legal/economic set of rights. ISO 6166:2021 supplies the ISIN framework for financial and referential instruments. ANNA’s version 24 guidelines say the allocation generally identifies the financial instrument rather than the market where it trades. Corporate-action and fungibility rules determine whether an ISIN remains active, becomes inactive, or another code is assigned.

A nine-character CUSIP combines issuer, issue, and check-digit components according to CUSIP Global Services. The full identifier is issue/instrument-level; its first six characters having issuer meaning does not make the full identifier a legal-entity code.

Share class

A share class identifies one set of rights within an equity structure. Two classes may differ in votes, dividends, conversion, or transfer rights. A share class may have several listings.

Listing and trading line

A listing is an admission to a market. A trading line is a more specific tradable representation, potentially tied to venue, currency, session, settlement, or pricing source. FIGI 1.2 distinguishes a basic venue-context identifier, a composite identifier, and a share-class identifier. The same specification says a ticker need not be unique to one identifier.

Venue and quote symbol

ISO 10383 defines Market Identifier Codes for venues and reporting facilities. A quote symbol is assigned in a market context and can change. FINRA’s corporate-action guidance includes trading-symbol and name changes. Therefore this package requires venueMic for every TICKER source.

Match schemes to layers

The teaching adapter enforces this matrix:

SchemeLayer used hereKey caution
LEILegal entityNot a security ID
ISIN, CUSIPInstrumentNot automatically a venue listing
Share Class FIGIShare classCan group several market contexts
Composite FIGIListing/country-market contextNot the venue-specific line
FIGITrading-line contextKeep its exchange/pricing context
TickerTrading lineVenue qualifier is mandatory
MICVenueNot an instrument ID
InternalDeclared layerOpaque and case-sensitive in this package

This matrix is intentionally conservative. If a production provider defines a different level, build a named adapter from its current specification. Do not weaken the model to make mismatched data join.

Two clocks, not one date

A mapping assertion has a business-validity interval and an evidence-availability timestamp.

  • validAt asks when the mapping applies.
  • knowledgeAt asks which revision the system could have known.

Suppose revision 1 says ALP / XNAS maps to line Alpha. A correction published on June 25 says it should have mapped to line Beta. A June 20 historical replay must still select revision 1, even when today’s database already contains revision 2.

For assertion a, select the latest revision available at cutoff k:

Ra(k)=arg maxra{r.revisionr.availableAtk}.R_a(k)=\operatorname*{arg\,max}_{r\in a}\{r.revision\mid r.availableAt\le k\}.

Then apply the selected revision at validity time t only when it is active and:

validFromt<validTo.validFrom \le t < validTo.

A null validTo means open-ended. The interval is half-open so two consecutive assignments can meet at one exact boundary without both matching.

Three valid outcomes

Let C(t,k) be the distinct canonical targets supported by known, active, source-matching assertions at those two clocks.

state(t,k)={unmappedC=0,resolvedC=1,ambiguousC>1.state(t,k)= \begin{cases} unmapped & |C|=0,\\ resolved & |C|=1,\\ ambiguous & |C|>1. \end{cases}

Those states are not error synonyms:

  • unmapped means the evidence has no applicable assertion. Do not substitute the raw symbol.
  • resolved means every applicable assertion supports one target.
  • ambiguous means known assertions support different targets. Do not let sort order decide.

Resolved, unmapped, and ambiguous crosswalk outcomes

Malformed timestamps, invalid check-digit status, reversed intervals, cross-layer targets, or broken revision order are contract errors and raise immediately.

The complete assertion record

Every revision carries:

  • the source scheme, value, entity level, and venue context;
  • a same-layer canonical target;
  • [validFrom, validTo);
  • observedAt and availableAt;
  • active or cancelled status;
  • authoritative, corroborated, or inferred confidence;
  • check-digit status;
  • assignment authority and source-document ID.

Confidence is evidence classification, not a probability. An authoritative source does not get permission to cross identity layers. An inferred mapping does not get to defeat a conflicting candidate.

Check digits do less than many systems assume

ANNA’s guidelines describe an ISIN as 12 characters with a terminal check digit. CGS describes a CUSIP’s final character as a mathematical check of the previous eight. But a passing digit only supports syntactic integrity. It does not prove:

  • the assignment exists;
  • the assignment authority issued it;
  • the descriptive record is current;
  • it belongs to this entity;
  • your license permits redistribution.

The implementation therefore consumes checkDigitStatus from a scheme-specific ingestion adapter. It does not compute every scheme’s checksum. invalid rejects the batch, while not_verified remains visible as a limitation.

A worked synthetic replay

The fixture asks:

Plain text
source       = TICKER / ALP / trading_line / XNAS
validAt      = 2024-06-15T14:30:00Z
knowledgeAt  = 2024-06-20T12:00:00Z

Four facts matter:

  1. SYN-MAP-001 revision 1 supports SYN-LINE-ALPHA-XNAS-USD and was available in December.
  2. Revision 2 corrects the target to Beta but is unavailable until June 25.
  3. A Gamma assignment is already known but begins July 1.
  4. ALP / XLON is a different source key.

Two-clock selection in the synthetic example

The June 20 replay resolves Alpha. Moving only knowledgeAt beyond June 25 resolves Beta for the same economic instant. Moving validAt to the exact July boundary resolves Gamma. Adding an independent assertion to a different target returns ambiguous.

No string, authority, or date in this example is a real mapping.

Implement the resolver

The core steps in both languages are:

Plain text
validate query and every assertion revision
group revisions by assertionId
select latest revision available by knowledgeAt
match scheme + value + entity level + venue
apply active status and half-open validity at validAt
collect distinct canonical targets
return unmapped, resolved, or ambiguous with evidence

The Python and TypeScript implementations consume the same JSON fixture. They also agree that public scheme values are uppercased by this teaching adapter while INTERNAL values remain case-sensitive.

Open the guided identifier lab to step through the two clocks. Reset always returns to the canonical June 20 replay; Step advances exactly one evidence decision.

Test the failure modes, not just the happy join

Each language has 20 tests covering:

  • exact fixture parity and no input mutation;
  • half-open validity boundary;
  • future correction hidden until available;
  • same ticker on different venues;
  • explicit unmapped and ambiguous results;
  • same-target corroboration;
  • cancellation;
  • case-sensitive internal keys;
  • invalid dates, intervals, revisions, checks, source context, and cross-layer targets;
  • rejection of LEI as an instrument identifier.

Passing these tests proves the declared package rules. It does not prove completeness against a real provider.

Lifecycle rules belong to the source scheme

“Permanent” still requires lifecycle data.

  • ANNA says ISINs should not be reused, while its corporate-action tables determine when codes remain, become inactive, or change.
  • GLEIF’s LEI data model distinguishes issued, duplicate, lapsed, retired, annulled, and cancelled records and can identify successors.
  • The MIC register supports creation, modification, and deactivation.
  • Trading symbols can change with corporate actions.

Do not overwrite an old crosswalk because the source calls its identifier persistent. Keep validity, availability, status, and revision history. A merger or share-class conversion should add predecessor/successor relationships, not silently repoint an old canonical ID.

Where implementations fail

Watch for these production traps:

  • joining ticker without venue, currency, or time;
  • using LEI as if it named the issued security;
  • using an ISIN as if it named one market listing;
  • confusing CUSIP’s issuer prefix with the full issue identifier;
  • treating a share-class FIGI as a venue-specific line;
  • choosing the “highest confidence” target when assertions conflict;
  • applying today’s correction inside yesterday’s backtest;
  • treating delisting as instrument termination;
  • treating a valid checksum as proof of assignment;
  • lowercasing or uppercasing an opaque case-sensitive vendor key;
  • publishing licensed symbology in an open fixture.

Evidence limitation

This article intentionally contains no named real-company crosswalk. A credible historical example would need authoritative evidence for every identifier, identity layer, venue context, validity boundary, publication/availability time, revision, and redistribution right. The public sources establish scheme behavior, but they do not provide one complete, freely redistributable mapping record for this package’s full contract. Synthetic data is more honest than filling those gaps with assumptions.

Summary

A reliable security master does not ask which ID is “most permanent.” It asks which authority names which layer, in what context, during which interval, and according to what evidence available at the time.

Keep the two clocks. Keep the layers separate. Preserve ambiguity instead of guessing. Then continue with Ticker-Change Chain Resolution, where successive symbol assignments become an auditable history rather than an overwrite.

Asset map

AssetLearning purposeVideo sceneSource
Scoped-key heroNaked symbol versus full source key1–2visuals/static/article-hero.svg
Identity ladderSeparate entity layers3visuals/mermaid/identity-layers.md
Two-clock selectorExplain revision then validity4–5visuals/mermaid/resolution-flow.md
Worked exampleInspect exclusions and selected target6visuals/static/worked-example.svg
Outcome guardCompare resolved, unmapped, ambiguous8visuals/static/failure-guard.svg
Guided labStep through scenario decisions5–8visuals/animated/playground.html

Versions, access dates, source roles, and limitations are recorded in ../REFERENCES.md.

Identity layers

Purpose: prevent an identifier from being joined to a different kind of entity.

Rendering system map…

Takeaway: mapping is same-layer identity; arrows are relationships that require their own sourced records.

Two-clock resolution flow

Purpose: show why revision availability is evaluated before business validity.

Rendering system map…

Takeaway: latest database state is not automatically the state that was knowable at the requested cutoff.

References10 primary sources and evidence notes

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

Accessed 2026-07-22. The package publishes no real identifier mapping. Every canonical ID, source value, authority name, notice ID, and effective date in its fixtures is explicitly synthetic.

R1 — ISO 6166:2021, International securities identification number

  • Organization: International Organization for Standardization (ISO)
  • Source type: International standard landing page and public abstract
  • Publication date: 2021-02; edition 8
  • Current state at access: Published; systematic review closed 2026-06-05 and revision is indicated
  • URL: https://www.iso.org/standard/78502.html
  • Jurisdiction: Global
  • Supports: ISIN supplies a uniform structure for identifying financial and referential instruments.
  • Limitations: The complete normative text is licensed. The public abstract does not justify treating an ISIN as an issuer, listing, venue, or universally persistent internal entity key.

R2 — ANNA ISIN Uniform Guidelines relating to ISO 6166

  • Organization: Association of National Numbering Agencies (ANNA), ISO 6166 Registration Authority
  • Source type: Official allocation and maintenance guidelines
  • Version: 24, June 2025; NNA implementation date 2025-07-01
  • URL: https://anna-web.org/wp-content/uploads/2025/06/ISIN-Guidelines-Version-24-Jun-2025.pdf
  • Jurisdiction: Global, with documented market exceptions
  • Supports: An ISIN generally identifies a financial instrument rather than its market; fungibility affects allocation; ISIN is 12 characters with a terminal check digit; the guidelines say codes should not be reused; corporate actions can leave an ISIN active, make it inactive, or require a new one depending on the instrument outcome.
  • Limitations: Allocation has stated exceptions and country practices. “Should never be re-used” is a rule of this scheme, not evidence that every external scheme or every data-provider mapping is eternal.

R3 — The Legal Entity Identifier: Questions and Answers

R4 — LEI Common Data File format 3.1

  • Organization: GLEIF
  • Source type: Official technical reference-data schema
  • Version: LEI-CDF 3.1
  • URL: https://www.gleif.org/en/lei-data/access-and-use-lei-data/level-1-data-lei-cdf-3-1-format
  • Jurisdiction: Global LEI System
  • Supports: Reference records have lifecycle states such as issued, duplicate, lapsed, retired, annulled, and cancelled; successor data can describe corporate-action continuity.
  • Limitations: These are LEI-record semantics. The package uses its own smaller active/cancelled assertion lifecycle and does not reproduce the GLEIF schema.

R5 — About CGS Identifiers

  • Organization: CUSIP Global Services (CGS)
  • Source type: Official identifier description
  • URL: https://www.cusip.com/identifiers.html
  • Jurisdiction: Primarily United States and Canada for CUSIP
  • Supports: A CUSIP has nine characters: six issuer characters, two issue characters, and a check digit; the full code identifies an issue/instrument, while the first six have issuer-level meaning.
  • Limitations: CUSIP data and descriptions carry intellectual-property and licensing restrictions. This package does not redistribute them, and it does not equate a full CUSIP with a legal-entity key.

R6 — CGS identifier request terms

  • Organization: CUSIP Global Services
  • Source type: Official application page and data-rights notice
  • URL: https://www.cusip.com/apply/index.html
  • Jurisdiction: CGS services
  • Supports: Assignment uses issuer and issue documentation; CGS identifier data is maintained intellectual property and assignment does not transfer data rights.
  • Limitations: Product terms can change and must be checked before production ingestion or redistribution.

R7 — Financial Instrument Global Identifier Specification

  • Organization: Object Management Group (OMG)
  • Source type: Open normative specification
  • Version: FIGI 1.2, March 2026
  • URL: https://www.omg.org/spec/FIGI/1.2/PDF
  • Landing page: https://www.omg.org/spec/FIGI
  • Jurisdiction: Global specification
  • Supports: FIGI distinguishes venue-context identifiers, composite identifiers, and share-class identifiers; ticker need not be unique to an identifier; identifier meaning is contextual.
  • Limitations: The package’s names FIGI, COMPOSITE_FIGI, and SHARE_CLASS_FIGI are adapter labels for teaching. Production mapping must use the official specification and provider terms.

R8 — ISO 10383:2012 and MIC Registration Authority

  • Organizations: ISO; SWIFT as Registration Authority
  • Source type: International standard abstract and official current register
  • Standard URL: https://www.iso.org/standard/61067.html
  • Register URL: https://www.iso20022.org/market-identifier-codes
  • Version: ISO 10383:2012, confirmed current in 2023; register publication is continuously updated
  • Jurisdiction: Global
  • Supports: Market Identifier Codes identify exchanges, trading platforms, markets, and reporting facilities; the current register has explicit creation, modification, deactivation, publication, and effective dates.
  • Limitations: A MIC identifies a venue or segment. It does not identify a security, and a stale local MIC table can mis-scope symbol resolution.

R9 — Corporate Actions by Public Companies

  • Organization: Financial Industry Regulatory Authority (FINRA)
  • Source type: Official investor guidance
  • Publication date: 2022-04-08
  • URL: https://www.finra.org/investors/insights/corporate-actions-public-companies-what-you-should-know
  • Jurisdiction: United States, with OTC-specific operational discussion
  • Supports: Trading-symbol and name changes are corporate-action events; the FINRA Daily List carries new issues, deleted issues, deletions, and symbol/name changes for OTC securities.
  • Limitations: Guidance does not define a global symbology crosswalk and is not a substitute for exchange or licensed reference-data feeds.

R10 — Nasdaq Symbol Directory Definitions

  • Organization: Nasdaq Trader
  • Source type: Official directory field definitions
  • URL: https://www.nasdaqtrader.com/trader.aspx?id=symboldirdefs
  • Jurisdiction: Nasdaq-operated data products and U.S. market context
  • Supports: Symbol directories are timestamped operational datasets; market and participant identifiers have product-specific fields and meanings.
  • Limitations: Directory coverage and entitlement terms are product-specific. A display symbol without venue, effective time, and source context is not a durable cross-market key.
permanent_security_identifier_mapping.ts
/** Point-in-time security-identifier crosswalk resolution. */

export type EntityLevel = "legal_entity" | "issuer" | "instrument" | "share_class" | "listing" | "trading_line" | "venue";
export type Confidence = "inferred" | "corroborated" | "authoritative";
type Source = {scheme: string; value: string; entityLevel: EntityLevel; venueMic: string | null};

const entityLevels = new Set<EntityLevel>(["legal_entity", "issuer", "instrument", "share_class", "listing", "trading_line", "venue"]);
const schemeLevels: Record<string, Set<EntityLevel>> = {
  LEI: new Set(["legal_entity"]), ISIN: new Set(["instrument"]), CUSIP: new Set(["instrument"]),
  FIGI: new Set(["trading_line"]), COMPOSITE_FIGI: new Set(["listing"]), SHARE_CLASS_FIGI: new Set(["share_class"]),
  TICKER: new Set(["trading_line"]), MIC: new Set(["venue"]), INTERNAL: entityLevels,
};
const checkDigitSchemes = new Set(["LEI", "ISIN", "CUSIP", "FIGI", "COMPOSITE_FIGI", "SHARE_CLASS_FIGI"]);
const confidenceRank: Record<Confidence, number> = {inferred: 0, corroborated: 1, authoritative: 2};
const instantPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;

function text(value: unknown, name: string): string {
  if (typeof value !== "string" || !value.trim()) throw new TypeError(`${name} must be a non-empty string`);
  return value.trim();
}
function instant(value: unknown, name: string): number {
  const candidate = text(value, name);
  if (!instantPattern.test(candidate)) throw new RangeError(`${name} must be an RFC 3339 UTC instant ending in Z`);
  const parsed = Date.parse(candidate);
  if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== candidate.replace("Z", ".000Z")) {
    throw new RangeError(`${name} is not a valid calendar instant`);
  }
  return parsed;
}
function enumValue<T extends string>(value: unknown, name: string, allowed: Set<T>): T {
  const candidate = text(value, name) as T;
  if (!allowed.has(candidate)) throw new RangeError(`${name} is unsupported`);
  return candidate;
}
function normalizeSource(raw: unknown, name: string): Source {
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new TypeError(`${name} must be an object`);
  const item = raw as Record<string, unknown>;
  const scheme = text(item.scheme, `${name}.scheme`).toUpperCase();
  if (!(scheme in schemeLevels)) throw new RangeError(`${name}.scheme is unsupported`);
  const entityLevel = enumValue(item.entityLevel, `${name}.entityLevel`, entityLevels);
  if (!schemeLevels[scheme].has(entityLevel)) throw new RangeError(`${scheme} cannot identify the ${entityLevel} level in this package`);
  const rawValue = text(item.value, `${name}.value`);
  // INTERNAL is opaque and case-sensitive; named public schemes use this
  // adapter's uppercase representation.
  const value = scheme === "INTERNAL" ? rawValue : rawValue.toUpperCase();
  const venueMic = item.venueMic == null ? null : text(item.venueMic, `${name}.venueMic`).toUpperCase();
  if (scheme === "TICKER" && venueMic === null) throw new RangeError("TICKER requires venueMic; a quote symbol alone is not a key");
  if (scheme === "MIC" && venueMic !== null) throw new RangeError("MIC identifies a venue and cannot qualify itself with venueMic");
  return {scheme, value, entityLevel, venueMic};
}
const sourceKey = (source: Source): string => JSON.stringify([source.scheme, source.value, source.entityLevel, source.venueMic]);

type ValidAssertion = {
  assertionId: string; revision: number; source: Source;
  target: {canonicalId: string; entityLevel: EntityLevel};
  validFrom: number; validTo: number | null; observedAt: number; availableAt: number;
  status: "active" | "cancelled"; confidence: Confidence;
  assignmentAuthority: string; sourceDocumentId: string;
  checkDigitStatus: "valid" | "not_verified" | "not_applicable";
};

function validateAssertion(raw: unknown, index: number): ValidAssertion {
  const name = `assertions[${index}]`;
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new TypeError(`${name} must be an object`);
  const item = raw as Record<string, unknown>;
  const assertionId = text(item.assertionId, `${name}.assertionId`);
  if (!Number.isInteger(item.revision) || (item.revision as number) < 1) throw new RangeError(`${name}.revision must be a positive integer`);
  const revision = item.revision as number;
  const source = normalizeSource(item.source, `${name}.source`);
  if (!item.target || typeof item.target !== "object" || Array.isArray(item.target)) throw new TypeError(`${name}.target must be an object`);
  const targetRaw = item.target as Record<string, unknown>;
  const target = {canonicalId: text(targetRaw.canonicalId, `${name}.target.canonicalId`), entityLevel: enumValue(targetRaw.entityLevel, `${name}.target.entityLevel`, entityLevels)};
  if (target.entityLevel !== source.entityLevel) throw new RangeError(`${name} crosses identity levels; model a relationship instead`);
  const validFrom = instant(item.validFrom, `${name}.validFrom`);
  const validTo = item.validTo == null ? null : instant(item.validTo, `${name}.validTo`);
  if (validTo !== null && validTo <= validFrom) throw new RangeError(`${name}.validTo must be later than validFrom`);
  const observedAt = instant(item.observedAt, `${name}.observedAt`);
  const availableAt = instant(item.availableAt, `${name}.availableAt`);
  if (availableAt < observedAt) throw new RangeError(`${name}.availableAt cannot precede observedAt`);
  const status = enumValue(item.status, `${name}.status`, new Set(["active", "cancelled"] as const));
  const confidence = enumValue(item.confidence, `${name}.confidence`, new Set(["inferred", "corroborated", "authoritative"] as const));
  const checkDigitStatus = enumValue(item.checkDigitStatus, `${name}.checkDigitStatus`, new Set(["valid", "invalid", "not_verified", "not_applicable"] as const));
  if (checkDigitSchemes.has(source.scheme) && checkDigitStatus === "not_applicable") throw new RangeError(`${name}.checkDigitStatus cannot be not_applicable for ${source.scheme}`);
  if (checkDigitStatus === "invalid") throw new RangeError(`${name} contains an identifier with an invalid check digit`);
  return {assertionId, revision, source, target, validFrom, validTo, observedAt, availableAt, status, confidence, checkDigitStatus,
    assignmentAuthority: text(item.assignmentAuthority, `${name}.assignmentAuthority`),
    sourceDocumentId: text(item.sourceDocumentId, `${name}.sourceDocumentId`)};
}

export function resolveIdentifier(data: unknown): Record<string, unknown> {
  if (!data || typeof data !== "object" || Array.isArray(data)) throw new TypeError("data must be an object");
  const root = data as Record<string, unknown>;
  if (!root.query || typeof root.query !== "object" || Array.isArray(root.query)) throw new TypeError("query must be an object");
  const query = root.query as Record<string, unknown>;
  const source = normalizeSource(query.source, "query.source");
  const validAtText = text(query.validAt, "query.validAt");
  const knowledgeAtText = text(query.knowledgeAt, "query.knowledgeAt");
  const validAt = instant(validAtText, "query.validAt");
  const knowledgeAt = instant(knowledgeAtText, "query.knowledgeAt");
  if (!Array.isArray(root.assertions)) throw new TypeError("assertions must be a list");
  const assertions = root.assertions.map(validateAssertion);
  const groups = new Map<string, ValidAssertion[]>();
  for (const assertion of assertions) groups.set(assertion.assertionId, [...(groups.get(assertion.assertionId) ?? []), assertion]);
  const selected: ValidAssertion[] = [];
  for (const [assertionId, versions] of groups) {
    versions.sort((a, b) => a.revision - b.revision);
    if (new Set(versions.map(row => row.revision)).size !== versions.length) throw new RangeError(`${assertionId} has duplicate revision numbers`);
    if (new Set(versions.map(row => sourceKey(row.source))).size !== 1) throw new RangeError(`${assertionId} changes its source key across revisions`);
    for (let i = 1; i < versions.length; i += 1) if (versions[i].availableAt <= versions[i - 1].availableAt) throw new RangeError(`${assertionId} revisions must become available in revision order`);
    const eligible = versions.filter(row => row.availableAt <= knowledgeAt);
    if (eligible.length) selected.push(eligible.at(-1)!);
  }
  const matching = selected.filter(row => sourceKey(row.source) === sourceKey(source) && row.status === "active" && row.validFrom <= validAt && (row.validTo === null || validAt < row.validTo));
  const base = {source, validAt: validAtText, knowledgeAt: knowledgeAtText};
  if (!matching.length) return {status: "unmapped", ...base, canonicalId: null, candidateCanonicalIds: [], confidence: null, evidence: [], reason: "No known active assertion covers the requested validity instant."};
  const candidates = [...new Set(matching.map(row => row.target.canonicalId))].sort();
  const evidence = matching.map(row => ({assertionId: row.assertionId, revision: row.revision, availableAt: new Date(row.availableAt).toISOString().replace(".000Z", "Z"), assignmentAuthority: row.assignmentAuthority, sourceDocumentId: row.sourceDocumentId, confidence: row.confidence, checkDigitStatus: row.checkDigitStatus})).sort((a, b) => a.assertionId.localeCompare(b.assertionId) || a.revision - b.revision);
  if (candidates.length > 1) return {status: "ambiguous", ...base, canonicalId: null, candidateCanonicalIds: candidates, confidence: null, evidence, reason: "Known active assertions map the same scoped source key to different canonical entities."};
  const confidence = matching.map(row => row.confidence).reduce((best, value) => confidenceRank[value] > confidenceRank[best] ? value : best);
  return {status: "resolved", ...base, canonicalId: candidates[0], candidateCanonicalIds: candidates, confidence, evidence, reason: "Exactly one canonical entity is supported at both requested clocks."};
}

export const calculate = resolveIdentifier;
Full-height labplaygroundOpen full screen