Service Tickets · Deep-dive

The closure and verification loop

What happens after a CSP taps Resolve — traced through code, and measured. The short version: the system runs a connectivity check, gets a FAIL 1 time in 3, closes the ticket as “resolved, system verified” anyway, and tells no one. Those tickets reopen at 3.4× the rate.

Ashish Raj · 21 Sep 2026 · code verified on csp-os-yaml@1169a1d0 (main, 18 Sep) · ticket-service-java@d14ad5f (17 Sep) · wiom-csp-app-apr09@f51cdf71 (development, 18 Sep, v1.1.99) · Technician-App@7db6210f (development, 18 Sep) · customer-java (19 Sep). Numbers from Snowflake via Metabase, 22 Aug – 20 Sep 2026.

01

The headline

47,658service-issue tickets resolved30 days · ~1,590/day
98.0%closed by a human tap in the CSP or Technician app2.0% system-rescued
14,390closed “resolved, system verified” while the ping check said the line was down33.9% of checks that ran
0customers asked to confirm the fixnot low — structurally zero

Cohort: CSP_ACCOUNT ACTIVE + PARTNER_ID NOT NULL. Volume anchored on COMPLAINTS.RESOLVED_AT; ping state read from the matching RESTORE_EXECUTION_CANDIDATES row at STATE='COMPLETED'.

The one sentence

Wiom already knows, at the moment of closure, whether the customer’s line is up. It spends that knowledge on nothing: the verdict does not gate the close, is not shown to the CSP, is not shown to the customer, and cannot reach the CSP’s quality score. It is written to a column and forgotten.

02

What actually happens when a CSP taps Resolve

One tap, eleven hops, a couple of seconds. Every step below is a code path on the current main, not a spec.

CSP / TECH APP TAS RESTORE SRS CVS QUALITY OS TICKET SVC (JAVA) KAPTURE CUSTOMER 1 · TAP 2 · CLAIM 3 · CALL 4 · CHECK 5 · CLOSE 6 · FAN-OUT Tap Resolve accept / recall first AWAITING_VERIF. actions = [] · actor saved /verification sync · errors swallowed Ping the line PASS · 64% FAIL · 34% Close · RESOLVED SYSTEM_VERIFIED, always verdict is never read CleverTap push restore_task_completed · CSP roles only state = COMPLETED card leaves the feed · no verdict shown Resolution ledger outcome always RESOLVED · no score impact POST /Ticket/ResolveTicket comment carries the verdict · local RESOLVED sub-status CO / RS async, after a live state re-check TICKET_RESOLVED push an announcement · no reply path Customer confirms the fix no reachable endpoint · 0 records ✕ tap to terminal state: ~2–5 seconds

Read the green and red arrows. They leave the check carrying opposite answers and arrive at the same box. That single convergence is the whole finding — everything downstream, including the CSP’s quality score and the customer’s “resolved” push, is computed as if the line came back up. The dashed lane at the bottom is the path that does not exist.

CSP appCSP taps “रिज़ॉल्व हो गया”If the card is still PENDING_ACCEPTANCE the app silently fires ACCEPT_TASK first; if it is delegated to a technician, RECALL_TASK first. Then one call.HomeViewModel.kt:2781 · Technician app RestoreDrilldownViewModel.kt:181
Gateway → TASPOST /restore/candidates/{id}/actions/TRIGGER_SYSTEM_VERIFICATIONTAS moves the candidate IN_PROGRESS → AWAITING_VERIFICATION, sets system_verification_result = PENDING, captures resolved_by_actor_id/_type from the live JWT, sets is_csp_actionable = false and allowed_actions = [], bumps the attention version and writes an attention log.RestoreCandidateServiceImpl.java:174–208 · AllowedActionsResolver.java:131
TAS → SRSPOST /srs/api/complaints/{id}/verification/systemSynchronous HTTP, wrapped in a try/catch that logs and swallows. TAS does not read the result — it learns the outcome later, as an event.RestoreCandidateServiceImpl.java:234–240 · HttpSrOsAdapter.java:37
SRS → CVSConnectivity check — the only real verification in the systemRuns only for NO_INTERNET and RECHARGE_DONE_NO_INTERNET. Every other subtype takes the trust-based branch with NOT_RUN. CVS returns YES/NO plus a confidence; SRS maps YES → PASS, NO → FAIL only when confidence is “High” (otherwise downgraded to UNAVAILABLE). Verdict, confidence and reason code are written to VERIFICATION_LOGS.VerificationServiceImpl.java:363–421, 512–529
SRSThe complaint is closed — unconditionallyresolutionFinalizer.finalizeResolvedSystemVerified(…) is called on the very next line, regardless of the verdict. It hardcodes resolution_outcome = RESOLVED, verification_method = SYSTEM_VERIFIED and customer_verification_outcome = NOT_REQUIRED, computes TAT and within_tat, cancels the SLA-breach timer, and emits two events. Nothing reads the verdict it was handed — it travels only as a string field on the outbound event.VerificationServiceImpl.java:426 → ResolutionFinalizer.java:68–148
SRS → TASCOMPLAINT_RESOLVEDTAS completes the candidate: state = COMPLETED, reason COMPLAINT_RESOLVED, all timers cancelled, emits ES_RESTORE_CANDIDATE_COMPLETED.restore/InboundEventProcessingServiceImpl.java · RestoreCandidateServiceImpl.java:286
SRS → QualityCOMPLAINT_RESOLUTION_SIGNALLands in complaint_resolution_ledger and feeds M2/M3 on the CSP scorecard. It carries within_tat, escalation count and reopen flags — but the outcome is always RESOLVED and the method always SYSTEM_VERIFIED, so a FAIL verdict cannot reduce a CSP’s score. SHIFTING deliberately emits no signal at all.csp-quality-service InboundEventProcessingServiceImpl.java:85
SRS → TSJPOST /Ticket/ResolveTicket — Ticket Service JavaTicket Service writes a comment ("csp id: … | Verified: system check passed" — the verdict is rendered here, for the CC agent), sets local status RESOLVED, then asynchronously fetches live Kapture state and pushes sub-status CO (normal) or RS (high-priority), skipping the write if Kapture already shows Completed. Careful, production-grade sync.TicketController.java:1552–1718, describeVerification:1763
TSJ → Cust.CUSTOMER_TICKET_NOTIF · action = TICKET_RESOLVEDTicket Service Java pushes it to the CUSTOMER_FIFO queue — not Kapture, and not the CSP notification service. This is the only thing the customer receives. It is an announcement, not a question — there is no accept or reject on it.TicketServiceImpl.java:837–849
→ CSPCleverTap restore_task_completedFanned out to CSP roles only — OWNER / MANAGER_PLUS / MANAGER / TECHNICIAN. csp-notification-service has no customer audience at all.EventRoleMappingProperties.java:35–76
OutboxRESOLVED → CLOSEDTerminal close is deferred to the outbox TerminalStateGate once delivery is confirmed — which is why RESOLVED_AT and CLOSED_TIMESTAMP are different columns carrying different values.

End-to-end is ~2–5s: both apps hardcode a delay (3,000ms CSP, 2,000ms Technician) before re-fetching, and that is enough for the card to come back COMPLETED.

03

The numbers

Who closes the ticket

ActorClosuresShareWhat it is
CSP (owner)27,32057.3%JWT role OWNER — the partner themself
Technician9,92920.8%JWT role TECHNICIAN — Technician App
Other CSP role9,47819.9%Any other role on the CSP account (manager, co-owner)
No actor9312.0%No JWT — closed by a system path, e.g. platform takeover
Total47,658100%

Source: RESTORE_EXECUTION_CANDIDATES.RESOLVED_BY_ACTOR_TYPE, TASK_FAMILY='RESTORE', STATE='COMPLETED'. Role mapping is ResolverActorType.fromJwtRole: OWNER→CSP, TECHNICIAN→TECHNICIAN, everything else→OTHER.

Is the ping up when the ticket is closed?

Verdict at closureTickets% of all% of checks that ranMeaning
PASS — line up27,27057.2%64.2%CVS saw the connection alive
FAIL — line down14,39030.2%33.9%CVS saw the connection still down, at high confidence
UNAVAILABLE8211.7%1.9%CVS unreachable, or a NO at low confidence
NOT_RUN5,17710.9%—Subtype out of CVS scope — never checked at all
Total47,658100%
This is stable, not a blip — and it is not a few bad actors

FAIL share of checks that actually ran: 38.9% (Jun) → 36.5% (Jul) → 34.8% (Aug) → 34.5% (Sep). Four months, ~158,000 resolutions, one third down at closure throughout.

Of 677 CSPs with ≥10 checked resolutions in the window: 250 (37%) close 40% or more of their tickets with the line down, and 74 of those close 60%+. Only 24 CSPs are under 10%. A distribution that broad is a product default, not misconduct.

Only two subtypes are ever checked

SubtypeResolvedPASSFAILNever checkedFAIL % of checked
NO_INTERNET38,15724,97012,642033.6%
RECHARGE_DONE_NO_INTERNET2,9021,3571,308049.1%
FREQUENT_DISCONNECTION3,47310903,364—
SLOW_INTERNET869120857—
RECHARGE_EXPIRED · TV_CAMERA · OPTICAL_POWER · ADAPTER902260876—

CVS_IN_SCOPE_SUBTYPES is a two-element set at VerificationServiceImpl.java:57. RECHARGE_DONE_NO_INTERNET — the customer has already paid and still has no internet — is the worst performer, at nearly 1 in 2 closed while down. (The handful of PASS rows on out-of-scope subtypes are reclassifications between candidate and complaint.)

Does the customer come back? Yes — 3.4× more often.

Reopen here is the CSP Metric Tracker’s own definition, unmodified — the st_reopen_rate query behind Service Tickets → Efficiency. Grain is the Kapture ticket, the cohort is anchored on ticket creation, and a ticket counts as reopened when it carries more than one SRS complaint. The only thing added is the ping dimension.

Ping at first closureSTM ticketsReopenedRate1×2×3×3+
PASS — line up24,2861,2935.3%9352377942
FAIL — line down12,6832,27317.9%1,503488157125
UNAVAILABLE1,00215315.3%9442143
NOT_RUN3,41443512.7%2701023231
No closed candidate2,4891345.4%1101833
All — reconciles to the tracker’s M-143,8744,2889.8%2,912887285204

Cohort = M-1 (tickets created in August 2026), exactly as the tracker buckets it. The bottom row matches the live dashboard’s M-1 column cell-for-cell — the ping split is a strict partition of the number you already look at. “Ping at first closure” = SYSTEM_VERIFICATION_RESULT on the completed candidate for the ticket’s first complaint.

Repeat reopens are where the gap really opens

Reopening more than once after a PASS close: 358 of 24,286 tickets, 1.5%. After a FAIL close: 770 of 12,683, 6.1% — 4.1× higher. Closing on a dead line does not just buy one more ticket; it disproportionately buys the customer who has to call three and four times.

Two sensitivity checks — the finding survives both

Prior month (M-1 → M-2, July). PASS 6.0% vs FAIL 17.5%, on a base of 47,087 tickets that again reconciles to the dashboard exactly. Two independent months, 2.9× and 3.4×.

A stricter numerator. The tracker counts a reopen as “ticket has >1 complaint”, which also catches the duplicate-creation path (F-14) — 20.8% of the tickets it flags carry no IS_REOPEN complaint at all. Swapping the numerator for the IS_REOPEN flag instead gives PASS 9.4% vs FAIL 22.1%, a 2.4× spread. Different absolute levels, same conclusion. Reported numbers use the tracker’s definition.

Worth knowing that the two definitions catch genuinely different populations, which is why the flag-based rate comes out higher overall (13.0% vs 9.8%): >1 complaint requires Kapture to reuse the same ticket, while IS_REOPEN fires whenever SRS matches a recent prior resolution on the same connection — including under a brand-new ticket id.st_reopen_rate (refresh_workflows.py:4338) · ComplaintServiceImpl.java:879–894

04

What the customer does today

Almost nothing, and nothing that closes the loop. This is the complete list of customer-side actions in the live system.

MomentWhat the customer can doVolumeWhere it lands
RaiseCall the call centre (Kapture), raise it in customer-app chat, or report to a field executive~1,600/dayAll three land as intake_channel = CALL_CENTER — SRS cannot tell them apart
DuringReceive a TICKET_ASSIGNED push. Nothing else — no status surface, no ETA, no reply path—CUSTOMER_FIFO
At closureReceive a TICKET_RESOLVED push. It is an announcement. There is no accept, no reject, no “it’s still down”1 per closureCUSTOMER_FIFO
RateGive a 1–5 star rating in the customer app5,332 / 30dCUSTOMER_RATING_SUBMITTED → Quality OS
Confirm the fixNothing. There is no reachable path by which a customer can say “yes it’s fixed” or “no it isn’t”0—
ReopenCall again, or chat again. A fresh ticket is created; SRS links it as a reopen only if it matches connection + class + subtype within ~72h~5,900 / 22dNew complaint, IS_REOPEN = TRUE
“Zero customers confirm the fix” is not a low number — it is a structural zero

Across 47,658 resolutions in 30 days: verification_method = CUSTOMER_CONFIRMED appears 0 times; TIMEOUT_AUTO_RESOLVED appears 0 times; customer_verification_outcome is NOT_REQUIRED on 100% of rows; CUSTOMER_VERIFICATION_RESULT on the TAS candidate is NOT_RUN on 100%. This is not an adoption problem. The code cannot produce any other value — see finding D4.

05

The rating data — where it lives, and can it be tied to a ticket

The claim to check: a customer rating a service ticket from the app lands in customer_rating_records. Verified. The behaviour is real; the record is not a ticket rating.

QuestionAnswer
Does the table exist, and is it live?Yes. PROD_DB.CSP_QUALITY_SERVICE_CSP_QUALITY_SERVICE.CUSTOMER_RATING_RECORDS — 324,724 rows, written within the last hour.
What is in a row?id · csp_id · connection_id · rating (1–5) · submitted_at · event_id · version. That is the entire schema.V13__customer_rating_records.sql · CustomerRatingRecordEntity.java
Is there a ticket_id or complaint_id?No — on neither the table nor the event. The canonical contract is CustomerRatingSubmitted{event_id, csp_id, connection_id, rating, submitted_at, timestamp}. Producer is declared as customer-app; the stated purpose is “updates CE domain intermediate score for QU-M-07 (service rating)” — it is a rating of the CSP, scored per evaluation window, not a rating of a ticket.shared-libs/csp-event-contracts/…/quality/CustomerRatingSubmitted.java:7–20
Is the rating nonetheless triggered by a ticket closing?Yes, overwhelmingly. 79.4% of ratings land within 24h of a resolution on the same connection. Placebo tests: shift the same ratings back 7 days and it collapses to 3.5%; look 24h before a resolve instead of after and it is 12.3%. That is a real causal signature, not a base rate.
So can it be attributed to a ticket?Only by reconstruction — joining on (connection_id, submitted_at within N hours after resolved_at). At 24h that captures ~79% of ratings. It is ambiguous whenever a connection has two resolutions in the same day, and it will never be exact. If per-ticket CSAT matters, the fix is one field on the event.

And the rating is not a usable verification signal today

Verdict at closureResolvedRated within 24hCoverageAvg rating1–2 star
PASS — line up26,4741,9937.5%4.568.4%
FAIL — line down13,9501,2559.0%4.5110.2%
NOT_RUN5,0974759.3%4.4611.6%
UNAVAILABLE782577.3%4.1417.5%
All46,3033,7808.2%4.539.3%

Why only 8.2% rate — nothing ever asks them to

The customer is notified on resolution. What they are not given is anywhere to go.

TSJ → queueCUSTOMER_TICKET_NOTIF · action = TICKET_RESOLVEDPublished to CUSTOMER_FIFO. Consumed by customer-java — the repo that was missing from this study until now.CustomerSqsHandler.java:115 → CustomerFunctions.java:940
PushTICKET_RESOLVED_NOTIF to the customer’s FCM tokensTokens come from getFcmFromMobile(mobile). No tokens → no push.CustomerFunctions.java:951–956, 992–995
PayloadsendPushNotification(fcmTokens, templateCode, null)The third argument is the payload. It is null. No ticket id, no deeplink, no rating prompt, no call to action of any kind. The push is an announcement with nowhere to tap.CustomerFunctions.java:994
SMSTICKET_RESOLVED_MSG — fallback onlyThis one does carry a link (wiom.app.link/…), in Hindi. But it fires only when the push did not. So the customers best placed to act — app installed, live token — receive the less actionable of the two messages.CustomerFunctions.java:1002–1009
GappushSent = true means “did not throw”, not “delivered”FCM accepting a stale token does not throw. That customer gets a push that never lands and no SMS, because the code already believes it succeeded.CustomerFunctions.java:991–1000

So there is no rating prompt anywhere in the resolution flow. The 8.2% is organic app-opening, not response to an ask — which fits the earlier evidence exactly: 79% of ratings land within 24h of a resolve because the push does drive app opens, but nothing in it asks for a rating. The cheapest available lift is a payload on a push that already goes out.

The ping outperforms the rating by a wide margin

As a predictor of “this fix did not hold”, the ping separates 17.9% vs 5.3% reopen — a 3.4× spread, available on 89% of tickets, free, and instant. The rating separates 10.2% vs 8.4% detractors — a 1.2× spread, available on 8% of tickets, and it arrives late. Customers give 4.5 stars on a line the network says is still down.

Practical read: do not build the closure loop on the rating. Build it on the ping, and keep the rating as a slow secondary.

06

Apology coupons — the guide vs. production

The CS guide describes a coupon that fires automatically on every resolved service complaint, sized by how long the fix took. The sizing is exactly right. The word automatically needs a caveat it never had: the coupon reached only ~50% of resolved tickets even at its peak, for reasons the guide does not explain — and the PayG migration has since switched it off entirely for migrated customers.

What the guide saysVerdictEvidence
Worth ₹20 / 30 / 40 / 50, usable once, valid 365 days, comes off the billConfirmedT_COUPON_BLUEPRINT ids 54–57, coupon_type = apologyCoupon, discount 20/30/40/50, validity_days 365, frequency 1. Codes carry a space: APOLOGY 20.
The longer the fix took, the bigger the couponConfirmedMedian ticket TAT by code: 3.9h (A20) → 42.1h (A30) → 59.6h (A40) → 99.0h (A50). A clean monotonic ladder.
Given automatically when a service complaint is resolvedOnly if the customer has a home-router planPeaked at 55.8% of resolved Internet Issues tickets, not 100%. The gap is now explained: processTicketBenefits returns early when FetchLastPlan is null, and that requires a DynamoDB HomeRouterPlan with deviceLimit ≥ HOME_ROUTER_DEVICE_LIMIT. No qualifying plan → silent no-op, no coupon, no log above debug. It is now 2.3% because PayG customers do not carry that plan shape.TicketService.java:89–94 · CustomerService.java:1160–1186
Other ticket types do not get a couponConsistentOnly apologyCoupon instances carry this blueprint set; no other ticket family appears.
The customer receives a push notificationConfirmedTwo, in fact: TICKET_RESOLVED_NOTIF from the ticket handler, plus APOLOGY_COUPON_NOTIFICATION (or a plan-extension variant) from the benefits path.CustomerFunctions.java:978 · TicketService.java:251–266
Amounts come from t_ticket_resolution_bucket (first vs repeat × X–Y hours)Confirmed in codeBuckets are loaded once at service construction from SHARD0 and cached, then matched on hours ≥ minTime && hours ≤ maxTime && bucket.scenario == scenario. The table itself is still not replicated to Snowflake, so the row values remain unreadable — but the lookup shape is exactly as the guide describes.TicketService.java:70, 221–233 · TicketResolutionBucketDao.java:75
§4 per-cycle limits (one fewer when the first was fixed within 24h)Confirmed in codeshouldAssignCoupon implements it literally: single ticket → only if no coupon yet this cycle; otherwise couponCount == 0, or firstFix ≤ 24h && couponCount < ticketCount-1, or firstFix > 24h && couponCount < ticketCount.TicketService.java:238–249

Coverage fell from ~50% to ~2% as customers moved to PayG

MonthResolved tickets
(all Wiom)
of which
Internet Issues
Apology coupons% of Internet Issues resolved
Jan 202695,25063,20129,93347.4%
Feb 202690,72959,36433,14455.8%
Mar 202681,30755,32127,95950.5%
Apr 202682,09259,68521,58736.2%
May 202680,81660,7517,46712.3%
Jun 202672,02651,4662380.5%
Jul 202664,14346,7503160.7%
Aug 202657,81243,1815311.2%
Sep 2026 (to 21st)37,49230,1446992.3%

Denominator is the whole Wiom resolved base (SERVICE_TICKET_MODEL.FINAL_RESOLVED_TIME), not the migrated-CSP subset — the coupon engine is monolith-side and is not scoped to CSP tickets. Coupon counts are all rows on blueprints 54–57, filter-free. All-time: 642,527 apology coupons, 50–65% redeemed.

Sync is current and complete — every day in September carries 20–50 coupons with up-to-date _FIVETRAN_SYNCED, so the fall is issuance, not replication. Total coupon issuance across all types did not fall in June, which rules out a table-wide outage.

A plan extension is still being announced, and no longer granted

The benefit was never only a coupon — each bucket also carries extraInternetInHours, and the customer is told about it: sendResolutionNotification passes that number straight into the push body, under templates named APOLOGY_EXTEND_PLAN_NOTIFICATION and APOLOGY_COUPON_WITH_EXTEND_PLAN_NOTIFICATION.

But the call that actually grants it — extendPlanIfEligible(lastPlan, nasId, extraInternetInHours) — is commented out. The notification still fires, unconditionally, with the hours filled in.

I cannot read the template copy, so I cannot quote what the customer is told. What is certain is that the value passed is the granted-hours figure, the templates are named for a plan extension, and no extension happens. Worth pulling the two template bodies before anything else in this section.TicketService.java:141–151 (call site, commented) · 251–266 (notification) · 268–284 (dead method)

The cause: the PayG migration switches the legacy coupon engine off

Of 36,284 accounts migrated to PayG, 11,633 received an apology coupon before their migration and exactly 0 received one after. Not fewer — zero.

That is not an account-id artifact. The same account ids go on to receive 92,197 payg_customer_free_coupon and 13,870 pmwani coupons after migration, so the id is stable and the coupon pipeline works fine. What stops is the legacy coupon family specifically — apologyCoupon, reward, half_reward, festival, trigger and migration are all at 0 after migration.

The timing matches exactly: payg_migration issuance jumps to 35,348 in June 2026, the same month apology falls to 238. This is an unmigrated feature, not a silent outage — which is a better problem to have, and a different one to fix.

Something was rewired in August — and it is the useful half

ISSUED_BY = 'system' first appears in August 2026, and TICKET_ID goes from never populated to 100% populated over the same period. So a new issuance path exists, it is small, and unlike the old one it stamps the ticket.

That matters well beyond coupons: it is the one customer-facing artifact in this whole study that can be joined back to a ticket without reconstruction — the thing the rating record (D5) cannot do. Whatever wrote that field solved the problem the rating pipeline still has.

29% of apology coupons go out on a line the system knows is still down

Ping at closureCouponsShareA20A30A40A50Face value
PASS — line up51163.1%422453113₹11,680
FAIL — line down23829.4%199161211₹5,490
UNAVAILABLE202.5%15122₹510
NOT_RUN20.2%1100₹50
No CSP candidate394.8%24483₹1,070
All coupons carrying a ticket_id, Aug–Sep810100%661675329₹18,800
The coupon rides the same unconditional close — so the apology lands on a dead line

Nearly 3 in 10 apology coupons are issued against a ticket whose closing ping said the connection was still down. The customer receives, in the same few minutes: a “your ticket is resolved” push, a coupon apologising for the delay, and no internet. Wiom pays to apologise for taking too long, on a fault it has not fixed.

And the sizing works backwards here. Because the amount scales with time-to-fix, a premature close shrinks the apology: close at 4 hours on a dead line and the customer gets APOLOGY 20; the honest 60-hour fix would have paid APOLOGY 40. The guide’s §4 rule — one coupon fewer when the first complaint was fixed within 24 hours — then removes a coupon on the strength of a fast first fix that §03 shows is wrong a third of the time.

Where this logic lives

Found: wiom-tech/customer-java, the Java port of the customer monolith. It consumes the CUSTOMER_FIFO queue that Ticket Service Java publishes to, and holds the whole customer-side chain — the resolution push, the coupon engine (TicketService.processTicketBenefits), the bucket DAO, and the rating path. Nothing in csp-os-yaml, ticket-service-java or booking-service-java issues a coupon; Ticket Service Java only reads Kapture’s coupon_information block.

Two things follow. The engine is entirely outside the CSP OS estate, which is why it keys on Kapture ticket resolution rather than the SRS complaint. And t_ticket_resolution_bucket is read from SHARD0 once at service construction and cached — so a threshold change needs a restart, not just a row edit.

07

CVS — how it works, and what it could cover

CVS is a single yes/no question asked once, at the moment of closure: is there a recent ping from the device bound to this connection? Everything it is good at, and every limit on extending it, follows from that one sentence.

The contract

AspectWhat it is
Called bySRS only, synchronously, inside the resolve transaction. POST {CVS_SERVICE_URL}/validation/check, bearer token.CvsClient.java:31–65
Request{request_id, ticket_id, caller:"SRS", workflow_type, identifier:{connection_id}}. Only two workflow types exist: NO_INTERNET_TICKET and SHIFTING_TICKET.
Response{validation_id, ticket_id, result, confidence, reason_code, validation_ts, latency_ms}
Identity resolutionCVS maps connection_id → device itself, via asset-custody. SRS never sends a device id.
Failure behaviourNever throws. Any error, timeout or parse failure returns null, which SRS maps to UNAVAILABLE — and closes the ticket anyway.
Timeouts5s connect / 10s read. The spec’s 2s interrupt was deferred as GAP-3 and never built — but see the verdict table: it has never fired.
Its own recordCVS writes validation_records, joinable to SRS by request_id = verification_logs.correlation_id. Not replicated into Snowflake, so CVS’s internal view is unreadable from the warehouse — everything below is SRS’s record of what CVS said.

What CVS actually returns — 41,411 calls, 30 days

VerdictConfidenceReason codeCallsShareSRS maps it to
YESHigh—26,28863.5%PASS
NOHighNO_RECENT_PING14,33534.6%FAIL
NOInsufficientDataIDENTITY_UNRESOLVED5631.4%UNAVAILABLE
NOInsufficientDataDEVICE_UNKNOWN2250.5%UNAVAILABLE
TIMEOUT——00%never observed
CVS is reliable. That is the strongest argument for expanding it.

It answers with High confidence 98.1% of the time, and has never once timed out in 30 days — so GAP-3, the missing 2s interrupt, is a paper risk rather than a live one. The only thing it cannot do is resolve identity, and that fails on 1.9% of calls (IDENTITY_UNRESOLVED + DEVICE_UNKNOWN). Whatever else is true, capacity and reliability are not the constraint on widening its domain.

One quiet loss worth fixing regardless: SRS downgrades a NO at InsufficientData to UNAVAILABLE, discarding 788 answers a month. Those are cases where CVS knows it cannot see the device — which is itself a useful finding for a CSP, and currently reads as “no result”.VerificationServiceImpl.java:521–529

The data source

The reason code is NO_RECENT_PING, and that is the whole signal. The underlying stream is InfluxDB router-ping telemetry, which lands in the warehouse as:

TableRowsWhat it carries
PUBLIC.INFLUX_PING_EVENTS_RAW1.25bnPer-ping: deviceid, nasid, lcoaccountid, rxpower, txpower, connectedclientsjson, timestamp
PUBLIC.HOURLY_DEVICE_PING_INFLUX121mPer device-hour: pings received/missed, continuous_missed_ping_instances, max_pings_missed_in_continuous_instance, optical_min/avg/max, optical_in_range_pings, ping_bitmap, connected-client counts by band

CVS reads the live Influx source, not these tables — but they are the same stream, and they are what an expansion business case can be built and back-tested on. The candidate already carries DEVICE_ID on 99% of closures, so tickets join to ping data directly.

The important part: the hourly rollup already contains signals CVS does not use

continuous_missed_ping_instances is an intermittency measure. optical_avg is a line-quality measure. Both are computed, every hour, for every device, today. Extending CVS to the subtypes below is not blocked on new telemetry — it is blocked on CVS asking a richer question than “is there a recent ping”.

Subtype by subtype — what the ping layer saw at closure

SubtypeClosuresSilent at closureAny dropoutAvg opticalCan CVS serve it?
NO_INTERNET (in scope)19,31119.7%37.1%−20.67Yes — today
RECHARGE_DONE_NO_INTERNET (in scope)1,45028.2%45.0%−20.34Yes — today
RECHARGE_EXPIRED24358.4%39.6%−21.73Yes — add it
OPTICAL_POWER_OUT_OF_RANGE424.8%27.5%−27.73Yes, on optical not ping
FREQUENT_DISCONNECTION1,9244.4%14.9%−20.56Only with a time window
SLOW_INTERNET4695.5%12.0%−20.22No signal exists
TV_CAMERA · ADAPTER1655.4%14.5%−20.99Not a connectivity fault

Closures 1–15 Sep 2026, joined DEVICE_ID → HOURLY_DEVICE_PING_INFLUX on the hour containing RESOLVED_AT. “Silent at closure” = no ping row for that device-hour at all. Note this is a coarser window than CVS’s own recency check, so it under-reads CVS’s 33.6% FAIL on NO_INTERNET — the ordering across subtypes is what matters here, not the level.

Read the three rows that decide the scope question

RECHARGE_EXPIRED is free money. 58.4% of these closures happen with the device completely silent — the highest of any subtype, higher even than NO_INTERNET. “Customer recharged, line should be up” is exactly the question CVS already answers. It needs a third entry in a two-element set.

OPTICAL_POWER_OUT_OF_RANGE separates cleanly on a signal CVS does not read. Average optical is −27.7 against ~−20.5 for every other subtype. The measure already exists hourly. This is a new check, not a wider allowlist — but a well-evidenced one, on a small volume (86/month).

SLOW_INTERNET is the one to refuse. At closure it is indistinguishable from a healthy line: 12% dropout, optical −20.2, 5.5% silent — the lowest distress readings in the table. A liveness check would auto-PASS ~95% of them and manufacture false confidence. There is no throughput signal anywhere in ping telemetry. Extending CVS here would be worse than leaving it out.

Does a post-closure window rescue FREQUENT_DISCONNECTION?

A point-in-time ping cannot see intermittency — at the moment of closure an intermittent line is usually up. So I tested the obvious alternative: dropouts in the 24 hours after closure, split by whether the ticket went on to reopen.

SubtypeOutcomeTicketsAny dropout in 24hSilent for 24hSeparation
FREQUENT_DISCONNECTIONheld1,43240.2%3.8%1.27×
reopened48851.2%5.3%
NO_INTERNETheld17,04542.9%6.3%1.16× / 2.5× on silence
reopened4,47349.9%16.0%
SLOW_INTERNETheld37939.1%1.3%1.11×
reopened13443.3%4.5%
It works, but it is a much weaker instrument than the one you already have

A 24-hour stability window separates reopen from hold at 1.27× for FREQUENT_DISCONNECTION. Real, but set it against the instant liveness check on NO_INTERNET, which separates at 3.4× (17.9% vs 5.3% reopen). The point check is roughly three times the instrument, and it is available at the moment of closure rather than a day later.

So the honest sequencing is: widen the instant check where “line up = fixed” holds (RECHARGE_EXPIRED now; optical as a second check), and treat a windowed stability check as a separate, later, weaker product — one that cannot gate a close because its answer arrives a day after the CSP has gone.

08

How the apps look — and what they hide

CSP App — restore drilldown

wiom-csp-app-apr09 · development · v1.1.99

1 · Working Status + Proof section + Resolve CTA, all above the fold. Location, connection details, timeline and past issues collapsed below. Diagnose sits under Resolve by design. रिज़ॉल्व हो गया enabled = an executor is assigned. Nothing else.
2 · Awaiting verification “जांच हो रही हਜ” / Awaiting verification · “Wiom is verifying”. Card is tappable but has zero actions. Lasts ~3 seconds.
3 · Resolved “Resolved”. The proof section is explicitly hidden in this state. No verdict, no PASS/FAIL, no line status. The card stays on the feed until the CSP opens it once, then disappears. RestoreDrilldownContent.kt:284 — showProof excludes RESOLVED

The app also maps CANCELLED → RESOLVED, so a cancelled candidate reads to the CSP as a successful fix (RestoreModels.kt:36).

Technician App — restore drilldown

Technician-App · development

1 · Working Same guided-proof shape, plus a Resolve confirm sheet. Resolve
2 · Tap → gone triggerVerification() fires the action and navigates straight back to the feed, deliberately not re-loading the detail. Two forced feed refreshes 2s apart make the card vanish. RestoreDrilldownViewModel.kt:176–198
3 · There is no step 3 The technician who did the work never sees a verdict, a confirmation, or the ticket again. 20.8% of all closures go through this path.

Customer App

repo not supplied — described from the contracts it produces

Ticket raised → push on assignment → push on resolution → a 1–5 star prompt. No ticket status surface, no “still not fixed” affordance, no reply path on either push.

Evidenced by the CUSTOMER_TICKET_NOTIF actions in ticket-service-java, the CustomerRatingSubmitted contract (“Producer: customer-app”), and the 79%/3.5% temporal test above. The customer-app repo itself was not available to read — worth a 10-minute confirmation with that team.

Kapture — the CC agent

the only human who is shown the verdict

Ticket comment on resolve
csp id: a0b7c3 | Verified: system check passed
… or | Verified: system check did not pass, plus a sub-status of CO or RS. TicketController.describeVerification:1763

A genuinely useful signal, landing on the one surface that cannot act on it — and only after the ticket is already closed.

09

Findings

D1 · CRITICAL

The verification verdict is computed and then discarded

runCvsVerification computes PASS / FAIL / UNAVAILABLE / TIMEOUT, then calls finalizeResolvedSystemVerified on the next line unconditionally. That method hardcodes RESOLVED / SYSTEM_VERIFIED and never reads the verdict it is handed. A FAIL closes the ticket as a successful, system-verified fix.

Scale: 14,390 tickets/month, ~480/day. Cost: on the August cohort, tickets whose first close had the ping down reopened at 17.9% against 5.3% for a PASS — roughly 1,600 avoidable reopens a month, each one a fresh dispatch, a fresh TAT clock, and a customer who was told the problem was fixed while it wasn’t.

VerificationServiceImpl.java:426 → ResolutionFinalizer.java:68–76. Unchanged since the Aug study (F-01); re-verified on main @ 1169a1d0, 18 Sep 2026.

D2 · CRITICAL

Nobody who could act on the verdict is shown it

The CSP app hides the proof section once the card is RESOLVED. The Technician app navigates away before the result exists. Quality OS receives RESOLVED / SYSTEM_VERIFIED regardless, so the score is unaffected. The customer gets a “resolved” push. The only surface that renders the verdict is a Kapture comment, read by a CC agent who is not working the ticket.

A CSP who closes 60% of their tickets on a dead line has never once been told.

RestoreDrilldownContent.kt:284 · RestoreDrilldownViewModel.kt:176–198 · ResolutionFinalizer.java:120–146

D3 · DESIGN DRIFT

The visual spec’s proof gate was never built

Restore Flow Visual Spec v1.1 §3.4: “Resolve enabled when ≥1 proof shows ✓. Server validates independently — CSP self-declaration alone is not proof.” The §7 state table says the Resolve CTA is “disabled until proof”.

Shipped: ctaEnabled is gated only on an executor being assigned. The server does send a resolve_enabled flag — the app parses it into the model and then never uses it on that CTA. The proof is the close, rather than the gate on it. This inversion is the mechanism behind D1.

RestoreDrilldownContent.kt:481–485 · RestoreApiService.kt:265 · docs/features/restore-flow/Restore_Flow_Visual_Spec_v1.1.md §3.4, §7

D4 · CRITICAL

The customer-verification branch is dead in three independent places

(a) TRIGGER_CUSTOMER_VERIFICATION is still offered to CSPs on IN_PROGRESS. It posts to /srs/api/complaints/{id}/verification/customer — an endpoint SRS does not expose (zero occurrences of the string anywhere in SRS). The 404 is swallowed by a try/catch. The candidate has already moved to AWAITING_VERIFICATION with allowed_actions = [], so the CSP is left holding a dead card until an unrelated resolution rescues it.

(b) POST /srs/api/tasks/{taskId}/resolution is the only code path that ever sets PENDING_VERIFICATION and arms the P98 telemetry and P97 customer-verification timers. It has no production caller — only an integration test.

(c) POST /srs/api/complaints/{id}/verification-response is the only place a customer yes/no could land. It has no caller anywhere in the estate, and it guards on PENDING_VERIFICATION — which, by (b), is never set.

Together these explain the structural zero. The two-step verification ladder described in SR OS exists in code and is unreachable from any live path.

HttpSrOsAdapter.java:46 · ComplaintController.java (no /verification/customer route) · ComplaintTaskServiceImpl.java:120 · ComplaintServiceImpl.java:595–633

D5 · DATA

The rating cannot be attributed to a ticket, and nothing asks for it

Behaviourally the rating is ticket-driven (79% within 24h of a resolve; 3.5% on a 7-day placebo). Structurally it carries only connection_id. Adding ticket_id (or complaint_id) to the CUSTOMER_RATING_SUBMITTED contract would make per-ticket CSAT exact, and costs one field.

And the 8.2% coverage has a cause, not just a level. The resolution push is sent with a null payload — no deeplink, no ticket context, no rating prompt. The only message carrying a link is the SMS, which fires only when the push did not. Nothing in the flow asks the customer to rate anything.CustomerFunctions.java:994, 1002–1009

Until then, any per-ticket CSAT number is a reconstruction — say so when quoting it, and note the ambiguity on connections with multiple same-day resolutions.

D7 · CRITICAL · NEW

The PayG migration silently switched apology coupons off

Coverage went from 55.8% of resolved Internet Issues tickets (Feb 2026) to 2.3% (Sep), and the cause is specific: of 36,284 PayG-migrated accounts, 11,633 got an apology coupon before migration and 0 got one after. The same accounts still receive PayG-native coupons in volume, so the pipeline is healthy — it is the legacy coupon family (apologyCoupon, reward, half_reward, festival, trigger) that has no PayG equivalent.

So the goodwill payment Wiom makes when it fixes a fault slowly has been quietly withdrawn from every migrated customer, and the CS guide still promises it. Two decisions are owed: whether PayG should carry an apology mechanism at all, and what CS should be told in the meantime.

DYNAMODB.COUPON_INSTANCE, blueprints 54–57 · verified filter-free, against the full Wiom resolved base, with the account-id confound ruled out

D8 · NEW

The apology lands on the dead line too — and shrinks when the close is premature

Of the coupons that carry a ticket id, 29.4% were issued against a closure whose ping said the connection was still down. The customer gets a “resolved” push, an apology for the delay, and no internet, within the same few minutes.

Because the amount scales with time-to-fix (confirmed: median TAT 3.9h / 42.1h / 59.6h / 99.0h across A20–A50), closing early on a dead line pays a smaller apology than the honest slow fix would have. D1 and the coupon ladder compound rather than offset.

D9 · NEW

A plan extension is announced to the customer and never granted

Each resolution bucket carries an extraInternetInHours benefit alongside the coupon, and the customer is told about it — the number is passed straight into the push body under templates named APOLOGY_EXTEND_PLAN_NOTIFICATION and APOLOGY_COUPON_WITH_EXTEND_PLAN_NOTIFICATION. The call that would actually grant it, extendPlanIfEligible(...), is commented out; the notification still fires unconditionally.

I could not read the template copy, so I cannot quote what the customer is told — but the parameter passed is the granted-hours figure and the templates are named for an extension. Pull those two bodies; if they say what their names suggest, this is a live false promise on a customer-facing push.

customer-java TicketService.java:141–151, 251–266, 268–284

D6 · KNOWN, RE-VERIFIED

Closure still syncs one way only

CSP → Kapture works well. Kapture → SRS does not exist: SrsClient has exactly two outbound methods, notifyComplaint and notifyReopenComplaint. There is no close, resolve or cancel call. A ticket closed by an agent in Kapture stays open on the CSP’s card with its SRS clock running.

ticket-service-java @ d14ad5f, consistent with F-18 (27 Aug). The separate Kapture-closure-sync study quantified this at 12.96% of tickets and ~1,078 wasted dispatches/month.

10

What this opens up

Not recommendations — the options the evidence puts on the table, roughly in order of how little they cost.

Worth saying plainly

This is not a broken system. The Kapture sync is careful, the state machine is sound, the events are well-shaped, and the CVS check itself works — it is right about the line often enough to predict reopens at 3.4×. The gap is a single missing wire: the verdict is produced and never consumed. Everything above follows from that one omission.

Definitions used. Universe = CSP_ACCOUNT where _FIVETRAN_ACTIVE and STATUS='ACTIVE' and PARTNER_ID IS NOT NULL. “Resolved” = an SRS complaint with a non-null RESOLVED_AT, joined 1:1 by COMPLAINT_ID to its TAS candidate at STATE='COMPLETED'; TASK_FAMILY='RESTORE' and PRIMARY_CLASS='SERVICE_ISSUE' unless stated otherwise. Ping state = RESTORE_EXECUTION_CANDIDATES.SYSTEM_VERIFICATION_RESULT. Volume window 22 Aug – 20 Sep 2026, dates IST.

Reopen is the CSP Metric Tracker’s definition, used unmodified — st_reopen_rate (refresh_workflows.py:4338), the query behind Service Tickets → Efficiency. Grain = Kapture TICKET_ID; cohort anchored on SERVICE_TICKET_MODEL.TICKET_ADDED_TIME (IST, +330 min) across the six LAST_TITLE families it covers; universe joined on PARTNER_ID vs COALESCE(CURRENT_PARTNER_ACCOUNT_ID, LCO_ACCOUNT_ID); reopened = COUNT(DISTINCT COMPLAINT_ID) > 1 on the ticket, with no time bound; rate = reopened ÷ STM tickets. Reopen cohort = M-1 (August 2026), the tracker’s own bucket. The sql/st_reopen_funnel.sql file in the same repo is a different, unrendered variant (SRS-grain denominator, IS_REOPEN-based numerator) and returns different numbers — it is not what the dashboard shows.

Not established. The customer-app repo was not available, so its screens are inferred from the contracts it produces rather than read directly. The CSP-concentration figure covers only CSPs with ≥10 checked resolutions in the window (677 of them). A suspected duplicate customer-notification path on the ResolveTicket flow was tested against DYNAMODB.TICKET_COMMENTS and not supported (89.7% of tickets carry exactly one resolve comment), so it is not reported as a finding — note that table stops replicating on 7 Jul 2026, so the test ran on a June–July cohort.