Skip to main content

CDR Mediation Service — Domain Model

Version: 1.0 Status: Draft Owner: Commerce + Regulator Liaison Last Updated: 2026-04-21 Companion: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · EVENT_SCHEMAS · API_CONTRACTS · SECURITY_MODEL

1. Bounded Context

Commerce / Regulatory Settlement. The cdr-mediation-service owns the regulator-grade Charging Data Record (CDR) lifecycle for every SMS event that traverses the Ghasi national backbone. It is a peer to billing-service (which owns tenant invoicing) and regulator-portal-service (which consumes CDR evidence); it is not a module of either.

The boundary is drawn so that:

  • Inside the boundary: CdrRecord (canonical row), CdrRollup (hourly aggregate), CdrChainEntry (hash-chain head), CdrExport (TAP/RAP file + delivery), CdrAdjustment (correction/void/re-rate), CdrAuditEntry (chain verifier evidence), RegulatorSchema (pluggable ATRA adapter config), TransparencyAnchor (daily Trillian-style submission), ExportDelivery (SFTP/HTTPS receipt), QuarantineEntry (per-record encoder failure).
  • Outside the boundary: message storage (sms-orchestrator), DLR correlation (dlr-processor), pricing / tariff definition (billing-service), operator metadata (operator-management-service), regulator portal UI (regulator-portal-service), cold analytics (analytics-service ClickHouse tier), HSM key material (Vault Transit + PKCS#11 HSM appliance).

The service is the authoritative source for two invariants that no other service may contradict:

  1. What happened, to whom, via which operator, at what tariff — from the moment a DLR terminalises until seven years later.
  2. The cryptographic proof that (1) was not altered — the hash chain + transparency anchor + HSM-signed export files.

2. Aggregates

2.1 CdrRecord (aggregate root)

The canonical telephony event row — one per terminalised DLR plus one per issued adjustment. Immutable after insert.

FieldTypeNotes
cdrIdUUIDv7Identity; time-sortable; prefix cdr_ on the wire
cdrSequencebigintMonotonically increasing within a bucketHour partition
tenantIdUUIDv4Submitter (may be null for inbound international MT)
messageIdUUIDv4Source message reference (FK logical to orch.sms_messages)
operatorIdstring(8)Terminating operator code (e.g. AWCC, MTN_AF, ETS, ROSHAN, SALAAM)
msisdnHashTobytea(32)SHA-256 of destination MSISDN + tenant-scoped salt (see §3 MsisdnHash)
msisdnHashFrombytea(32)SHA-256 of calling MSISDN (null for alpha sender IDs)
senderIdRawstring(11)Alphanumeric sender ID if applicable; MSISDN E.164 otherwise
recordingEntitystring(15)TAP 3.12 recording entity: MCC(3) + MNC(2) + operator sequence
serviceCenterAddressE.164SMSC identifier (the Ghasi SMSC or carrier SMSC)
messageReferencestring(20)Operator-side message reference (from SMPP message_id)
eventTimeStamptimestamptzDLR final-state UTC timestamp (ms precision)
localTimeStamptimestamptzAsia/Kabul localised for ATRA encoding
chargeAmountnumeric(18,6)Tariff at CDR-creation (nullable until billing snapshot arrives)
chargeCurrencychar(3)ISO 4217 — AFN default; USD for international MT
chargeTypecdr.charge_typeMO, MT, BROADCAST, INTERNATIONAL_MT, P2P, A2P
billingIndicatorcdr.billing_indicatorCHARGEABLE, FREE, REVERSE_CHARGED, CPP, MPP
tapTariffClassstring(4)ATRA tariff class code
segmentCountsmallintPer GSM 03.40 segmentation
encodingcdr.encodingGSM7, UCS2, GSM8_LATIN
directioncdr.directionMO (mobile-originated), MT (mobile-terminated)
bucketHourtimestamptzTruncated to hour; partition key
statecdr.stateRAWAGGREGATEDEXPORTEDACKED | ADJUSTED | ARCHIVED
chainHashPrevbytea(32)Hash of previous CDR in same partition (or bucket root of prior hour for first row)
rowHashbytea(32)sha256(canonicalJson(row) ‖ chainHashPrev) — RFC 8785 JCS
adjustmentOfUUIDv7 | nullSet on adjustment rows — references original cdrId
adjustmentTypecdr.adjustment_type | nullCORRECTION, VOID, RE_RATE
voidReasonstring(40) | nulle.g. DUPLICATE_DLR, FRAUD_REVERSAL, TEST_TRAFFIC
ticketIdstring(24) | nullChange-management ticket (mandatory on adjustments)
sourceEventIdUUIDv4sms.dlr.inbound event id (for idempotency)
createdAttimestamptzInsert timestamp

Invariants (aggregate-level):

  • Immutability. Once CdrRecord.createdAt is persisted, no field may be updated. Corrections, voids, and re-rates are new rows linked via adjustmentOf.
  • Chain continuity. For any two consecutive rows in the same partition, row_N.chainHashPrev == row_{N-1}.rowHash.
  • Monotonic sequence. cdrSequence within a bucketHour strictly increases.
  • Idempotent projection. The pair (sourceEventId, adjustmentOf IS NULL) has a unique constraint — NATS redelivery of the same DLR never produces duplicate CDRs.
  • State transitions. RAW → AGGREGATED → EXPORTED → ACKED is the happy path. ADJUSTED is reachable from any state via a new adjustment row (the original's state changes only via cdr.records.state marker update — see DATA_MODEL §3). ARCHIVED is terminal and represents the 13-month hot-to-cold migration.
  • No retroactive re-rates in place. chargeAmount is never updated on CdrRecord; a new RE_RATE adjustment row captures the new value.

2.2 CdrRollup (aggregate root)

Hourly aggregate that seals a bucket. One row per (bucketHour, operatorId).

FieldTypeNotes
rollupIdUUIDv7roll_ prefix
bucketHourtimestamptzHour UTC (sealed after roll-over)
operatorIdstring(8)Partition key
recordCountbigintCDRs included (including adjustments issued in the hour)
moCount, mtCount, broadcastCount, internationalMtCountbigintPer-chargeType counts
chargeableSumnumeric(18,6)Sum of chargeAmount where billingIndicator=CHARGEABLE
freeSumnumeric(18,6)Sum where billingIndicator=FREE
bucketRootbytea(32)SHA-256 Merkle root of ordered rowHash list
chainHashbytea(32)sha256(prevBucket.chainHash ‖ bucketRoot)
prevBucketChainHashbytea(32)Forward-link to previous hour
sealedAttimestamptzWhen the rollup job completed
signerKeyIdstring(40)HSM key id used for rollup signature
emptyBucketbooleanTrue when zero records — sentinel row with sha256("EMPTY:" + bucketHour)

Invariants. sealedAt is monotonic per-operator. chainHash is deterministically recomputable from (prevBucketChainHash, bucketRoot). Zero-record hours still produce a CdrRollup (audit continuity).

2.3 CdrExport (aggregate root)

A regulator-submittable file (TAP 3.12 or RAP) plus the delivery status.

FieldTypeNotes
exportIdUUIDv7exp_ prefix
exportTypecdr.export_typeTAP_3_12, RAP_1_5, CSV_ANCILLARY
schemaVariantstring(20)atra-tap-v1, atra-tap-v2, tap-3-12-standard
recordingEntitystring(15)For TAP files
senderRoamingPartnerstring(15)Counterparty code
settlementDaydateDay covered
fileSequenceNumberbigintStrictly monotonic per (recordingEntity, exportType)
fileNamestring(128)See §4 naming convention
fileSha256bytea(32)Content hash
byteSizebigintFile size
recordsIncludedbigintRow count in file
quarantinedCountbigintRecords that failed encoding
signerKeyIdstring(40)HSM key id (cdr-export-signer-v1 etc.)
signatureBase64string(1024)Ed25519 signature (base64)
objectStoreUristring(512)S3 URI of signed file + .sig sidecar
deliveryStatecdr.delivery_statePENDING, UPLOADING, DELIVERED_SFTP, DELIVERED_API, FAILED, REJECTED, ACKED
atraReceiptIdstring(64) | nullATRA-side tracking id
atraVerificationStatuscdr.verification_status | nullVERIFIED, INTEGRITY_MISMATCH, SCHEMA_REJECTED, UNKNOWN
deliveredAt, ackedAttimestamptz | nullDelivery lifecycle
retryCountintDelivery retries so far
lastErrorReasonstring(200) | nullLatest failure reason

Invariants. fileSequenceNumber gaps are a SEV1 signal (files were deleted or not generated). A file never leaves UPLOADING without a fileSha256 match on the ATRA side when API delivery is used. ACKED is terminal.

2.4 CdrAdjustment (aggregate root)

Conceptually a specialised CdrRecord with adjustmentOf IS NOT NULL. Modelled as its own aggregate to express lifecycle semantics (job tracking, four-eyes approval, RAP batching).

FieldTypeNotes
adjustmentIdUUIDv7adj_ prefix; also the cdrId of the adjustment row
originalCdrIdUUIDv7The CDR being corrected/voided/re-rated
adjustmentTypecdr.adjustment_typeCORRECTION, VOID, RE_RATE
reasonstring(400)Free-text rationale
ticketIdstring(24)Change-management reference (mandatory)
issuedByUserIdUUIDv4Actor
approverUserIdUUIDv4 | nullFour-eyes approver (required for bulk re-rates > 100k CDRs)
correctedFieldsjsonb | nullField-name → new-value map (for CORRECTION)
voidReasonstring(40) | nullEnumerated code
newPricingTableIdUUIDv4 | nullFor RE_RATE — billing-service reference
rapBatchedAttimestamptz | nullWhen included in a RAP file (see §2.3)
rapFileIdUUIDv7 | nullFK → cdr.exports.exportId
jobIdUUIDv7 | nullBulk job tracker; set for bulk re-rates
issuedAttimestamptzWrite time

Invariants. An adjustment cannot reference an adjustment of the opposite type if it would produce an inconsistent net effect (VOID → cannot RE_RATE a voided row). Business logic enforces this in IssueAdjustment (see APPLICATION_LOGIC UC-06). Correction-of-correction is permitted.

2.5 CdrAuditEntry (aggregate root)

Append-only record of chain verification runs and regulator-grade audit events.

FieldTypeNotes
auditIdUUIDv7
entryTypecdr.audit_entry_typeCHAIN_VERIFY_OK, CHAIN_BREAK_DETECTED, TRANSPARENCY_ANCHORED, SCHEMA_CHANGED, KEY_ROTATED, EXPORT_REPLAYED, ADJUSTMENT_ISSUED
bucketHourtimestamptz | nullScope
operatorIdstring(8) | nullScope
detailsjsonbStructured evidence (bucketRoot, prevChainHash, computedChainHash, mismatchIndex, etc.)
actorUserIdUUIDv4 | nullOperator on manual actions
traceIdstring(32)W3C trace id
occurredAttimestamptz

Append-only at the DB level (Postgres DO INSTEAD NOTHING rule — see DATA_MODEL §3).


3. Value Objects

VOShapeInvariants
MsisdnHashbytea(32) = sha256(e164 ‖ tenantSalt)Tenant-scoped salt from Vault Transit; MSISDN never stored in cleartext in CDR rows; raw MSISDN preserved only in encrypted Quarantine / TAP-encoded output
MessageIdUUIDv4Logical FK to orch.sms_messages
TenantIdUUIDv4FK to auth.accounts.tenant_id
OperatorIdstring(8) enumAWCC · MTN_AF · ETS · ROSHAN · SALAAM · INTL_IN · INTL_OUT
DirectionMO | MTApplied consistently across billing and analytics
BillingIndicatorCHARGEABLE | FREE | REVERSE_CHARGED | CPP | MPPTAP 3.12 chargeInformation derivation
CdrStateRAW | AGGREGATED | EXPORTED | ACKED | ADJUSTED | ARCHIVEDFSM (see §4)
RowHash / ChainHash / BucketRootbytea(32)All SHA-256; hex-encoded only at API boundary
RecordingEntitystring(15) matching \d{3}\d{2}\d+MCC + MNC + operator counter
TapTariffClassstring(4)ATRA-provided tariff code
FileSequenceNumberbigintStrictly monotonic per (recordingEntity, exportType)
Ed25519Signature64-byte opaqueComputed inside HSM; never leaves HSM as private key material
SchemaVariantstring enumPluggable ATRA adapter id

4. State Machine — CdrRecord.state

┌────────────┐
DLR ───► │ RAW │ (just projected; chargeAmount may be NULL)
└────┬───────┘
│ billing snapshot arrives / hourly seal runs

┌────────────┐
│ AGGREGATED │ (row sealed in CdrRollup; chainHash minted)
└────┬───────┘
│ daily TAP encoder picks up row

┌────────────┐
│ EXPORTED │ (row included in a CdrExport file)
└────┬───────┘
│ ATRA ACKs the file

┌────────────┐
│ ACKED │ (regulator has verified and acknowledged)
└────┬───────┘
│ adjustment row issued referencing this cdrId

┌────────────┐
│ ADJUSTED │ (original untouched; net-effect via latest-by-messageId)
└────┬───────┘
│ 13 months elapsed — partition drops to cold

┌────────────┐
│ ARCHIVED │ (row lives only in S3 Object-Lock archive)
└────────────┘

Transitions from EXPORTEDREJECTED (regulator rejected the containing file) are handled on CdrExport, not CdrRecord — the CDR is re-exported in the next file once the schema issue is resolved.


5. Global Invariants

  • Fail-loud on missing hour. If the hourly seal cron cannot run for any reason, alert CdrRollupBehind fires and ATRA exports for that day are held until the gap is reconciled. Silent gaps are never acceptable (regulator penalty).
  • Append-only everywhere. CdrRecord, CdrRollup, CdrExport, CdrAdjustment, CdrAuditEntry all reject UPDATE and DELETE at the schema level except for bounded fields documented in DATA_MODEL §3.
  • Hash chain is unbroken. Daily verifier walks every chain hash since the service's genesis row. Break → SEV1 CdrChainBroken + Legal notification.
  • Export nonces are monotonic. File sequence numbers never repeat; gaps are a SEV1 signal.
  • Every export carries a signature. Unsigned files never reach ATRA. HSM unavailability pauses exports rather than producing unsigned output.
  • MSISDN confidentiality. Raw MSISDNs live only in TAP-encoded output (regulator obligation) and in transient encoder memory. Database rows carry msisdnHash* only. The TAP encoder fetches raw MSISDNs through an OperatorRegistryPort lookup or stored ciphertext in a separate cdr.msisdn_vault table (encrypted with per-operator KEK, see SECURITY_MODEL §3).
  • Pricing snapshot is frozen at CDR-creation. Tariff changes post-CDR produce RE_RATE adjustments, not in-place updates.
  • Region locality. CDRs are written in the region where the terminating DLR landed. Multi-region replication is async to dxb DR only; see SYNC_CONTRACT §2.
  • Adjustment depth is bounded. An adjustment of an adjustment is permitted up to depth 5; deeper chains require Commerce lead approval (anti-abuse).

6. Domain Events (produced)

Full schemas in EVENT_SCHEMAS.md. Summary:

EventTrigger
cdr.generated.v1One per hourly CdrRollup seal (replaces the per-row event for downstream efficiency)
cdr.record.repriced.v1Late-arriving billing snapshot populates a NULL chargeAmount
cdr.bucket.sealed.v1Hourly roll-up job completes
cdr.exported.v1CdrExport.deliveryState = DELIVERED_*
cdr.export.acked.v1ATRA acknowledges the file
cdr.export.rejected.v1ATRA rejects (schema mismatch, integrity failure)
cdr.adjustment.created.v1IssueAdjustment succeeds (single)
cdr.adjustment.batch.issued.v1Bulk re-rate job completes
cdr.audit.v1CdrAuditEntry inserted (chain-verify, anchor, schema change, key rotation)
cdr.archive.completed.v113m partition successfully migrated to S3 cold

7. Aggregate Boundary Diagram


8. Cross-References

End of DOMAIN_MODEL.md