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.
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'.
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.
One tap, eleven hops, a couple of seconds. Every step below is a code path on the current main, not a spec.
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.
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:181POST /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:131POST /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:37NO_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–529resolutionFinalizer.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–148COMPLAINT_RESOLVEDTAS completes the candidate: state = COMPLETED, reason COMPLAINT_RESOLVED, all timers cancelled, emits ES_RESTORE_CANDIDATE_COMPLETED.restore/InboundEventProcessingServiceImpl.java · RestoreCandidateServiceImpl.java:286COMPLAINT_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:85POST /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:1763CUSTOMER_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–849restore_task_completedFanned out to CSP roles only — OWNER / MANAGER_PLUS / MANAGER / TECHNICIAN. csp-notification-service has no customer audience at all.EventRoleMappingProperties.java:35–76TerminalStateGate 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.
| Actor | Closures | Share | What it is |
|---|---|---|---|
| CSP (owner) | 27,320 | 57.3% | JWT role OWNER — the partner themself |
| Technician | 9,929 | 20.8% | JWT role TECHNICIAN — Technician App |
| Other CSP role | 9,478 | 19.9% | Any other role on the CSP account (manager, co-owner) |
| No actor | 931 | 2.0% | No JWT — closed by a system path, e.g. platform takeover |
| Total | 47,658 | 100% |
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.
| Verdict at closure | Tickets | % of all | % of checks that ran | Meaning |
|---|---|---|---|---|
| PASS — line up | 27,270 | 57.2% | 64.2% | CVS saw the connection alive |
| FAIL — line down | 14,390 | 30.2% | 33.9% | CVS saw the connection still down, at high confidence |
| UNAVAILABLE | 821 | 1.7% | 1.9% | CVS unreachable, or a NO at low confidence |
| NOT_RUN | 5,177 | 10.9% | — | Subtype out of CVS scope — never checked at all |
| Total | 47,658 | 100% |
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.
| Subtype | Resolved | PASS | FAIL | Never checked | FAIL % of checked |
|---|---|---|---|---|---|
| NO_INTERNET | 38,157 | 24,970 | 12,642 | 0 | 33.6% |
| RECHARGE_DONE_NO_INTERNET | 2,902 | 1,357 | 1,308 | 0 | 49.1% |
| FREQUENT_DISCONNECTION | 3,473 | 109 | 0 | 3,364 | — |
| SLOW_INTERNET | 869 | 12 | 0 | 857 | — |
| RECHARGE_EXPIRED · TV_CAMERA · OPTICAL_POWER · ADAPTER | 902 | 26 | 0 | 876 | — |
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.)
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 closure | STM tickets | Reopened | Rate | 1× | 2× | 3× | 3+ |
|---|---|---|---|---|---|---|---|
| PASS — line up | 24,286 | 1,293 | 5.3% | 935 | 237 | 79 | 42 |
| FAIL — line down | 12,683 | 2,273 | 17.9% | 1,503 | 488 | 157 | 125 |
| UNAVAILABLE | 1,002 | 153 | 15.3% | 94 | 42 | 14 | 3 |
| NOT_RUN | 3,414 | 435 | 12.7% | 270 | 102 | 32 | 31 |
| No closed candidate | 2,489 | 134 | 5.4% | 110 | 18 | 3 | 3 |
| All — reconciles to the tracker’s M-1 | 43,874 | 4,288 | 9.8% | 2,912 | 887 | 285 | 204 |
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.
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.
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
Almost nothing, and nothing that closes the loop. This is the complete list of customer-side actions in the live system.
| Moment | What the customer can do | Volume | Where it lands |
|---|---|---|---|
| Raise | Call the call centre (Kapture), raise it in customer-app chat, or report to a field executive | ~1,600/day | All three land as intake_channel = CALL_CENTER — SRS cannot tell them apart |
| During | Receive a TICKET_ASSIGNED push. Nothing else — no status surface, no ETA, no reply path | — | CUSTOMER_FIFO |
| At closure | Receive a TICKET_RESOLVED push. It is an announcement. There is no accept, no reject, no “it’s still down” | 1 per closure | CUSTOMER_FIFO |
| Rate | Give a 1–5 star rating in the customer app | 5,332 / 30d | CUSTOMER_RATING_SUBMITTED → Quality OS |
| Confirm the fix | Nothing. There is no reachable path by which a customer can say “yes it’s fixed” or “no it isn’t” | 0 | — |
| Reopen | Call 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 / 22d | New complaint, IS_REOPEN = TRUE |
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.
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.
| Question | Answer |
|---|---|
| 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. |
| Verdict at closure | Resolved | Rated within 24h | Coverage | Avg rating | 1–2 star |
|---|---|---|---|---|---|
| PASS — line up | 26,474 | 1,993 | 7.5% | 4.56 | 8.4% |
| FAIL — line down | 13,950 | 1,255 | 9.0% | 4.51 | 10.2% |
| NOT_RUN | 5,097 | 475 | 9.3% | 4.46 | 11.6% |
| UNAVAILABLE | 782 | 57 | 7.3% | 4.14 | 17.5% |
| All | 46,303 | 3,780 | 8.2% | 4.53 | 9.3% |
The customer is notified on resolution. What they are not given is anywhere to go.
CUSTOMER_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:940TICKET_RESOLVED_NOTIF to the customer’s FCM tokensTokens come from getFcmFromMobile(mobile). No tokens → no push.CustomerFunctions.java:951–956, 992–995sendPushNotification(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:994TICKET_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–1009pushSent = 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–1000So 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.
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.
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 says | Verdict | Evidence |
|---|---|---|
| Worth ₹20 / 30 / 40 / 50, usable once, valid 365 days, comes off the bill | Confirmed | T_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 coupon | Confirmed | Median 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 resolved | Only if the customer has a home-router plan | Peaked 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 coupon | Consistent | Only apologyCoupon instances carry this blueprint set; no other ticket family appears. |
| The customer receives a push notification | Confirmed | Two, 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 code | Buckets 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 code | shouldAssignCoupon 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 |
| Month | Resolved tickets (all Wiom) | of which Internet Issues | Apology coupons | % of Internet Issues resolved |
|---|---|---|---|---|
| Jan 2026 | 95,250 | 63,201 | 29,933 | 47.4% |
| Feb 2026 | 90,729 | 59,364 | 33,144 | 55.8% |
| Mar 2026 | 81,307 | 55,321 | 27,959 | 50.5% |
| Apr 2026 | 82,092 | 59,685 | 21,587 | 36.2% |
| May 2026 | 80,816 | 60,751 | 7,467 | 12.3% |
| Jun 2026 | 72,026 | 51,466 | 238 | 0.5% |
| Jul 2026 | 64,143 | 46,750 | 316 | 0.7% |
| Aug 2026 | 57,812 | 43,181 | 531 | 1.2% |
| Sep 2026 (to 21st) | 37,492 | 30,144 | 699 | 2.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.
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)
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.
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.
| Ping at closure | Coupons | Share | A20 | A30 | A40 | A50 | Face value |
|---|---|---|---|---|---|---|---|
| PASS — line up | 511 | 63.1% | 422 | 45 | 31 | 13 | ₹11,680 |
| FAIL — line down | 238 | 29.4% | 199 | 16 | 12 | 11 | ₹5,490 |
| UNAVAILABLE | 20 | 2.5% | 15 | 1 | 2 | 2 | ₹510 |
| NOT_RUN | 2 | 0.2% | 1 | 1 | 0 | 0 | ₹50 |
| No CSP candidate | 39 | 4.8% | 24 | 4 | 8 | 3 | ₹1,070 |
| All coupons carrying a ticket_id, Aug–Sep | 810 | 100% | 661 | 67 | 53 | 29 | ₹18,800 |
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.
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.
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.
| Aspect | What it is |
|---|---|
| Called by | SRS 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 resolution | CVS maps connection_id → device itself, via asset-custody. SRS never sends a device id. |
| Failure behaviour | Never throws. Any error, timeout or parse failure returns null, which SRS maps to UNAVAILABLE — and closes the ticket anyway. |
| Timeouts | 5s 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 record | CVS 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. |
| Verdict | Confidence | Reason code | Calls | Share | SRS maps it to |
|---|---|---|---|---|---|
| YES | High | — | 26,288 | 63.5% | PASS |
| NO | High | NO_RECENT_PING | 14,335 | 34.6% | FAIL |
| NO | InsufficientData | IDENTITY_UNRESOLVED | 563 | 1.4% | UNAVAILABLE |
| NO | InsufficientData | DEVICE_UNKNOWN | 225 | 0.5% | UNAVAILABLE |
| TIMEOUT | — | — | 0 | 0% | never observed |
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 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:
| Table | Rows | What it carries |
|---|---|---|
PUBLIC.INFLUX_PING_EVENTS_RAW | 1.25bn | Per-ping: deviceid, nasid, lcoaccountid, rxpower, txpower, connectedclientsjson, timestamp |
PUBLIC.HOURLY_DEVICE_PING_INFLUX | 121m | Per 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.
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 | Closures | Silent at closure | Any dropout | Avg optical | Can CVS serve it? |
|---|---|---|---|---|---|
| NO_INTERNET (in scope) | 19,311 | 19.7% | 37.1% | −20.67 | Yes — today |
| RECHARGE_DONE_NO_INTERNET (in scope) | 1,450 | 28.2% | 45.0% | −20.34 | Yes — today |
| RECHARGE_EXPIRED | 243 | 58.4% | 39.6% | −21.73 | Yes — add it |
| OPTICAL_POWER_OUT_OF_RANGE | 42 | 4.8% | 27.5% | −27.73 | Yes, on optical not ping |
| FREQUENT_DISCONNECTION | 1,924 | 4.4% | 14.9% | −20.56 | Only with a time window |
| SLOW_INTERNET | 469 | 5.5% | 12.0% | −20.22 | No signal exists |
| TV_CAMERA · ADAPTER | 165 | 5.4% | 14.5% | −20.99 | Not 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.
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.
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.
| Subtype | Outcome | Tickets | Any dropout in 24h | Silent for 24h | Separation |
|---|---|---|---|---|---|
| FREQUENT_DISCONNECTION | held | 1,432 | 40.2% | 3.8% | 1.27× |
| reopened | 488 | 51.2% | 5.3% | ||
| NO_INTERNET | held | 17,045 | 42.9% | 6.3% | 1.16× / 2.5× on silence |
| reopened | 4,473 | 49.9% | 16.0% | ||
| SLOW_INTERNET | held | 379 | 39.1% | 1.3% | 1.11× |
| reopened | 134 | 43.3% | 4.5% |
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.
wiom-csp-app-apr09 · development · v1.1.99
The app also maps CANCELLED → RESOLVED, so a cancelled candidate reads to the CSP as a successful fix (RestoreModels.kt:36).
Technician-App · development
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
repo not supplied — described from the contracts it produces
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.
the only human who is shown the verdict
csp id: a0b7c3 | Verified: system check passed| Verified: system check did not pass, plus a sub-status of CO or RS.
TicketController.describeVerification:1763A genuinely useful signal, landing on the one surface that cannot act on it — and only after the ticket is already closed.
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.
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
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
(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
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.
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
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.
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
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.
Not recommendations — the options the evidence puts on the table, roughly in order of how little they cost.
system_verification_result; the app already knows how to render a proof step; today it is deliberately hidden on the RESOLVED state. Even as pure feedback with no consequence attached, 37% of CSPs would learn something they have never been told.optical_avg is already computed hourly for every device. This is a second CVS question rather than a wider allowlist — small volume, clean separation, good place to prove the pattern.null — no deeplink, no rating prompt. This is the cheapest lift on the 8.2% rating rate in the whole study, and needs no new message.ticket_id to the rating event. One field; turns a reconstructed 79% into an exact join. The August coupon path already does exactly this — copy it.TRIGGER_CUSTOMER_VERIFICATION. Right now it is a live CTA that 404s silently and strands the card. Either outcome is better than the present one.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.