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-serviceClickHouse 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:
- What happened, to whom, via which operator, at what tariff — from the moment a DLR terminalises until seven years later.
- 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.
| Field | Type | Notes |
|---|---|---|
cdrId | UUIDv7 | Identity; time-sortable; prefix cdr_ on the wire |
cdrSequence | bigint | Monotonically increasing within a bucketHour partition |
tenantId | UUIDv4 | Submitter (may be null for inbound international MT) |
messageId | UUIDv4 | Source message reference (FK logical to orch.sms_messages) |
operatorId | string(8) | Terminating operator code (e.g. AWCC, MTN_AF, ETS, ROSHAN, SALAAM) |
msisdnHashTo | bytea(32) | SHA-256 of destination MSISDN + tenant-scoped salt (see §3 MsisdnHash) |
msisdnHashFrom | bytea(32) | SHA-256 of calling MSISDN (null for alpha sender IDs) |
senderIdRaw | string(11) | Alphanumeric sender ID if applicable; MSISDN E.164 otherwise |
recordingEntity | string(15) | TAP 3.12 recording entity: MCC(3) + MNC(2) + operator sequence |
serviceCenterAddress | E.164 | SMSC identifier (the Ghasi SMSC or carrier SMSC) |
messageReference | string(20) | Operator-side message reference (from SMPP message_id) |
eventTimeStamp | timestamptz | DLR final-state UTC timestamp (ms precision) |
localTimeStamp | timestamptz | Asia/Kabul localised for ATRA encoding |
chargeAmount | numeric(18,6) | Tariff at CDR-creation (nullable until billing snapshot arrives) |
chargeCurrency | char(3) | ISO 4217 — AFN default; USD for international MT |
chargeType | cdr.charge_type | MO, MT, BROADCAST, INTERNATIONAL_MT, P2P, A2P |
billingIndicator | cdr.billing_indicator | CHARGEABLE, FREE, REVERSE_CHARGED, CPP, MPP |
tapTariffClass | string(4) | ATRA tariff class code |
segmentCount | smallint | Per GSM 03.40 segmentation |
encoding | cdr.encoding | GSM7, UCS2, GSM8_LATIN |
direction | cdr.direction | MO (mobile-originated), MT (mobile-terminated) |
bucketHour | timestamptz | Truncated to hour; partition key |
state | cdr.state | RAW → AGGREGATED → EXPORTED → ACKED | ADJUSTED | ARCHIVED |
chainHashPrev | bytea(32) | Hash of previous CDR in same partition (or bucket root of prior hour for first row) |
rowHash | bytea(32) | sha256(canonicalJson(row) ‖ chainHashPrev) — RFC 8785 JCS |
adjustmentOf | UUIDv7 | null | Set on adjustment rows — references original cdrId |
adjustmentType | cdr.adjustment_type | null | CORRECTION, VOID, RE_RATE |
voidReason | string(40) | null | e.g. DUPLICATE_DLR, FRAUD_REVERSAL, TEST_TRAFFIC |
ticketId | string(24) | null | Change-management ticket (mandatory on adjustments) |
sourceEventId | UUIDv4 | sms.dlr.inbound event id (for idempotency) |
createdAt | timestamptz | Insert timestamp |
Invariants (aggregate-level):
- Immutability. Once
CdrRecord.createdAtis persisted, no field may be updated. Corrections, voids, and re-rates are new rows linked viaadjustmentOf. - Chain continuity. For any two consecutive rows in the same partition,
row_N.chainHashPrev == row_{N-1}.rowHash. - Monotonic sequence.
cdrSequencewithin abucketHourstrictly 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 → ACKEDis the happy path.ADJUSTEDis reachable from any state via a new adjustment row (the original's state changes only viacdr.records.statemarker update — see DATA_MODEL §3).ARCHIVEDis terminal and represents the 13-month hot-to-cold migration. - No retroactive re-rates in place.
chargeAmountis never updated onCdrRecord; a newRE_RATEadjustment row captures the new value.
2.2 CdrRollup (aggregate root)
Hourly aggregate that seals a bucket. One row per (bucketHour, operatorId).
| Field | Type | Notes |
|---|---|---|
rollupId | UUIDv7 | roll_ prefix |
bucketHour | timestamptz | Hour UTC (sealed after roll-over) |
operatorId | string(8) | Partition key |
recordCount | bigint | CDRs included (including adjustments issued in the hour) |
moCount, mtCount, broadcastCount, internationalMtCount | bigint | Per-chargeType counts |
chargeableSum | numeric(18,6) | Sum of chargeAmount where billingIndicator=CHARGEABLE |
freeSum | numeric(18,6) | Sum where billingIndicator=FREE |
bucketRoot | bytea(32) | SHA-256 Merkle root of ordered rowHash list |
chainHash | bytea(32) | sha256(prevBucket.chainHash ‖ bucketRoot) |
prevBucketChainHash | bytea(32) | Forward-link to previous hour |
sealedAt | timestamptz | When the rollup job completed |
signerKeyId | string(40) | HSM key id used for rollup signature |
emptyBucket | boolean | True 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.
| Field | Type | Notes |
|---|---|---|
exportId | UUIDv7 | exp_ prefix |
exportType | cdr.export_type | TAP_3_12, RAP_1_5, CSV_ANCILLARY |
schemaVariant | string(20) | atra-tap-v1, atra-tap-v2, tap-3-12-standard |
recordingEntity | string(15) | For TAP files |
senderRoamingPartner | string(15) | Counterparty code |
settlementDay | date | Day covered |
fileSequenceNumber | bigint | Strictly monotonic per (recordingEntity, exportType) |
fileName | string(128) | See §4 naming convention |
fileSha256 | bytea(32) | Content hash |
byteSize | bigint | File size |
recordsIncluded | bigint | Row count in file |
quarantinedCount | bigint | Records that failed encoding |
signerKeyId | string(40) | HSM key id (cdr-export-signer-v1 etc.) |
signatureBase64 | string(1024) | Ed25519 signature (base64) |
objectStoreUri | string(512) | S3 URI of signed file + .sig sidecar |
deliveryState | cdr.delivery_state | PENDING, UPLOADING, DELIVERED_SFTP, DELIVERED_API, FAILED, REJECTED, ACKED |
atraReceiptId | string(64) | null | ATRA-side tracking id |
atraVerificationStatus | cdr.verification_status | null | VERIFIED, INTEGRITY_MISMATCH, SCHEMA_REJECTED, UNKNOWN |
deliveredAt, ackedAt | timestamptz | null | Delivery lifecycle |
retryCount | int | Delivery retries so far |
lastErrorReason | string(200) | null | Latest 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).
| Field | Type | Notes |
|---|---|---|
adjustmentId | UUIDv7 | adj_ prefix; also the cdrId of the adjustment row |
originalCdrId | UUIDv7 | The CDR being corrected/voided/re-rated |
adjustmentType | cdr.adjustment_type | CORRECTION, VOID, RE_RATE |
reason | string(400) | Free-text rationale |
ticketId | string(24) | Change-management reference (mandatory) |
issuedByUserId | UUIDv4 | Actor |
approverUserId | UUIDv4 | null | Four-eyes approver (required for bulk re-rates > 100k CDRs) |
correctedFields | jsonb | null | Field-name → new-value map (for CORRECTION) |
voidReason | string(40) | null | Enumerated code |
newPricingTableId | UUIDv4 | null | For RE_RATE — billing-service reference |
rapBatchedAt | timestamptz | null | When included in a RAP file (see §2.3) |
rapFileId | UUIDv7 | null | FK → cdr.exports.exportId |
jobId | UUIDv7 | null | Bulk job tracker; set for bulk re-rates |
issuedAt | timestamptz | Write 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.
| Field | Type | Notes |
|---|---|---|
auditId | UUIDv7 | — |
entryType | cdr.audit_entry_type | CHAIN_VERIFY_OK, CHAIN_BREAK_DETECTED, TRANSPARENCY_ANCHORED, SCHEMA_CHANGED, KEY_ROTATED, EXPORT_REPLAYED, ADJUSTMENT_ISSUED |
bucketHour | timestamptz | null | Scope |
operatorId | string(8) | null | Scope |
details | jsonb | Structured evidence (bucketRoot, prevChainHash, computedChainHash, mismatchIndex, etc.) |
actorUserId | UUIDv4 | null | Operator on manual actions |
traceId | string(32) | W3C trace id |
occurredAt | timestamptz |
Append-only at the DB level (Postgres DO INSTEAD NOTHING rule — see DATA_MODEL §3).
3. Value Objects
| VO | Shape | Invariants |
|---|---|---|
MsisdnHash | bytea(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 |
MessageId | UUIDv4 | Logical FK to orch.sms_messages |
TenantId | UUIDv4 | FK to auth.accounts.tenant_id |
OperatorId | string(8) enum | AWCC · MTN_AF · ETS · ROSHAN · SALAAM · INTL_IN · INTL_OUT |
Direction | MO | MT | Applied consistently across billing and analytics |
BillingIndicator | CHARGEABLE | FREE | REVERSE_CHARGED | CPP | MPP | TAP 3.12 chargeInformation derivation |
CdrState | RAW | AGGREGATED | EXPORTED | ACKED | ADJUSTED | ARCHIVED | FSM (see §4) |
RowHash / ChainHash / BucketRoot | bytea(32) | All SHA-256; hex-encoded only at API boundary |
RecordingEntity | string(15) matching \d{3}\d{2}\d+ | MCC + MNC + operator counter |
TapTariffClass | string(4) | ATRA-provided tariff code |
FileSequenceNumber | bigint | Strictly monotonic per (recordingEntity, exportType) |
Ed25519Signature | 64-byte opaque | Computed inside HSM; never leaves HSM as private key material |
SchemaVariant | string enum | Pluggable 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 EXPORTED → REJECTED (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
CdrRollupBehindfires 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,CdrAuditEntryall 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 anOperatorRegistryPortlookup or stored ciphertext in a separatecdr.msisdn_vaulttable (encrypted with per-operator KEK, see SECURITY_MODEL §3). - Pricing snapshot is frozen at CDR-creation. Tariff changes post-CDR produce
RE_RATEadjustments, not in-place updates. - Region locality. CDRs are written in the region where the terminating DLR landed. Multi-region replication is async to
dxbDR 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:
| Event | Trigger |
|---|---|
cdr.generated.v1 | One per hourly CdrRollup seal (replaces the per-row event for downstream efficiency) |
cdr.record.repriced.v1 | Late-arriving billing snapshot populates a NULL chargeAmount |
cdr.bucket.sealed.v1 | Hourly roll-up job completes |
cdr.exported.v1 | CdrExport.deliveryState = DELIVERED_* |
cdr.export.acked.v1 | ATRA acknowledges the file |
cdr.export.rejected.v1 | ATRA rejects (schema mismatch, integrity failure) |
cdr.adjustment.created.v1 | IssueAdjustment succeeds (single) |
cdr.adjustment.batch.issued.v1 | Bulk re-rate job completes |
cdr.audit.v1 | CdrAuditEntry inserted (chain-verify, anchor, schema change, key rotation) |
cdr.archive.completed.v1 | 13m partition successfully migrated to S3 cold |
7. Aggregate Boundary Diagram
8. Cross-References
- SERVICE_OVERVIEW.md §7 (Canonical CDR Schema) · §8 (Hash Chain) · §10 (Adjustment Semantics)
- ADR-0004 §15 — CDR pipeline
- DATA_MODEL.md
- EVENT_SCHEMAS.md
- APPLICATION_LOGIC.md
- compliance-engine/DOMAIN_MODEL.md — audit chain pattern inherited here
- consent-ledger-service — same append-only ethos
End of DOMAIN_MODEL.md