diff --git a/.gitignore b/.gitignore
index 91a800a..2635ac1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,6 +38,7 @@ nb-configuration.xml
# Local environment
.env
+.mcp.json
# Plugin directory
/.quarkus/cli/plugins/
diff --git a/docs/superpowers/plans/2026-05-06-signal-endpoint.md b/docs/superpowers/plans/2026-05-06-signal-endpoint.md
new file mode 100644
index 0000000..dae2d2b
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-06-signal-endpoint.md
@@ -0,0 +1,969 @@
+# Signal Endpoint Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Implement POST /api/v1/cases/{caseId}/signals endpoint for sending external signals to case instances
+
+**Architecture:** New SignalResource JAX-RS endpoint that validates requests and delegates to CaseHubRuntime.signal(). Returns 202 Accepted on success, 404 for missing cases, 400 for validation errors. No service layer - direct runtime injection.
+
+**Tech Stack:** Quarkus REST, Mutiny (reactive), casehub-engine CaseHubRuntime, JUnit 5, RestAssured, Mockito
+
+---
+
+## File Structure
+
+**New files to create:**
+- `src/main/java/io/casehub/flow/rest/dto/SendSignalRequest.java` — Request DTO for signal payload
+- `src/main/java/io/casehub/flow/rest/dto/SignalResponse.java` — Response DTO for 202 Accepted
+- `src/main/java/io/casehub/flow/rest/SignalResource.java` — JAX-RS endpoint
+- `src/test/java/io/casehub/flow/rest/SignalResourceTest.java` — Unit tests with mocked runtime
+- `src/test/java/io/casehub/flow/rest/SignalResourceIT.java` — Integration tests with real runtime
+
+**Existing files to reference:**
+- `src/main/java/io/casehub/flow/rest/CaseInstanceResource.java` — Pattern for error handling, ProblemDetail
+- `src/main/java/io/casehub/flow/exception/CaseInstanceNotFoundException.java` — Exception for 404 errors
+- `src/test/java/io/casehub/flow/rest/CaseInstanceResourceTest.java` — Test pattern reference
+
+**Note on exceptions:** The design spec references `CaseNotFoundException`, but the codebase uses `CaseInstanceNotFoundException`. We'll use the existing `CaseInstanceNotFoundException` for consistency.
+
+---
+
+## Task 1: Create SendSignalRequest DTO
+
+**Files:**
+- Create: `src/main/java/io/casehub/flow/rest/dto/SendSignalRequest.java`
+
+- [ ] **Step 1: Create SendSignalRequest record**
+
+Create file with copyright header and record definition:
+
+```java
+/*
+ * Copyright 2026-Present The Case Hub Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.casehub.flow.rest.dto;
+
+/**
+ * Request payload for sending signal to case instance.
+ *
+ * @param path dot-notation path in CaseContext (e.g., "approvals.user", "orders[0].status")
+ * @param value signal data to set at path
+ */
+public record SendSignalRequest(String path, Object value) {}
+```
+
+- [ ] **Step 2: Verify compilation**
+
+Run: `./mvnw compile -DskipTests`
+
+Expected: SUCCESS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/main/java/io/casehub/flow/rest/dto/SendSignalRequest.java
+git commit -m "feat: add SendSignalRequest DTO for signal endpoint"
+```
+
+---
+
+## Task 2: Create SignalResponse DTO
+
+**Files:**
+- Create: `src/main/java/io/casehub/flow/rest/dto/SignalResponse.java`
+
+- [ ] **Step 1: Create SignalResponse record**
+
+Create file with copyright header and record definition:
+
+```java
+/*
+ * Copyright 2026-Present The Case Hub Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.casehub.flow.rest.dto;
+
+import java.util.UUID;
+
+/**
+ * Response for signal acceptance.
+ *
+ * @param caseId case instance UUID
+ * @param status acceptance status ("accepted")
+ * @param message human-readable message
+ */
+public record SignalResponse(UUID caseId, String status, String message) {}
+```
+
+- [ ] **Step 2: Verify compilation**
+
+Run: `./mvnw compile -DskipTests`
+
+Expected: SUCCESS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/main/java/io/casehub/flow/rest/dto/SignalResponse.java
+git commit -m "feat: add SignalResponse DTO for signal endpoint"
+```
+
+---
+
+## Task 3: Create SignalResource skeleton with validation test
+
+**Files:**
+- Create: `src/main/java/io/casehub/flow/rest/SignalResource.java`
+- Create: `src/test/java/io/casehub/flow/rest/SignalResourceTest.java`
+
+- [ ] **Step 1: Write test for null request body validation**
+
+Create test file:
+
+```java
+/*
+ * Copyright 2026-Present The Case Hub Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.casehub.flow.rest;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.equalTo;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.http.ContentType;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+@QuarkusTest
+class SignalResourceTest {
+
+ @Test
+ void sendSignal_nullRequestBody_returns400() {
+ given()
+ .contentType(ContentType.JSON)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID())
+ .then()
+ .statusCode(400)
+ .body("title", equalTo("Invalid request"))
+ .body("status", equalTo(400));
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_nullRequestBody_returns400`
+
+Expected: FAIL - endpoint not found (404)
+
+- [ ] **Step 3: Create minimal SignalResource**
+
+Create resource file:
+
+```java
+/*
+ * Copyright 2026-Present The Case Hub Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.casehub.flow.rest;
+
+import io.casehub.flow.rest.dto.SendSignalRequest;
+import io.smallrye.mutiny.Uni;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import java.util.UUID;
+
+/**
+ * REST API for sending signals to case instances.
+ *
+ *
Endpoints:
+ *
+ *
+ * - POST /api/v1/cases/{caseId}/signals — send signal to case
+ *
+ */
+@Path("/api/v1/cases/{caseId}/signals")
+@Produces(MediaType.APPLICATION_JSON)
+@Consumes(MediaType.APPLICATION_JSON)
+public class SignalResource {
+
+ @POST
+ public Uni sendSignal(
+ @PathParam("caseId") UUID caseId, SendSignalRequest request) {
+
+ if (request == null || request.path() == null || request.value() == null) {
+ return Uni.createFrom()
+ .item(
+ Response.status(400)
+ .entity(
+ new ProblemDetail(
+ "Invalid request",
+ 400,
+ "Request body, path, and value are required"))
+ .build());
+ }
+
+ return Uni.createFrom().item(Response.status(202).build());
+ }
+
+ /**
+ * RFC 7807 Problem Details for HTTP APIs.
+ *
+ * @param title a short, human-readable summary of the problem type
+ * @param status the HTTP status code
+ * @param detail a human-readable explanation specific to this occurrence
+ */
+ public record ProblemDetail(String title, int status, String detail) {}
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_nullRequestBody_returns400`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/main/java/io/casehub/flow/rest/SignalResource.java src/test/java/io/casehub/flow/rest/SignalResourceTest.java
+git commit -m "feat: add SignalResource with null request validation"
+```
+
+---
+
+## Task 4: Add null path validation
+
+**Files:**
+- Modify: `src/test/java/io/casehub/flow/rest/SignalResourceTest.java`
+
+- [ ] **Step 1: Write test for null path**
+
+Add test method to SignalResourceTest:
+
+```java
+@Test
+void sendSignal_nullPath_returns400() {
+ given()
+ .contentType(ContentType.JSON)
+ .body(
+ """
+ {
+ "path": null,
+ "value": {"approved": true}
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID())
+ .then()
+ .statusCode(400)
+ .body("title", equalTo("Invalid request"))
+ .body("status", equalTo(400));
+}
+```
+
+- [ ] **Step 2: Run test to verify it passes**
+
+Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_nullPath_returns400`
+
+Expected: PASS (already handled by existing validation)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/test/java/io/casehub/flow/rest/SignalResourceTest.java
+git commit -m "test: add null path validation test"
+```
+
+---
+
+## Task 5: Add null value validation
+
+**Files:**
+- Modify: `src/test/java/io/casehub/flow/rest/SignalResourceTest.java`
+
+- [ ] **Step 1: Write test for null value**
+
+Add test method to SignalResourceTest:
+
+```java
+@Test
+void sendSignal_nullValue_returns400() {
+ given()
+ .contentType(ContentType.JSON)
+ .body(
+ """
+ {
+ "path": "approvals.user",
+ "value": null
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID())
+ .then()
+ .statusCode(400)
+ .body("title", equalTo("Invalid request"));
+}
+```
+
+- [ ] **Step 2: Run test to verify it passes**
+
+Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_nullValue_returns400`
+
+Expected: PASS (already handled by existing validation)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/test/java/io/casehub/flow/rest/SignalResourceTest.java
+git commit -m "test: add null value validation test"
+```
+
+---
+
+## Task 6: Add CaseHubRuntime injection and happy path test
+
+**Files:**
+- Modify: `src/main/java/io/casehub/flow/rest/SignalResource.java`
+- Modify: `src/test/java/io/casehub/flow/rest/SignalResourceTest.java`
+
+- [ ] **Step 1: Write happy path test with mocked runtime**
+
+Add imports and mock setup to SignalResourceTest:
+
+```java
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+
+import io.casehub.api.engine.CaseHubRuntime;
+import io.quarkus.test.InjectMock;
+import static org.hamcrest.Matchers.containsString;
+```
+
+Add test method:
+
+```java
+@InjectMock CaseHubRuntime caseHubRuntime;
+
+@Test
+void sendSignal_validRequest_returns202() {
+ UUID caseId = UUID.randomUUID();
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(
+ """
+ {
+ "path": "approvals.user",
+ "value": {"approved": true}
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", caseId)
+ .then()
+ .statusCode(202)
+ .body("caseId", equalTo(caseId.toString()))
+ .body("status", equalTo("accepted"))
+ .body("message", containsString("queued"));
+
+ verify(caseHubRuntime).signal(eq(caseId), eq("approvals.user"), any());
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_validRequest_returns202`
+
+Expected: FAIL - response body doesn't match (empty 202 response)
+
+- [ ] **Step 3: Inject CaseHubRuntime and implement signal call**
+
+Modify SignalResource:
+
+```java
+import io.casehub.api.engine.CaseHubRuntime;
+import io.casehub.flow.rest.dto.SignalResponse;
+import jakarta.inject.Inject;
+import org.jboss.logging.Logger;
+```
+
+Add fields and update sendSignal method:
+
+```java
+private static final Logger LOG = Logger.getLogger(SignalResource.class);
+
+@Inject CaseHubRuntime caseHubRuntime;
+
+@POST
+public Uni sendSignal(
+ @PathParam("caseId") UUID caseId, SendSignalRequest request) {
+
+ // Validation
+ if (request == null || request.path() == null || request.value() == null) {
+ return Uni.createFrom()
+ .item(
+ Response.status(400)
+ .entity(
+ new ProblemDetail(
+ "Invalid request",
+ 400,
+ "Request body, path, and value are required"))
+ .build());
+ }
+
+ // Send signal to engine
+ return Uni.createFrom()
+ .item(
+ () -> {
+ caseHubRuntime.signal(caseId, request.path(), request.value());
+ return new SignalResponse(caseId, "accepted", "Signal queued for processing");
+ })
+ .map(response -> Response.status(202).entity(response).build());
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_validRequest_returns202`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/main/java/io/casehub/flow/rest/SignalResource.java src/test/java/io/casehub/flow/rest/SignalResourceTest.java
+git commit -m "feat: add CaseHubRuntime integration and signal delivery"
+```
+
+---
+
+## Task 7: Add case not found error handling
+
+**Files:**
+- Modify: `src/main/java/io/casehub/flow/rest/SignalResource.java`
+- Modify: `src/test/java/io/casehub/flow/rest/SignalResourceTest.java`
+
+- [ ] **Step 1: Write test for case not found**
+
+Add imports to SignalResourceTest:
+
+```java
+import static org.mockito.Mockito.doThrow;
+import io.casehub.flow.exception.CaseInstanceNotFoundException;
+```
+
+Add test method:
+
+```java
+@Test
+void sendSignal_caseNotFound_returns404() {
+ UUID caseId = UUID.randomUUID();
+ doThrow(new CaseInstanceNotFoundException(caseId))
+ .when(caseHubRuntime)
+ .signal(any(), any(), any());
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(
+ """
+ {
+ "path": "test.path",
+ "value": "test"
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", caseId)
+ .then()
+ .statusCode(404)
+ .body("title", equalTo("Case not found"));
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_caseNotFound_returns404`
+
+Expected: FAIL - no error handling, returns 500 or exception
+
+- [ ] **Step 3: Add error handling for CaseInstanceNotFoundException**
+
+Modify SignalResource sendSignal method to add error recovery:
+
+```java
+import io.casehub.flow.exception.CaseInstanceNotFoundException;
+```
+
+Update the return statement in sendSignal:
+
+```java
+// Send signal to engine
+return Uni.createFrom()
+ .item(
+ () -> {
+ caseHubRuntime.signal(caseId, request.path(), request.value());
+ return new SignalResponse(caseId, "accepted", "Signal queued for processing");
+ })
+ .map(response -> Response.status(202).entity(response).build())
+ .onFailure(CaseInstanceNotFoundException.class)
+ .recoverWithItem(
+ ex -> {
+ LOG.warnf(ex, "Case not found: %s", caseId);
+ return Response.status(404)
+ .entity(new ProblemDetail("Case not found", 404, ex.getMessage()))
+ .build();
+ });
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_caseNotFound_returns404`
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/main/java/io/casehub/flow/rest/SignalResource.java src/test/java/io/casehub/flow/rest/SignalResourceTest.java
+git commit -m "feat: add 404 error handling for non-existent cases"
+```
+
+---
+
+## Task 8: Add generic runtime error handling
+
+**Files:**
+- Modify: `src/main/java/io/casehub/flow/rest/SignalResource.java`
+- Modify: `src/test/java/io/casehub/flow/rest/SignalResourceTest.java`
+
+- [ ] **Step 1: Write test for runtime exception**
+
+Add test method to SignalResourceTest:
+
+```java
+@Test
+void sendSignal_runtimeException_returns500() {
+ doThrow(new RuntimeException("Database error"))
+ .when(caseHubRuntime)
+ .signal(any(), any(), any());
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(
+ """
+ {
+ "path": "test.path",
+ "value": "test"
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID())
+ .then()
+ .statusCode(500)
+ .body("title", equalTo("Internal server error"))
+ .body("detail", containsString("Failed to send signal"));
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_runtimeException_returns500`
+
+Expected: FAIL - no generic error handling
+
+- [ ] **Step 3: Add generic error recovery**
+
+Modify SignalResource sendSignal method to add final error handler:
+
+```java
+// Send signal to engine
+return Uni.createFrom()
+ .item(
+ () -> {
+ caseHubRuntime.signal(caseId, request.path(), request.value());
+ return new SignalResponse(caseId, "accepted", "Signal queued for processing");
+ })
+ .map(response -> Response.status(202).entity(response).build())
+ .onFailure(CaseInstanceNotFoundException.class)
+ .recoverWithItem(
+ ex -> {
+ LOG.warnf(ex, "Case not found: %s", caseId);
+ return Response.status(404)
+ .entity(new ProblemDetail("Case not found", 404, ex.getMessage()))
+ .build();
+ })
+ .onFailure()
+ .recoverWithItem(
+ ex -> {
+ LOG.errorf(
+ ex, "Failed to send signal to case %s at path %s", caseId, request.path());
+ return Response.status(500)
+ .entity(
+ new ProblemDetail(
+ "Internal server error",
+ 500,
+ "Failed to send signal: " + ex.getMessage()))
+ .build();
+ });
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `./mvnw test -Dtest=SignalResourceTest#sendSignal_runtimeException_returns500`
+
+Expected: PASS
+
+- [ ] **Step 5: Run all unit tests**
+
+Run: `./mvnw test -Dtest=SignalResourceTest`
+
+Expected: All tests PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/main/java/io/casehub/flow/rest/SignalResource.java src/test/java/io/casehub/flow/rest/SignalResourceTest.java
+git commit -m "feat: add 500 error handling for runtime exceptions"
+```
+
+---
+
+## Task 9: Add integration test for end-to-end signal processing
+
+**Files:**
+- Create: `src/test/java/io/casehub/flow/rest/SignalResourceIT.java`
+
+- [ ] **Step 1: Create integration test skeleton**
+
+Create test file:
+
+```java
+/*
+ * Copyright 2026-Present The Case Hub Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.casehub.flow.rest;
+
+import static io.restassured.RestAssured.given;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+import static org.hamcrest.Matchers.equalTo;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.http.ContentType;
+import java.util.Map;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+@QuarkusTest
+class SignalResourceIT {
+
+ @Test
+ void sendSignal_nonExistentCase_returns404() {
+ UUID nonExistentCaseId = UUID.randomUUID();
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(
+ """
+ {
+ "path": "test.path",
+ "value": "test"
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", nonExistentCaseId)
+ .then()
+ .statusCode(404)
+ .body("title", equalTo("Case not found"));
+ }
+
+ @Test
+ void sendSignal_updatesContextAndTriggersWorkers() {
+ // 1. Start a test case
+ UUID caseId = startTestCase();
+
+ // 2. Send signal
+ given()
+ .contentType(ContentType.JSON)
+ .body(
+ """
+ {
+ "path": "approval.status",
+ "value": "approved"
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", caseId)
+ .then()
+ .statusCode(202);
+
+ // 3. Wait for async worker processing
+ await()
+ .atMost(5, SECONDS)
+ .untilAsserted(
+ () -> {
+ // 4. Verify context updated
+ String contextValue =
+ given()
+ .when()
+ .get("/api/v1/cases/{caseId}/context/approval.status", caseId)
+ .then()
+ .statusCode(200)
+ .extract()
+ .asString();
+
+ assertThat(contextValue).isEqualTo("\"approved\"");
+ });
+ }
+
+ private UUID startTestCase() {
+ Map request =
+ Map.of(
+ "definition",
+ Map.of("namespace", "test-api", "name", "Document Approval", "version", "1.0.0"),
+ "context",
+ Map.of("documentId", "DOC-123", "submittedBy", "alice@example.com"));
+
+ String response =
+ given()
+ .contentType(ContentType.JSON)
+ .body(request)
+ .when()
+ .post("/api/v1/cases")
+ .then()
+ .statusCode(200)
+ .extract()
+ .path("caseId");
+
+ return UUID.fromString(response);
+ }
+}
+```
+
+- [ ] **Step 2: Run integration tests**
+
+Run: `./mvnw verify -Dit.test=SignalResourceIT`
+
+Expected: Tests PASS (verifies end-to-end signal delivery and context update)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/test/java/io/casehub/flow/rest/SignalResourceIT.java
+git commit -m "test: add integration tests for signal endpoint"
+```
+
+---
+
+## Task 10: Run full test suite and verify
+
+**Files:**
+- N/A (verification task)
+
+- [ ] **Step 1: Run all tests**
+
+Run: `./mvnw verify`
+
+Expected: All tests PASS
+
+- [ ] **Step 2: Verify compilation without warnings**
+
+Run: `./mvnw clean compile`
+
+Expected: BUILD SUCCESS with no warnings
+
+- [ ] **Step 3: Manual smoke test with curl**
+
+Start dev mode:
+```bash
+./mvnw quarkus:dev
+```
+
+In another terminal, start a case and send a signal:
+
+```bash
+# Start a case
+CASE_ID=$(curl -s -X POST http://localhost:8080/api/v1/cases \
+ -H "Content-Type: application/json" \
+ -d '{
+ "definition": {
+ "namespace": "test-api",
+ "name": "Document Approval",
+ "version": "1.0.0"
+ },
+ "context": {
+ "documentId": "DOC-999"
+ }
+ }' | jq -r '.caseId')
+
+echo "Started case: $CASE_ID"
+
+# Send signal
+curl -X POST http://localhost:8080/api/v1/cases/$CASE_ID/signals \
+ -H "Content-Type: application/json" \
+ -d '{
+ "path": "approval.status",
+ "value": "approved"
+ }'
+
+# Check context
+curl -s http://localhost:8080/api/v1/cases/$CASE_ID/context/approval.status
+```
+
+Expected:
+- Signal returns 202 with `{"caseId":"...","status":"accepted","message":"Signal queued for processing"}`
+- Context endpoint returns `"approved"`
+
+- [ ] **Step 4: Test error cases manually**
+
+Test 404:
+```bash
+curl -X POST http://localhost:8080/api/v1/cases/00000000-0000-0000-0000-000000000000/signals \
+ -H "Content-Type: application/json" \
+ -d '{"path": "test", "value": "test"}'
+```
+
+Expected: 404 with RFC 7807 format
+
+Test 400:
+```bash
+curl -X POST http://localhost:8080/api/v1/cases/$CASE_ID/signals \
+ -H "Content-Type: application/json" \
+ -d '{"path": null, "value": "test"}'
+```
+
+Expected: 400 with "Invalid request"
+
+Stop dev mode: `Ctrl+C`
+
+- [ ] **Step 5: Final commit**
+
+```bash
+git add -A
+git commit -m "feat: complete signal endpoint implementation
+
+Implements POST /api/v1/cases/{caseId}/signals endpoint for sending
+external signals to case instances (issue #5).
+
+Features:
+- Accepts {path, value} payload
+- Returns 202 Accepted on success
+- 404 for missing cases, 400 for validation errors
+- RFC 7807 error format
+- Full unit and integration test coverage
+
+Manual testing verified with curl."
+```
+
+---
+
+## Self-Review Checklist
+
+**Spec coverage:**
+- ✅ POST /api/v1/cases/{caseId}/signals endpoint
+- ✅ Accepts path and value in request body
+- ✅ Returns 202 Accepted on success
+- ✅ Returns 404 if caseId doesn't exist
+- ✅ Returns 400 for validation errors (null path/value)
+- ✅ RFC 7807 error format
+- ✅ Unit tests with mocked runtime
+- ✅ Integration tests with real runtime
+- ✅ Logging at appropriate levels
+
+**Placeholders:** None - all code is complete
+
+**Type consistency:**
+- SendSignalRequest(String path, Object value) - used consistently
+- SignalResponse(UUID caseId, String status, String message) - used consistently
+- CaseHubRuntime.signal(UUID caseId, String path, Object value) - matches engine API
+
+**Missing from spec:** None - all requirements covered
+
+---
+
+## Execution Notes
+
+**Estimated time:** 45-60 minutes for full implementation
+
+**Dependencies:**
+- Requires existing CaseInstanceResource for ProblemDetail pattern reference
+- Requires existing test infrastructure (QuarkusTest, RestAssured)
+- Requires casehub-engine with CaseHubRuntime.signal() method
+
+**Known issues:** None anticipated
+
+**Testing strategy:**
+- Unit tests use @InjectMock for CaseHubRuntime
+- Integration tests use real runtime with test case definitions
+- Manual testing verifies full workflow
+
+**Future work (not in this plan):**
+- Idempotency support (Idempotency-Key header)
+- Signal history endpoint (issue #7)
+- Metrics/observability (Micrometer)
+- OpenAPI spec update (issue #11)
diff --git a/docs/superpowers/specs/2026-05-06-signal-endpoint-design.md b/docs/superpowers/specs/2026-05-06-signal-endpoint-design.md
new file mode 100644
index 0000000..b8bb00f
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-06-signal-endpoint-design.md
@@ -0,0 +1,709 @@
+# Signal Endpoint REST API Design
+
+**Date:** 2026-05-06
+**Issue:** #5 - Implement REST API v1 — signal endpoint for external events
+**Status:** Design Approved
+**Author:** Claude Code + Dmitrii Tikhomirov
+
+## Overview
+
+Implement REST API endpoint for sending external signals/events into running case instances. Signals update the CaseContext at a specified path, triggering worker execution and potential state transitions in casehub-engine.
+
+## Requirements Summary
+
+From issue #5:
+
+**Endpoint:**
+- `POST /api/v1/cases/{caseId}/signals` — send external signal to case instance
+
+**Acceptance Criteria:**
+- POST endpoint accepts path (location in CaseContext) and value (signal data)
+- Signal is routed to casehub-engine's signal processing mechanism via `CaseHubRuntime.signal()`
+- Returns 202 Accepted if signal queued successfully
+- Returns 404 if caseId doesn't exist
+- Returns 400 if signal payload validation fails (null path or value)
+- Error responses follow RFC 7807 format
+- Integration tests verify signal triggers worker execution and context updates
+
+**Deferred (not in MVP):**
+- Idempotency: duplicate signal deduplication (can be added later with Idempotency-Key header)
+- Signal history/audit trail (covered by issue #7)
+
+**Key Decisions:**
+- **Async model:** 202 Accepted response (signal queued, not processed)
+- **Direct mapping:** REST API `{ path, value }` maps directly to `CaseHubRuntime.signal(caseId, path, value)`
+- **Minimal validation:** Null-checks only; rely on engine for semantic validation
+- **Reactive error handling:** Catch exceptions from runtime, don't pre-validate case existence
+- **Separate resource:** New `SignalResource` class for clean isolation (future: signal history)
+
+## Architecture
+
+### Component Layers
+
+```
+┌─────────────────────────────────────────┐
+│ REST Layer │
+│ SignalResource │
+│ POST /api/v1/cases/{caseId}/signals │
+└─────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────┐
+│ Runtime Layer │
+│ CaseHubRuntime.signal(caseId, path, value) │
+└─────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────┐
+│ casehub-engine │
+│ - Updates CaseContext at path │
+│ - Triggers worker execution │
+│ - Processes state transitions │
+└─────────────────────────────────────────┘
+```
+
+**Design Rationale:**
+
+- **Separate SignalResource**: Isolates signal functionality from CaseInstanceResource, allows future expansion (GET /signals for history)
+- **No service layer**: `signal()` is a simple void method on runtime; adding a service would be over-engineering
+- **Direct runtime injection**: Minimal indirection for straightforward operation
+
+## Components
+
+### 1. SignalResource
+
+**Purpose:** JAX-RS endpoint for receiving signal requests and delegating to casehub-engine runtime.
+
+**Location:** `src/main/java/io/casehub/flow/rest/SignalResource.java`
+
+**Implementation:**
+
+```java
+package io.casehub.flow.rest;
+
+import io.casehub.api.engine.CaseHubRuntime;
+import io.casehub.flow.exception.CaseNotFoundException;
+import io.casehub.flow.rest.dto.SendSignalRequest;
+import io.casehub.flow.rest.dto.SignalResponse;
+import io.smallrye.mutiny.Uni;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import java.util.UUID;
+import org.jboss.logging.Logger;
+
+/**
+ * REST API for sending signals to case instances.
+ *
+ * Endpoints:
+ *
+ *
+ * - POST /api/v1/cases/{caseId}/signals — send signal to case
+ *
+ */
+@Path("/api/v1/cases/{caseId}/signals")
+@Produces(MediaType.APPLICATION_JSON)
+@Consumes(MediaType.APPLICATION_JSON)
+public class SignalResource {
+
+ private static final Logger LOG = Logger.getLogger(SignalResource.class);
+
+ @Inject CaseHubRuntime caseHubRuntime;
+
+ /**
+ * Send signal to case instance.
+ *
+ * @param caseId case instance UUID
+ * @param request signal request with path and value
+ * @return 202 Accepted if signal queued, 404 if case not found, 400 for invalid request
+ */
+ @POST
+ public Uni sendSignal(
+ @PathParam("caseId") UUID caseId,
+ SendSignalRequest request) {
+
+ // Validation
+ if (request == null || request.path() == null || request.value() == null) {
+ return Uni.createFrom()
+ .item(
+ Response.status(400)
+ .entity(
+ new ProblemDetail(
+ "Invalid request",
+ 400,
+ "Request body, path, and value are required"))
+ .build());
+ }
+
+ // Send signal to engine
+ return Uni.createFrom()
+ .item(
+ () -> {
+ caseHubRuntime.signal(caseId, request.path(), request.value());
+ return new SignalResponse(caseId, "accepted", "Signal queued for processing");
+ })
+ .map(response -> Response.status(202).entity(response).build())
+ .onFailure(CaseNotFoundException.class)
+ .recoverWithItem(
+ ex -> {
+ LOG.warnf(ex, "Case not found: %s", caseId);
+ return Response.status(404)
+ .entity(new ProblemDetail("Case not found", 404, ex.getMessage()))
+ .build();
+ })
+ .onFailure()
+ .recoverWithItem(
+ ex -> {
+ LOG.errorf(
+ ex, "Failed to send signal to case %s at path %s", caseId, request.path());
+ return Response.status(500)
+ .entity(
+ new ProblemDetail(
+ "Internal server error",
+ 500,
+ "Failed to send signal: " + ex.getMessage()))
+ .build();
+ });
+ }
+
+ /**
+ * RFC 7807 Problem Details for HTTP APIs.
+ *
+ * @param title a short, human-readable summary of the problem type
+ * @param status the HTTP status code
+ * @param detail a human-readable explanation specific to this occurrence
+ */
+ public record ProblemDetail(String title, int status, String detail) {}
+}
+```
+
+**Key Points:**
+
+- `Uni.createFrom().item(() -> ...)` wraps the void `signal()` call
+- Minimal validation: null-checks for request, path, and value
+- Reactive error handling: catch exceptions from `caseHubRuntime.signal()`
+- Logging at appropriate levels: WARN for 404, ERROR for 500
+- RFC 7807 ProblemDetail for all error responses
+
+### 2. SendSignalRequest (DTO)
+
+**Purpose:** Request payload for signal endpoint.
+
+**Location:** `src/main/java/io/casehub/flow/rest/dto/SendSignalRequest.java`
+
+**Implementation:**
+
+```java
+package io.casehub.flow.rest.dto;
+
+/**
+ * Request payload for sending signal to case instance.
+ *
+ * @param path dot-notation path in CaseContext (e.g., "approvals.user", "orders[0].status")
+ * @param value signal data to set at path
+ */
+public record SendSignalRequest(String path, Object value) {}
+```
+
+**Example Request:**
+
+```json
+POST /api/v1/cases/550e8400-e29b-41d4-a716-446655440000/signals
+Content-Type: application/json
+
+{
+ "path": "approvals.user",
+ "value": {
+ "approved": true,
+ "userId": "123",
+ "timestamp": "2026-05-06T10:30:00Z"
+ }
+}
+```
+
+### 3. SignalResponse (DTO)
+
+**Purpose:** Response payload for successful signal acceptance.
+
+**Location:** `src/main/java/io/casehub/flow/rest/dto/SignalResponse.java`
+
+**Implementation:**
+
+```java
+package io.casehub.flow.rest.dto;
+
+import java.util.UUID;
+
+/**
+ * Response for signal acceptance.
+ *
+ * @param caseId case instance UUID
+ * @param status acceptance status ("accepted")
+ * @param message human-readable message
+ */
+public record SignalResponse(UUID caseId, String status, String message) {}
+```
+
+**Example Response (202 Accepted):**
+
+```json
+HTTP/1.1 202 Accepted
+Content-Type: application/json
+
+{
+ "caseId": "550e8400-e29b-41d4-a716-446655440000",
+ "status": "accepted",
+ "message": "Signal queued for processing"
+}
+```
+
+## Error Handling
+
+### Validation Errors (400 Bad Request)
+
+**Triggers:**
+- Request body is null
+- `path` field is null
+- `value` field is null
+
+**Response:**
+
+```json
+HTTP/1.1 400 Bad Request
+Content-Type: application/json
+
+{
+ "title": "Invalid request",
+ "status": 400,
+ "detail": "Request body, path, and value are required"
+}
+```
+
+### Case Not Found (404 Not Found)
+
+**Triggers:**
+- `CaseNotFoundException` thrown by `caseHubRuntime.signal()`
+
+**Response:**
+
+```json
+HTTP/1.1 404 Not Found
+Content-Type: application/json
+
+{
+ "title": "Case not found",
+ "status": 404,
+ "detail": "Case instance with UUID 550e8400-... not found"
+}
+```
+
+**Logging:** WARN level with caseId
+
+### Runtime Errors (500 Internal Server Error)
+
+**Triggers:**
+- Any other `RuntimeException` from `caseHubRuntime.signal()`
+
+**Response:**
+
+```json
+HTTP/1.1 500 Internal Server Error
+Content-Type: application/json
+
+{
+ "title": "Internal server error",
+ "status": 500,
+ "detail": "Failed to send signal: "
+}
+```
+
+**Logging:** ERROR level with full exception stack trace, caseId, and path
+
+## Data Flow
+
+```
+1. Client sends POST /api/v1/cases/{caseId}/signals
+ ↓
+2. SignalResource validates request (null checks)
+ ↓
+3. SignalResource calls caseHubRuntime.signal(caseId, path, value)
+ ↓
+4. CaseHubRuntime updates CaseContext at path
+ ↓
+5. casehub-engine triggers worker execution (async)
+ ↓
+6. Workers process context change, update state
+ ↓
+7. SignalResource returns 202 Accepted immediately (before workers finish)
+```
+
+**Important:** The 202 response indicates signal acceptance, NOT completion of processing. Workers execute asynchronously after the HTTP response is returned.
+
+## Testing Strategy
+
+### Unit Tests (SignalResourceTest)
+
+**Test Coverage:**
+
+1. **Happy path** — 202 Accepted with valid request
+ - Verify `caseHubRuntime.signal()` called with correct arguments
+ - Verify response status and body
+
+2. **Null path validation** — 400 Bad Request
+ - Request with `path: null`
+ - Verify error response
+
+3. **Null value validation** — 400 Bad Request
+ - Request with `value: null`
+ - Verify error response
+
+4. **Null request body** — 400 Bad Request
+ - No request body
+ - Verify error response
+
+5. **Case not found** — 404 Not Found
+ - Mock `CaseNotFoundException` from runtime
+ - Verify error response and logging
+
+6. **Runtime exception** — 500 Internal Server Error
+ - Mock generic `RuntimeException` from runtime
+ - Verify error response and logging
+
+**Implementation:**
+
+```java
+@QuarkusTest
+class SignalResourceTest {
+
+ @InjectMock CaseHubRuntime caseHubRuntime;
+
+ @Test
+ void sendSignal_success_returns202() {
+ UUID caseId = UUID.randomUUID();
+
+ given()
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(
+ """
+ {
+ "path": "approvals.user",
+ "value": {"approved": true}
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", caseId)
+ .then()
+ .statusCode(202)
+ .body("caseId", equalTo(caseId.toString()))
+ .body("status", equalTo("accepted"))
+ .body("message", containsString("queued"));
+
+ verify(caseHubRuntime).signal(eq(caseId), eq("approvals.user"), any());
+ }
+
+ @Test
+ void sendSignal_nullPath_returns400() {
+ given()
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(
+ """
+ {
+ "path": null,
+ "value": {"approved": true}
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID())
+ .then()
+ .statusCode(400)
+ .body("title", equalTo("Invalid request"))
+ .body("status", equalTo(400));
+ }
+
+ @Test
+ void sendSignal_nullValue_returns400() {
+ given()
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(
+ """
+ {
+ "path": "approvals.user",
+ "value": null
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID())
+ .then()
+ .statusCode(400)
+ .body("title", equalTo("Invalid request"));
+ }
+
+ @Test
+ void sendSignal_caseNotFound_returns404() {
+ UUID caseId = UUID.randomUUID();
+ doThrow(new CaseNotFoundException(caseId))
+ .when(caseHubRuntime)
+ .signal(any(), any(), any());
+
+ given()
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(
+ """
+ {
+ "path": "test.path",
+ "value": "test"
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", caseId)
+ .then()
+ .statusCode(404)
+ .body("title", equalTo("Case not found"));
+ }
+
+ @Test
+ void sendSignal_runtimeException_returns500() {
+ doThrow(new RuntimeException("Database error"))
+ .when(caseHubRuntime)
+ .signal(any(), any(), any());
+
+ given()
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(
+ """
+ {
+ "path": "test.path",
+ "value": "test"
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID())
+ .then()
+ .statusCode(500)
+ .body("title", equalTo("Internal server error"))
+ .body("detail", containsString("Failed to send signal"));
+ }
+}
+```
+
+### Integration Tests (SignalResourceIT)
+
+**Test Coverage:**
+
+1. **End-to-end signal processing**
+ - Start a real case instance
+ - Send signal via REST API
+ - Wait for worker execution (async)
+ - Verify context updated via GET /context endpoint
+
+2. **Signal triggers state transition**
+ - Start case in initial state
+ - Send signal that should trigger transition
+ - Verify case moved to new state
+
+**Implementation:**
+
+```java
+@QuarkusTest
+class SignalResourceIT {
+
+ @Inject CaseDefinitionService definitionService;
+ @Inject CaseInstanceService caseInstanceService;
+
+ @Test
+ void sendSignal_updatesContextAndTriggersWorkers() {
+ // 1. Start a test case
+ UUID caseId = startTestCase();
+
+ // 2. Send signal
+ given()
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(
+ """
+ {
+ "path": "approval.status",
+ "value": "approved"
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", caseId)
+ .then()
+ .statusCode(202);
+
+ // 3. Wait for async worker processing
+ await()
+ .atMost(5, SECONDS)
+ .untilAsserted(
+ () -> {
+ // 4. Verify context updated
+ String contextValue =
+ given()
+ .when()
+ .get("/api/v1/cases/{caseId}/context/approval.status", caseId)
+ .then()
+ .statusCode(200)
+ .extract()
+ .asString();
+
+ assertThat(contextValue).isEqualTo("\"approved\"");
+ });
+ }
+
+ @Test
+ void sendSignal_nonExistentCase_returns404() {
+ UUID nonExistentCaseId = UUID.randomUUID();
+
+ given()
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(
+ """
+ {
+ "path": "test.path",
+ "value": "test"
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", nonExistentCaseId)
+ .then()
+ .statusCode(404)
+ .body("title", equalTo("Case not found"));
+ }
+
+ private UUID startTestCase() {
+ // Helper method to start a test case instance
+ // Implementation depends on test case definition setup
+ }
+}
+```
+
+## Future Enhancements
+
+### 1. Idempotency (Deferred)
+
+**Approach:** Client-side idempotency key
+
+**Implementation:**
+```java
+@POST
+public Uni sendSignal(
+ @PathParam("caseId") UUID caseId,
+ @HeaderParam("Idempotency-Key") String idempotencyKey,
+ SendSignalRequest request) {
+
+ if (idempotencyKey != null) {
+ // Check idempotency table
+ return checkIdempotency(caseId, idempotencyKey)
+ .flatMap(isDuplicate -> {
+ if (isDuplicate) {
+ return Uni.createFrom().item(202 Accepted);
+ }
+ // Store idempotency key, send signal
+ return storeAndSendSignal(caseId, idempotencyKey, request);
+ });
+ }
+
+ // No idempotency key, send signal directly
+ return sendSignalDirect(caseId, request);
+}
+```
+
+**Database Schema:**
+```sql
+CREATE TABLE signal_idempotency (
+ case_id UUID NOT NULL,
+ idempotency_key VARCHAR(255) NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (case_id, idempotency_key)
+);
+
+CREATE INDEX idx_signal_idempotency_created_at
+ ON signal_idempotency(created_at);
+
+-- Cleanup job: DELETE WHERE created_at < NOW() - INTERVAL '24 hours'
+```
+
+### 2. Signal History (Issue #7)
+
+**Endpoint:**
+```
+GET /api/v1/cases/{caseId}/signals
+```
+
+**Response:**
+```json
+{
+ "signals": [
+ {
+ "path": "approvals.user",
+ "value": {"approved": true},
+ "timestamp": "2026-05-06T10:30:00Z",
+ "source": "external-api"
+ }
+ ]
+}
+```
+
+**Implementation:** Query casehub-engine ledger/event log
+
+### 3. Observability
+
+**Metrics (Micrometer/Prometheus):**
+- `signals_sent_total` — counter, labels: {caseId, path, status}
+- `signals_errors_total` — counter, labels: {errorType}
+- `signal_processing_duration_seconds` — histogram
+
+**Tracing (OpenTelemetry):**
+- Span: `POST /api/v1/cases/{caseId}/signals`
+- Attributes: caseId, path, responseStatus
+
+**Logging:**
+- Already included: WARN for 404, ERROR for 500
+- Future: structured logging with correlation IDs
+
+### 4. Security (Future)
+
+- **Authentication:** JWT/OAuth2 integration
+- **Authorization:** Case-level permissions (who can send signals to which cases)
+- **Rate Limiting:** Prevent signal flooding/abuse
+- **Input Sanitization:** Additional validation for path format, value size limits
+
+### 5. casehub-engine DLQ Integration
+
+From issue #5 notes:
+> Check if casehub-engine has built-in signal deduplication (DLQ module from #194, #193)
+
+**Action:** Investigate casehubio/engine issues #194, #193 to understand:
+- Does DLQ provide signal deduplication?
+- Is it automatic or opt-in?
+- What configuration is needed?
+
+**If DLQ provides deduplication:** Document behavior in API docs, no need to implement in REST layer
+
+**If DLQ doesn't provide deduplication:** Implement idempotency as described above
+
+## Implementation Checklist
+
+- [ ] Create `SignalResource.java`
+- [ ] Create `SendSignalRequest.java` DTO
+- [ ] Create `SignalResponse.java` DTO
+- [ ] Add `CaseNotFoundException` if not already exists
+- [ ] Write unit tests (`SignalResourceTest`)
+- [ ] Write integration tests (`SignalResourceIT`)
+- [ ] Update API documentation / OpenAPI spec (issue #11)
+- [ ] Verify error logging works correctly
+- [ ] Manual testing: send signals via curl/Postman
+- [ ] Update README with signal endpoint examples
+
+## Open Questions
+
+None — all questions resolved during design phase.
+
+## References
+
+- Issue #5: https://github.com/casehubio/flow/issues/5
+- Epic #1: https://github.com/casehubio/flow/issues/1
+- casehub-engine API: `CaseHubRuntime.signal(UUID caseId, String path, Object value)`
+- Previous design: `2026-05-05-case-lifecycle-api-design.md`
diff --git a/pom.xml b/pom.xml
index c6c8a89..67fd5cb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -74,6 +74,10 @@
io.quarkus
quarkus-rest
+
+ io.quarkus
+ quarkus-hibernate-validator
+
io.smallrye
jandex
@@ -121,6 +125,11 @@
4.2.2
test
+
+ io.quarkus
+ quarkus-junit-mockito
+ test
+
diff --git a/src/main/java/io/casehub/flow/rest/CaseDefinitionResource.java b/src/main/java/io/casehub/flow/rest/CaseDefinitionResource.java
index f4991d6..7ac1b9c 100644
--- a/src/main/java/io/casehub/flow/rest/CaseDefinitionResource.java
+++ b/src/main/java/io/casehub/flow/rest/CaseDefinitionResource.java
@@ -16,6 +16,7 @@
package io.casehub.flow.rest;
import io.casehub.api.model.CaseDefinition;
+import io.casehub.flow.rest.dto.ProblemDetail;
import io.casehub.flow.service.CaseDefinitionService;
import io.smallrye.mutiny.Uni;
import org.jboss.resteasy.reactive.RestQuery;
@@ -159,13 +160,4 @@ public Uni getByNamespaceAndNameAndVersion(
return Response.ok(definition).build();
});
}
-
- /**
- * RFC 7807 Problem Detail for error responses.
- *
- * @param title short, human-readable summary
- * @param status HTTP status code
- * @param detail human-readable explanation specific to this occurrence
- */
- public record ProblemDetail(String title, int status, String detail) {}
}
diff --git a/src/main/java/io/casehub/flow/rest/CaseInstanceResource.java b/src/main/java/io/casehub/flow/rest/CaseInstanceResource.java
index e043a72..d114a93 100644
--- a/src/main/java/io/casehub/flow/rest/CaseInstanceResource.java
+++ b/src/main/java/io/casehub/flow/rest/CaseInstanceResource.java
@@ -17,6 +17,7 @@
import io.casehub.flow.exception.CaseInstanceNotFoundException;
import io.casehub.flow.exception.DefinitionNotFoundException;
+import io.casehub.flow.rest.dto.ProblemDetail;
import io.casehub.flow.rest.dto.StartCaseRequest;
import io.casehub.flow.service.CaseInstanceService;
import io.smallrye.mutiny.Uni;
@@ -183,13 +184,4 @@ public Uni getContextPath(@PathParam("caseId") UUID caseId, @PathParam
"Internal server error", 500, ex.getMessage()))
.build());
}
-
- /**
- * RFC 7807 Problem Details for HTTP APIs.
- *
- * @param title a short, human-readable summary of the problem type
- * @param status the HTTP status code
- * @param detail a human-readable explanation specific to this occurrence
- */
- public record ProblemDetail(String title, int status, String detail) {}
}
diff --git a/src/main/java/io/casehub/flow/rest/SignalResource.java b/src/main/java/io/casehub/flow/rest/SignalResource.java
new file mode 100644
index 0000000..19f91a2
--- /dev/null
+++ b/src/main/java/io/casehub/flow/rest/SignalResource.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright 2026-Present The Case Hub Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.casehub.flow.rest;
+
+import io.casehub.api.engine.CaseHubRuntime;
+import io.casehub.flow.exception.CaseInstanceNotFoundException;
+import io.casehub.flow.rest.dto.ProblemDetail;
+import io.casehub.flow.rest.dto.SendSignalRequest;
+import io.casehub.flow.rest.dto.SignalResponse;
+import io.smallrye.mutiny.Uni;
+import jakarta.inject.Inject;
+import jakarta.validation.Valid;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import java.util.UUID;
+import org.jboss.logging.Logger;
+
+/**
+ * REST API for sending signals to case instances.
+ *
+ * Endpoints:
+ *
+ *
+ * - POST /api/v1/cases/{caseId}/signals — send signal to case
+ *
+ */
+@Path("/api/v1/cases/{caseId}/signals")
+@Produces(MediaType.APPLICATION_JSON)
+@Consumes(MediaType.APPLICATION_JSON)
+public class SignalResource {
+
+ private static final Logger LOG = Logger.getLogger(SignalResource.class);
+
+ @Inject CaseHubRuntime caseHubRuntime;
+
+ @POST
+ public Uni sendSignal(
+ @PathParam("caseId") UUID caseId, @Valid SendSignalRequest request) {
+
+ if (request == null) {
+ return Uni.createFrom()
+ .item(
+ Response.status(400)
+ .entity(
+ new ProblemDetail(
+ "Invalid request",
+ 400,
+ "Request body is required"))
+ .build());
+ }
+
+ // Validate case exists and send signal
+ return Uni.createFrom()
+ .completionStage(() -> caseHubRuntime.query(caseId, ".", Object.class))
+ .map(
+ ignored -> {
+ caseHubRuntime.signal(caseId, request.path(), request.value());
+ return new SignalResponse(caseId, "accepted", "Signal queued for processing");
+ })
+ .map(response -> Response.status(202).entity(response).build())
+ .onFailure(CaseInstanceNotFoundException.class)
+ .recoverWithItem(
+ ex -> {
+ LOG.warnf(ex, "Case not found: %s", caseId);
+ return Response.status(404)
+ .entity(new ProblemDetail("Case not found", 404, ex.getMessage()))
+ .build();
+ })
+ .onFailure(RuntimeException.class)
+ .recoverWithUni(
+ ex -> {
+ if (ex.getMessage() != null && ex.getMessage().contains("not found")) {
+ LOG.warnf(ex, "Case not found: %s", caseId);
+ return Uni.createFrom()
+ .item(
+ Response.status(404)
+ .entity(
+ new ProblemDetail(
+ "Case not found",
+ 404,
+ ex.getMessage()))
+ .build());
+ }
+ LOG.errorf(
+ ex, "Failed to send signal to case %s at path %s", caseId, request.path());
+ return Uni.createFrom()
+ .item(
+ Response.status(500)
+ .entity(
+ new ProblemDetail(
+ "Internal server error",
+ 500,
+ "Failed to send signal: " + ex.getMessage()))
+ .build());
+ });
+ }
+}
diff --git a/src/main/java/io/casehub/flow/rest/PagedResponse.java b/src/main/java/io/casehub/flow/rest/dto/PagedResponse.java
similarity index 96%
rename from src/main/java/io/casehub/flow/rest/PagedResponse.java
rename to src/main/java/io/casehub/flow/rest/dto/PagedResponse.java
index ef94f61..d3b9be0 100644
--- a/src/main/java/io/casehub/flow/rest/PagedResponse.java
+++ b/src/main/java/io/casehub/flow/rest/dto/PagedResponse.java
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package io.casehub.flow.rest;
+package io.casehub.flow.rest.dto;
import java.util.List;
diff --git a/src/main/java/io/casehub/flow/rest/dto/ProblemDetail.java b/src/main/java/io/casehub/flow/rest/dto/ProblemDetail.java
new file mode 100644
index 0000000..1a58a02
--- /dev/null
+++ b/src/main/java/io/casehub/flow/rest/dto/ProblemDetail.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2026-Present The Case Hub Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.casehub.flow.rest.dto;
+
+/**
+ * RFC 7807 Problem Details for HTTP APIs.
+ *
+ * @param title a short, human-readable summary of the problem type
+ * @param status the HTTP status code
+ * @param detail a human-readable explanation specific to this occurrence
+ */
+public record ProblemDetail(String title, int status, String detail) {}
diff --git a/src/main/java/io/casehub/flow/rest/dto/SendSignalRequest.java b/src/main/java/io/casehub/flow/rest/dto/SendSignalRequest.java
new file mode 100644
index 0000000..24cc9ba
--- /dev/null
+++ b/src/main/java/io/casehub/flow/rest/dto/SendSignalRequest.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2026-Present The Case Hub Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.casehub.flow.rest.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+/**
+ * Request payload for sending signal to case instance.
+ *
+ * @param path dot-notation path in CaseContext (e.g., "approvals.user", "orders[0].status")
+ * @param value signal data to set at path. Acceptable value types: String, Number, Boolean,
+ * null, Map, List.
+ */
+public record SendSignalRequest(@NotBlank String path, @NotNull Object value) {}
diff --git a/src/main/java/io/casehub/flow/rest/dto/SignalResponse.java b/src/main/java/io/casehub/flow/rest/dto/SignalResponse.java
new file mode 100644
index 0000000..847319c
--- /dev/null
+++ b/src/main/java/io/casehub/flow/rest/dto/SignalResponse.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2026-Present The Case Hub Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.casehub.flow.rest.dto;
+
+import java.util.UUID;
+
+/**
+ * Response for signal acceptance.
+ *
+ * @param caseId case instance UUID
+ * @param status acceptance status ("accepted")
+ * @param message human-readable message
+ */
+public record SignalResponse(UUID caseId, String status, String message) {}
diff --git a/src/main/java/io/casehub/flow/rest/validation/ConstraintViolationExceptionMapper.java b/src/main/java/io/casehub/flow/rest/validation/ConstraintViolationExceptionMapper.java
new file mode 100644
index 0000000..a93ad91
--- /dev/null
+++ b/src/main/java/io/casehub/flow/rest/validation/ConstraintViolationExceptionMapper.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2026-Present The Case Hub Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.casehub.flow.rest.validation;
+
+import io.casehub.flow.rest.dto.ProblemDetail;
+import jakarta.validation.ConstraintViolationException;
+import jakarta.ws.rs.core.Response;
+import jakarta.ws.rs.ext.ExceptionMapper;
+import jakarta.ws.rs.ext.Provider;
+
+/**
+ * Maps validation constraint violations to HTTP 400 responses.
+ */
+@Provider
+public class ConstraintViolationExceptionMapper
+ implements ExceptionMapper {
+
+ @Override
+ public Response toResponse(ConstraintViolationException exception) {
+ return Response.status(400)
+ .entity(new ProblemDetail("Invalid request", 400, exception.getMessage()))
+ .build();
+ }
+}
diff --git a/src/main/java/io/casehub/flow/service/CaseDefinitionService.java b/src/main/java/io/casehub/flow/service/CaseDefinitionService.java
index 0c6d687..1d658c0 100644
--- a/src/main/java/io/casehub/flow/service/CaseDefinitionService.java
+++ b/src/main/java/io/casehub/flow/service/CaseDefinitionService.java
@@ -20,7 +20,7 @@
import io.casehub.api.model.CaseDefinition;
import io.casehub.engine.internal.model.CaseMetaModel;
import io.casehub.engine.spi.CaseDefinitionRegistry;
-import io.casehub.flow.rest.PagedResponse;
+import io.casehub.flow.rest.dto.PagedResponse;
import io.casehub.persistence.jpa.CaseMetaModelEntity;
import io.quarkus.hibernate.reactive.panache.Panache;
import io.quarkus.panache.common.Page;
diff --git a/src/test/java/io/casehub/flow/rest/SignalResourceIT.java b/src/test/java/io/casehub/flow/rest/SignalResourceIT.java
new file mode 100644
index 0000000..49fe009
--- /dev/null
+++ b/src/test/java/io/casehub/flow/rest/SignalResourceIT.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright 2026-Present The Case Hub Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.casehub.flow.rest;
+
+import static io.restassured.RestAssured.given;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+import static org.hamcrest.Matchers.equalTo;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.http.ContentType;
+import java.util.Map;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+@QuarkusTest
+class SignalResourceIT {
+
+ @Test
+ void sendSignal_nonExistentCase_returns404() {
+ UUID nonExistentCaseId = UUID.randomUUID();
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(
+ """
+ {
+ "path": "test.path",
+ "value": "test"
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", nonExistentCaseId)
+ .then()
+ .statusCode(404)
+ .body("title", equalTo("Case not found"));
+ }
+
+ @Test
+ void sendSignal_updatesContextAndTriggersWorkers() {
+ // 1. Start a test case
+ UUID caseId = startTestCase();
+
+ // 2. Send signal
+ given()
+ .contentType(ContentType.JSON)
+ .body(
+ """
+ {
+ "path": "approval.status",
+ "value": "approved"
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", caseId)
+ .then()
+ .statusCode(202);
+
+ // 3. Wait for async worker processing
+ await()
+ .atMost(5, SECONDS)
+ .untilAsserted(
+ () -> {
+ // 4. Verify context updated
+ String contextValue =
+ given()
+ .when()
+ .get("/api/v1/cases/{caseId}/context/approval.status", caseId)
+ .then()
+ .statusCode(200)
+ .extract()
+ .asString();
+
+ assertThat(contextValue).isEqualTo("approved");
+ });
+ }
+
+ private UUID startTestCase() {
+ Map request =
+ Map.of(
+ "definition",
+ Map.of("namespace", "test-api", "name", "Document Approval", "version", "1.0.0"),
+ "context",
+ Map.of("documentId", "DOC-123", "submittedBy", "alice@example.com"));
+
+ String response =
+ given()
+ .contentType(ContentType.JSON)
+ .body(request)
+ .when()
+ .post("/api/v1/cases")
+ .then()
+ .statusCode(200)
+ .extract()
+ .path("caseId");
+
+ return UUID.fromString(response);
+ }
+}
diff --git a/src/test/java/io/casehub/flow/rest/SignalResourceTest.java b/src/test/java/io/casehub/flow/rest/SignalResourceTest.java
new file mode 100644
index 0000000..b56360d
--- /dev/null
+++ b/src/test/java/io/casehub/flow/rest/SignalResourceTest.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright 2026-Present The Case Hub Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.casehub.flow.rest;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.equalTo;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import io.casehub.api.engine.CaseHubRuntime;
+import io.casehub.flow.exception.CaseInstanceNotFoundException;
+import io.quarkus.test.junit.QuarkusTest;
+import io.quarkus.test.InjectMock;
+import io.restassured.http.ContentType;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import org.junit.jupiter.api.Test;
+
+@QuarkusTest
+class SignalResourceTest {
+
+ @InjectMock CaseHubRuntime caseHubRuntime;
+
+ @Test
+ void sendSignal_validRequest_returns202() {
+ UUID caseId = UUID.randomUUID();
+
+ // Mock query to validate case exists
+ when(caseHubRuntime.query(eq(caseId), eq("."), eq(Object.class)))
+ .thenReturn(CompletableFuture.completedFuture(new Object()));
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(
+ """
+ {
+ "path": "approvals.user",
+ "value": {"approved": true}
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", caseId)
+ .then()
+ .statusCode(202)
+ .body("caseId", equalTo(caseId.toString()))
+ .body("status", equalTo("accepted"))
+ .body("message", containsString("queued"));
+
+ verify(caseHubRuntime).signal(eq(caseId), eq("approvals.user"), any());
+ }
+
+ @Test
+ void sendSignal_nullRequestBody_returns400() {
+ given()
+ .contentType(ContentType.JSON)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID())
+ .then()
+ .statusCode(400)
+ .body("title", equalTo("Invalid request"))
+ .body("status", equalTo(400));
+ }
+
+ @Test
+ void sendSignal_nullPath_returns400() {
+ given()
+ .contentType(ContentType.JSON)
+ .body(
+ """
+ {
+ "path": null,
+ "value": {"approved": true}
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID())
+ .then()
+ .statusCode(400)
+ .body("title", equalTo("Invalid request"))
+ .body("status", equalTo(400));
+ }
+
+ @Test
+ void sendSignal_nullValue_returns400() {
+ given()
+ .contentType(ContentType.JSON)
+ .body(
+ """
+ {
+ "path": "approvals.user",
+ "value": null
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", UUID.randomUUID())
+ .then()
+ .statusCode(400)
+ .body("title", equalTo("Invalid request"));
+ }
+
+ @Test
+ void sendSignal_caseNotFound_returns404() {
+ UUID caseId = UUID.randomUUID();
+ doThrow(new CaseInstanceNotFoundException(caseId))
+ .when(caseHubRuntime)
+ .query(eq(caseId), eq("."), eq(Object.class));
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(
+ """
+ {
+ "path": "test.path",
+ "value": "test"
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", caseId)
+ .then()
+ .statusCode(404)
+ .body("title", equalTo("Case not found"));
+ }
+
+ @Test
+ void sendSignal_runtimeException_returns500() {
+ UUID caseId = UUID.randomUUID();
+ doThrow(new RuntimeException("Database error"))
+ .when(caseHubRuntime)
+ .query(eq(caseId), eq("."), eq(Object.class));
+
+ given()
+ .contentType(ContentType.JSON)
+ .body(
+ """
+ {
+ "path": "test.path",
+ "value": "test"
+ }
+ """)
+ .when()
+ .post("/api/v1/cases/{caseId}/signals", caseId)
+ .then()
+ .statusCode(500)
+ .body("title", equalTo("Internal server error"))
+ .body("detail", containsString("Failed to send signal"));
+ }
+}