Skip to content

Strip courtCentre.roomId/roomName for unallocated CROWN hearings#307

Closed
ozturkmenb wants to merge 6 commits into
team/ccsph2from
dev/progression-strip-crown-roomid
Closed

Strip courtCentre.roomId/roomName for unallocated CROWN hearings#307
ozturkmenb wants to merge 6 commits into
team/ccsph2from
dev/progression-strip-crown-roomid

Conversation

@ozturkmenb
Copy link
Copy Markdown
Contributor

Summary

For unallocated CROWN hearings, strips the UI-denormalised courtCentre.roomId/roomName from the incoming progression.command.list-new-hearing payload before any downstream operation in ListHearingRequestedProcessor.handle reads it. This prevents the phantom courtroom from being written to progression.hearing.payload and from appearing in the new-hearing notification email.

Asks the new listing.query.court.schedule.draft.status endpoint on cpp-context-listing (see companion PR https://github.com/hmcts/cpp-context-listing/pull/854) whether any of the booked court-schedule sessions is DRAFT; if yes, the strip fires.

Background

  • Symptom: On team/ccsph2n listing + team/ccsph2 progression, every CROWN SENT_FOR_LISTING / HEARING_INITIALISED hearing in progressionviewstore.hearing.payload has courtCentre.roomId/roomName populated, even though listing's own listing.events.hearing-listed has top-level courtRoomId: null for the same hearing. The CCP2 UI denormalises the slot's courtroom into courtCentre for display; progression copies it verbatim into hearing.payload via ProgressionService.transformHearingListingNeeds:285 and reads it into the notification via sendHearingNotificationsToDefenceAndProsecutor.
  • Listing-side mitigation: HearingEnrichmentOrchestrator.stripRoomInfoIfAnyDraft (SPRDT-858) already strips room info on listing's side — but listing sees the payload after progression has already done its writes.
  • This PR: defence-in-depth in progression — strip at the gate of ListHearingRequestedProcessor.handle so all three downstream operations (listing forward, hearing.payload write, notification email) see the sanitised payload.

Files

  • NEW CourtScheduleQueryAdapter.java — calls listing.query.court.schedule.draft.status via Requester.requestAsAdmin. Goes through listing-context (not directly to listingcourtscheduler) so the cross-context boundary stays clean. Fails-safe by returning anyDraft=true on remote-call exception or malformed response — leaking a phantom courtroom is worse than dropping room info for what may actually be a confirmed-allocated hearing.
  • MODIFIED ListHearingRequestedProcessor.java — adds stripCourtCentreRoomIfAnyDraftSession(...) invoked at the top of handle(...). Filters: jurisdiction=CROWN, bookedSlots non-empty, courtCentre.roomId set. Sanitised ListHearingRequested then flows to all three downstream calls.
  • MODIFIED ListHearingRequestedProcessorTest.java@Mock CourtScheduleQueryAdapter + 4 new strip tests covering: any-draft → strip happens, all-non-draft → no strip, MAGS → adapter not called, CROWN without bookedSlots → adapter not called.
  • NEW CourtScheduleQueryAdapterTest.java — 6 tests covering null/empty input, any-draft, all-non-draft, missing anyDraft key (fail-safe true), exception (fail-safe true).

What is NOT changed

  • No backfill — existing polluted hearing.payload rows on ste are not retroactively scrubbed. The fix is prevention-only; the user explicitly chose this trade-off.
  • No UI change — the source-of-leak in cpp-ui-prosecution-casefile/.../manual-case-details.effects.ts:148-149 is unchanged. Progression now acts as the boundary that catches the bad data.
  • No progression.event.list-hearing-requested event payload sanitisation — events are an immutable log of what arrived from the user; we never rewrite history. Only downstream reads of it are sanitised.

Test plan

  • mvn install -DskipTests -Denforcer.skipMoJLatestInterfacesRule=true on progression-event/progression-event-processor -am — BUILD SUCCESS
  • mvn test -Dtest='CourtScheduleQueryAdapterTest,ListHearingRequestedProcessorTest' — 26 run, 0 failures (20 processor + 6 adapter)
  • CI build green (Sonar quality gate, etc.)
  • After the listing companion PR is merged and deployed to ste, deploy this PR and verify: a new unallocated CROWN hearing has courtCentre: { id, name } only in progression.hearing.payload (no roomId/roomName), and the new-hearing notification email has no courtroom field.

Deploy ordering

  • Listing PR first: must be merged + deployed before this progression PR is deployed, otherwise progression's Requester call to listing.query.court.schedule.draft.status will fail (and adapter will fail-safe to strip — degraded but not incorrect behaviour).
  • Progression PR can be merged independently; Requester dispatches by string action name at runtime, so no compile-time listing.version bump is needed.

For an unallocated CROWN hearing (DRAFT court-schedule session), the CCP2
UI sends progression.command.list-new-hearing with courtCentre.roomId
and courtCentre.roomName populated (denormalised from bookedSlots for
display purposes). Progression today copies that courtCentre verbatim
into progression.hearing.payload via ProgressionService.transform-
HearingListingNeeds, and ListHearingRequestedProcessor.sendHearing-
NotificationsToDefenceAndProsecutor reads courtCentre.getRoomId() into
the new-hearing notification email. The result: the email and the
persisted hearing snapshot both name a phantom courtroom the hearing
is not actually allocated to.

Listing already strips this on its side in HearingEnrichmentOrchestra-
tor.stripRoomInfoIfAnyDraft (SPRDT-858), but progression observes the
payload first and would otherwise leak it before listing sees it.

This change adds defence-in-depth at the top of ListHearingRequested-
Processor.handle: if jurisdiction is CROWN, bookedSlots is non-empty,
and courtCentre.roomId is set, we call listing-query-api's new
listing.query.court.schedule.draft.status endpoint via Requester to
ask whether any of the booked sessions is DRAFT. If yes, we replace
listHearingRequested with a sanitised clone whose courtCentre has
null roomId and null roomName. All three downstream operations
(listing forward, hearing.payload write, notification) then operate
on the sanitised view.

Cross-context boundary: progression talks to listing-query-api over
the JJ messaging bus via Requester, never directly to listing-court-
scheduler. CourtScheduleQueryAdapter fails-safe by reporting
anyDraft=true if the remote call throws or returns a malformed
payload - leaking a phantom courtroom is worse than dropping room
info for what may be a confirmed-allocated hearing.

NB: requires listing to publish the listing.query.court.schedule.
draft.status endpoint at runtime. The corresponding listing PR adds
that endpoint to listing-query-api on team/ccsph2n; this progression
PR can merge independently because the call is dispatched by string
action name at runtime - compile-time RAML knowledge isn't required.
At deploy time, the listing service must be running the post-PR
listing version.
@ozturkmenb ozturkmenb requested a review from a team as a code owner May 19, 2026 02:19
@ozturkmenb ozturkmenb requested review from VenkataNRChalla, newmaldenite and sarathdrhmcts and removed request for a team May 19, 2026 02:19
@cpp-github-management
Copy link
Copy Markdown

Passed

The Weld CDI container needs a component qualifier on the Requester
field to pick the right RequesterProducer-produced instance. Without
it the deploy fails with:

  java.lang.IllegalStateException: No annotation found to define
  component for class uk.gov.moj.cpp.progression.service.CourtScheduleQueryAdapter
    at ComponentNameExtractor.componentFromComponentAnnotation
    at RequesterProducer.produceRequester

Matches the same pattern used by ProgressionService.requester and
ListingService.sender — qualifier annotation on the field, no class
level @servicecomponent needed (CourtScheduleQueryAdapter is a plain
helper, not a JAX-RS resource or @Handles processor).

Unit tests bypass Weld entirely (Mockito @Injectmocks uses field
injection without container validation), which is why this only
surfaced at deploy time in ns-ste-ccm-72.
@cpp-github-management
Copy link
Copy Markdown

Passed

… error)

The original fail-safe returned anyDraft=true when the listing-query call
threw, came back null, or returned a malformed payload. That makes the
"treat as draft → strip room info" path the default during any listing-side
outage - which strips the courtroom from ALLOCATED CROWN hearings too.
Observed in ns-ste-ccm-72: every CROWN-with-bookedSlots hearing lost its
courtCentre.roomId in the notification email because the listing PR
(#854) endpoint was not yet deployed there, so every Requester call hit
the catch-block and triggered the strip.

Flip the direction: on error / null / malformed response, return false
(fail-open). The reasoning is asymmetric:

  - Allocated CROWN (the common case): preserve room info always,
    even when listing-query is unreachable. Notification email and
    hearing.payload keep the correct courtroom.

  - Unallocated CROWN (the case we are guarding against): strip room
    info only when we can prove the session is draft. During a
    listing-query outage the original unallocated-leak resumes - which
    is exactly the same behaviour as before the strip was added, so a
    regression to a known-bad-but-recoverable state rather than a new
    silent corruption of allocated hearings.

The WARN logs still fire so persistent failures are visible.

Test changes:
  - Renamed two adapter tests from failsSafeToTrue* to failsOpenToFalse*
    and flipped assertions.
  - Added a third adapter test for the requestAsAdmin-returns-null path
    (the Requester contract permits null on some dispatch failures).
  - Tightened the processor no-strip test to assert exact roomId/roomName
    preservation rather than the toString().isEmpty() check, which was
    trivially true for any UUID and would have passed even if the strip
    leaked into the allocated path.
Black-box IT for the unallocated-CROWN courtCentre.roomId/roomName strip
behaviour. Exercises the full chain from
progression.list-new-hearing through to the body forwarded to
listing-command:

  - shouldStripCourtCentreRoomWhenListingDraftStatusReportsDraft
      Stubs listing-query-api's court-schedule-draft-status to return
      {"anyDraft":true} and verifies the body sent on to
      listing-command has courtCentre with roomId/roomName stripped.
      This is the unallocated CROWN scenario the user hit on
      ns-ste-ccm-72.

  - shouldPreserveCourtCentreRoomWhenListingDraftStatusReportsNonDraft
      Stubs listing-query-api to return {"anyDraft":false} and verifies
      the body sent on to listing-command keeps courtCentre.roomId
      intact. This is the allocated CROWN regression we hit when the
      adapter was failing-closed to anyDraft=true.

Verification approach: we inspect the body of the outgoing
listing.command.list-court-hearing request captured by WireMock,
rather than polling progression's hearing query API. The
ListNewHearingHandler picks a random hearingId for new (non-related)
hearings, so polling by ID would require coupling to that random
value. Inspecting the captured downstream request body is direct.

Stub additions in ListingStub:
  - stubCourtScheduleDraftStatusReturnsDraft / ReturnsNonDraft
    stub the new /listing-service/query/api/rest/listing/courtScheduleDraftStatus
    endpoint to return {"anyDraft":true} or {"anyDraft":false} respectively.
@cpp-github-management
Copy link
Copy Markdown

Passed

Request shape sent to listing.query.court.schedule.draft.status:
  Before: {"courtScheduleIdList": [{"courtScheduleId": "<uuid>"}, ...]}
  After:  {"courtScheduleIdList": ["<uuid>", "<uuid>", ...]}

Mirrors the listing-side schema simplification - every element is a UUID
so the per-entry object wrapper added no information. The adapter now
appends each id as a JSON string directly.

Two new unit tests added that capture the JSON sent to the enveloper and
assert (a) the array contains UUID strings (not wrapper objects) and
(b) the order is preserved. These pin the wire contract so a regression
to the wrapped form would fail loudly here rather than at runtime in
ns-ste-ccm-72.

Removes the now-unused COURT_SCHEDULE_ID constant.
@cpp-github-management
Copy link
Copy Markdown

Passed

@ozturkmenb
Copy link
Copy Markdown
Contributor Author

Superseded by source-side filtering in cpp-context-listing-courtscheduler#828: courtscheduler now strips courtRoomId/Name/Number from draft sessions, and the cpp-ui-prosecution-casefile crown-scheduling flow reads room straight from the slot response — so the list-new-hearing payload is cleaned end-to-end at source. This progression-side event-processor strip is therefore redundant. See ADR-005. Strip commits live only on this unmerged branch; team/ccsph2 is unaffected (nothing to revert).

@ozturkmenb ozturkmenb closed this May 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant