CDR Mediation Service — Jira-Ready Epics & User Stories
Status: populated Owner: Commerce + Regulator Liaison Last updated: 2026-04-20 Service prefix: CDR Scope: Canonical CDR generation, hash-chained append-only storage, TAP 3.12 / RAP regulator exports, signed file drops to ATRA, and adjustment records (corrections, voids, re-rates) without mutating originals.
Epic Summary
| Epic ID | Title | Stories | Points |
|---|---|---|---|
| EP-CDR-01 | Canonical CDR Schema, Hourly Roll-up, Hash-Chained Append-Only Storage | US-CDR-001 – US-CDR-006 | 39 |
| EP-CDR-02 | TAP 3.12 / RAP Export Pipelines (per regulator schema) | US-CDR-007 – US-CDR-011 | 34 |
| EP-CDR-03 | Daily Signed-File Drop to ATRA SFTP/API | US-CDR-012 – US-CDR-015 | 24 |
| EP-CDR-04 | Adjustment Records (corrections, voids) without mutating originals | US-CDR-016 – US-CDR-018 | 18 |
Totals: 4 epics · 18 stories · 115 points
EP-CDR-01 · Canonical CDR Schema, Hourly Roll-up, Hash-Chained Append-Only Storage
Context: Foundation epic. Every billable event becomes a canonical CDR; hourly buckets are sealed with Merkle roots; chain hashes link buckets forward; sealed buckets are mirrored to S3 Object-Lock storage; daily anchors are submitted to a transparency log.
US-CDR-001 · Canonical CDR Projection from DLR
Type: Feature | Points: 8
Description:
As the CDR mediation pipeline, I need to project every final-state DLR received on sms.dlr.inbound into a canonical CDR row so that every billable telephony event has a regulator-grade record.
Acceptance Criteria:
- Final-state DLR (
DELIVERED|FAILED|EXPIRED) onsms.dlr.inboundproduces exactly one CDR row within P95 ≤ 200 ms - Canonical fields populated:
callingPartyAddress,calledPartyAddress,recordingEntity,serviceCenterAddress,messageReference,eventTimeStamp,localTimeStamp(Asia/Kabul),segmentCount,bucketHour - NATS redelivery for the same
messageIdis rejected by the unique index on(messageId, adjustmentOf NULL) - Missing pricing → CDR written with
chargeAmount=NULLand follow-upcdr.record.repricedwithin 24h once pricing arrives -
cdr.record.appendedpublished within 1 s with{cdrId, messageId, bucketHour, rowHash} - Zero loss on consumer restart (JetStream
AckExplicit+ PG transaction)
US-CDR-002 · Per-Row Hash Computation
Type: Feature | Points: 5
Description: As the CDR mediation pipeline, I need a deterministic SHA-256 row hash for every CDR forward-linked to the previous row so that any tampering invalidates the chain.
Acceptance Criteria:
-
rowHash = sha256(canonicalJson(row))per RFC 8785 JCS -
chainHashPrev = previous row's rowHashwithin the same partition - First row of a partition:
chainHashPrev = bucketRoot[H-1] - Mutating a stored row (test-only) makes
verifyRow(cdrId)returnMISMATCH -
POST /v1/cdr/verifyreturns{rowHash, chainHashPrev, computedHash, status: VALID|MISMATCH} - decimal(18,6) fields rendered with fixed-point precision in canonical JSON
US-CDR-003 · Hourly Bucket Seal & Merkle Root
Type: Feature | Points: 8
Description: As the CDR scheduler, I need to close each hour's bucket and compute its Merkle root within 5 minutes of hour-close so that a tamper-evident seal exists for every operating hour.
Acceptance Criteria:
- Leader replica triggers seal job at
HH:00forHH-1; completes within 5 minutes (P95) -
bucketRoot= SHA-256 Merkle root of orderedrowHashlist (binary tree, padded with zero leaves) -
chainHash[HH-1] = sha256(chainHash[HH-2] ‖ bucketRoot[HH-1])INSERTed intocdr.cdr_chain - Empty hours record sentinel
bucketRoot = sha256("EMPTY:" + bucketHour) -
cdr.bucket.sealed{bucketHour, chainHash, recordCount, sealedAt}published - SEV1
cdr_bucket_seal_overdueif not sealed byHH+10m - Leader-only via
cdr.export.leaderadvisory lock
US-CDR-004 · Append-Only Object Storage Mirror
Type: Feature | Points: 8
Description: As a long-term audit consumer, I need every sealed bucket mirrored to object storage with S3 Object Lock in Compliance mode so that even root credentials cannot delete sealed CDRs within the 10-year retention window.
Acceptance Criteria:
- Files written to
s3://ghasi-cdr-archive/v1/year=YYYY/month=MM/day=DD/hour=HH/<sequence>.cdr.json.zst(1000 rows/file) -
x-amz-object-lock-mode: COMPLIANCE,retain-until = sealedAt + 10y -
_MANIFEST.jsonper hour-bucket:{bucketHour, recordCount, bucketRoot, chainHash, files:[{name,sha256,recordCount}], signerKeyId} - Mirror failure: exponential backoff 24h, then SEV1
- Delete attempt on locked object →
AccessDenied+ audit log entry - Auditor verifies chain integrity from manifest + public anchors alone
US-CDR-005 · Daily Transparency Log Anchor
Type: Feature | Points: 5
Description:
As an external auditor, I need each day's terminal chainHash published to a Trillian-style transparency log so that I can verify chain integrity without trusting Ghasi-operated infrastructure.
Acceptance Criteria:
- Day-rollover (00:00 Asia/Kabul): chainHash for hour 23 of prior day submitted within 30 minutes
- Signed Log Entry stored in
cdr.transparency_anchors{day, chainHash, sleSignature, leafIndex, logRootHash} - Wrong chain hash returned by log → SEV1 + retry with fresh inclusion proof
-
GET /v1/cdr/transparency/{day}returns SLE + inclusion proof - Nightly 7-day chain integrity check job walks all chain hashes vs row hashes
US-CDR-006 · Chain Verification API
Type: Feature | Points: 5
Description:
As a regulator-portal user, I need to call POST /v1/cdr/chain/verify with a bucketHour and receive cryptographic proof of integrity so that I can certify to ATRA that no CDR was lost, mutated, or backdated.
Acceptance Criteria:
- Returns
{bucketRoot, chainHash, prevChainHash, recordCount, sealedAt, signerKeyId, verified: true}within P95 ≤ 1.5 s - Unsealed bucket → 409
BUCKET_NOT_SEALED - Recomputed Merkle root mismatch →
verified: false+ SEV1 -
proofForCdrIdparameter returns Merkle inclusion proof - Missing
cdr:readscope → 403
EP-CDR-02 · TAP 3.12 / RAP Export Pipelines
Context: Daily encoder jobs convert canonical CDRs into ATRA-mandated TAP 3.12 (settlement) and RAP (corrections) files. Schema variants are configurable to absorb regulator schema drift without code change.
US-CDR-007 · TAP 3.12 Daily Encoder Job
Type: Feature | Points: 13
Description: As the regulator export pipeline, I need to encode all sealed CDRs from the prior calendar day into ATRA-compliant TAP 3.12 files per operator so that ATRA receives the lawful settlement record by the 02:00 Asia/Kabul deadline.
Acceptance Criteria:
- Job completes file generation for all
(operatorId, settlementDay=D-1)tuples by 02:00 Asia/Kabul - Zero-record operator-day produces a NIL-filler TAP file (absence is auditable)
- Output: BER-encoded ASN.1 TAP 3.12 with mandatory fields
recordingEntity,originalSubscriberId,callingPartyAddress,calledPartyAddress,serviceCenterAddress,messageReference,eventTimeStamp,chargeInformation,totalCallEventDuration=0 - Schema variant configurable (
atra-tap-v1,atra-tap-v2,tap-3-12-standard) - Single-record encode failure → quarantine table; job continues
- SHA-256 stored alongside file path in
cdr.export_files
US-CDR-008 · RAP Adjustment File Encoder
Type: Feature | Points: 8
Description: As the regulator export pipeline, I need to encode all adjustment records issued since the last RAP cut-off into a daily RAP file so that corrections, voids, and re-rates are reconciled with ATRA.
Acceptance Criteria:
- Adjustment rows with
rapBatchedAt IS NULLbatched at 02:30 Asia/Kabul into RAP file per(operatorId, settlementDay) - RAP 1.5 conformance with
returnDetaillinking each adjustment to the originalmessageReference - VOID: zero
chargeInformation+ configured void code inreturnReason - RE_RATE: new
chargeInformation+ original referenced - All included adjustments updated atomically with
rapBatchedAt = now()andrapFileId - No adjustments → no RAP file (logged)
US-CDR-009 · Configurable Regulator Schema Variants
Type: Feature | Points: 5
Description: As a platform operator, I need to switch between ATRA TAP schema variants via configuration without redeploying code so that regulator schema changes can be rolled out within hours.
Acceptance Criteria:
-
cdr.export.tap.schemaVariantconfig key supportsatra-tap-v1,atra-tap-v2,tap-3-12-standard - ASN.1 modules load from
config/tap-schemas/at runtime - Variant change emits
cdr.config.changedaudit event with old/new + operator identity - Variant configured but file missing → service refuses to start
- Variant pinned at file-generation time, not CDR-creation time
US-CDR-010 · TAP File Sequence Numbering
Type: Feature | Points: 3
Description:
As the ATRA file-receipt system, I need each TAP file from a given recording entity to carry a strictly monotonic fileSequenceNumber so that I can detect missing files and reject out-of-order submissions.
Acceptance Criteria:
-
fileSequenceNumber = previous + 1perrecordingEntity - Counter stored in
cdr.tap_sequence (recordingEntity, lastSequence), updated atomically with file generation - Generation failure → sequence increment rolled back via transaction
- Filename
TAP_{recordingEntity}_{senderRoamingPartner}_{seq:08d}.{YYYYMMDD}.tap -
GET /v1/cdr/exports?recordingEntity=...lists in sequence order, gaps flagged
US-CDR-011 · Quarantine and Resubmission for Encoder Failures
Type: Feature | Points: 5
Description: As the on-call engineer, I need records that fail TAP encoding to land in a quarantine table that I can fix and resubmit so that a single bad record cannot block an entire daily export.
Acceptance Criteria:
- Encoder failure → row moved to
cdr.export_quarantine {cdrId, encoderError, encoderVariant, quarantinedAt} - Daily TAP file still generated for the rest with
quarantined: Nreported -
POST /v1/cdr/quarantine/{id}/resubmitre-encodes; success → included in next-day supplement - >100 quarantined in 24h → SEV2
cdr_quarantine_high -
cdr.quarantine.resubmittedevent emitted
EP-CDR-03 · Daily Signed-File Drop to ATRA SFTP/API
Context: Generated TAP/RAP files are signed Ed25519 (HSM-resident key), atomically uploaded to ATRA SFTP, with REST API fallback when SFTP is down. Delivery receipts are tracked end-to-end.
US-CDR-012 · Ed25519 Signing of Export Files
Type: Feature | Points: 8
Description: As the ATRA file-receipt system, I need every TAP and RAP file signed with an Ed25519 signature whose public key I have on file so that I can verify the file originated from Ghasi and was not tampered with.
Acceptance Criteria:
- Signing key id
cdr-export-signer-v1used via PKCS#11 against the HSM - Sidecar
<filename>.sigwritten:KeyId:,Algorithm: Ed25519,Signature:,FileSha256: - Key rotation: both old + new keys valid for verification 30 days; active key id recorded per file
- HSM unreachable → exponential backoff 1h, then SEV1
- Code-side audit confirms no key material logged or written to disk
- Post-upload ATRA verification status captured in
cdr.export_deliveries.atraVerificationStatus
US-CDR-013 · SFTP File Drop with Atomic Rename
Type: Feature | Points: 5
Description:
As the ATRA SFTP drop zone, I need files to arrive atomically (uploaded to .tmp then renamed) so that downstream batch jobs do not pick up half-written files.
Acceptance Criteria:
- Upload writes
<filename>.tmp, verifies size + SHA-256 viaSTAT, thenRENAMEto final - SFTP host fingerprint mismatch → connection refused + SEV1
- SFTP key rotation: 7-day overlap, both keys tried in order
- Permission error → fail fast + page on-call (no retry)
- Signature sidecar uploaded immediately after with same atomic pattern
- Existing
(recordingEntity, fileSequenceNumber)on SFTP →DUPLICATE_FILEabort + page on-call
US-CDR-014 · ATRA REST API Fallback
Type: Feature | Points: 8
Description: As the CDR export pipeline, I need to fall back to the ATRA REST upload API when SFTP is unreachable so that a single-channel outage does not delay the daily file drop.
Acceptance Criteria:
- Trigger: 3 SFTP failures in 15-min window
-
POST https://atra.gov.af/api/v1/cdr/upload(multipart, mTLS) with file + signature sidecar - 201 response with
{receiptId, sha256}stored incdr.export_deliveries (status=DELIVERED_API) - Hash mismatch → SEV1 +
INTEGRITY_MISMATCH - mTLS handshake failure (cert expired) → immediate SEV1 + page
- 5xx → exponential backoff with jitter, 6 retries over 1h
- Both channels failing >2h →
cdr.export.delivery.failed+ auto P0 incident
US-CDR-015 · Delivery Receipt Tracking
Type: Feature | Points: 3
Description: As a compliance auditor, I need to query the delivery status of every TAP/RAP file ever sent to ATRA so that I can answer "did file X reach ATRA on date Y?" without log diving.
Acceptance Criteria:
-
GET /v1/cdr/exports?from=&to=(paginated,cdr:read) returns{filename, recordingEntity, fileSequenceNumber, sha256, signedAt, deliveryChannel, deliveryStatus, atraReceiptId, deliveredAt, atraVerificationStatus} - FAILED status includes latest error reason + retry count
-
POST /v1/cdr/exports/{id}/redropenqueues fresh attempt with original signature reused - Audit log records every redrop with operator identity from JWT
EP-CDR-04 · Adjustment Records (Corrections, Voids) without Mutating Originals
Context: Corrections, voids, and retroactive re-rates are issued as new immutable rows that reference the original
cdrId. Originals are never updated. Adjustments themselves are chained, hashed, and exported via RAP.
US-CDR-016 · Issue Adjustment Record (CORRECTION)
Type: Feature | Points: 5
Description: As a settlement operations engineer, I need to issue a CORRECTION adjustment for a CDR with a wrong field value, without mutating the original, so that audit integrity is preserved while the regulator receives corrected data.
Acceptance Criteria:
-
POST /v1/cdr/{cdrId}/adjustmentswith{type: "CORRECTION", correctedFields, reason, ticketId}INSERTs new rowadjustmentOf=cdrId, adjustmentType=CORRECTION - Original CDR row remains byte-for-byte unchanged
-
cdr.adjustment.issued{adjustmentId, originalCdrId, type, reason}published - Correction-of-correction supported via
adjustmentOfchain - Adjustment sealed in the hour it was issued, not original CDR's hour
- Missing
cdr:writescope → 403 - Missing
ticketId→ 422
US-CDR-017 · Issue Adjustment Record (VOID)
Type: Feature | Points: 5
Description: As the fraud investigations team, I need to void a CDR that was incorrectly produced (e.g. duplicate DLR projection or fraud reversal) so that the record is functionally removed without violating immutability.
Acceptance Criteria:
-
POST /v1/cdr/{cdrId}/adjustmentswith{type: "VOID", voidReason, ticketId}INSERTs new rowadjustmentType=VOID, chargeAmount=0 - Net-effect-by-
messageIdconsumers see void cancelling original - Already-voided CDR → 409
ALREADY_VOIDED -
cdr.adjustment.issued{type: VOID}published; included in next RAP batch - Fraud-reversal flag from operator-management auto-issues void with
voidReason: "FRAUD_REVERSAL"+ linked fraudticketId
US-CDR-018 · Issue Adjustment Record (RE_RATE)
Type: Feature | Points: 8
Description: As the commercial pricing team, I need to issue RE_RATE adjustments when a retroactive tariff change applies so that ATRA receives corrected charge amounts via the next RAP file.
Acceptance Criteria:
- Bulk
POST /v1/cdr/adjustments/reratewith{filter, newPricingTableId, ticketId, reason}INSERTs oneRE_RATErow per matched CDR - New pricing snapshot fetched from
billing-serviceat adjustment-creation time;chargeAmount+tapTariffClassreflect new pricing - >100,000 CDR impact requires
approverJwtheader (four-eyes principle); else 403 -
GET /v1/cdr/adjustments/jobs/{jobId}returns progress + final counts - Single
cdr.adjustment.batch.issued{jobId, type: RE_RATE, count, sumDelta}event on completion - All re-rate adjustments included in next RAP file with
returnReason: TARIFF_CHANGE
End of report — 4 epics, 18 stories, 115 points