Data Model
Every persisted and on-the-wire identifier in Nexa is a URN. URNs are structural — the partner and live status are part of the identifier itself, not side fields. That single rule eliminates a category of bugs that plagues platforms with opaque numeric IDs: you never need an if/else chain to figure out which adapter to call, and you never lose track of whether r-8842 is the Nexa-side ID or a partner-side locator.
URN format
urn:<entity>:<id>[:vendor:<vendor>][:status:<status>]
<entity>— the entity type (case,sub-case,pnr,reservation,passenger, …)<id>— the entity-side identifier (Nexa-minted or partner-supplied)vendor:<vendor>(optional) — the issuer when the ID comes from outside Nexa (amadeus,hotelbeds,pomelo,aeroapi)status:<status>(optional) — live status when it's load-bearing for routing or display
URNs parse structurally: extracting the partner from a URN is a one-call helper, and the booking engine dispatches to the matching adapter without an if/else chain.
URN registry
| URN type | Example | Issued by | Notes |
|---|---|---|---|
urn:case | urn:case:c-7f8e1 | Case orchestrator | The macro disruption. |
urn:sub-case | urn:sub-case:sc-91a4 | Case orchestrator | One PNR's saga unit. |
urn:flight | urn:flight:LA500@20260505:vendor:aeroapi | Flight predictor | Joins to the predictor's snapshot. |
urn:airline | urn:airline:LAN | platform registry | Tenant identity. |
urn:airport | urn:airport:SCL | platform registry | IATA-coded. |
urn:passenger | urn:passenger:p-1124:vendor:latam | Airline adapter | Reference only — toxic PII stays at the airline. |
urn:pnr | urn:pnr:XYZ123:vendor:amadeus | Airline PSS | Partner-tagged — the same locator string can exist across partners. |
urn:offer | urn:offer:8842 | Booking engine | Ephemeral search-result handle; the soft-hold key. |
urn:hotel | urn:hotel:SCL1234:vendor:amadeus | Booking adapter | Partner-tagged for adapter routing. |
urn:reservation | urn:reservation:r-8842 | Booking engine | Stable Nexa-side; minted before partner call so it survives retries. |
urn:reservation (with status) | urn:reservation:AMAD-1A-99XX:vendor:amadeus:status:confirmed | Partner | Partner confirmation echoed back into a URN. |
urn:hold-attempt | urn:hold-attempt:h-a91 | Booking engine | Per-acquisition; one per soft-hold attempt. |
urn:issued-card | urn:issued-card:ic-44b1 | Wallet | Tied to (caseUrn, groupId). |
urn:transfer | urn:transfer:t-3301:vendor:uber | Transport | Ground transport. |
urn:user | urn:user:u-7 | Platform identity | Operator / admin / finance. |
urn:correlation | urn:correlation:<w3c-trace-id> | Observability | W3C traceparent header rewritten as a URN; flows through every event envelope. |
urn:compensation-dead-letter | urn:compensation-dead-letter:cdl-118 | Case orchestrator | Permanent saga-rollback failures. |
Cross-collection references are URN-keyed
Every cross-aggregate reference is on urn, never on a storage primary key. The internal _id on any record is a storage-only primitive; no other aggregate ever references it.
// sub-cases reference cases by URN
const subCases = await store.subCases.find({ caseUrn: 'urn:case:c-7f8e1' });
// reservations reference sub-cases by URN
const reservation = await store.reservations.findOne({ subCaseUrn: 'urn:sub-case:sc-91a4' });
This convention has two practical benefits:
- Events carry URNs, not storage IDs. Every event payload references an aggregate by URN. Consumers in any domain can resolve it without knowing the producer's storage layout.
- URNs survive denormalization. When the case orchestrator publishes to the read-side snapshot, the URN goes through unchanged. The passenger API can correlate a snapshot record to the live operational case without an extra lookup.
Tenant identity
Every tenant-scoped record carries an airlineUrn field, denormalized from the parent case if necessary. This is what the application-level tenant guard filters on, what the workflow topic prefix derives from, and what cost-attribution labels stamp onto the deployable. A single missing airlineUrn field is a tenant-isolation bug.
Aggregates and ownership
| Aggregate | Owner domain | Carries airlineUrn directly | Notes |
|---|---|---|---|
| Case | Case orchestrator | yes | Denormalized from manifest. |
| Sub-case | Case orchestrator | inherited via caseUrn | Per-PNR. |
| PNR reference | Airline adapter (cache only) | yes | Zero-persistence; refreshed from PSS on demand. |
| Policy | Policies | yes | Scoped to (airlineUrn, airportUrn?, countryCode?). |
| Reservation | Booking | yes | Linked to subCaseUrn. |
| Issued card | Wallet | yes | Linked to (caseUrn, groupId). |
| Workflow record | producer domain | yes | One per producer; durable. |
| Audit row | Audit | yes | Append-only; never mutated. |
| Compensation dead-letter | Case orchestrator | yes | Permanent forensic record. |
Toxic PII boundary
Nexa stores reference URNs to passengers, not the passenger documents themselves. Passport / DNI / national ID documents stay in the airline's system of record. The passenger sub-document on a sub-case carries:
passengerUrns: string[]— references back to the airline.contact: { email, phone?, language }— minimum required to deliver the voucher.cabinClass,loyaltyTier, optionalspecialNeedsflags — minimum required to match policy.
Nothing else. If a regulator audits Nexa for a deletion request, the cascade re-targets the airline; if Nexa's read-side snapshot is breached, no usable passport data is in the blast radius.
Card data is similarly bounded — Nexa stores Pomelo card URNs and webhook-delivered transaction events, never PAN or CVV. The PAN/CVV reveal in the passenger PWA is an iframe served directly by Pomelo's PCI-compliant infrastructure; Nexa never sees it.
Schema versioning and concurrent writes
Every aggregate root carries a version number that is checked on every update. If a concurrent writer already moved the aggregate forward, the second write fails fast, the caller reloads, and the operator UI surfaces "this case was just updated." There is no "last writer wins" anywhere in the platform.
For schema changes, additive migrations land alongside the code change. Rolling deploys mean both versions of the schema run simultaneously for several minutes; every read path tolerates absent fields, and every write path lands the new field whether the read path needs it or not. Destructive migrations (column drops, type changes) are never co-deployed with code that references the change — they ship in a separate release after every replica has been bumped.
The durable workflow record
The single most important data-plane invariant in Nexa: state changes and outbound events are written together in a single durable step. Domain state changes do not "fire and forget" their next event — the next event is committed atomically with the state change.
If the platform crashes between writing the state and dispatching the next step, the durable record means the next step is dispatched on recovery. The "we updated the database but lost the next step" failure mode is structurally impossible.
The durable record is insert-only and tracked by a resume token; there is no application-managed dispatched flag, and there is no polling cron. Records age out after a configured retention window; the long-term audit trail lives in the audit log.
Observability identifiers
Every request and event carries:
traceparent— W3C trace context, unmodified across HTTP and event boundaries.tracestate— vendor-specific trace context (optional).correlationUrn—urn:correlation:<trace-id>for human-readable joins across logs, metrics, traces, and audit rows.
Log lines and audit rows always carry the correlation URN; an operator can paste it into the platform dashboard and see every step of a saga end-to-end.
Where to next
- Architecture Overview — the system topology.
- Case Lifecycle — the state machine and the saga.
- Public API Reference — the URNs as they appear on the wire.