CDR Mediation Service (cdr-mediation-service) — Service Overview
Status: populated Owner: Commerce + Regulator Liaison Last updated: 2026-04-20 Companion: DOMAIN_MODEL · API_CONTRACTS · EVENT_SCHEMAS · DATA_MODEL
1. Purpose — The Regulator-Facing System of Record for Traffic
The CDR Mediation Service generates, stores, signs, and exports the canonical Charging Data Records (CDRs) for every billable SMS event traversing the Ghasi-SMS-Gateway national backbone. Although the platform separately runs billing-service for tenant invoicing, CDRs are an independent, regulator-grade artifact: they are the lawful record of who sent what to whom, when, through which operator, and at what tariff. ATRA (Afghanistan Telecom Regulatory Authority), MNO settlement teams, and external auditors consume CDRs — not billing events.
Two design tenets shape the service:
- CDRs are immutable, hash-chained, and append-only. Once an hour-bucket of CDRs is sealed, its SHA-256 root is computed, recorded in the chain table, and the prior bucket's root is folded into the new one. Corrections never mutate originals — they are issued as adjustment records that reference the original
cdrId. - CDRs and billing events are intentionally distinct. A billing event is "platform charged tenant X for message Y at price Z." A CDR is "operator-recordable telephony event with calling/called party, timestamps, segment count, sender ID, recording entity, and tariff class." They share input data (DLR + routing + pricing) but diverge in schema, retention, and consumer.
The service implements four capabilities:
| Capability | Description |
|---|---|
| Canonical CDR generation | Subscribe to sms.dlr.inbound, project DLR + message metadata into a canonical CDR row, append to the hash-chained store |
| TAP 3.12 / RAP exports | Daily roll-up jobs translate canonical CDRs into TAP 3.12 batches (and RAP correction batches) per ATRA-prescribed schema variants |
| Signed file drops | Daily TAP/RAP files are signed (Ed25519 over SHA-256) and pushed to ATRA SFTP / API endpoints with delivery receipts and replay protection |
| Adjustment records | Corrections, voids, and tariff re-rates are issued as new immutable adjustment rows, never as in-place edits to original CDRs |
2. Bounded Context
| Dimension | Value |
|---|---|
| Domain | Commerce / Regulatory Settlement |
| Owner squad | Commerce Engineering + Regulator Liaison |
| Deployment unit | Kubernetes Deployment — cdr-mediation-service (3 replicas; one elected leader for export jobs) |
| Communication style | Inbound: NATS JetStream (sms.dlr.inbound, sms.events.status) · gRPC (admin/query) · HTTP REST (regulator-portal-service callable) |
| Storage | PostgreSQL schema cdr (hot, last 90 days) · Object storage s3://ghasi-cdr-archive/ (cold, ≥ 10 years, append-only) |
| Failure mode | Fail-loud, never lose — CDR write must be durable before NATS ACK; export jobs retry indefinitely; missing hours raise SEV1 |
3. Position in the Platform
4. Responsibilities
| # | Responsibility |
|---|---|
| R1 | Consume sms.dlr.inbound (final-state DLRs) and sms.events.status (terminal status transitions) and project each into exactly one canonical CDR row |
| R2 | Persist CDRs to PostgreSQL cdr.cdr_records partitioned hourly with monotonically increasing cdrSequence per partition |
| R3 | Mirror every sealed hour-bucket to object storage at s3://ghasi-cdr-archive/v1/year=YYYY/month=MM/day=DD/hour=HH/<sequence>.cdr.json.zst |
| R4 | Maintain the SHA-256 hash chain in cdr.cdr_chain such that chainHash[N] = sha256(chainHash[N-1] ‖ bucketRoot[N]), allowing tamper-evident verification |
| R5 | Run hourly roll-up job that closes the previous hour's bucket, computes its Merkle root, writes the chain entry, and emits cdr.bucket.sealed |
| R6 | Run daily TAP 3.12 encoder job emitting one TAP file per (operatorId, settlement-day) tuple per regulator schema variant declared in config |
| R7 | Run daily RAP encoder job for any adjustment records issued since the last RAP cut-off |
| R8 | Sign every TAP and RAP file with Ed25519 keys held in the HSM (key id cdr-export-signer-v1); embed Signature: <base64> and KeyId: headers per ATRA spec |
| R9 | Push signed files to ATRA SFTP (primary, port 2222) or ATRA REST API (POST /atra/v1/cdr/upload, mTLS) and record delivery receipts |
| R10 | Issue adjustment records (`adjustmentType: CORRECTION |
| R11 | Serve regulator-portal-service queries over gRPC: GetCDRById, ListCDRsByMSISDN, VerifyChain(bucketHour) |
| R12 | Emit lifecycle events: cdr.record.appended, cdr.bucket.sealed, cdr.export.tap.delivered, cdr.export.rap.delivered, cdr.adjustment.issued |
5. Non-Responsibilities
- Does not invoice tenants —
billing-serviceowns customer billing - Does not compute tenant pricing — pulls a pricing snapshot from
billing-serviceat CDR-creation time - Does not route or transmit messages — strictly downstream of the dispatch pipeline
- Does not house the regulator portal UI —
regulator-portal-serviceconsumes CDR APIs - Does not decide retention class — retention is set by data classification policy in
docs/security/data-classification.md(CDRs = Class IV, ≥ 10 years)
6. Upstream / Downstream Dependencies
| Direction | Service | Protocol | Purpose |
|---|---|---|---|
| Inbound event | dlr-processor | NATS JetStream sms.dlr.inbound | Final delivery state with operator timestamps |
| Inbound event | sms-orchestrator | NATS JetStream sms.events.status | Terminal status (DELIVERED, FAILED, EXPIRED) |
| Inbound (sync) | billing-service | gRPC (mTLS) | GetPricingSnapshot(messageId) for tariff class + customerPrice + operatorCost at charge time |
| Inbound (sync) | operator-management-service | gRPC (mTLS) | GetRecordingEntity(operatorId) — returns the TAP recordingEntity (3-digit MCC + 2-digit MNC + sequence) per operator |
| Inbound (sync) | regulator-portal-service | gRPC (mTLS) | CDR query, chain verification, file-drop status |
| Outbound write | PostgreSQL cdr schema | TCP | Hot CDRs, chain table, export jobs, delivery receipts |
| Outbound write | Object storage (S3-compatible, regional) | HTTPS | Cold archive, append-only bucket policy + Object Lock (Compliance mode) |
| Outbound | HSM (PKCS#11 / KMIP) | TLS | Ed25519 signing of TAP/RAP files |
| Outbound | ATRA SFTP (primary) | SSH/SFTP port 2222 | Daily signed file drop |
| Outbound | ATRA REST (fallback) | HTTPS + mTLS | API-mode upload when SFTP unreachable |
| Outbound events | NATS JetStream | TCP | Lifecycle events to regulator-portal-service, analytics-service |
7. Canonical CDR Schema (extract)
The canonical row mirrors TAP 3.12 fields without TAP encoding so downstream encoders are pure transforms.
| Field | Type | Notes |
|---|---|---|
cdrId | UUIDv7 | Identity |
cdrSequence | bigint | Monotonic per (yearMonthDayHour) partition |
tenantId | UUIDv4 | Submitter |
messageId | UUIDv4 | Source message reference |
callingPartyAddress | E.164 / alpha-id | Sender ID (alphanumeric or MSISDN) |
calledPartyAddress | E.164 | Recipient MSISDN |
originalServedSubscriberId | E.164 | For MNP cases — original IMSI/MSISDN before port |
recordingEntity | string(15) | TAP 3.12 recording entity (operator code) |
serviceCenterAddress | E.164 | SMSC identifier |
messageReference | string(20) | Operator-side message reference |
eventTimeStamp | UTC ts(ms) | DLR final-state timestamp |
localTimeStamp | local ts(ms) | Asia/Kabul localised |
chargeAmount | decimal(18,6) | From billing snapshot |
chargeType | enum | MO, MT, BROADCAST, INTERNATIONAL_MT |
tapTariffClass | string(4) | ATRA tariff class code |
segmentCount | smallint | Per GSM 03.40 |
bucketHour | timestamptz | Truncated to hour, partition key |
chainHashPrev | bytea(32) | Previous CDR's row hash for forward-link |
rowHash | bytea(32) | sha256 of canonical-form JSON |
adjustmentOf | UUIDv7? | Set on adjustment records |
adjustmentType | enum? | CORRECTION, VOID, RE_RATE |
8. Hash Chain & Bucket Sealing
hour H-1 closes → enumerate rows ordered by cdrSequence
→ compute Merkle root of rowHashes (binary tree, sha256)
→ bucketRoot[H-1] = merkle_root
→ chainHash[H-1] = sha256(chainHash[H-2] ‖ bucketRoot[H-1])
→ INSERT cdr.cdr_chain (bucketHour, bucketRoot, chainHash, sealedAt, signerKeyId)
→ write s3://ghasi-cdr-archive/.../hour=H-1/_MANIFEST.json
→ publish cdr.bucket.sealed{bucketHour, chainHash, count}
A second-tier daily anchor is published to a public transparency log (Trillian-style) on day close so external auditors can verify retroactive integrity without trusting Ghasi infra.
9. TAP 3.12 / RAP Export Pipeline
File naming convention (per ATRA spec, configurable):
- TAP:
TAP_{recordingEntity}_{senderRoamingPartner}_{fileSequence}.{YYYYMMDD}.tap - RAP:
RAP_{recordingEntity}_{senderRoamingPartner}_{fileSequence}.{YYYYMMDD}.rap - Signature sidecar:
<filename>.sigcontainingKeyId:,Algorithm: Ed25519,Signature: <base64>
10. Adjustment Record Semantics
| Operation | Behaviour |
|---|---|
CORRECTION | Issued when a CDR field needs amending (e.g. wrong tariff class). New row with same messageId, adjustmentOf=originalCdrId, full corrected payload. Original row is never updated. Net effect computed by consumers as latest-by-messageId. |
VOID | Issued when a CDR was incorrectly produced (duplicate DLR projection, fraud reversal). New row with adjustmentType=VOID, zero chargeAmount, voidReason populated. |
RE_RATE | Issued when retroactive pricing change applies. New row carries the new chargeAmount and tapTariffClass. RAP file batches these for regulator reconciliation. |
All adjustments are themselves chained — a correction-of-a-correction is permitted and traceable.
11. Key Design Decisions
| Decision | Rationale |
|---|---|
| CDRs separated from billing events | Different schema, retention class, consumer set, and audit obligations. Coupling them would force lowest-common-denominator schema and make ATRA changes leak into invoice code. |
| Hash-chained + Merkle bucket sealing | Tamper-evidence at row, hour, and day granularity. ATRA can request VerifyChain(2026-04-20T13:00Z) and receive cryptographic proof. |
| Object storage with S3 Object Lock (Compliance mode) | Append-only at the storage layer — even root credentials cannot delete sealed buckets within the retention window. |
| Fail-loud on missing hour | Better to page on-call at 03:05 for a missing 02:00 bucket than to ship a TAP file with silent gaps. |
| Ed25519 over RSA for export signatures | Faster, smaller signatures, and aligns with the platform PKI defined in ADR-0004. |
| HSM-resident signing key | Compromise of the cdr-mediation-service host must not expose the signing key. Key never leaves HSM. |
| Leader-election for export jobs | Daily file drop must run exactly once per (operator, day). Replicas race for cdr.export.leader advisory lock; loser replicas only do CDR ingestion. |
| Pricing snapshot at CDR-creation, not at export | Tariff changes mid-day must not retro-rewrite morning CDRs; instead, RAP batches retro re-rates as adjustment records. |
| Adjustments by append, never by mutation | Required by ETSI / TAP rules and by the ADR-0004 immutability principle. |
| Daily transparency-log anchor | External cryptographic anchoring lets any auditor verify chain integrity without trusting Ghasi-operated infrastructure. |
12. Operational SLOs
| SLO | Target |
|---|---|
| CDR row append latency from DLR receipt | P95 ≤ 200 ms |
| Hour-bucket seal completion | within 5 minutes of hour-close |
| Daily TAP file generation | complete by 02:00 Asia/Kabul for prior calendar day |
| ATRA SFTP delivery success rate | ≥ 99.9 % monthly |
| Chain verification API | P95 ≤ 1.5 s for any 24h range |
| Zero data loss objective | RPO 0 (synchronous PG commit before NATS ACK); RTO 15 min |
13. Cross-References
- ADR-0004 National Backbone Resilience — drives leader-election, RPO 0, transparency anchoring
- docs/07-epics-and-user-stories.md §6.5 — epic catalog
- regulator-portal-service — primary downstream consumer
- billing-service — pricing snapshot source
- dlr-processor — primary upstream event source
End of SERVICE_OVERVIEW.md