The governed definitions this build depends on. Read them first if a term is unfamiliar.
- PrimaryStable Security IdentifierA stable security identifier is a governed key intended to preserve instrument identity across changes in mutable labels such as ticker or name.
- PrerequisiteFinancial InstrumentA financial instrument is a contract or arrangement that creates a financial asset for one party and a corresponding liability or equity interest for another.
- PrerequisiteSecurityA security is a financial instrument representing an investment or financing interest that can be identified and transferred under applicable rules.
- ImportantData LineageData lineage records where data came from, how it was transformed, and which entities, activities, and agents affected it.
- ImportantEffective TimeEffective time is the instant or interval boundary from which a rule, status, or fact applies in its modeled business domain.
- ImportantExchangeAn exchange is a governed trading venue that operates markets under its own rules and applicable regulation.
- ImportantInstrument CurrencyInstrument currency is the currency in which a financial instrument's contractual amounts or reference terms are denominated.
- ImportantIssuerAn issuer is the legal entity or other organization responsible for creating and honoring a financial instrument.
- ImportantListingA listing is the effective-dated relationship that makes a security available under specified terms on a trading venue.
- ImportantSecurity MasterA security master is a governed reference-data system that organizes instruments, issuers, identifiers, listings, currencies, statuses, and effective dates.
- ImportantSeries IdentitySeries identity is the complete key and semantic contract that states what a sequence of observations represents.
- ImportantTicker SymbolA ticker symbol is a short, human-readable code used to refer to a security or listing within a particular market context.
- ImportantTrading VenueA trading venue is an organized market, platform, or facility on which financial instruments can be quoted, traded, or reported.
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 instantt, and knowledge cutoffk, 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.
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.
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:
| Scheme | Layer used here | Key caution |
|---|---|---|
| LEI | Legal entity | Not a security ID |
| ISIN, CUSIP | Instrument | Not automatically a venue listing |
| Share Class FIGI | Share class | Can group several market contexts |
| Composite FIGI | Listing/country-market context | Not the venue-specific line |
| FIGI | Trading-line context | Keep its exchange/pricing context |
| Ticker | Trading line | Venue qualifier is mandatory |
| MIC | Venue | Not an instrument ID |
| Internal | Declared layer | Opaque 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.
validAtasks when the mapping applies.knowledgeAtasks 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:
Then apply the selected revision at validity time t only when it is active and:
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.
Those states are not error synonyms:
unmappedmeans the evidence has no applicable assertion. Do not substitute the raw symbol.resolvedmeans every applicable assertion supports one target.ambiguousmeans known assertions support different targets. Do not let sort order decide.
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);observedAtandavailableAt;activeorcancelledstatus;authoritative,corroborated, orinferredconfidence;- 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:
source = TICKER / ALP / trading_line / XNAS
validAt = 2024-06-15T14:30:00Z
knowledgeAt = 2024-06-20T12:00:00Z
Four facts matter:
SYN-MAP-001revision 1 supportsSYN-LINE-ALPHA-XNAS-USDand was available in December.- Revision 2 corrects the target to Beta but is unavailable until June 25.
- A Gamma assignment is already known but begins July 1.
ALP / XLONis a different source key.
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:
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
| Asset | Learning purpose | Video scene | Source |
|---|---|---|---|
| Scoped-key hero | Naked symbol versus full source key | 1–2 | visuals/static/article-hero.svg |
| Identity ladder | Separate entity layers | 3 | visuals/mermaid/identity-layers.md |
| Two-clock selector | Explain revision then validity | 4–5 | visuals/mermaid/resolution-flow.md |
| Worked example | Inspect exclusions and selected target | 6 | visuals/static/worked-example.svg |
| Outcome guard | Compare resolved, unmapped, ambiguous | 8 | visuals/static/failure-guard.svg |
| Guided lab | Step through scenario decisions | 5–8 | visuals/animated/playground.html |
Versions, access dates, source roles, and limitations are recorded in ../REFERENCES.md.
Rendered from the canonical Mermaid sources linked by this article.
Identity layers
Purpose: prevent an identifier from being joined to a different kind of entity.
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.
Takeaway: latest database state is not automatically the state that was knowable at the requested cutoff.
References10 primary 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.
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
- Organization: Global Legal Entity Identifier Foundation (GLEIF)
- Source type: Official FAQ
- Publication date: 2024-04-22; version 1.0
- URL: https://www.gleif.org/organizational-identity/introducing-the-legal-entity-identifier-lei/2024-04-22_the-legal-entity-identifier-faqs_v1.0-final_approved.pdf
- Jurisdiction: Global LEI System
- Supports: An LEI is a 20-character alphanumeric code linked to reference information for clear identification of legal entities participating in financial transactions.
- Limitations: LEI identifies a legal entity, not the instruments it issues, its share classes, listings, or quote symbols.
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, andSHARE_CLASS_FIGIare 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.
Full dependency-light reference implementations in both supported languages.
/** 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;
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.