D02-F04-A03 / Complete engineering topic

IPO Availability Timestamping: Separate Listing Events from Research Knowledge

A production-minded guide to IPO Availability Timestamping: Separate Listing Events from Research Knowledge.

D02 · CORPORATE ACTIONS AND SECURIT…
D02-F04-A03Canonical / Tested / Open
D02 / D02-F04
Key concepts

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

An IPO does not become usable because one date appears in a database. A registration can be effective while the exchange opening is still ahead. A listing notice can exist before the first quote. A vendor can publish before your own system ingests the record. If research collapses those events into one ipo_date, a historical universe can acquire data that was not yet known.

This tutorial builds a reproducible alternative: select a named readiness policy, evaluate source events at one instant, and restrict evidence to revisions known at a separate cutoff.

IPO lifecycle and research knowledge clocks

One listing, many clocks

Keep these milestones separate:

MilestoneClock ownerSafe interpretation
Filing acceptedRegulatorA filing record exists
Registration effectiveRegulatorThe registration reached its effective boundary
Listing notice or approvalExchangeThe exchange published a listing event
Offer pricingIssuer or underwriterThe offering price was set
AllocationUnderwriterShares were allocated
First eligible sessionExchange calendarA declared session boundary was reached
First observed quote or tradeVenue or vendorThat source observed market data
Vendor releaseVendorThe vendor published its record
Local ingestionResearch systemYour system acquired the record

None of these facts alone proves investability, tradability, liquidity, legal offering eligibility, or a return.

The two questions hidden inside “as of”

A historical query needs two cutoffs:

  • effectiveAt: Which source events had taken effect for the session being evaluated?
  • knownAt: Which evidence and revisions had the research system acquired?

For event revision rr:

V(r;tk)=1[availableAtrtk]V(r;t_k)=\mathbf{1}[availableAt_r\le t_k]

and

E(r;te,tk)=1[effectiveAtrte]V(r;tk).E(r;t_e,t_k)=\mathbf{1}[effectiveAt_r\le t_e]V(r;t_k).

The first expression blocks future knowledge. The second also prevents a known announcement about a future session from being treated as already effective.

The policy must also be point in time

“Require registration, a listing notice, a trade, and local ingestion” is a policy, not a law of nature. A team may revise that policy later. If the current rule is applied to every historical query, the model has another form of lookahead.

The package therefore versions the policy with its own stable ID, source, effectiveAt, and availableAt. A May 14 correction can change a May 14-known answer without changing the answer produced with a May 13 knowledge cutoff.

Rendering system map…

How resolution works

For each stable event ID, revisions must be contiguous, availability must increase, and identity/provenance cannot change. A cancellation is terminal. Records unavailable at knownAt are not parsed deeply, exposed, or allowed to break an earlier result.

For each required event type:

  • no active current record means incomplete;
  • a known cancellation remains incomplete with a cancellation diagnostic;
  • distinct active effective timestamps mean ambiguous;
  • matching timestamps from multiple sources are treated as corroboration;
  • a selected timestamp after effectiveAt is pending, so the result is incomplete.

When all required clocks resolve, the package returns two audit boundaries:

effectiveGateAt=max(effectiveAtpolicy,maxeffectiveAtrequired)effectiveGateAt=\max\left(effectiveAt_{policy},\max effectiveAt_{required}\right) knowledgeGateAt=max(availableAtpolicy,maxavailableAtrequired).knowledgeGateAt=\max\left(availableAt_{policy},\max availableAt_{required}\right).

These are readiness-policy outputs, not predictions or access decisions.

Synthetic example: the five-second knowledge gap

The fixture evaluates a fictional listing at 2025-05-13T13:36:10Z.

  • At knownAt = 13:35:30Z, the vendor release is known but local ingestion is not. Status: incomplete.
  • At knownAt = 13:36:10Z, local ingestion is known. Status: resolved.
  • The resolved effective gate is 13:36:00Z.
  • The resolved knowledge gate is 13:36:05Z.

Two knowledge cutoffs produce different readiness states

A policy and listing-notice correction becomes known on May 14. The later-known query selects those revisions; neither May 13 answer changes.

Real event: Airbnb shows why one date is not enough

Airbnb's 2020 Form 10-K reports that its IPO registration statement became effective on December 9, 2020, its Class A common stock began trading on Nasdaq on December 10, and the IPO completed on December 14. It also reports 55 million company shares sold at $68, including the underwriter option (SEC filing).

A Nasdaq-hosted issuer release announced pricing of 51,323,531 total offered shares at $68 and said trading was expected to begin December 10 (pricing announcement). The later 10-K confirms the trading date.

The word “expected” matters. The announcement is not an observed trade. These public sources also do not provide the exact listing-approval time, allocation, first quote, first trade time, vendor release, local ingestion, price reaction, or causal effect. Those fields stay unknown; the executable timeline is synthetic.

Direct listings, de-SPACs, uplistings, and when-issued trades

A resale direct listing may not have an underwritten offer-price or allocation event. Nasdaq's current rules describe that structure and separately describe direct listings with a capital raise (Nasdaq Rule IM-5315-1). A resale-direct-listing policy that requires underwritten pricing or allocation is returned as unsupported; a capital-raise direct listing uses its own context and source-backed policy.

For a de-SPAC, the successor needs one typed, source-backed predecessor-successor link. No known link or a cancelled link is incomplete; conflicting active links are ambiguous. An uplisting keeps the new listing ID distinct, so a trade on the previous venue cannot satisfy the new policy. A when_issued observation is likewise unable to satisfy a regular-phase policy.

The first eligible session includes calendarId and sessionId, but this algorithm does not calculate holidays, early closes, daylight-saving transitions, or local session boundaries. That work belongs to an authoritative calendar service upstream.

Build and test the selector

The Python implementation and TypeScript implementation consume the same fixture. Their tests cover policy corrections, event revisions, cancellation, conflicts, delayed vendors, direct listings, de-SPAC identity links, uplistings, when-issued trading, timezone offsets, and future-leakage regressions.

Open the guided IPO evidence lab to compare a clean resolution, missing local ingestion, a conflicting trade timestamp, and a direct-listing policy mismatch. Step advances one evidence decision; Reset returns to the same synthetic starting state.

Failure guard

Do not collapse distinct clocks into one IPO date

Reject or diagnose these shortcuts:

  • current policy applied retroactively;
  • future event revision exposed in an earlier-known query;
  • exchange notice treated as a trade;
  • expected trading date treated as an observation;
  • prior-listing or when-issued data used for the regular target listing;
  • ticker used instead of issuer, instrument, and listing IDs;
  • midnight, zero, or a guessed timestamp substituted for missing evidence;
  • provider release assumed to equal local ingestion;
  • historical dates used to imply performance.

Summary

IPO availability timestamping is a bitemporal evidence problem. Keep every clock under its owner, version both events and policies, match stable identities and market phase, and make missing or conflicting evidence visible. The result tells you whether a declared research-data policy is reproducible at two cutoffs. It does not tell you whether to trade.

Continue with Filing-Revision Versioning to apply the same knowledge-time discipline to amended financial disclosures.

Asset map

AssetPlacementVideo sceneSourceStatic fallbackStatus
Lifecycle overviewOpening2visuals/static/article-hero.svgSame SVGReady
Causal flowPolicy section3visuals/mermaid/calculation-flow.mdNumbered algorithmReady
Two-cutoff exampleSynthetic example5visuals/static/worked-example.svgWorked tableReady
Failure guardFailure section8visuals/static/failure-guard.svgFailure listReady
Guided labImplementation4-8visuals/animated/playground.htmlWorked-example SVGReady

Source roles and limitations are recorded in ../REFERENCES.md.

Causal IPO evidence flow

This flow keeps policy selection, knowledge filtering, effect testing, identity, and diagnostics in causal order.

Rendering system map…

Takeaway: evidence unavailable at knownAt cannot change, invalidate, or appear in the historical answer.

IPO evidence query state lifecycle

This state model separates incomplete, ambiguous, unsupported, and resolved outcomes while preserving later-known correction behavior.

Rendering system map…

Takeaway: a later correction starts a later-known query; it never mutates the earlier-known result.

References7 primary sources and evidence notes

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

Accessed 2026-07-22. The executable fixtures are synthetic. No commercial-provider records, credentials, or inferred provider timestamps are included.

R1 - SEC: Determine the Status of My Filing

  • Organization: U.S. Securities and Exchange Commission (SEC)
  • Source type: Official EDGAR filing guidance
  • Published: June 4, 2024
  • URL: https://www.sec.gov/submit-filings/filer-support-resources/how-do-i-guides/determine-status-my-filing
  • Jurisdiction: United States
  • Supports: A submission is not an official filing until EDGAR accepts it and returns a filing date; EDGAR operating-day rules can affect the assigned filing date.
  • Limitations: Filing acceptance does not establish exchange approval, first trading, vendor delivery, or local research availability.

R2 - SEC: Webmaster Frequently Asked Questions

  • Organization: SEC
  • Source type: Official technical guidance
  • URL: https://www.sec.gov/about/webmaster-frequently-asked-questions
  • Jurisdiction: United States
  • Supports: SEC explains its acceptance timestamp fields, says filings are often available on sec.gov within one to three minutes of the EDGAR timestamp, warns that lag can increase, and says there is no timestamp for first website availability.
  • Limitations: The described website behavior is not a service-level guarantee and is not evidence of a local system's ingestion time.

R3 - Airbnb 2020 Form 10-K

  • Organization: Airbnb, Inc.; filed with the SEC
  • Source type: Issuer annual report
  • Filing date: February 26, 2021
  • URL: https://www.sec.gov/Archives/edgar/data/1559720/000155972021000010/airbnb-10k.htm
  • Jurisdiction: United States
  • Supports: Airbnb reports that its IPO registration statement became effective on December 9, 2020; Class A common stock began trading on Nasdaq on December 10; and the IPO completed on December 14. It also reports 55 million company shares sold at $68, including the exercised underwriter option.
  • Limitations: The 10-K does not establish exact intraday first-quote, first-trade, vendor-release, allocation, or local-ingestion timestamps.

R4 - Airbnb final prospectus filing index

R5 - Airbnb pricing announcement hosted by Nasdaq

  • Organization: Airbnb, Inc.; hosted by Nasdaq
  • Source type: Issuer press release
  • Published: December 10, 2020 at 2:04 a.m. EST; release dateline December 9
  • URL: https://www.nasdaq.com/press-release/airbnb-announces-pricing-of-initial-public-offering-2020-12-10
  • Jurisdiction: United States
  • Supports: The release announces pricing of 51,323,531 shares at $68 and says Class A common stock was expected to begin trading on December 10.
  • Limitations: An expected trading date is not an observed first trade. The release is issuer-supplied content, not an exchange execution record.

R6 - Nasdaq Listing Rule IM-5315-1

  • Organization: Nasdaq
  • Source type: Current official listing rule
  • URL: https://listingcenter.nasdaq.com/rulebook/nasdaq/rules/nasdaq-5300-series
  • Jurisdiction: United States
  • Supports: Nasdaq separately describes a resale direct listing without a related underwritten offering and a direct listing with a capital raise, showing why context-specific policies are required.
  • Limitations: Current rule text is cited for the structural variant, not as a reconstruction of any historical listing's complete rule set.

R7 - Nasdaq Daily List product description

  • Organization: Nasdaq
  • Source type: Official exchange data-product description
  • URL: https://nasdaqtrader.com/Trader.aspx?id=DailyListPD
  • Jurisdiction: United States
  • Supports: Nasdaq describes notices for new listings, delistings, name and symbol changes, dividends, and other corporate actions.
  • Limitations: Historical access and some identifier content may require a subscription. A notice record is not proof of a first observed trade or local ingestion.
ipo_availability_timestamping.ts
/** Bitemporal IPO evidence selection for D02-F04-A03. */

const EVENT_TYPES = [
  "issuer_filing", "registration_effective", "listing_notice", "offer_pricing", "allocation",
  "first_eligible_session", "first_observed_quote", "first_observed_trade", "vendor_release", "local_ingestion",
];
const EVENT_ORDER = new Map(EVENT_TYPES.map((name, index) => [name, index]));
const CONTEXTS = new Set(["traditional_ipo", "direct_listing_resale", "direct_listing_capital_raise", "spac_ipo", "de_spac", "uplisting"]);
const MARKET_PHASES = new Set(["all", "regular", "when_issued"]);
const STATES = new Set(["active", "cancelled"]);
const OWNER_BY_EVENT = {
  issuer_filing: new Set(["regulator"]), registration_effective: new Set(["regulator"]),
  listing_notice: new Set(["exchange"]), offer_pricing: new Set(["issuer", "underwriter"]),
  allocation: new Set(["underwriter"]), first_eligible_session: new Set(["exchange"]),
  first_observed_quote: new Set(["venue", "vendor"]), first_observed_trade: new Set(["venue", "vendor"]),
  vendor_release: new Set(["vendor"]), local_ingestion: new Set(["research_system"]),
};
const PHASE_SPECIFIC = new Set(["first_eligible_session", "first_observed_quote", "first_observed_trade"]);

const textValue = (value, name) => {
  if (typeof value !== "string" || !value.trim()) throw new TypeError(`${name} must be a non-empty string`);
  return value;
};
const instant = (value, name) => {
  const text = textValue(value, name);
  if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(text)) throw new RangeError(`${name} must include a UTC offset`);
  const milliseconds = Date.parse(text);
  if (!Number.isFinite(milliseconds)) throw new RangeError(`${name} must be a valid ISO 8601 instant`);
  return milliseconds;
};
const positiveInteger = (value, name) => {
  if (!Number.isInteger(value) || value < 1) throw new TypeError(`${name} must be a positive integer`);
  return value;
};
const enumValue = (value, name, choices) => {
  const text = textValue(value, name);
  if (!choices.has(text)) throw new RangeError(`${name} is unsupported`);
  return text;
};
const isoZ = milliseconds => new Date(milliseconds).toISOString().replace(".000Z", "Z");

function knownItems(raw, knownAt, name, allowEmpty = false) {
  if (!Array.isArray(raw) || (!raw.length && !allowEmpty)) throw new TypeError(`${name} must be ${allowEmpty ? "a list" : "a non-empty list"}`);
  return raw.flatMap((item, index) => {
    if (!item || typeof item !== "object" || Array.isArray(item)) throw new TypeError(`${name}[${index}] must be an object`);
    return instant(item.availableAt, `${name}[${index}].availableAt`) <= knownAt ? [[index, item]] : [];
  });
}

function revisionCurrent(records, label, immutable) {
  const ordered = records.toSorted((left, right) => left.revision - right.revision);
  if (ordered.some((record, index) => record.revision !== index + 1)) throw new RangeError(`${label} revisions must be contiguous from 1`);
  const first = ordered[0];
  let priorAvailable = null;
  let cancelled = false;
  for (const record of ordered) {
    if (immutable.some(field => JSON.stringify(record[field]) !== JSON.stringify(first[field]))) throw new RangeError(`${label} changed immutable identity or provenance`);
    if (priorAvailable !== null && record.availableInstant <= priorAvailable) throw new RangeError(`${label} revision availability must increase`);
    if (record.state === "cancelled") {
      if (record.revision === 1) throw new RangeError(`${label} cannot begin cancelled`);
      cancelled = true;
    } else if (cancelled) throw new RangeError(`${label} cannot reactivate after cancellation`);
    priorAvailable = record.availableInstant;
  }
  return ordered.at(-1);
}

function validatePolicy(item, index) {
  const prefix = `policies[${index}]`;
  if (!Array.isArray(item.requiredEventTypes) || !item.requiredEventTypes.length) throw new TypeError(`${prefix}.requiredEventTypes must be a non-empty list`);
  const requiredEventTypes = item.requiredEventTypes.map(value => enumValue(value, `${prefix}.requiredEventTypes item`, new Set(EVENT_TYPES)));
  if (new Set(requiredEventTypes).size !== requiredEventTypes.length) throw new RangeError(`${prefix}.requiredEventTypes must be unique`);
  return {
    ...item, policyId: textValue(item.policyId, `${prefix}.policyId`), revision: positiveInteger(item.revision, `${prefix}.revision`),
    state: enumValue(item.state, `${prefix}.state`, STATES), effectiveInstant: instant(item.effectiveAt, `${prefix}.effectiveAt`),
    availableInstant: instant(item.availableAt, `${prefix}.availableAt`), sourceId: textValue(item.sourceId, `${prefix}.sourceId`),
    context: enumValue(item.context, `${prefix}.context`, CONTEXTS),
    marketPhase: enumValue(item.marketPhase, `${prefix}.marketPhase`, new Set(["regular", "when_issued"])), requiredEventTypes,
  };
}

function selectPolicy(raw, policyId, effectiveAt, knownAt) {
  const relevant = knownItems(raw, knownAt, "policies").map(([index, item]) => validatePolicy(item, index))
    .filter(item => item.policyId === policyId && item.effectiveInstant <= effectiveAt);
  if (!relevant.length) return null;
  const current = revisionCurrent(relevant, `policy ${policyId}`, ["policyId", "sourceId", "context", "marketPhase"]);
  return current.state === "cancelled" ? null : current;
}

function validateEvent(item, index) {
  const prefix = `records[${index}]`;
  const eventType = enumValue(item.eventType, `${prefix}.eventType`, new Set(EVENT_TYPES));
  const clockOwner = textValue(item.clockOwner, `${prefix}.clockOwner`);
  if (!OWNER_BY_EVENT[eventType].has(clockOwner)) throw new RangeError(`${prefix}.clockOwner does not own the ${eventType} clock`);
  const marketPhase = enumValue(item.marketPhase, `${prefix}.marketPhase`, MARKET_PHASES);
  if (PHASE_SPECIFIC.has(eventType) && marketPhase === "all") throw new RangeError(`${prefix}.marketPhase must distinguish regular from when_issued`);
  if (!PHASE_SPECIFIC.has(eventType) && marketPhase !== "all") throw new RangeError(`${prefix}.marketPhase must be all for a non-session event`);
  const checked = {
    ...item, eventId: textValue(item.eventId, `${prefix}.eventId`), revision: positiveInteger(item.revision, `${prefix}.revision`),
    state: enumValue(item.state, `${prefix}.state`, STATES), eventType,
    issuerId: textValue(item.issuerId, `${prefix}.issuerId`), instrumentId: textValue(item.instrumentId, `${prefix}.instrumentId`),
    listingId: textValue(item.listingId, `${prefix}.listingId`), marketPhase,
    effectiveInstant: instant(item.effectiveAt, `${prefix}.effectiveAt`), availableInstant: instant(item.availableAt, `${prefix}.availableAt`),
    clockOwner, sourceId: textValue(item.sourceId, `${prefix}.sourceId`),
  };
  if (eventType === "first_eligible_session") {
    checked.calendarId = textValue(item.calendarId, `${prefix}.calendarId`);
    checked.sessionId = textValue(item.sessionId, `${prefix}.sessionId`);
  }
  return checked;
}

function currentEvents(records) {
  const byEvent = new Map();
  for (const record of records) byEvent.set(record.eventId, [...(byEvent.get(record.eventId) || []), record]);
  const immutable = ["eventType", "issuerId", "instrumentId", "listingId", "marketPhase", "clockOwner", "sourceId"];
  return [...byEvent].map(([eventId, revisions]) => revisionCurrent(revisions, `event ${eventId}`, immutable));
}

function resolveType(records) {
  const active = records.filter(record => record.state === "active");
  if (!active.length) return [null, false];
  if (new Set(active.map(record => record.effectiveInstant)).size !== 1) return [null, true];
  const primary = active.toSorted((left, right) => left.availableInstant - right.availableInstant || left.sourceId.localeCompare(right.sourceId) || left.eventId.localeCompare(right.eventId))[0];
  return [{ ...primary, corroboratingSources: [...new Set(active.map(record => record.sourceId))].sort() }, false];
}

function validateLink(item, index) {
  const prefix = `identityLinks[${index}]`;
  const checked = {
    ...item, linkId: textValue(item.linkId, `${prefix}.linkId`), revision: positiveInteger(item.revision, `${prefix}.revision`),
    state: enumValue(item.state, `${prefix}.state`, STATES),
    relationshipType: enumValue(item.relationshipType, `${prefix}.relationshipType`, new Set(["de_spac_successor"])),
    sourceId: textValue(item.sourceId, `${prefix}.sourceId`),
    sourceType: enumValue(item.sourceType, `${prefix}.sourceType`, new Set(["issuer_filing", "exchange_notice", "security_master"])),
    effectiveInstant: instant(item.effectiveAt, `${prefix}.effectiveAt`), availableInstant: instant(item.availableAt, `${prefix}.availableAt`),
  };
  for (const side of ["predecessor", "successor"]) {
    if (!item[side] || typeof item[side] !== "object" || Array.isArray(item[side])) throw new TypeError(`${prefix}.${side} must be an object`);
    checked[side] = {
      issuerId: textValue(item[side].issuerId, `${prefix}.${side}.issuerId`),
      instrumentId: textValue(item[side].instrumentId, `${prefix}.${side}.instrumentId`),
      listingId: textValue(item[side].listingId, `${prefix}.${side}.listingId`),
    };
  }
  return checked;
}

function selectIdentityLink(raw, identity, effectiveAt, knownAt) {
  if (raw == null) return [null, "missing"];
  const checked = knownItems(raw, knownAt, "identityLinks", true).map(([index, item]) => validateLink(item, index))
    .filter(item => JSON.stringify(item.successor) === JSON.stringify(identity) && item.effectiveInstant <= effectiveAt);
  const byLink = new Map();
  for (const item of checked) byLink.set(item.linkId, [...(byLink.get(item.linkId) || []), item]);
  const current = [...byLink].map(([linkId, revisions]) => revisionCurrent(revisions, `identity link ${linkId}`,
    ["linkId", "relationshipType", "sourceId", "sourceType", "predecessor", "successor"]));
  const active = current.filter(item => item.state === "active");
  if (active.length > 1) return [null, "ambiguous"];
  if (active.length === 1) return [active[0], "resolved"];
  return [null, current.some(item => item.state === "cancelled") ? "cancelled" : "missing"];
}

export function calculate(data) {
  if (!data || typeof data !== "object" || Array.isArray(data)) throw new TypeError("data must be an object");
  const query = data.query;
  if (!query || typeof query !== "object" || Array.isArray(query)) throw new TypeError("query must be an object");
  const identity = {
    issuerId: textValue(query.issuerId, "query.issuerId"), instrumentId: textValue(query.instrumentId, "query.instrumentId"),
    listingId: textValue(query.listingId, "query.listingId"),
  };
  const effectiveAt = textValue(query.effectiveAt, "query.effectiveAt");
  const knownAt = textValue(query.knownAt, "query.knownAt");
  const effectiveInstant = instant(effectiveAt, "query.effectiveAt");
  const knownInstant = instant(knownAt, "query.knownAt");
  const policyId = textValue(query.policyId, "query.policyId");
  const policy = selectPolicy(data.policies, policyId, effectiveInstant, knownInstant);
  const base = { effectiveAt, knownAt, identity: structuredClone(identity), policyId };
  const doesNotEstablish = ["investability", "tradability", "liquidity", "offering eligibility", "returns"];
  if (!policy) return {
    ...base, status: "incomplete", policySatisfied: false, policy: null, effectiveGateAt: null, knowledgeGateAt: null,
    missingPolicy: true, missingEventTypes: [], pendingEffectiveEventTypes: [], ambiguousEventTypes: [],
    supplementalAmbiguousEventTypes: [], cancelledEventTypes: [], unsupportedReasons: [],
    identityContinuityStatus: "not_evaluated", identityContinuityEvidence: null,
    evidence: [], doesNotEstablish,
  };

  const required = policy.requiredEventTypes;
  const knownEvents = knownItems(data.records, knownInstant, "records").map(([index, item]) => validateEvent(item, index));
  const knownCurrent = currentEvents(knownEvents);
  const relevant = knownCurrent.filter(record => Object.entries(identity).every(([key, value]) => record[key] === value)
    && (record.marketPhase === "all" || record.marketPhase === policy.marketPhase));
  const current = relevant;
  const cancelledEventTypes = [...new Set(current.filter(record => record.state === "cancelled").map(record => record.eventType))]
    .sort((left, right) => EVENT_ORDER.get(left) - EVENT_ORDER.get(right));
  const resolved = new Map();
  const ambiguousAll = [];
  for (const eventType of EVENT_TYPES) {
    const [selected, ambiguous] = resolveType(current.filter(record => record.eventType === eventType));
    if (selected) resolved.set(eventType, selected);
    if (ambiguous) ambiguousAll.push(eventType);
  }
  const missingEventTypes = required.filter(eventType => !resolved.has(eventType) && !ambiguousAll.includes(eventType));
  const ambiguousEventTypes = required.filter(eventType => ambiguousAll.includes(eventType));
  const pendingEffectiveEventTypes = required.filter(eventType => resolved.has(eventType) && resolved.get(eventType).effectiveInstant > effectiveInstant);
  const supplementalAmbiguousEventTypes = ambiguousAll.filter(eventType => !required.includes(eventType));
  const unsupportedReasons = [];
  if (policy.context === "direct_listing_resale" && ["offer_pricing", "allocation"].some(item => required.includes(item))) {
    unsupportedReasons.push("resale direct-listing policy cannot require underwritten offer pricing or allocation");
  }
  let identityLink = null;
  let identityContinuityStatus = "not_required";
  if (policy.context === "de_spac") {
    [identityLink, identityContinuityStatus] = selectIdentityLink(data.identityLinks, identity, effectiveInstant, knownInstant);
  }
  const status = unsupportedReasons.length ? "unsupported" :
    (ambiguousEventTypes.length || identityContinuityStatus === "ambiguous") ? "ambiguous" :
    (missingEventTypes.length || pendingEffectiveEventTypes.length || ["missing", "cancelled"].includes(identityContinuityStatus)) ? "incomplete" : "resolved";
  const evidence = EVENT_TYPES.flatMap(eventType => {
    const record = resolved.get(eventType);
    return record ? [{
      eventType, eventId: record.eventId, revision: record.revision, effectiveAt: record.effectiveAt, availableAt: record.availableAt,
      clockOwner: record.clockOwner, sourceId: record.sourceId, corroboratingSources: [...record.corroboratingSources],
      marketPhase: record.marketPhase, calendarId: record.calendarId ?? null, sessionId: record.sessionId ?? null,
    }] : [];
  });
  const effectiveGateAt = status === "resolved" ? isoZ(Math.max(policy.effectiveInstant, ...required.map(item => resolved.get(item).effectiveInstant))) : null;
  const knowledgeGateAt = status === "resolved" ? isoZ(Math.max(policy.availableInstant, ...required.map(item => resolved.get(item).availableInstant))) : null;
  const policyOutput = {
    policyId: policy.policyId, revision: policy.revision, sourceId: policy.sourceId, effectiveAt: policy.effectiveAt,
    availableAt: policy.availableAt, context: policy.context, marketPhase: policy.marketPhase,
    requiredEventTypes: [...required],
  };
  const identityContinuityEvidence = identityLink ? {
    linkId: identityLink.linkId, revision: identityLink.revision, relationshipType: identityLink.relationshipType,
    sourceId: identityLink.sourceId, sourceType: identityLink.sourceType, effectiveAt: identityLink.effectiveAt,
    availableAt: identityLink.availableAt, predecessor: structuredClone(identityLink.predecessor), successor: structuredClone(identityLink.successor),
  } : null;
  return {
    ...base, status, policySatisfied: status === "resolved", policy: policyOutput, effectiveGateAt, knowledgeGateAt,
    missingPolicy: false, missingEventTypes, pendingEffectiveEventTypes, ambiguousEventTypes,
    supplementalAmbiguousEventTypes, cancelledEventTypes, unsupportedReasons, identityContinuityStatus,
    identityContinuityEvidence, evidence, doesNotEstablish,
  };
}
Full-height labplaygroundOpen full screen