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.
BlockerInfo(service entity) — flat data class withrequiresForm,isSensitive,displayMessage, etc.BlockerResponseResolver(transport) — mapsBlockerInfo→StructuredBlockerResponse, generates LLM instructionsStructuredBlockerResponse(transport DTO) — flat@SerializablewithrequiresFormbooleanSubmitBlockerTool— acceptsblocker_type+ optionalblocker_inputJsonObjectFlowResult.BlockerRequiredholds aBlockerInfo
requiresFormis a negative concept —agentFriendlybetter expresses intent- No structured schema for expected input data — LLM relies on text instructions
- No way for LLM to cancel or decline a blocker
BlockerInfoconflates metadata for two fundamentally different paths (UI rendering vs. conversational)- The transport layer (
BlockerResponseResolver) makes decisions that belong in the adapter layer
generateJsonSchema<T>()is public in misk-mcp atmisk.mcp.GenerateJsonSchema.kt:18(user made public)blocker_action_iddoesn't exist in codebase; usingaction_iddefaulting to"action"- Request ID format:
"{flow_token}:{blocker_descriptor_id}:{action_id}" - ArchUnit blocks
com.squareup.protos.*in service layer — useokio.ByteStringfor raw proto bytes BlockerType.requiresForm()andisSensitive()helper functions inProtoExtensions.ktremain useful internally
After this change:
BlockerResponsesealed class in the service layer replacesBlockerInfoFlowResult.BlockerRequiredholds aBlockerResponseinstead ofBlockerInfo- Agent-friendly blockers return a
DataRequestSchematype; the transport layer generates JSON schemas from it - Form-required blockers pass through raw
BlockerDescriptorbytes for client rendering submit_blocker_datatool acceptsrequest_id,data, andaction(ACCEPT/DECLINE/CANCEL)BlockerResponseResolverandStructuredBlockerResponseare removedBlockerInfois removed (fully absorbed into sealed class)- Only
PASSCODE_VERIFICATIONis supported as an inline blocker type (others error) FORM_INFORMATIONALis supported as an inline acknowledgment (null schema)- Session stores
blockerTypefor use during submission
bin/gradle -p moneybot-core/service test --warnpassesbin/gradle -p moneybot-core/service build --warnpasses- ArchUnit tests pass (no proto imports in service layer)
- Not adding inline support for blocker types other than
PASSCODE_VERIFICATIONandFORM_INFORMATIONAL - Not changing the
enable_card/disable_cardtool 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_formstill usesfirstOrNull())
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.
Create the new sealed interface hierarchy, action enum, DataRequestSchema sealed interface, and passcode request data class. Update FlowResult. Delete BlockerInfo.
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
}
}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 DataRequestSchemaFile: 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,
}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,
) : DataRequestSchemaFile: 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()
}File: service/src/main/kotlin/com/squareup/cash/moneybotcore/service/entity/BlockerInfo.kt (DELETE)
All fields are absorbed into BlockerResponse variants:
blockerId→blockerDescriptorIdblockerType→blockerTypedisplayMessage→InlineDataRequest.messagehasInteractiveElements→ internal to adapter onlyrequiresForm→ inverted toagentFriendly, implicit in sealed variantisSensitive→InlineDataRequest.isSensitive
- Service layer compiles (expect compilation errors in adapter/transport/tests — that's OK for this phase, they're fixed in later phases)
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.
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.
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)
}
}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.)
- Adapter layer compiles with new entity types
Add blockerType to the flow session so the SubmitBlockerInteractor knows what type of blocker it's processing without needing it in the tool input.
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,
)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.nameUpdate the from() factory to accept blockerType: BlockerType.
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().
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;
}Files:
transport/grpc/flowsession/SaveFlowSessionGrpcAction.kt— passblocker_typethroughtransport/grpc/flowsession/GetFlowSessionGrpcAction.kt— includeblocker_typein response
File: service/src/main/kotlin/com/squareup/cash/moneybotcore/service/interactor/SaveFlowSessionInteractor.kt
Add blockerType parameter to execute().
- Storage layer compiles with new field
- Proto generates successfully
Update the card interactors to save blockerType in the session. Only save sessions for InlineDataRequest blockers (not FormRequired). Redesign SubmitBlockerInteractor to handle actions.
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 */ }
}File: service/src/main/kotlin/com/squareup/cash/moneybotcore/service/interactor/DisableCardInteractor.kt
Same pattern as EnableCardInteractor.
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,
): FlowResultImplementation:
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
}
}
}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?.
- All interactors compile with new sealed class handling
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.
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()
}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.
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)
}
}
}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.
File: service/src/main/kotlin/com/squareup/cash/moneybotcore/transport/mcp/plasma_adapter/tools/DisableCardTool.kt
Same pattern as EnableCardTool.
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.
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)
- Full build compiles:
bin/gradle -p moneybot-core/service build --warn
Update all existing tests for the new types and add new tests for action handling and sealed class behavior.
File: service/src/test/kotlin/.../adapter/plasma/ProtoExtensionsBlockerTest.kt
- Replace all
toBlockerInfo()calls withtoBlockerResponse(flowToken = "test-flow-token") - Update assertions from
BlockerInfofields toBlockerResponsesealed class variants - Assert
PASSCODE_VERIFICATIONreturnsInlineDataRequestwithdataRequestType = PasscodeVerificationRequest::class - Assert
FORM_INFORMATIONALreturnsInlineDataRequestwithdataRequestType = null - Assert other types return
FormRequiredwithblockerDescriptorBytes - Assert
requestIdformat is"test-flow-token:{descriptorId}:action"
File: service/src/test/kotlin/.../adapter/plasma/PlasmaFlowAdapterTest.kt
- Update result assertions from
result.blockerInfo.blockerIdtoresult.blockerResponse.blockerDescriptorId - Verify
blockerResponseis the expected sealed variant
File: service/src/test/kotlin/.../transport/mcp/plasma_adapter/BlockerResponseResolverTest.kt (DELETE)
Replaced by ProtoExtensionsBlockerTest which now tests the classification logic.
File: service/src/test/kotlin/.../service/interactor/SubmitBlockerInteractorTest.kt (NEW)
Test cases:
ACCEPT with passcode data submits to adapter and returns resultACCEPT with null data for informational blocker submits to adapterCANCEL deletes flow session and returns FlowCancelled errorDECLINE deletes flow session and returns FlowDeclined errorreturns error when flow session not foundreturns error when customer token mismatchesreturns error when request ID mismatchesACCEPT saves new session when next blocker is InlineDataRequestACCEPT does not save session when next blocker is FormRequiredACCEPT deletes session on success
File: service/src/test/kotlin/.../adapter/dynamodb/DynamoFlowSessionAdapterIntegrationTest.kt
- Add
blockerTypeparameter to allsaveFlowSession()calls - Verify
blockerTypeis correctly stored and retrieved
Verify Guice modules still wire correctly with the new SubmitBlockerDataTool binding.
- All tests pass:
bin/gradle -p moneybot-core/service test --warn - Full build passes:
bin/gradle -p moneybot-core/service build --warn
- 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.
- ProtoExtensionsBlockerTest: Sealed interface variant selection,
dataRequestTypeassignment, requestId format - PlasmaFlowAdapterTest: Passcode tokenization, error handling with new types
- SubmitBlockerInteractorTest: All three actions, session management, validation
- McpBlockerResponse mapping: Schema generation from
DataRequestSchematypes, correct translation - DataRequestSchema deserialization:
PasscodeVerificationRequestround-trip (serialize → schema → deserialize)
- DynamoFlowSessionAdapterIntegrationTest:
blockerTypepersistence round-trip
ACCEPTwith missing data for passcode blocker → error from adapterCANCELwhen session doesn't exist →FlowSessionNotFoundDECLINEwith no optional fields →FlowDeclined- Blocker type that's
isAgentFriendlybut notPASSCODE_VERIFICATIONorFORM_INFORMATIONAL→IllegalStateException requestIdmismatch →RequestIdMismatcherror
service/entity/BlockerResponse.ktservice/entity/DataRequestSchema.ktservice/entity/BlockerDataAction.ktservice/entity/PasscodeVerificationRequest.kttransport/mcp/plasma_adapter/McpBlockerResponse.kttransport/mcp/plasma_adapter/tools/SubmitBlockerDataTool.ktservice/interactor/SubmitBlockerInteractorTest.kt(test)
service/entity/FlowResult.ktadapter/plasma/ProtoExtensions.ktadapter/plasma/PlasmaFlowAdapter.ktadapter/dynamodb/DyFlowSessionItem.ktadapter/dynamodb/DynamoFlowSessionAdapter.ktservice/repository/FlowSessionRepository.ktservice/interactor/EnableCardInteractor.ktservice/interactor/DisableCardInteractor.ktservice/interactor/SubmitBlockerInteractor.ktservice/interactor/SaveFlowSessionInteractor.kttransport/mcp/plasma_adapter/PlasmaAdapterServerModule.kttransport/mcp/plasma_adapter/tools/EnableCardTool.kttransport/mcp/plasma_adapter/tools/DisableCardTool.ktprotos/.../flow_session_messages.prototransport/grpc/flowsession/SaveFlowSessionGrpcAction.kttransport/grpc/flowsession/GetFlowSessionGrpcAction.kt
service/entity/BlockerInfo.kttransport/mcp/plasma_adapter/BlockerResponseResolver.kttransport/mcp/plasma_adapter/StructuredBlockerResponse.kttransport/mcp/plasma_adapter/tools/SubmitBlockerTool.kt
adapter/plasma/ProtoExtensionsBlockerTest.ktadapter/plasma/PlasmaFlowAdapterTest.ktadapter/dynamodb/DynamoFlowSessionAdapterIntegrationTest.ktBlockerResponseResolverTest.kt(DELETE)
- 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