Workflow Input Contract & Trigger Failure Visibility 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: Make a workflow's expected inputs a documented, enforced contract, and surface trigger failures to the user in the UI.
Architecture: Axiom always injects a fixed canonical set of project-context inputs
(projectId, projectName, repository, ref). Publish-time validation constrains a definition's Start node
to that set; a run-time guard translates any engine input error into a clean HTTP 400; the UI surfaces the
message in an inline alert; and new definitions are scaffolded with the canonical inputs already declared.
Tech Stack: Java 25 / Quarkus (RESTEasy Reactive, Panache, JUnit 5 + RestAssured), React 19 + PatternFly 6 (TypeScript).
Spec: docs/superpowers/specs/2026-08-27-workflow-input-contract-design.md
Global Constraints
- Branch: work stays on the current branch
feat/228-workflow-execution-phase2. Do not create a new branch. - Do NOT run Maven builds or run tests automatically — per project preference the user compiles and runs all tests manually. Test code below is written test-first for design; the "verify it fails / passes" steps are performed by the user, not by the executor.
- Never include Claude attribution in commit messages.
- Canonical input set (exact names/types):
projectId(number, always present),projectName(string, always present),repository(string, optional),ref(string, optional). - Contract rules: a Start node may declare only canonical input names; only
projectIdandprojectNamemay be markedrequired: true. - Backend REST resources implement generated JAX-RS interfaces; never add
@Pathto impl classes. This plan does not change the OpenAPI spec or any generated bean.
Task 1: Publish-time Start-input validation
Reject publishing a workflow definition whose Start node violates the input contract. Adds the canonical-set
constants (reused by Task 2) and a validateStartInputs check to the publish path.
Files:
- Modify: app/src/main/java/io/apitomy/axiom/app/rest/WorkflowDefinitionsResourceImpl.java
- Test: app/src/test/java/io/apitomy/axiom/app/WorkflowDefinitionsResourceTest.java
Interfaces:
- Produces: private static final List<String> CANONICAL_INPUT_NAMES,
private static final Set<String> CANONICAL_INPUTS, private static final Set<String> ALWAYS_PRESENT_INPUTS,
and private void validateStartInputs(Workflow workflow) on WorkflowDefinitionsResourceImpl. The constants
are reused by Task 2's scaffolding.
- [ ] Step 1: Write the failing tests
Add these tests to WorkflowDefinitionsResourceTest.java (they reuse the existing createDefinition helper):
@Test
void testPublishRejectsNonCanonicalStartInput() {
int id = createDefinition("Non-Canonical Input WF");
given()
.contentType(ContentType.JSON)
.body("""
{
"id": "wf-nc",
"name": "Non-Canonical",
"nodes": [
{"id": "s1", "type": "start", "name": "Start",
"config": {"inputs": [
{"name": "issueNumber", "type": "string",
"required": true}
]},
"position": {"x": 100, "y": 100}},
{"id": "e1", "type": "end", "name": "End",
"config": {}, "position": {"x": 100, "y": 300}}
],
"edges": [
{"id": "edge1", "source": "s1", "target": "e1",
"priority": 0, "isDefault": true}
]
}
""")
.when()
.put(BASE_PATH + "/" + id + "/content")
.then()
.statusCode(204);
given()
.when()
.post(BASE_PATH + "/" + id + "/publish")
.then()
.statusCode(400);
}
@Test
void testPublishRejectsRequiredOptionalInput() {
int id = createDefinition("Required Optional Input WF");
given()
.contentType(ContentType.JSON)
.body("""
{
"id": "wf-ro",
"name": "Required Optional",
"nodes": [
{"id": "s1", "type": "start", "name": "Start",
"config": {"inputs": [
{"name": "repository", "type": "string",
"required": true}
]},
"position": {"x": 100, "y": 100}},
{"id": "e1", "type": "end", "name": "End",
"config": {}, "position": {"x": 100, "y": 300}}
],
"edges": [
{"id": "edge1", "source": "s1", "target": "e1",
"priority": 0, "isDefault": true}
]
}
""")
.when()
.put(BASE_PATH + "/" + id + "/content")
.then()
.statusCode(204);
given()
.when()
.post(BASE_PATH + "/" + id + "/publish")
.then()
.statusCode(400);
}
@Test
void testPublishAcceptsCanonicalStartInputs() {
int id = createDefinition("Canonical Input WF");
given()
.contentType(ContentType.JSON)
.body("""
{
"id": "wf-can",
"name": "Canonical",
"nodes": [
{"id": "s1", "type": "start", "name": "Start",
"config": {"inputs": [
{"name": "projectId", "type": "number",
"required": true},
{"name": "repository", "type": "string",
"required": false}
]},
"position": {"x": 100, "y": 100}},
{"id": "e1", "type": "end", "name": "End",
"config": {}, "position": {"x": 100, "y": 300}}
],
"edges": [
{"id": "edge1", "source": "s1", "target": "e1",
"priority": 0, "isDefault": true}
]
}
""")
.when()
.put(BASE_PATH + "/" + id + "/content")
.then()
.statusCode(204);
given()
.when()
.post(BASE_PATH + "/" + id + "/publish")
.then()
.statusCode(200)
.body("version", equalTo(1));
}
- [ ] Step 2: Run the tests to verify they fail (user runs)
Run: ./mvnw test -pl app -Dtest=WorkflowDefinitionsResourceTest
Expected: testPublishRejectsNonCanonicalStartInput and testPublishRejectsRequiredOptionalInput FAIL
(publish currently returns 200 for these); testPublishAcceptsCanonicalStartInputs passes.
- [ ] Step 3: Add the canonical-set constants
In WorkflowDefinitionsResourceImpl.java, add import java.util.Set; and
import io.apitomy.flow.model.WorkflowNode; to the imports, then add these fields at the top of the class body
(just after @Inject ObjectMapper objectMapper;):
/** Inputs Axiom always injects when starting a workflow (may be marked required). */
private static final Set<String> ALWAYS_PRESENT_INPUTS =
Set.of("projectId", "projectName");
/** All inputs Axiom may inject (always-present plus conditionally-present). */
private static final List<String> CANONICAL_INPUT_NAMES =
List.of("projectId", "projectName", "repository", "ref");
private static final Set<String> CANONICAL_INPUTS = Set.copyOf(CANONICAL_INPUT_NAMES);
- [ ] Step 4: Add the
validateStartInputshelper
Add this method to WorkflowDefinitionsResourceImpl.java in the "Helpers" section (e.g. after findOrThrow):
/**
* Validates that a workflow's Start node only declares inputs from the canonical
* input contract, and only marks always-present inputs as required. Throws a 400
* WebApplicationException on any violation.
*/
private void validateStartInputs(Workflow workflow) {
WorkflowNode startNode = workflow.findStartNode().orElse(null);
if (startNode == null) {
return; // missing Start node is handled by structural validation
}
Object inputsDef = startNode.config().get("inputs");
if (!(inputsDef instanceof List<?> inputs)) {
return;
}
for (Object inputObj : inputs) {
if (!(inputObj instanceof Map<?, ?> input)) {
continue;
}
Object nameObj = input.get("name");
String name = nameObj != null ? nameObj.toString() : null;
if (name == null || !CANONICAL_INPUTS.contains(name)) {
throw new WebApplicationException(
Response.status(400).entity(Map.of("message",
"Start node input '" + name + "' is not part of the "
+ "workflow input contract. Allowed inputs: "
+ String.join(", ", CANONICAL_INPUT_NAMES)))
.build());
}
if (Boolean.TRUE.equals(input.get("required"))
&& !ALWAYS_PRESENT_INPUTS.contains(name)) {
throw new WebApplicationException(
Response.status(400).entity(Map.of("message",
"Start node input '" + name + "' cannot be marked "
+ "required because Axiom does not always provide "
+ "it. Only projectId and projectName may be "
+ "required.")).build());
}
}
}
- [ ] Step 5: Call
validateStartInputsfrom the publish path
In publishWorkflowDefinition, immediately after the existing WorkflowValidator error block (the
if (!errors.isEmpty()) { ... } that ends around line 189) and before int newVersion = ..., insert:
- [ ] Step 6: Run the tests to verify they pass (user runs)
Run: ./mvnw test -pl app -Dtest=WorkflowDefinitionsResourceTest
Expected: all tests PASS.
- [ ] Step 7: Commit
git add app/src/main/java/io/apitomy/axiom/app/rest/WorkflowDefinitionsResourceImpl.java \
app/src/test/java/io/apitomy/axiom/app/WorkflowDefinitionsResourceTest.java
git commit -m "feat: validate workflow Start-node inputs against canonical contract at publish"
Task 2: Scaffold new definitions with the canonical inputs
New workflow definitions should be created with the canonical inputs already declared on the Start node, so authors see what context is available and definitions conform by default.
Files:
- Modify: app/src/main/java/io/apitomy/axiom/app/rest/WorkflowDefinitionsResourceImpl.java:306-335
(createEmptyWorkflowContent)
- Test: app/src/test/java/io/apitomy/axiom/app/WorkflowDefinitionsResourceTest.java
Interfaces:
- Consumes: nothing new (uses literal input descriptors; the canonical names also match Task 1's constants).
- Produces: a Start node whose config.inputs is the canonical four-element list.
- [ ] Step 1: Write the failing test
Add to WorkflowDefinitionsResourceTest.java:
@Test
void testNewDefinitionSeedsCanonicalInputs() {
int id = createDefinition("Seeded Inputs WF");
given()
.when()
.get(BASE_PATH + "/" + id)
.then()
.statusCode(200)
.body("content.nodes[0].type", equalTo("start"))
.body("content.nodes[0].config.inputs.size()", equalTo(4))
.body("content.nodes[0].config.inputs.name",
hasItems("projectId", "projectName",
"repository", "ref"));
}
- [ ] Step 2: Run the test to verify it fails (user runs)
Run: ./mvnw test -pl app -Dtest=WorkflowDefinitionsResourceTest#testNewDefinitionSeedsCanonicalInputs
Expected: FAIL — the Start node's config is currently empty (Map.of()), so config.inputs is null.
- [ ] Step 3: Seed the Start node config in
createEmptyWorkflowContent
In createEmptyWorkflowContent, build the input descriptors and attach them to the Start node's config.
Replace the current startNode declaration (the block that sets "config", Map.of()) with:
List<Map<String, Object>> startInputs = List.of(
Map.of("name", "projectId", "type", "number",
"required", true,
"description", "The Axiom project id"),
Map.of("name", "projectName", "type", "string",
"required", true,
"description", "The Axiom project name"),
Map.of("name", "repository", "type", "string",
"required", false,
"description", "The project git repository, if any"),
Map.of("name", "ref", "type", "string",
"required", false,
"description", "The project git ref, if any"));
Map<String, Object> startNode = Map.of(
"id", "start-1",
"type", "start",
"name", "Start",
"config", Map.of("inputs", startInputs),
"position", Map.of("x", 250, "y", 100));
- [ ] Step 4: Run the test to verify it passes (user runs)
Run: ./mvnw test -pl app -Dtest=WorkflowDefinitionsResourceTest#testNewDefinitionSeedsCanonicalInputs
Expected: PASS. Also re-run the full WorkflowDefinitionsResourceTest to confirm the existing
testCreateAndGetWorkflowDefinition (which asserts content.nodes.size() == 2) still passes.
- [ ] Step 5: Commit
git add app/src/main/java/io/apitomy/axiom/app/rest/WorkflowDefinitionsResourceImpl.java \
app/src/test/java/io/apitomy/axiom/app/WorkflowDefinitionsResourceTest.java
git commit -m "feat: seed new workflow definitions with canonical Start-node inputs"
Task 3: Run-time input-mismatch guard
Translate the engine's raw input error into a clean HTTP 400 with a readable message. This is defense-in-depth for hand-edited or legacy definitions that predate publish-time validation, so the test persists such a definition directly (bypassing the publish endpoint).
Files:
- Modify: app/src/main/java/io/apitomy/axiom/app/WorkflowExecutionService.java:121-122 (the
workflowEngine.startWorkflow(...) call in triggerWorkflow)
- Create: app/src/test/java/io/apitomy/axiom/app/WorkflowTriggerInputGuardTest.java
Interfaces:
- Consumes: WorkflowExecutionService.triggerWorkflow(long projectId, long definitionId) (existing);
io.apitomy.flow.engine.WorkflowValidationException (existing, extends RuntimeException).
- Produces: triggerWorkflow now throws WebApplicationException (status 400) instead of propagating
IllegalArgumentException / WorkflowValidationException.
- [ ] Step 1: Write the failing test
Create app/src/test/java/io/apitomy/axiom/app/WorkflowTriggerInputGuardTest.java:
package io.apitomy.axiom.app;
import io.apitomy.axiom.core.entities.ProjectEntity;
import io.apitomy.axiom.core.entities.WorkflowDefinitionEntity;
import io.apitomy.axiom.core.entities.WorkflowDefinitionVersionEntity;
import io.quarkus.narayana.jta.QuarkusTransaction;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import jakarta.ws.rs.WebApplicationException;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@QuarkusTest
class WorkflowTriggerInputGuardTest {
@Inject
WorkflowExecutionService service;
/**
* A structurally valid start→end workflow whose Start node requires a
* non-canonical input Axiom never provides. Simulates legacy/hand-edited
* content that bypassed publish-time validation.
*/
private static final String LEGACY_CONTENT = """
{
"id": "legacy-wf",
"name": "Legacy WF",
"nodes": [
{"id": "s1", "type": "start", "name": "Start",
"config": {"inputs": [
{"name": "issueNumber", "type": "string",
"required": true}
]},
"position": {"x": 100, "y": 100}},
{"id": "e1", "type": "end", "name": "End",
"config": {}, "position": {"x": 100, "y": 300}}
],
"edges": [
{"id": "edge1", "source": "s1", "target": "e1",
"priority": 0, "isDefault": true}
]
}
""";
@Test
void testTriggerWithMissingRequiredInputReturns400() {
long[] ids = QuarkusTransaction.requiringNew().call(() -> {
ProjectEntity project = new ProjectEntity();
project.name = "Input Guard Project";
project.type = "other";
project.status = "new";
project.ref = "test/input-guard";
project.createdOn = Instant.now();
project.updatedOn = Instant.now();
project.persist();
WorkflowDefinitionEntity def = new WorkflowDefinitionEntity();
def.name = "Input Guard WF";
def.content = LEGACY_CONTENT;
def.currentVersion = 1;
def.createdOn = Instant.now();
def.updatedOn = Instant.now();
def.persist();
WorkflowDefinitionVersionEntity version =
new WorkflowDefinitionVersionEntity();
version.definitionId = def.id;
version.version = 1;
version.content = LEGACY_CONTENT;
version.createdOn = Instant.now();
version.persist();
return new long[] { project.id, def.id };
});
WebApplicationException ex = assertThrows(
WebApplicationException.class,
() -> service.triggerWorkflow(ids[0], ids[1]));
assertEquals(400, ex.getResponse().getStatus());
}
}
- [ ] Step 2: Run the test to verify it fails (user runs)
Run: ./mvnw test -pl app -Dtest=WorkflowTriggerInputGuardTest
Expected: FAIL — triggerWorkflow currently throws a raw IllegalArgumentException (not a
WebApplicationException), so assertThrows(WebApplicationException.class, ...) fails.
- [ ] Step 3: Add the run-time guard
In WorkflowExecutionService.java, add these imports:
Then in triggerWorkflow, replace the direct engine call (currently
WorkflowInstance instance = workflowEngine.startWorkflow(workflow, context);) with:
WorkflowInstance instance;
try {
instance = workflowEngine.startWorkflow(workflow, context);
} catch (IllegalArgumentException | WorkflowValidationException e) {
throw new WebApplicationException(
Response.status(400)
.entity(Map.of("message", e.getMessage()))
.build());
}
- [ ] Step 4: Run the test to verify it passes (user runs)
Run: ./mvnw test -pl app -Dtest=WorkflowTriggerInputGuardTest
Expected: PASS. Also re-run WorkflowInstanceResourceTest to confirm the happy-path trigger tests still pass.
- [ ] Step 5: Commit
git add app/src/main/java/io/apitomy/axiom/app/WorkflowExecutionService.java \
app/src/test/java/io/apitomy/axiom/app/WorkflowTriggerInputGuardTest.java
git commit -m "feat: return 400 with a message when a workflow trigger fails input validation"
Task 4: Surface trigger failures in the UI
Stop discarding the backend error body in the API client, and render the failure as an inline alert in the Run Workflow modal (which now stays open on failure).
Files:
- Modify: ui/src/config/api.ts:2236-2250 (triggerWorkflow)
- Modify: ui/src/components/WorkflowTab.tsx
Interfaces:
- Consumes: backend 400 responses whose JSON body is { "message": "..." } (from Tasks 1 and 3).
- Produces: triggerWorkflow rejects with an Error whose .message is the backend message when available.
- [ ] Step 1: Surface the response body in
api.ts
In ui/src/config/api.ts, replace the error block inside triggerWorkflow (the
if (!response.ok) { throw new Error(...) }) with:
if (!response.ok) {
let message = `Failed to trigger workflow: ${response.status}`;
try {
const body = await response.json();
if (body && typeof body === "object"
&& typeof body.message === "string" && body.message) {
message = body.message;
} else if (typeof body === "string" && body) {
message = body;
}
} catch {
// No JSON body; keep the status-based message.
}
throw new Error(message);
}
- [ ] Step 2: Add error state and alert to
WorkflowTab.tsx
Make the following edits to ui/src/components/WorkflowTab.tsx:
(a) Add Alert to the PatternFly import:
import {
Alert,
Button, EmptyState, EmptyStateBody,
Flex, FlexItem, Label, Modal, ModalBody,
ModalFooter, ModalHeader, Form, FormGroup,
FormSelect, FormSelectOption,
} from "@patternfly/react-core";
(b) Add an error state next to the other useState hooks (near submitting):
(c) In openTriggerModal, clear the error before opening — add setTriggerError(null); inside the .then(...)
callback (e.g. right before setIsTriggerOpen(true);).
(d) Replace handleTrigger with a version that clears then captures the error:
const handleTrigger = useCallback(() => {
if (!selectedDefId) return;
setSubmitting(true);
setTriggerError(null);
triggerWorkflow(projectId, {
workflowDefinitionId: Number(selectedDefId),
})
.then(() => {
setIsTriggerOpen(false);
onRefresh();
loadInstance();
})
.catch((err) => setTriggerError(
err instanceof Error
? err.message
: "Failed to run workflow"))
.finally(() => setSubmitting(false));
}, [projectId, selectedDefId, onRefresh, loadInstance]);
(e) Clear the error when the modal is closed — change the trigger Modal's onClose and the footer Cancel
button onClick from () => setIsTriggerOpen(false) to:
(f) Render the alert at the top of the trigger modal's ModalBody, before the
{definitions.length === 0 ? ... } expression:
{triggerError && (
<Alert variant="danger" isInline
title="Failed to run workflow"
style={{ marginBottom: "16px" }}>
{triggerError}
</Alert>
)}
- [ ] Step 3: Verify in the browser (user runs the app)
Build/run per project convention (the user handles this). Open a project with no workflow, click Run Workflow, choose a definition whose Start node requires an unsatisfiable input (e.g. a legacy one), and confirm the modal stays open and shows the red alert with the backend message. Then run a valid definition and confirm the modal closes and the instance appears.
- [ ] Step 4: Commit
git add ui/src/config/api.ts ui/src/components/WorkflowTab.tsx
git commit -m "feat(ui): show workflow trigger failures in the Run Workflow modal"
Task 5: Documentation
Document the canonical input contract and update the Phase 2 spec's trigger error list.
Files:
- Create: docs/developer-guide/workflow-input-contract.md
- Modify: docs/superpowers/specs/2026-08-26-workflow-execution-phase2-design.md (trigger error-response list,
around lines 107-110)
- [ ] Step 1: Write the contract doc
Create docs/developer-guide/workflow-input-contract.md (prose wrapped at 110 columns; the table is exempt):
# Workflow Input Contract
When Axiom starts a workflow for a project, it injects a fixed, canonical set of inputs into the workflow's
initial context. Workflow definitions may depend only on these inputs. This keeps a definition runnable by
construction — there is no way to supply arbitrary inputs from the "Run Workflow" dialog.
## Canonical inputs
| name | type | presence | may be `required`? |
|---------------|--------|-----------------------------|--------------------|
| `projectId` | number | always | yes |
| `projectName` | string | always | yes |
| `repository` | string | only if the project has one | no — must be optional |
| `ref` | string | only if the project has one | no — must be optional |
`repository` and `ref` are injected only when the project defines them, so they can be absent at run time.
## Rules enforced at publish
A workflow definition's Start node declares its inputs under `config.inputs` (a list of
`{ name, type, required, description }`). At publish time Axiom rejects a definition when:
1. The Start node declares an input whose `name` is not one of the canonical inputs.
2. The Start node marks `repository` or `ref` (or any non-always-present input) as `required`.
New definitions are scaffolded with all four canonical inputs already declared (`projectId`/`projectName`
required, `repository`/`ref` optional).
## Run-time behavior
As defense-in-depth for legacy or hand-edited definitions, a trigger whose context does not satisfy a
required Start-node input fails with HTTP 400 and a message naming the missing input, which the UI surfaces in
the Run Workflow dialog.
- [ ] Step 2: Update the Phase 2 spec error list
In docs/superpowers/specs/2026-08-26-workflow-execution-phase2-design.md, in the trigger error-response list
(around lines 107-110), add an entry for the required-input case, matching the surrounding style, e.g.:
- `400` — the workflow's Start node requires an input Axiom does not provide (input contract violation).
- [ ] Step 3: Commit
git add docs/developer-guide/workflow-input-contract.md \
docs/superpowers/specs/2026-08-26-workflow-execution-phase2-design.md
git commit -m "docs: document the workflow input contract"
Self-Review
Spec coverage: - Canonical contract (spec §"The canonical input contract") → Task 1 constants + Task 5 doc. - Publish-time validation (spec §Design.1) → Task 1. - Run-time guard (spec §Design.2) → Task 3. - UI visibility, api.ts body + modal alert (spec §Design.3) → Task 4. - Start-node scaffolding (spec §Design.4) → Task 2. - Docs (spec §Design.5) → Task 5.
Type consistency: validateStartInputs(Workflow), CANONICAL_INPUT_NAMES, CANONICAL_INPUTS,
ALWAYS_PRESENT_INPUTS are named identically in Task 1's definition and Task 2's reference. Error bodies are
{ "message": string } in Tasks 1 and 3, and Task 4's api.ts reads exactly body.message. triggerWorkflow
signature matches the existing service method used in Task 3's test.
Placeholder scan: No TBD/TODO; every code step has concrete code.
Notes for the executor: Per the Global Constraints, do not run Maven or npm builds/tests yourself — the
user runs them. Confirm each backend edit compiles conceptually against the shown imports; the Response
import already exists in WorkflowDefinitionsResourceImpl but must be added to WorkflowExecutionService.