Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save jclyne/eb39c30e710dadf2c499ac8d99c97f82 to your computer and use it in GitHub Desktop.

Select an option

Save jclyne/eb39c30e710dadf2c499ac8d99c97f82 to your computer and use it in GitHub Desktop.

Blocker Response Sealed Class Redesign

Overview

Redesign the blocker response system to use a sealed class hierarchy that clearly separates agent-friendly blockers (handled conversationally by the LLM) from form-required blockers (rendered as native UI by the client). Replace the flat BlockerInfo entity and StructuredBlockerResponse DTO with a two-tier sealed class: a service-layer BlockerResponse and a transport-layer McpBlockerResponse. Rename submit_blocker to submit_blocker_data with a new input model supporting ACCEPT, DECLINE, and CANCEL actions.

Current State Analysis

What Exists

  • BlockerInfo (service entity) — flat data class with requiresForm, isSensitive, displayMessage, etc.
  • BlockerResponseResolver (transport) — maps BlockerInfoStructuredBlockerResponse, generates LLM instructions
  • StructuredBlockerResponse (transport DTO) — flat @Serializable with requiresForm boolean
  • SubmitBlockerTool — accepts blocker_type + optional blocker_input JsonObject
  • FlowResult.BlockerRequired holds a BlockerInfo

Problems

  • requiresForm is a negative concept — agentFriendly better expresses intent
  • No structured schema for expected input data — LLM relies on text instructions
  • No way for LLM to cancel or decline a blocker
  • BlockerInfo conflates metadata for two fundamentally different paths (UI rendering vs. conversational)
  • The transport layer (BlockerResponseResolver) makes decisions that belong in the adapter layer

Key Discoveries

  • generateJsonSchema<T>() is public in misk-mcp at misk.mcp.GenerateJsonSchema.kt:18 (user made public)
  • blocker_action_id doesn't exist in codebase; using action_id defaulting to "action"
  • Request ID format: "{flow_token}:{blocker_descriptor_id}:{action_id}"
  • ArchUnit blocks com.squareup.protos.* in service layer — use okio.ByteString for raw proto bytes
  • BlockerType.requiresForm() and isSensitive() helper functions in ProtoExtensions.kt remain useful internally

Desired End State

After this change:

  1. BlockerResponse sealed class in the service layer replaces BlockerInfo
  2. FlowResult.BlockerRequired holds a BlockerResponse instead of BlockerInfo
  3. Agent-friendly blockers return a DataRequestSchema type; the transport layer generates JSON schemas from it
  4. Form-required blockers pass through raw BlockerDescriptor bytes for client rendering
  5. submit_blocker_data tool accepts request_id, data, and action (ACCEPT/DECLINE/CANCEL)
  6. BlockerResponseResolver and StructuredBlockerResponse are removed
  7. BlockerInfo is removed (fully absorbed into sealed class)
  8. Only PASSCODE_VERIFICATION is supported as an inline blocker type (others error)
  9. FORM_INFORMATIONAL is supported as an inline acknowledgment (null schema)
  10. Session stores blockerType for use during submission

Verification

  • bin/gradle -p moneybot-core/service test --warn passes
  • bin/gradle -p moneybot-core/service build --warn passes
  • ArchUnit tests pass (no proto imports in service layer)

What We're NOT Doing

  • Not adding inline support for blocker types other than PASSCODE_VERIFICATION and FORM_INFORMATIONAL
  • Not changing the enable_card / disable_card tool interfaces
  • Not modifying Plasma client calls or proto definitions (only moneybot-core protos)
  • Not implementing blocker chaining for FormRequired (client owns that flow)
  • Not adding multi-blocker support (ui_form still uses firstOrNull())

Implementation Approach

Work bottom-up through the hexagonal layers: service entities first, then adapter, then storage, then interactors, then transport. This ensures each layer compiles before dependents change.


Phase 1: Service Layer — New Entities

Overview

Create the new sealed interface hierarchy, action enum, DataRequestSchema sealed interface, and passcode request data class. Update FlowResult. Delete BlockerInfo.

Changes Required

1. Create BlockerResponse sealed class

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/service/entity/BlockerResponse.kt (NEW)

package com.squareup.cash.moneybotcore.service.entity

import okio.ByteString
import kotlin.reflect.KClass

/** Response from a Plasma flow that requires user interaction. */
sealed interface BlockerResponse {
  /** The blocker descriptor ID from Plasma. */
  val blockerDescriptorId: String

  /** The classified blocker type. */
  val blockerType: BlockerType

  /** Whether this blocker can be handled conversationally by an LLM agent. */
  val agentFriendly: Boolean

  /**
   * Blocker that requires native UI rendering. The client receives the raw BlockerDescriptor
   * bytes and submits the response directly to Plasma (not through MCP).
   */
  data class FormRequired(
    override val blockerDescriptorId: String,
    override val blockerType: BlockerType,
    val blockerDescriptorBytes: ByteString,
  ) : BlockerResponse {
    override val agentFriendly: Boolean = false
  }

  /**
   * Blocker that can be handled conversationally by an LLM agent. Contains a message to
   * communicate to the customer and a reference to the DataRequestSchema type describing
   * the expected response data. The transport layer generates JSON schemas from the type
   * and deserializes incoming data into it.
   */
  data class InlineDataRequest(
    override val blockerDescriptorId: String,
    override val blockerType: BlockerType,
    val requestId: String,
    val message: String,
    val dataRequestType: KClass<out DataRequestSchema>?,
    val isSensitive: Boolean,
  ) : BlockerResponse {
    override val agentFriendly: Boolean = true
  }
}

2. Create DataRequestSchema sealed interface

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/service/entity/DataRequestSchema.kt (NEW)

package com.squareup.cash.moneybotcore.service.entity

/**
 * Sealed interface for data request types that can be handled conversationally by an LLM agent.
 * Each implementation defines the shape of data expected from the agent. The transport layer
 * generates JSON schemas from these types and deserializes incoming data into them.
 */
sealed interface DataRequestSchema

3. Create BlockerDataAction enum

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/service/entity/BlockerDataAction.kt (NEW)

package com.squareup.cash.moneybotcore.service.entity

/** Action the LLM agent takes in response to an inline data request. */
enum class BlockerDataAction {
  /** Submit the collected data to continue the flow. */
  ACCEPT,

  /** Decline the request. Skips optional fields; fails the flow for required fields. */
  DECLINE,

  /** Cancel the entire flow. */
  CANCEL,
}

4. Create PasscodeVerificationRequest data class

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/service/entity/PasscodeVerificationRequest.kt (NEW)

package com.squareup.cash.moneybotcore.service.entity

import kotlinx.serialization.Serializable
import misk.mcp.Description

/** Expected data format for passcode verification inline requests. */
@Serializable
data class PasscodeVerificationRequest(
  @Description("The customer's 4-digit Cash App passcode")
  val passcode: String,
) : DataRequestSchema

5. Update FlowResult.BlockerRequired

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/service/entity/FlowResult.kt

Change blockerInfo: BlockerInfo to blockerResponse: BlockerResponse:

sealed class FlowResult {
  data class Success(val flowToken: String) : FlowResult()
  data class BlockerRequired(val flowToken: String, val blockerResponse: BlockerResponse) : FlowResult()
  data class Error(val type: String, val message: String) : FlowResult()
}

6. Delete BlockerInfo

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/service/entity/BlockerInfo.kt (DELETE)

All fields are absorbed into BlockerResponse variants:

  • blockerIdblockerDescriptorId
  • blockerTypeblockerType
  • displayMessageInlineDataRequest.message
  • hasInteractiveElements → internal to adapter only
  • requiresForm → inverted to agentFriendly, implicit in sealed variant
  • isSensitiveInlineDataRequest.isSensitive

Success Criteria

Automated Verification:

  • Service layer compiles (expect compilation errors in adapter/transport/tests — that's OK for this phase, they're fixed in later phases)

Phase 2: Adapter Layer — PlasmaFlowAdapter & ProtoExtensions

Overview

Update the adapter to create BlockerResponse variants instead of BlockerInfo. The adapter now decides whether a blocker is agent-friendly based on BlockerType and sets the DataRequestSchema type reference. No schema generation here — that's the transport layer's job.

Changes Required

1. Update ProtoExtensions.kt

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/adapter/plasma/ProtoExtensions.kt

Replace toBlockerInfo() with toBlockerResponse(flowToken):

import com.squareup.cash.moneybotcore.service.entity.BlockerResponse
import com.squareup.cash.moneybotcore.service.entity.BlockerType
import com.squareup.cash.moneybotcore.service.entity.FlowType
import com.squareup.cash.moneybotcore.service.entity.PasscodeVerificationRequest
import com.squareup.protos.cash.plasma.flows.Flow
import com.squareup.protos.franklin.common.scenarios.BlockerDescriptor

/** Converts a FlowType entity to its corresponding Plasma Flow.Type proto. */
fun FlowType.toProto(): Flow.Type =
  when (this) {
    FlowType.ENABLE_CARD -> Flow.Type.ENABLE_ISSUED_CARD
    FlowType.DISABLE_CARD -> Flow.Type.DISABLE_ISSUED_CARD_IN_POSTCARD
  }

/** Converts a Plasma BlockerDescriptor proto to a BlockerResponse entity. */
fun BlockerDescriptor.toBlockerResponse(flowToken: String): BlockerResponse {
  val hasInteractiveElements = hasInteractiveElements()
  val blockerType = determineBlockerType(hasInteractiveElements)
  val descriptorId = id ?: "unknown"
  val requestId = "$flowToken:$descriptorId:action"

  return if (blockerType.isAgentFriendly()) {
    when (blockerType) {
      BlockerType.PASSCODE_VERIFICATION -> BlockerResponse.InlineDataRequest(
        blockerDescriptorId = descriptorId,
        blockerType = blockerType,
        requestId = requestId,
        message = "This operation requires your Cash App passcode for verification.",
        dataRequestType = PasscodeVerificationRequest::class,
        isSensitive = true,
      )
      BlockerType.FORM_INFORMATIONAL -> BlockerResponse.InlineDataRequest(
        blockerDescriptorId = descriptorId,
        blockerType = blockerType,
        requestId = requestId,
        message = extractDisplayMessage() ?: "Please acknowledge to continue.",
        dataRequestType = null,
        isSensitive = false,
      )
      else -> throw IllegalStateException("Unsupported agent-friendly blocker type: $blockerType")
    }
  } else {
    BlockerResponse.FormRequired(
      blockerDescriptorId = descriptorId,
      blockerType = blockerType,
      blockerDescriptorBytes = BlockerDescriptor.ADAPTER.encode(this),
    )
  }
}

Note: The adapter no longer imports misk.mcp.generateJsonSchema. It sets the DataRequestSchema KClass reference (PasscodeVerificationRequest::class), and the transport layer is responsible for generating JSON schemas from it.

Rename requiresForm() to isAgentFriendly() with inverted logic:

/** Determines if a blocker type can be handled conversationally by an LLM agent. */
private fun BlockerType.isAgentFriendly(): Boolean =
  when (this) {
    BlockerType.PASSCODE_VERIFICATION,
    BlockerType.FORM_INFORMATIONAL -> true
    else -> false
  }

Keep isSensitive(), determineBlockerType(), hasInteractiveElements(), and extractDisplayMessage() as private helpers — they're still used internally.

2. Update PlasmaFlowAdapter.kt

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/adapter/plasma/PlasmaFlowAdapter.kt

Update toFlowResult() private functions to use toBlockerResponse(flowToken):

private fun CreateFlowResponse.toFlowResult(flowToken: String): FlowResult {
  val blockerDescriptor = ui_flow?.extractBlockerDescriptor()
  return if (blockerDescriptor != null) {
    FlowResult.BlockerRequired(flowToken = flowToken, blockerResponse = blockerDescriptor.toBlockerResponse(flowToken))
  } else {
    FlowResult.Success(flowToken = flowToken)
  }
}

private fun SubmitBlockerInputResponse.toFlowResult(flowToken: String): FlowResult {
  val blockerDescriptor =
    ui_flow?.extractBlockerDescriptor() ?: response_context?.scenario_plan?.blocker_descriptors?.firstOrNull()
  return if (blockerDescriptor != null) {
    FlowResult.BlockerRequired(flowToken = flowToken, blockerResponse = blockerDescriptor.toBlockerResponse(flowToken))
  } else {
    FlowResult.Success(flowToken = flowToken)
  }
}

3. Update FakePlasmaModule

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/adapter/plasma/FakePlasmaModule.kt

Update FakeFlowAdapterRepository to return BlockerResponse in FlowResult:

class FakeFlowAdapterRepository @Inject constructor() : FlowAdapterRepository {
  override suspend fun startFlow(...): FlowResult =
    FlowResult.Success(flowToken = UUID.randomUUID().toString())

  override suspend fun submitBlocker(...): FlowResult =
    FlowResult.Success(flowToken = flowToken)
}

(No change needed here since fake already returns Success which doesn't use BlockerResponse.)

Success Criteria

Automated Verification:

  • Adapter layer compiles with new entity types

Phase 3: Session Storage — Add blockerType

Overview

Add blockerType to the flow session so the SubmitBlockerInteractor knows what type of blocker it's processing without needing it in the tool input.

Changes Required

1. Update FlowSessionRepository interface

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/service/repository/FlowSessionRepository.kt

Add blockerType: BlockerType parameter to saveFlowSession and FlowSessionData:

suspend fun saveFlowSession(
  sessionId: String,
  flowToken: String,
  flowType: FlowType,
  customerToken: CustomerToken,
  blockerDescriptorId: String,
  blockerType: BlockerType,
)

data class FlowSessionData(
  val sessionId: String,
  val flowToken: String,
  val flowType: FlowType,
  val customerToken: CustomerToken,
  val createdAt: Long,
  val blockerDescriptorId: String,
  val blockerType: BlockerType,
)

2. Update DyFlowSessionItem

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/adapter/dynamodb/DyFlowSessionItem.kt

Add blockerType attribute:

@get:DynamoDbAttribute(ATTR_BLOCKER_TYPE) var blockerType: String? = null

// In companion:
const val ATTR_BLOCKER_TYPE = "blocker_type"

// In toFlowSessionData():
blockerType = BlockerType.valueOf(blockerType!!)

// In from():
this.blockerType = blockerType.name

Update the from() factory to accept blockerType: BlockerType.

3. Update DynamoFlowSessionAdapter

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/adapter/dynamodb/DynamoFlowSessionAdapter.kt

Add blockerType parameter to saveFlowSession() and pass it through to DyFlowSessionItem.from().

4. Update flow session protos

File: protos/src/main/proto/squareup/cash/moneybotcore/api/v1beta1/flow_session_messages.proto

Add blocker_type field:

message SaveFlowSessionRequest {
  optional string session_id = 1;
  optional string flow_token = 2;
  optional string flow_type = 3;
  optional string customer_token = 4;
  optional string blocker_descriptor_id = 5;
  optional string blocker_type = 6;
}

message GetFlowSessionResponse {
  optional string session_id = 1;
  optional string flow_token = 2;
  optional string flow_type = 3;
  optional string customer_token = 4;
  optional int64 created_at = 5;
  optional string blocker_descriptor_id = 6;
  optional string blocker_type = 7;
}

5. Update gRPC actions

Files:

  • transport/grpc/flowsession/SaveFlowSessionGrpcAction.kt — pass blocker_type through
  • transport/grpc/flowsession/GetFlowSessionGrpcAction.kt — include blocker_type in response

6. Update SaveFlowSessionInteractor

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/service/interactor/SaveFlowSessionInteractor.kt

Add blockerType parameter to execute().

Success Criteria

Automated Verification:

  • Storage layer compiles with new field
  • Proto generates successfully

Phase 4: Interactor Updates

Overview

Update the card interactors to save blockerType in the session. Only save sessions for InlineDataRequest blockers (not FormRequired). Redesign SubmitBlockerInteractor to handle actions.

Changes Required

1. Update EnableCardInteractor

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/service/interactor/EnableCardInteractor.kt

when (result) {
  is FlowResult.BlockerRequired -> {
    when (val response = result.blockerResponse) {
      is BlockerResponse.InlineDataRequest -> {
        flowSessionRepository.saveFlowSession(
          sessionId = sessionId,
          flowToken = result.flowToken,
          flowType = FlowType.ENABLE_CARD,
          customerToken = customerToken,
          blockerDescriptorId = response.blockerDescriptorId,
          blockerType = response.blockerType,
        )
      }
      is BlockerResponse.FormRequired -> {
        // No session management — client handles submission directly to Plasma
      }
    }
  }
  is FlowResult.Success -> flowSessionRepository.deleteFlowSession(sessionId)
  is FlowResult.Error -> { /* No session management on error */ }
}

2. Update DisableCardInteractor

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/service/interactor/DisableCardInteractor.kt

Same pattern as EnableCardInteractor.

3. Redesign SubmitBlockerInteractor

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/service/interactor/SubmitBlockerInteractor.kt

New signature:

suspend fun execute(
  sessionId: String,
  customerToken: CustomerToken,
  requestId: String,
  data: JsonObject?,
  action: BlockerDataAction,
  forwardedCallContext: ForwardedCallContext,
): FlowResult

Implementation:

suspend fun execute(
  sessionId: String,
  customerToken: CustomerToken,
  requestId: String,
  data: JsonObject?,
  action: BlockerDataAction,
  forwardedCallContext: ForwardedCallContext,
): FlowResult {
  val flowSession =
    flowSessionRepository.getFlowSession(sessionId)
      ?: return FlowResult.Error(
        type = "FlowSessionNotFound",
        message = "No active flow found for this session. Please start a new operation.",
      )

  if (flowSession.customerToken.token != customerToken.token) {
    return FlowResult.Error(
      type = "CustomerTokenMismatch",
      message = "The customer token does not match the flow session.",
    )
  }

  // Validate requestId matches the session
  val expectedRequestId = "${flowSession.flowToken}:${flowSession.blockerDescriptorId}:action"
  if (requestId != expectedRequestId) {
    return FlowResult.Error(
      type = "RequestIdMismatch",
      message = "The request ID does not match the current blocker.",
    )
  }

  return when (action) {
    BlockerDataAction.CANCEL -> {
      flowSessionRepository.deleteFlowSession(sessionId)
      FlowResult.Error(type = "FlowCancelled", message = "The operation was cancelled by the customer.")
    }

    BlockerDataAction.DECLINE -> {
      // For now, all supported inline blocker types have required data.
      // Decline fails the flow.
      flowSessionRepository.deleteFlowSession(sessionId)
      FlowResult.Error(type = "FlowDeclined", message = "The required verification was declined.")
    }

    BlockerDataAction.ACCEPT -> {
      val result =
        flowAdapterRepository.submitBlocker(
          flowToken = flowSession.flowToken,
          customerToken = customerToken,
          blockerDescriptorId = flowSession.blockerDescriptorId,
          blockerType = flowSession.blockerType.name,
          blockerInput = data,
          forwardedCallContext = forwardedCallContext,
        )

      when (result) {
        is FlowResult.BlockerRequired -> {
          when (val response = result.blockerResponse) {
            is BlockerResponse.InlineDataRequest -> {
              flowSessionRepository.saveFlowSession(
                sessionId = sessionId,
                flowToken = flowSession.flowToken,
                flowType = flowSession.flowType,
                customerToken = customerToken,
                blockerDescriptorId = response.blockerDescriptorId,
                blockerType = response.blockerType,
              )
            }
            is BlockerResponse.FormRequired -> {
              // Hand off to client
            }
          }
        }
        is FlowResult.Success -> flowSessionRepository.deleteFlowSession(sessionId)
        is FlowResult.Error -> { /* Keep session for retry */ }
      }

      result
    }
  }
}

4. Update FlowAdapterRepository interface

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/service/repository/FlowAdapterRepository.kt

No signature change needed — submitBlocker already takes blockerType: String and blockerInput: JsonObject?.

Success Criteria

Automated Verification:

  • All interactors compile with new sealed class handling

Phase 5: Transport Layer — Tools & Response DTOs

Overview

Create the transport-layer sealed class. The transport layer is responsible for generating JSON schemas from DataRequestSchema types and deserializing incoming data back into them. Rename and redesign the submit tool. Update card tools. Remove BlockerResponseResolver and StructuredBlockerResponse.

Changes Required

1. Create McpBlockerResponse sealed class

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/transport/mcp/plasma_adapter/McpBlockerResponse.kt (NEW)

package com.squareup.cash.moneybotcore.transport.mcp.plasma_adapter

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject

/** MCP transport representation of a blocker response. */
@Serializable
sealed class McpBlockerResponse {
  /** Blocker that requires native UI rendering by the client. */
  @Serializable
  @SerialName("form_required")
  data class FormRequired(
    @SerialName("blocker_descriptor_id") val blockerDescriptorId: String,
    @SerialName("blocker_type") val blockerType: String,
    @SerialName("agent_friendly") val agentFriendly: Boolean = false,
    @SerialName("blocker_descriptor") val blockerDescriptor: String,
  ) : McpBlockerResponse()

  /** Blocker that can be handled conversationally by the LLM agent. */
  @Serializable
  @SerialName("inline_data_request")
  data class InlineDataRequest(
    @SerialName("request_id") val requestId: String,
    val message: String,
    @SerialName("data_schema") val dataSchema: JsonObject?,
    @SerialName("is_sensitive") val isSensitive: Boolean,
    @SerialName("blocker_type") val blockerType: String,
    @SerialName("agent_friendly") val agentFriendly: Boolean = true,
  ) : McpBlockerResponse()
}

2. Create mapping extension and schema utilities

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/transport/mcp/plasma_adapter/McpBlockerResponse.kt (same file, add extensions)

import com.squareup.cash.moneybotcore.service.entity.BlockerResponse
import com.squareup.cash.moneybotcore.service.entity.DataRequestSchema
import com.squareup.cash.moneybotcore.service.entity.PasscodeVerificationRequest
import misk.mcp.generateJsonSchema
import kotlin.reflect.KClass

/** Maps service-layer BlockerResponse to transport-layer McpBlockerResponse. */
fun BlockerResponse.toMcpResponse(): McpBlockerResponse =
  when (this) {
    is BlockerResponse.FormRequired -> McpBlockerResponse.FormRequired(
      blockerDescriptorId = blockerDescriptorId,
      blockerType = blockerType.name,
      blockerDescriptor = blockerDescriptorBytes.base64(),
    )
    is BlockerResponse.InlineDataRequest -> McpBlockerResponse.InlineDataRequest(
      requestId = requestId,
      message = message,
      dataSchema = dataRequestType?.toJsonSchema(),
      isSensitive = isSensitive,
      blockerType = blockerType.name,
    )
  }

/** Generates a JSON schema from a DataRequestSchema KClass. Transport layer responsibility. */
fun KClass<out DataRequestSchema>.toJsonSchema(): JsonObject =
  when (this) {
    PasscodeVerificationRequest::class -> generateJsonSchema<PasscodeVerificationRequest>()
    else -> throw IllegalStateException("No schema registered for DataRequestSchema type: $simpleName")
  }

/** Deserializes a JsonObject into the appropriate DataRequestSchema subtype. */
fun KClass<out DataRequestSchema>.deserialize(data: JsonObject): DataRequestSchema =
  when (this) {
    PasscodeVerificationRequest::class -> McpJson.decodeFromJsonElement<PasscodeVerificationRequest>(data)
    else -> throw IllegalStateException("Cannot deserialize DataRequestSchema type: $simpleName")
  }

The when branches are exhaustive over the DataRequestSchema sealed interface — adding a new subtype forces adding a branch here.

3. Create SubmitBlockerDataTool (rename from SubmitBlockerTool)

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/transport/mcp/plasma_adapter/tools/SubmitBlockerDataTool.kt (NEW, replaces SubmitBlockerTool.kt)

@Serializable
data class SubmitBlockerDataInput(
  @Description("The request_id from the previous inline_data_request response.")
  @SerialName("request_id")
  val requestId: String,

  @Description("The collected data matching the data_schema from the request. Null for acknowledgment-only requests.")
  val data: JsonObject? = null,

  @Description("The action to take: ACCEPT to submit data, DECLINE to skip/fail, CANCEL to abandon the flow.")
  val action: BlockerDataAction,
)

class SubmitBlockerDataTool
@Inject
constructor(
  private val sessionProvider: SessionProvider,
  private val submitBlockerInteractor: SubmitBlockerInteractor,
  private val forwardedCallContext: ActionScoped<ForwardedCallContext>,
) : MoneybotTool<SubmitBlockerDataInput>() {

  override val name = "submit_blocker_data"

  override val description =
    """
    Submits a response to an inline data request to continue a flow.
    Use this after receiving an inline_data_request from enable_card or disable_card.

    Parameters:
    - request_id: The request_id from the inline_data_request response
    - data: JSON object matching the data_schema (null for acknowledgment-only)
    - action: ACCEPT (submit data), DECLINE (skip/fail), or CANCEL (abandon flow)
    """
      .trimIndent()

  override suspend fun handle(input: SubmitBlockerDataInput): ToolResult {
    val sessionId = sessionProvider.getSessionId()
    val customerToken = sessionProvider.getCustomerToken()
    val fcc = forwardedCallContext.get()

    return when (
      val result =
        submitBlockerInteractor.execute(
          sessionId = sessionId,
          customerToken = customerToken,
          requestId = input.requestId,
          data = input.data,
          action = input.action,
          forwardedCallContext = fcc,
        )
    ) {
      is FlowResult.Success -> result(message = "Operation completed successfully.")
      is FlowResult.BlockerRequired -> {
        val mcpResponse = result.blockerResponse.toMcpResponse()
        val message =
          if (result.blockerResponse.agentFriendly) {
            "Additional information required."
          } else {
            "Additional verification required in the Cash App."
          }
        result(message = message, data = mcpResponse)
      }
      is FlowResult.Error -> errorResult(type = result.type, message = result.message)
    }
  }
}

4. Update EnableCardTool

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/transport/mcp/plasma_adapter/tools/EnableCardTool.kt

Replace BlockerResponseResolver usage with direct sealed class mapping:

override val description =
  """
  Enables the customer's Cash Card if it is currently disabled.
  Use this when the customer wants to turn their card back on after it was disabled.
  This may require additional verification steps which will be returned as blockers.

  When a blocker is returned:
  - If agent_friendly is true: Follow the inline_data_request to collect data conversationally
  - If agent_friendly is false: The customer needs to complete verification in the Cash App
  """
    .trimIndent()

// In handle():
is FlowResult.BlockerRequired -> {
  val mcpResponse = result.blockerResponse.toMcpResponse()
  val message =
    if (result.blockerResponse.agentFriendly) {
      "Card enabling requires additional verification."
    } else {
      "Card enabling requires additional verification that must be completed in the Cash App."
    }
  result(message = message, data = mcpResponse)
}

Remove import of BlockerResponseResolver.

5. Update DisableCardTool

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/transport/mcp/plasma_adapter/tools/DisableCardTool.kt

Same pattern as EnableCardTool.

6. Update PlasmaAdapterServerModule

File: service/src/main/kotlin/com/squareup/cash/moneybotcore/transport/mcp/plasma_adapter/PlasmaAdapterServerModule.kt

// Replace:
install(McpToolModule.create<SubmitBlockerTool>(plasmaAdapterV1))
// With:
install(McpToolModule.create<SubmitBlockerDataTool>(plasmaAdapterV1))

Update SERVER_INSTRUCTIONS to reference submit_blocker_data instead of submit_blocker.

7. Delete old files

  • service/src/main/kotlin/.../transport/mcp/plasma_adapter/BlockerResponseResolver.kt (DELETE)
  • service/src/main/kotlin/.../transport/mcp/plasma_adapter/StructuredBlockerResponse.kt (DELETE)
  • service/src/main/kotlin/.../transport/mcp/plasma_adapter/tools/SubmitBlockerTool.kt (DELETE)

Success Criteria

Automated Verification:

  • Full build compiles: bin/gradle -p moneybot-core/service build --warn

Phase 6: Test Updates

Overview

Update all existing tests for the new types and add new tests for action handling and sealed class behavior.

Changes Required

1. Update ProtoExtensionsBlockerTest.kt

File: service/src/test/kotlin/.../adapter/plasma/ProtoExtensionsBlockerTest.kt

  • Replace all toBlockerInfo() calls with toBlockerResponse(flowToken = "test-flow-token")
  • Update assertions from BlockerInfo fields to BlockerResponse sealed class variants
  • Assert PASSCODE_VERIFICATION returns InlineDataRequest with dataRequestType = PasscodeVerificationRequest::class
  • Assert FORM_INFORMATIONAL returns InlineDataRequest with dataRequestType = null
  • Assert other types return FormRequired with blockerDescriptorBytes
  • Assert requestId format is "test-flow-token:{descriptorId}:action"

2. Update PlasmaFlowAdapterTest.kt

File: service/src/test/kotlin/.../adapter/plasma/PlasmaFlowAdapterTest.kt

  • Update result assertions from result.blockerInfo.blockerId to result.blockerResponse.blockerDescriptorId
  • Verify blockerResponse is the expected sealed variant

3. Delete BlockerResponseResolverTest.kt

File: service/src/test/kotlin/.../transport/mcp/plasma_adapter/BlockerResponseResolverTest.kt (DELETE)

Replaced by ProtoExtensionsBlockerTest which now tests the classification logic.

4. Add SubmitBlockerInteractorTest.kt

File: service/src/test/kotlin/.../service/interactor/SubmitBlockerInteractorTest.kt (NEW)

Test cases:

  • ACCEPT with passcode data submits to adapter and returns result
  • ACCEPT with null data for informational blocker submits to adapter
  • CANCEL deletes flow session and returns FlowCancelled error
  • DECLINE deletes flow session and returns FlowDeclined error
  • returns error when flow session not found
  • returns error when customer token mismatches
  • returns error when request ID mismatches
  • ACCEPT saves new session when next blocker is InlineDataRequest
  • ACCEPT does not save session when next blocker is FormRequired
  • ACCEPT deletes session on success

5. Update DynamoFlowSessionAdapterIntegrationTest.kt

File: service/src/test/kotlin/.../adapter/dynamodb/DynamoFlowSessionAdapterIntegrationTest.kt

  • Add blockerType parameter to all saveFlowSession() calls
  • Verify blockerType is correctly stored and retrieved

6. Update InjectorTest.kt

Verify Guice modules still wire correctly with the new SubmitBlockerDataTool binding.

Success Criteria

Automated Verification:

  • All tests pass: bin/gradle -p moneybot-core/service test --warn
  • Full build passes: bin/gradle -p moneybot-core/service build --warn

Manual Verification:

  • Review test coverage for all three action types (ACCEPT/DECLINE/CANCEL)
  • Verify ArchUnit tests still pass (no proto imports in service layer)

Implementation Note: After all automated verification passes, pause for manual review of the test coverage and overall design before proceeding.


Testing Strategy

Unit Tests

  • ProtoExtensionsBlockerTest: Sealed interface variant selection, dataRequestType assignment, requestId format
  • PlasmaFlowAdapterTest: Passcode tokenization, error handling with new types
  • SubmitBlockerInteractorTest: All three actions, session management, validation
  • McpBlockerResponse mapping: Schema generation from DataRequestSchema types, correct translation
  • DataRequestSchema deserialization: PasscodeVerificationRequest round-trip (serialize → schema → deserialize)

Integration Tests

  • DynamoFlowSessionAdapterIntegrationTest: blockerType persistence round-trip

Edge Cases to Cover

  • ACCEPT with missing data for passcode blocker → error from adapter
  • CANCEL when session doesn't exist → FlowSessionNotFound
  • DECLINE with no optional fields → FlowDeclined
  • Blocker type that's isAgentFriendly but not PASSCODE_VERIFICATION or FORM_INFORMATIONALIllegalStateException
  • requestId mismatch → RequestIdMismatch error

File Change Summary

New Files (7)

  • service/entity/BlockerResponse.kt
  • service/entity/DataRequestSchema.kt
  • service/entity/BlockerDataAction.kt
  • service/entity/PasscodeVerificationRequest.kt
  • transport/mcp/plasma_adapter/McpBlockerResponse.kt
  • transport/mcp/plasma_adapter/tools/SubmitBlockerDataTool.kt
  • service/interactor/SubmitBlockerInteractorTest.kt (test)

Modified Files (14)

  • service/entity/FlowResult.kt
  • adapter/plasma/ProtoExtensions.kt
  • adapter/plasma/PlasmaFlowAdapter.kt
  • adapter/dynamodb/DyFlowSessionItem.kt
  • adapter/dynamodb/DynamoFlowSessionAdapter.kt
  • service/repository/FlowSessionRepository.kt
  • service/interactor/EnableCardInteractor.kt
  • service/interactor/DisableCardInteractor.kt
  • service/interactor/SubmitBlockerInteractor.kt
  • service/interactor/SaveFlowSessionInteractor.kt
  • transport/mcp/plasma_adapter/PlasmaAdapterServerModule.kt
  • transport/mcp/plasma_adapter/tools/EnableCardTool.kt
  • transport/mcp/plasma_adapter/tools/DisableCardTool.kt
  • protos/.../flow_session_messages.proto
  • transport/grpc/flowsession/SaveFlowSessionGrpcAction.kt
  • transport/grpc/flowsession/GetFlowSessionGrpcAction.kt

Deleted Files (4)

  • service/entity/BlockerInfo.kt
  • transport/mcp/plasma_adapter/BlockerResponseResolver.kt
  • transport/mcp/plasma_adapter/StructuredBlockerResponse.kt
  • transport/mcp/plasma_adapter/tools/SubmitBlockerTool.kt

Modified Test Files (4)

  • adapter/plasma/ProtoExtensionsBlockerTest.kt
  • adapter/plasma/PlasmaFlowAdapterTest.kt
  • adapter/dynamodb/DynamoFlowSessionAdapterIntegrationTest.kt
  • BlockerResponseResolverTest.kt (DELETE)

References

  • Research document: thoughts/shared/research/2026-02-06-mcp-plasma-adapter-deep-dive.md
  • Blocker response research: thoughts/shared/research/2026-02-04-blocker-response-resolver-data-prompting.md
  • generateJsonSchema() public API: misk-mcp/src/main/kotlin/misk/mcp/GenerateJsonSchema.kt:18
  • Existing plan for context: thoughts/shared/plans/2026-02-04-enable-web-sessions-for-mcp-tools.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment