diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/CompileError.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/CompileError.java
new file mode 100644
index 00000000..d4ab52ff
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/CompileError.java
@@ -0,0 +1,19 @@
+package vip.mate.workflow.compiler;
+
+/**
+ * Single workflow compile-time diagnostic. {@code path} points at the offending
+ * field using a JSONPath-ish notation rooted at the workflow definition (e.g.
+ * {@code steps[2].mode.expression} or {@code steps[5]}).
+ */
+public record CompileError(String code, String path, String message) {
+
+ /** Convenience for step-rooted errors. */
+ public static CompileError step(int index, String code, String message) {
+ return new CompileError(code, "steps[" + index + "]", message);
+ }
+
+ /** Step-rooted error pointing at a specific sub-field. */
+ public static CompileError stepField(int index, String field, String code, String message) {
+ return new CompileError(code, "steps[" + index + "]." + field, message);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ExpressionException.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ExpressionException.java
new file mode 100644
index 00000000..ff94933a
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ExpressionException.java
@@ -0,0 +1,11 @@
+package vip.mate.workflow.compiler;
+
+/**
+ * Raised by {@link PebbleSubsetEvaluator} on parse or evaluate failures so
+ * callers (the schema validator, output-content-type checker, and runtime)
+ * see a single exception type for all expression-language errors.
+ */
+public class ExpressionException extends RuntimeException {
+ public ExpressionException(String message) { super(message); }
+ public ExpressionException(String message, Throwable cause) { super(message, cause); }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/OutputContentTypeChecker.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/OutputContentTypeChecker.java
new file mode 100644
index 00000000..e3228845
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/OutputContentTypeChecker.java
@@ -0,0 +1,99 @@
+package vip.mate.workflow.compiler;
+
+import org.springframework.stereotype.Component;
+import vip.mate.workflow.compiler.ir.StepMode;
+import vip.mate.workflow.compiler.ir.WorkflowGraph;
+import vip.mate.workflow.compiler.ir.WorkflowStep;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Compile-time guard against accessing a sub-field on a step output whose
+ * content type is plain text. The rule:
+ *
+ * - {@code outputs.X} is always allowed — the value is always defined as
+ * a string for text outputs and as a parsed JSON for json outputs.
+ * - {@code outputs.X.field} is only allowed when step X has
+ * {@code outputContentType: json}; on a text output the access raises
+ * a compile-time error.
+ *
+ *
+ * The check uses a regex over the expression / template source rather
+ * than a full Pebble AST walk. This is good enough for v0 — the only
+ * sub-field reads that matter are the literal {@code outputs..}
+ * pattern; users who genuinely need richer JSON paths use the {@code | jq}
+ * filter (added in Lane 2) instead of dotted access.
+ */
+@Component
+public class OutputContentTypeChecker {
+
+ private static final Pattern OUTPUT_FIELD_REF = Pattern.compile(
+ "\\boutputs\\.([A-Za-z_][A-Za-z0-9_]*)\\.([A-Za-z_][A-Za-z0-9_.]*)");
+
+ public List check(WorkflowGraph graph) {
+ if (graph == null || graph.steps().isEmpty()) {
+ return List.of();
+ }
+ Map outputVarToContentType = collectOutputVars(graph);
+
+ List errors = new ArrayList<>();
+ for (int i = 0; i < graph.steps().size(); i++) {
+ WorkflowStep s = graph.steps().get(i);
+ // Each step contributes a few sources that may carry expressions:
+ // promptTemplate, conditional.expression, dispatch_channel.content,
+ // write_memory.content. Walk them all.
+ checkSource(i, "promptTemplate", s.promptTemplate(), outputVarToContentType, errors);
+ if (s.mode() instanceof StepMode.Conditional c) {
+ checkSource(i, "mode.expression", c.expression(), outputVarToContentType, errors);
+ } else if (s.mode() instanceof StepMode.DispatchChannel d) {
+ checkSource(i, "mode.content", d.content(), outputVarToContentType, errors);
+ } else if (s.mode() instanceof StepMode.WriteMemory w) {
+ checkSource(i, "mode.content", w.content(), outputVarToContentType, errors);
+ }
+ }
+ return errors;
+ }
+
+ private static Map collectOutputVars(WorkflowGraph graph) {
+ Map out = new HashMap<>();
+ for (WorkflowStep s : graph.steps()) {
+ String var = s.outputVar();
+ if (var != null && !var.isBlank()) {
+ out.put(var, s.effectiveOutputContentType());
+ }
+ }
+ return out;
+ }
+
+ private static void checkSource(int stepIndex, String fieldPath, String source,
+ Map outputContentTypes,
+ List errors) {
+ if (source == null || source.isEmpty()) {
+ return;
+ }
+ Matcher m = OUTPUT_FIELD_REF.matcher(source);
+ while (m.find()) {
+ String varName = m.group(1);
+ String fieldRest = m.group(2);
+ String contentType = outputContentTypes.get(varName);
+ if (contentType == null) {
+ errors.add(CompileError.stepField(stepIndex, fieldPath,
+ "expression.unknown_output_var",
+ "expression references unknown outputVar '" + varName + "'"));
+ continue;
+ }
+ if (!"json".equals(contentType)) {
+ errors.add(CompileError.stepField(stepIndex, fieldPath,
+ "expression.field_on_text_output",
+ "cannot access '." + fieldRest + "' on output '" + varName
+ + "' because its outputContentType is text — "
+ + "set outputContentType: json on the producing step"));
+ }
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/PebbleSubsetEvaluator.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/PebbleSubsetEvaluator.java
new file mode 100644
index 00000000..d0aface2
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/PebbleSubsetEvaluator.java
@@ -0,0 +1,141 @@
+package vip.mate.workflow.compiler;
+
+import io.pebbletemplates.pebble.PebbleEngine;
+import io.pebbletemplates.pebble.template.PebbleTemplate;
+import org.springframework.stereotype.Component;
+
+import java.io.StringWriter;
+import java.io.Writer;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Wraps Pebble with a v0 expression-language subset suitable for workflow
+ * conditionals and string templates. The wrapper:
+ *
+ * - Pre-screens the source for blocked tags ({@code {% include %}},
+ * {@code {% extends %}}, {@code {% import %}}, {@code {% from %}},
+ * {@code {% set %}}, {@code {% macro %}}, {@code {% block %}}). These
+ * reach beyond the expression sandbox and are never required for a
+ * workflow expression.
+ * - Disables auto-escaping (workflow content is not HTML), turns the
+ * template cache off (each compile is one-shot), and runs in
+ * non-strict variable mode so {@code default('x')} and missing-field
+ * access remain ergonomic.
+ * - Treats expressions and full string templates as the same engine
+ * artifact — {@link #parseExpression(String)} accepts either the bare
+ * expression ({@code outputs.x.tier == 'enterprise'}) or the wrapped
+ * form ({@code "{{ outputs.x.tier == 'enterprise' }}"}).
+ *
+ *
+ * JSONPath-style filtering (the {@code | jq('.foo')} syntax in the design
+ * doc) is intentionally not yet wired here — Day 2-3 ships only the engine
+ * wrapper plus parse / evaluate; the {@code jq} filter will be added in
+ * Lane 2 alongside its runtime tests so we can exercise it against real
+ * step outputs.
+ */
+@Component
+public class PebbleSubsetEvaluator {
+
+ private static final Pattern BLOCKED_TAG_PATTERN = Pattern.compile(
+ "\\{%\\s*(include|extends|import|from|set|macro|block)\\b",
+ Pattern.CASE_INSENSITIVE);
+
+ /** Wrapping form recognized for bare conditional expressions. */
+ private static final Pattern WRAPPED_EXPRESSION = Pattern.compile(
+ "^\\s*\\{\\{(.*)\\}\\}\\s*$", Pattern.DOTALL);
+
+ private final PebbleEngine engine;
+
+ public PebbleSubsetEvaluator() {
+ this.engine = new PebbleEngine.Builder()
+ .strictVariables(false)
+ .cacheActive(false)
+ .autoEscaping(false)
+ .build();
+ }
+
+ /**
+ * Parse a conditional expression into a compiled artifact ready for
+ * repeated evaluation. Accepts either {@code expr} or {@code "{{ expr }}"}.
+ */
+ public Compiled parseExpression(String expression) {
+ if (expression == null || expression.isBlank()) {
+ throw new ExpressionException("expression is empty");
+ }
+ rejectBlockedTags(expression);
+
+ String inner = stripWrapping(expression);
+ String source = "{{ " + inner + " }}";
+ return compile(source, expression);
+ }
+
+ /**
+ * Parse a multi-segment string template (prompt template, dispatch_channel
+ * content, write_memory content). The whole string is treated as a Pebble
+ * template body.
+ */
+ public Compiled parseTemplate(String template) {
+ if (template == null) {
+ throw new ExpressionException("template is null");
+ }
+ rejectBlockedTags(template);
+ return compile(template, template);
+ }
+
+ public String evaluateAsString(Compiled compiled, Map context) {
+ StringWriter writer = new StringWriter();
+ evaluate(compiled, context, writer);
+ return writer.toString();
+ }
+
+ public boolean evaluateAsBoolean(Compiled compiled, Map context) {
+ String rendered = evaluateAsString(compiled, context).trim();
+ return "true".equalsIgnoreCase(rendered);
+ }
+
+ private void evaluate(Compiled compiled, Map context, Writer writer) {
+ try {
+ compiled.template.evaluate(writer, context == null ? Map.of() : context);
+ } catch (Exception e) {
+ throw new ExpressionException(
+ "expression evaluation failed: " + e.getMessage()
+ + " (source: " + compiled.originalSource + ")",
+ e);
+ }
+ }
+
+ private Compiled compile(String pebbleSource, String originalSource) {
+ try {
+ // getLiteralTemplate uses the source string itself as the template
+ // body, bypassing the Loader (which is the right call here — we
+ // never want to read templates from the filesystem or classpath).
+ PebbleTemplate template = engine.getLiteralTemplate(pebbleSource);
+ return new Compiled(template, originalSource);
+ } catch (Exception e) {
+ throw new ExpressionException(
+ "expression parse failed: " + e.getMessage()
+ + " (source: " + originalSource + ")",
+ e);
+ }
+ }
+
+ private static void rejectBlockedTags(String source) {
+ Matcher m = BLOCKED_TAG_PATTERN.matcher(source);
+ if (m.find()) {
+ throw new ExpressionException(
+ "expression uses blocked tag '" + m.group(1)
+ + "' — workflow expressions only allow {{ ... }} substitutions");
+ }
+ }
+
+ private static String stripWrapping(String expression) {
+ Matcher m = WRAPPED_EXPRESSION.matcher(expression);
+ return m.matches() ? m.group(1).trim() : expression.trim();
+ }
+
+ /** Compiled, reusable expression. */
+ public record Compiled(PebbleTemplate template, String originalSource) {
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/PublishContext.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/PublishContext.java
new file mode 100644
index 00000000..4c950065
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/PublishContext.java
@@ -0,0 +1,9 @@
+package vip.mate.workflow.compiler;
+
+/**
+ * Immutable scope passed to publish-time validators: the workspace the
+ * workflow lives in plus the user attempting to publish. ACL checks compare
+ * these against the resolvable agent / channel / employee scope.
+ */
+public record PublishContext(long workspaceId, Long publisherId) {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowAclPort.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowAclPort.java
new file mode 100644
index 00000000..86bd2cb9
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowAclPort.java
@@ -0,0 +1,24 @@
+package vip.mate.workflow.compiler;
+
+/**
+ * Pluggable ACL probe used by {@link WorkflowAclValidator}. The validator
+ * stays free of Spring-bean dependencies (mapper / service injection) so its
+ * unit tests can stub a port directly. The runtime wiring sits in
+ * {@code vip.mate.workflow.runtime} where this port is implemented in terms
+ * of {@code AgentBindingService}, the workspace channel allowlist, and the
+ * mate_skill.enabled view.
+ */
+public interface WorkflowAclPort {
+
+ /** True if the named agent exists, is enabled, and lives in the workspace. */
+ boolean agentExists(long workspaceId, String agentName);
+
+ /** True if the agentId resolves to an enabled agent in the workspace. */
+ boolean agentIdExists(long workspaceId, long agentId);
+
+ /** True if the channel is on the workspace allowlist. */
+ boolean channelAllowed(long workspaceId, String channelName);
+
+ /** True if employeeId is a member of the workspace. */
+ boolean employeeInWorkspace(long workspaceId, String employeeId);
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowAclValidator.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowAclValidator.java
new file mode 100644
index 00000000..40b2ff29
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowAclValidator.java
@@ -0,0 +1,106 @@
+package vip.mate.workflow.compiler;
+
+import org.springframework.stereotype.Component;
+import vip.mate.workflow.compiler.ir.StepMode;
+import vip.mate.workflow.compiler.ir.WorkflowGraph;
+import vip.mate.workflow.compiler.ir.WorkflowStep;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Publish-time access-control validator. For each step that touches an
+ * external scope (agent / channel / employee memory), the validator asks
+ * the {@link WorkflowAclPort} whether the reference resolves inside the
+ * publishing workspace. Any negative answer is recorded as a
+ * {@link CompileError}; downstream the publish flow refuses to write a new
+ * revision when the error list is non-empty.
+ *
+ * Pure structural ACL — workflow-level actor identity (the publisher
+ * versus the runtime acting agent) is enforced separately when steps are
+ * registered with the runtime, where {@code AgentBindingService.getEffectiveToolNames}
+ * applies the per-agent tool ACL.
+ */
+@Component
+public class WorkflowAclValidator {
+
+ public List validate(WorkflowGraph graph, PublishContext ctx, WorkflowAclPort port) {
+ if (graph == null || graph.steps().isEmpty()) {
+ return List.of();
+ }
+ List errors = new ArrayList<>();
+ for (int i = 0; i < graph.steps().size(); i++) {
+ WorkflowStep s = graph.steps().get(i);
+ checkAgent(i, s, ctx, port, errors);
+ checkChannels(i, s, ctx, port, errors);
+ checkEmployee(i, s, ctx, port, errors);
+ }
+ return errors;
+ }
+
+ private static void checkAgent(int i, WorkflowStep s, PublishContext ctx,
+ WorkflowAclPort port, List errors) {
+ if (s.mode() instanceof StepMode.AwaitApproval
+ || s.mode() instanceof StepMode.Collect
+ || s.mode() instanceof StepMode.DispatchChannel
+ || s.mode() instanceof StepMode.WriteMemory) {
+ return; // these modes do not invoke an agent at runtime
+ }
+ if (s.agentId() != null) {
+ if (!port.agentIdExists(ctx.workspaceId(), s.agentId())) {
+ errors.add(CompileError.stepField(i, "agentId",
+ "acl.agent_not_resolvable",
+ "agentId " + s.agentId() + " does not resolve to an enabled agent in this workspace"));
+ }
+ return;
+ }
+ if (s.agentName() != null && !s.agentName().isBlank()
+ && !port.agentExists(ctx.workspaceId(), s.agentName())) {
+ errors.add(CompileError.stepField(i, "agentName",
+ "acl.agent_not_resolvable",
+ "agent '" + s.agentName() + "' does not resolve to an enabled agent in this workspace"));
+ }
+ }
+
+ private static void checkChannels(int i, WorkflowStep s, PublishContext ctx,
+ WorkflowAclPort port, List errors) {
+ if (!(s.mode() instanceof StepMode.DispatchChannel d)) {
+ return;
+ }
+ if (d.channels() == null) return;
+ for (int c = 0; c < d.channels().size(); c++) {
+ String ch = d.channels().get(c);
+ if (ch == null || ch.isBlank()) continue;
+ if (!port.channelAllowed(ctx.workspaceId(), ch)) {
+ errors.add(CompileError.stepField(i, "mode.channels[" + c + "]",
+ "acl.channel_not_allowed",
+ "channel '" + ch + "' is not on the workspace allowlist"));
+ }
+ }
+ }
+
+ private static void checkEmployee(int i, WorkflowStep s, PublishContext ctx,
+ WorkflowAclPort port, List errors) {
+ if (!(s.mode() instanceof StepMode.WriteMemory w)) {
+ return;
+ }
+ // Pebble templates resolve at runtime — do not ACL-check expressions
+ // that aren't a literal employee id. Literal forms are the safe
+ // common case worth guarding.
+ if (w.employeeId() == null || w.employeeId().isBlank()) {
+ return;
+ }
+ if (containsTemplate(w.employeeId())) {
+ return;
+ }
+ if (!port.employeeInWorkspace(ctx.workspaceId(), w.employeeId())) {
+ errors.add(CompileError.stepField(i, "mode.employeeId",
+ "acl.employee_not_in_workspace",
+ "employeeId '" + w.employeeId() + "' is not a member of this workspace"));
+ }
+ }
+
+ private static boolean containsTemplate(String s) {
+ return s != null && s.contains("{{");
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowCompileFailedException.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowCompileFailedException.java
new file mode 100644
index 00000000..670e1848
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowCompileFailedException.java
@@ -0,0 +1,36 @@
+package vip.mate.workflow.compiler;
+
+import java.util.List;
+
+/**
+ * Thrown by {@link WorkflowCompiler.Result#requireOk()} when at least one
+ * compile error was raised. The error list is preserved on the exception so
+ * callers (REST endpoints, persistence layers) can surface every problem
+ * back to the publishing user without losing diagnostic context.
+ */
+public class WorkflowCompileFailedException extends RuntimeException {
+
+ private final List errors;
+
+ public WorkflowCompileFailedException(List errors) {
+ super(buildMessage(errors));
+ this.errors = List.copyOf(errors);
+ }
+
+ public List errors() {
+ return errors;
+ }
+
+ private static String buildMessage(List errors) {
+ if (errors == null || errors.isEmpty()) {
+ return "workflow compile failed";
+ }
+ StringBuilder sb = new StringBuilder();
+ sb.append("workflow compile failed with ").append(errors.size()).append(" error(s):");
+ for (CompileError e : errors) {
+ sb.append("\n - [").append(e.code()).append("] ").append(e.path())
+ .append(": ").append(e.message());
+ }
+ return sb.toString();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowCompiler.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowCompiler.java
new file mode 100644
index 00000000..d0ea6fad
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowCompiler.java
@@ -0,0 +1,112 @@
+package vip.mate.workflow.compiler;
+
+import org.springframework.stereotype.Component;
+import vip.mate.workflow.compiler.ir.WorkflowGraph;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Top-level compile entry point. Runs each pass in order and collects the
+ * resulting diagnostics into a single {@link Result}. Phases short-circuit
+ * on the kind of failure that would invalidate later passes:
+ *
+ * - Parse failure raises a {@link WorkflowParseException} immediately —
+ * structural validation needs an IR.
+ * - Schema, expression, and ACL checks are independent and all run, so
+ * a single compile call surfaces every problem instead of
+ * error-by-error round-trips.
+ *
+ */
+@Component
+public class WorkflowCompiler {
+
+ private final WorkflowParser parser;
+ private final WorkflowSchemaValidator schemaValidator;
+ private final OutputContentTypeChecker outputContentTypeChecker;
+ private final WorkflowAclValidator aclValidator;
+ private final PebbleSubsetEvaluator pebbleEvaluator;
+
+ public WorkflowCompiler(WorkflowParser parser,
+ WorkflowSchemaValidator schemaValidator,
+ OutputContentTypeChecker outputContentTypeChecker,
+ WorkflowAclValidator aclValidator,
+ PebbleSubsetEvaluator pebbleEvaluator) {
+ this.parser = parser;
+ this.schemaValidator = schemaValidator;
+ this.outputContentTypeChecker = outputContentTypeChecker;
+ this.aclValidator = aclValidator;
+ this.pebbleEvaluator = pebbleEvaluator;
+ }
+
+ public Result compile(String json, PublishContext ctx, WorkflowAclPort aclPort) {
+ WorkflowGraph graph = parser.parse(json);
+ List errors = new ArrayList<>();
+ errors.addAll(schemaValidator.validate(graph));
+ errors.addAll(checkExpressionSyntax(graph));
+ errors.addAll(outputContentTypeChecker.check(graph));
+ if (aclPort != null) {
+ errors.addAll(aclValidator.validate(graph, ctx, aclPort));
+ }
+ return new Result(graph, Collections.unmodifiableList(errors));
+ }
+
+ private List checkExpressionSyntax(WorkflowGraph graph) {
+ List errors = new ArrayList<>();
+ for (int i = 0; i < graph.steps().size(); i++) {
+ var step = graph.steps().get(i);
+ if (step.mode() instanceof vip.mate.workflow.compiler.ir.StepMode.Conditional c
+ && c.expression() != null && !c.expression().isBlank()) {
+ try {
+ pebbleEvaluator.parseExpression(c.expression());
+ } catch (ExpressionException e) {
+ errors.add(CompileError.stepField(i, "mode.expression",
+ "expression.parse_failed", e.getMessage()));
+ }
+ }
+ if (step.promptTemplate() != null && !step.promptTemplate().isBlank()) {
+ try {
+ pebbleEvaluator.parseTemplate(step.promptTemplate());
+ } catch (ExpressionException e) {
+ errors.add(CompileError.stepField(i, "promptTemplate",
+ "expression.parse_failed", e.getMessage()));
+ }
+ }
+ if (step.mode() instanceof vip.mate.workflow.compiler.ir.StepMode.DispatchChannel d
+ && d.content() != null && !d.content().isBlank()) {
+ try {
+ pebbleEvaluator.parseTemplate(d.content());
+ } catch (ExpressionException e) {
+ errors.add(CompileError.stepField(i, "mode.content",
+ "expression.parse_failed", e.getMessage()));
+ }
+ }
+ if (step.mode() instanceof vip.mate.workflow.compiler.ir.StepMode.WriteMemory w
+ && w.content() != null && !w.content().isBlank()) {
+ try {
+ pebbleEvaluator.parseTemplate(w.content());
+ } catch (ExpressionException e) {
+ errors.add(CompileError.stepField(i, "mode.content",
+ "expression.parse_failed", e.getMessage()));
+ }
+ }
+ }
+ return errors;
+ }
+
+ /**
+ * Compile result. Callers that want strictness can do
+ * {@code result.requireOk()}; the publish flow uses that to refuse
+ * persisting a new revision row when there are errors.
+ */
+ public record Result(WorkflowGraph graph, List errors) {
+ public boolean ok() { return errors.isEmpty(); }
+
+ public void requireOk() {
+ if (!ok()) {
+ throw new WorkflowCompileFailedException(errors);
+ }
+ }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowParseException.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowParseException.java
new file mode 100644
index 00000000..2b870155
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowParseException.java
@@ -0,0 +1,12 @@
+package vip.mate.workflow.compiler;
+
+/**
+ * Thrown by {@link WorkflowParser} when the JSON wire format cannot be turned
+ * into a {@link vip.mate.workflow.compiler.ir.WorkflowGraph}. Distinct from
+ * {@link CompileError} so that wire-format problems never reach the validator
+ * passes — those operate exclusively on a syntactically valid IR.
+ */
+public class WorkflowParseException extends RuntimeException {
+ public WorkflowParseException(String message) { super(message); }
+ public WorkflowParseException(String message, Throwable cause) { super(message, cause); }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowParser.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowParser.java
new file mode 100644
index 00000000..79244e13
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowParser.java
@@ -0,0 +1,243 @@
+package vip.mate.workflow.compiler;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.stereotype.Component;
+import vip.mate.workflow.compiler.ir.ErrorMode;
+import vip.mate.workflow.compiler.ir.StepMode;
+import vip.mate.workflow.compiler.ir.WorkflowGraph;
+import vip.mate.workflow.compiler.ir.WorkflowInput;
+import vip.mate.workflow.compiler.ir.WorkflowStep;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Parse the workflow JSON wire format into the immutable {@link WorkflowGraph}
+ * IR. The parser is structural-only: it surfaces malformed JSON and unknown
+ * mode types as {@link WorkflowParseException}s but does not run schema /
+ * expression / ACL validation — those passes consume the IR and emit
+ * {@link CompileError}s.
+ *
+ * Field naming matches the wire format documented in the workflow design
+ * (see {@code mate_workflow_revision.graph_json}).
+ */
+@Component
+public class WorkflowParser {
+
+ private final ObjectMapper objectMapper;
+
+ public WorkflowParser(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ public WorkflowGraph parse(String json) {
+ if (json == null || json.isBlank()) {
+ throw new WorkflowParseException("workflow definition is empty");
+ }
+ JsonNode root;
+ try {
+ root = objectMapper.readTree(json);
+ } catch (Exception e) {
+ throw new WorkflowParseException("workflow JSON is not parseable: " + e.getMessage(), e);
+ }
+ if (!root.isObject()) {
+ throw new WorkflowParseException("workflow definition root must be a JSON object");
+ }
+
+ String schemaVersion = textOrNull(root.get("schemaVersion"));
+ List inputs = parseInputs(root.get("inputs"));
+ List steps = parseSteps(root.get("steps"));
+
+ return new WorkflowGraph(schemaVersion, inputs, steps);
+ }
+
+ private List parseInputs(JsonNode node) {
+ if (node == null || node.isNull()) {
+ return List.of();
+ }
+ if (!node.isArray()) {
+ throw new WorkflowParseException("inputs must be a JSON array");
+ }
+ List out = new ArrayList<>(node.size());
+ for (int i = 0; i < node.size(); i++) {
+ JsonNode entry = node.get(i);
+ if (!entry.isObject()) {
+ throw new WorkflowParseException("inputs[" + i + "] must be a JSON object");
+ }
+ out.add(new WorkflowInput(
+ textOrNull(entry.get("name")),
+ textOrNull(entry.get("type"))
+ ));
+ }
+ return out;
+ }
+
+ private List parseSteps(JsonNode node) {
+ if (node == null || node.isNull()) {
+ return List.of();
+ }
+ if (!node.isArray()) {
+ throw new WorkflowParseException("steps must be a JSON array");
+ }
+ List out = new ArrayList<>(node.size());
+ for (int i = 0; i < node.size(); i++) {
+ JsonNode raw = node.get(i);
+ if (!raw.isObject()) {
+ throw new WorkflowParseException("steps[" + i + "] must be a JSON object");
+ }
+ out.add(parseStep(raw, i));
+ }
+ return out;
+ }
+
+ private WorkflowStep parseStep(JsonNode raw, int index) {
+ Long agentId = null;
+ JsonNode agentIdNode = raw.get("agentId");
+ if (agentIdNode != null && !agentIdNode.isNull()) {
+ if (agentIdNode.isNumber()) {
+ agentId = agentIdNode.asLong();
+ } else if (agentIdNode.isTextual()) {
+ try {
+ agentId = Long.parseLong(agentIdNode.asText());
+ } catch (NumberFormatException e) {
+ throw new WorkflowParseException("steps[" + index + "].agentId must be numeric");
+ }
+ } else {
+ throw new WorkflowParseException("steps[" + index + "].agentId must be numeric");
+ }
+ }
+
+ Integer timeoutSecs = null;
+ JsonNode toNode = raw.get("timeoutSecs");
+ if (toNode != null && !toNode.isNull()) {
+ if (!toNode.isInt() && !toNode.isLong()) {
+ throw new WorkflowParseException("steps[" + index + "].timeoutSecs must be an integer");
+ }
+ timeoutSecs = toNode.asInt();
+ }
+
+ StepMode mode = parseMode(raw.get("mode"), index);
+ ErrorMode errorMode = parseErrorMode(raw.get("errorMode"), index);
+
+ return new WorkflowStep(
+ textOrNull(raw.get("name")),
+ textOrNull(raw.get("agentName")),
+ agentId,
+ textOrNull(raw.get("promptTemplate")),
+ mode,
+ timeoutSecs,
+ errorMode,
+ textOrNull(raw.get("outputVar")),
+ textOrNull(raw.get("outputContentType"))
+ );
+ }
+
+ private StepMode parseMode(JsonNode raw, int stepIndex) {
+ if (raw == null || raw.isNull()) {
+ throw new WorkflowParseException("steps[" + stepIndex + "].mode is required");
+ }
+ if (!raw.isObject()) {
+ throw new WorkflowParseException("steps[" + stepIndex + "].mode must be a JSON object");
+ }
+ String type = textOrNull(raw.get("type"));
+ if (type == null || type.isBlank()) {
+ throw new WorkflowParseException("steps[" + stepIndex + "].mode.type is required");
+ }
+ return switch (type) {
+ case "sequential" -> new StepMode.Sequential();
+ case "fan_out" -> new StepMode.FanOut();
+ case "collect" -> new StepMode.Collect();
+ case "conditional" -> new StepMode.Conditional(textOrNull(raw.get("expression")));
+ case "await_approval" -> new StepMode.AwaitApproval(
+ textOrNull(raw.get("approvalKind")),
+ parseStringList(raw.get("approverChannels")),
+ textOrNull(raw.get("approvalMessage")),
+ raw.has("timeoutSecs") && raw.get("timeoutSecs").isInt() ? raw.get("timeoutSecs").asInt() : null
+ );
+ case "dispatch_channel" -> new StepMode.DispatchChannel(
+ parseStringList(raw.get("channels")),
+ parseStringMap(raw.get("targets")),
+ textOrNull(raw.get("content"))
+ );
+ case "write_memory" -> new StepMode.WriteMemory(
+ textOrNull(raw.get("employeeId")),
+ textOrNull(raw.get("file")),
+ textOrNull(raw.get("mergeStrategy")),
+ textOrNull(raw.get("content"))
+ );
+ default -> throw new WorkflowParseException(
+ "steps[" + stepIndex + "].mode.type '" + type
+ + "' is not supported in v0 (loop / invoke_skill are deferred)");
+ };
+ }
+
+ private ErrorMode parseErrorMode(JsonNode raw, int stepIndex) {
+ if (raw == null || raw.isNull()) {
+ return null;
+ }
+ if (!raw.isObject()) {
+ throw new WorkflowParseException("steps[" + stepIndex + "].errorMode must be a JSON object");
+ }
+ String type = textOrNull(raw.get("type"));
+ if (type == null) {
+ throw new WorkflowParseException("steps[" + stepIndex + "].errorMode.type is required");
+ }
+ return switch (type) {
+ case "fail" -> new ErrorMode.Fail();
+ case "skip" -> new ErrorMode.Skip();
+ case "retry" -> {
+ JsonNode mr = raw.get("maxRetries");
+ int max = (mr != null && mr.isInt()) ? mr.asInt() : 1;
+ yield new ErrorMode.Retry(max);
+ }
+ default -> throw new WorkflowParseException(
+ "steps[" + stepIndex + "].errorMode.type '" + type + "' is unknown");
+ };
+ }
+
+ private static String textOrNull(JsonNode node) {
+ if (node == null || node.isNull()) {
+ return null;
+ }
+ return node.isTextual() ? node.asText() : node.asText(null);
+ }
+
+ private static List parseStringList(JsonNode node) {
+ if (node == null || node.isNull()) {
+ return List.of();
+ }
+ if (!node.isArray()) {
+ throw new WorkflowParseException("expected JSON array, got " + node.getNodeType());
+ }
+ List out = new ArrayList<>(node.size());
+ for (int i = 0; i < node.size(); i++) {
+ JsonNode v = node.get(i);
+ if (v == null || v.isNull()) {
+ continue;
+ }
+ out.add(v.asText());
+ }
+ return out;
+ }
+
+ private static Map parseStringMap(JsonNode node) {
+ if (node == null || node.isNull()) {
+ return Map.of();
+ }
+ if (!node.isObject()) {
+ throw new WorkflowParseException("expected JSON object, got " + node.getNodeType());
+ }
+ Map out = new HashMap<>();
+ Iterator> it = node.fields();
+ while (it.hasNext()) {
+ Map.Entry e = it.next();
+ JsonNode v = e.getValue();
+ out.put(e.getKey(), v == null || v.isNull() ? null : v.asText());
+ }
+ return out;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowSchemaValidator.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowSchemaValidator.java
new file mode 100644
index 00000000..9f27de4a
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/WorkflowSchemaValidator.java
@@ -0,0 +1,226 @@
+package vip.mate.workflow.compiler;
+
+import org.springframework.stereotype.Component;
+import vip.mate.workflow.compiler.ir.StepMode;
+import vip.mate.workflow.compiler.ir.WorkflowGraph;
+import vip.mate.workflow.compiler.ir.WorkflowStep;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Structural validator. Ensures required fields are present per mode, names
+ * are unique, the step count is bounded, and the fan_out / collect grouping
+ * follows the workflow design rules:
+ *
+ * - A fan_out group must have at least two consecutive fan_out steps and
+ * must be terminated by a collect.
+ * - A collect must follow a fan_out group.
+ * - An await_approval step cannot live inside a fan_out group (multiple
+ * concurrent approvals have no aggregation UX).
+ *
+ *
+ * Expression-language and ACL checks live in dedicated validators so each
+ * pass has a single responsibility.
+ */
+@Component
+public class WorkflowSchemaValidator {
+
+ /** Default ceiling — flags runaway templates / config mistakes early. */
+ public static final int DEFAULT_MAX_STEPS = 200;
+
+ private final int maxSteps;
+
+ public WorkflowSchemaValidator() { this(DEFAULT_MAX_STEPS); }
+
+ public WorkflowSchemaValidator(int maxSteps) {
+ this.maxSteps = maxSteps;
+ }
+
+ public List validate(WorkflowGraph graph) {
+ List errors = new ArrayList<>();
+ if (graph == null) {
+ errors.add(new CompileError("workflow.null", "$", "workflow definition is null"));
+ return errors;
+ }
+ if (graph.steps().isEmpty()) {
+ errors.add(new CompileError("workflow.no_steps", "steps",
+ "workflow must declare at least one step"));
+ return errors;
+ }
+ if (graph.steps().size() > maxSteps) {
+ errors.add(new CompileError(
+ "workflow.too_many_steps",
+ "steps",
+ "workflow has " + graph.steps().size() + " steps; max is " + maxSteps));
+ }
+
+ validatePerStepFields(graph, errors);
+ validateUniqueNames(graph, errors);
+ validateFanOutCollectGrouping(graph, errors);
+ return errors;
+ }
+
+ private void validatePerStepFields(WorkflowGraph graph, List errors) {
+ for (int i = 0; i < graph.steps().size(); i++) {
+ WorkflowStep s = graph.steps().get(i);
+ if (s.name() == null || s.name().isBlank()) {
+ errors.add(CompileError.stepField(i, "name",
+ "step.name_required", "step name is required"));
+ }
+ if (s.mode() == null) {
+ errors.add(CompileError.stepField(i, "mode",
+ "step.mode_required", "step mode is required"));
+ continue;
+ }
+ String oct = s.effectiveOutputContentType();
+ if (!oct.equals("text") && !oct.equals("json")) {
+ errors.add(CompileError.stepField(i, "outputContentType",
+ "step.output_content_type_unsupported",
+ "outputContentType must be 'text' or 'json' (got '" + oct + "')"));
+ }
+ validateModeFields(i, s, errors);
+ }
+ }
+
+ private void validateModeFields(int i, WorkflowStep s, List errors) {
+ StepMode m = s.mode();
+ switch (m) {
+ case StepMode.Sequential ignored -> requireAgent(i, s, errors);
+ case StepMode.FanOut ignored -> requireAgent(i, s, errors);
+ case StepMode.Collect ignored -> {
+ // Agent invocation is optional on collect — the runtime can
+ // either feed the collected payload into the next step or
+ // run an agent at this step. Both are valid v0 shapes.
+ }
+ case StepMode.Conditional c -> {
+ if (c.expression() == null || c.expression().isBlank()) {
+ errors.add(CompileError.stepField(i, "mode.expression",
+ "step.conditional_expression_required",
+ "conditional mode requires an expression"));
+ }
+ requireAgent(i, s, errors);
+ }
+ case StepMode.AwaitApproval a -> {
+ if (a.approvalKind() == null || a.approvalKind().isBlank()) {
+ errors.add(CompileError.stepField(i, "mode.approvalKind",
+ "step.await_approval.kind_required",
+ "await_approval requires approvalKind"));
+ }
+ if (a.approverChannels() == null || a.approverChannels().isEmpty()) {
+ errors.add(CompileError.stepField(i, "mode.approverChannels",
+ "step.await_approval.channels_required",
+ "await_approval requires at least one approverChannel"));
+ }
+ }
+ case StepMode.DispatchChannel d -> {
+ if (d.channels() == null || d.channels().isEmpty()) {
+ errors.add(CompileError.stepField(i, "mode.channels",
+ "step.dispatch_channel.channels_required",
+ "dispatch_channel requires at least one channel"));
+ }
+ if (d.content() == null || d.content().isBlank()) {
+ errors.add(CompileError.stepField(i, "mode.content",
+ "step.dispatch_channel.content_required",
+ "dispatch_channel requires content"));
+ }
+ }
+ case StepMode.WriteMemory w -> {
+ if (w.employeeId() == null || w.employeeId().isBlank()) {
+ errors.add(CompileError.stepField(i, "mode.employeeId",
+ "step.write_memory.employee_required",
+ "write_memory requires employeeId"));
+ }
+ if (w.file() == null || w.file().isBlank()) {
+ errors.add(CompileError.stepField(i, "mode.file",
+ "step.write_memory.file_required",
+ "write_memory requires file"));
+ }
+ if (w.mergeStrategy() == null || w.mergeStrategy().isBlank()) {
+ errors.add(CompileError.stepField(i, "mode.mergeStrategy",
+ "step.write_memory.merge_required",
+ "write_memory requires mergeStrategy"));
+ } else if (!isKnownMergeStrategy(w.mergeStrategy())) {
+ errors.add(CompileError.stepField(i, "mode.mergeStrategy",
+ "step.write_memory.merge_unknown",
+ "mergeStrategy '" + w.mergeStrategy()
+ + "' must be one of append / replace_section / upsert_kv / overwrite"));
+ }
+ }
+ }
+ }
+
+ private static boolean isKnownMergeStrategy(String s) {
+ return "append".equals(s) || "replace_section".equals(s)
+ || "upsert_kv".equals(s) || "overwrite".equals(s);
+ }
+
+ private static void requireAgent(int i, WorkflowStep s, List errors) {
+ boolean hasName = s.agentName() != null && !s.agentName().isBlank();
+ boolean hasId = s.agentId() != null;
+ if (!hasName && !hasId) {
+ errors.add(CompileError.step(i, "step.agent_required",
+ "step requires either agentName or agentId for mode '"
+ + s.mode().typeName() + "'"));
+ }
+ }
+
+ private void validateUniqueNames(WorkflowGraph graph, List errors) {
+ Set seen = new HashSet<>();
+ for (int i = 0; i < graph.steps().size(); i++) {
+ String name = graph.steps().get(i).name();
+ if (name == null || name.isBlank()) continue;
+ if (!seen.add(name)) {
+ errors.add(CompileError.stepField(i, "name",
+ "step.name_duplicate", "step name '" + name + "' is duplicated"));
+ }
+ }
+ }
+
+ private void validateFanOutCollectGrouping(WorkflowGraph graph, List errors) {
+ List steps = graph.steps();
+ int i = 0;
+ while (i < steps.size()) {
+ StepMode m = steps.get(i).mode();
+ if (m instanceof StepMode.FanOut) {
+ int groupStart = i;
+ int j = i;
+ while (j < steps.size() && steps.get(j).mode() instanceof StepMode.FanOut) {
+ if (containsAwaitApproval(steps.get(j))) {
+ // Defensive — fan_out with await_approval mode object
+ // can only appear if a single step had two modes,
+ // which the parser already rejects. Keeping the check
+ // costs nothing.
+ }
+ j++;
+ }
+ int groupSize = j - groupStart;
+ if (groupSize < 2) {
+ errors.add(CompileError.step(groupStart, "step.fan_out.singleton",
+ "fan_out groups must have at least 2 consecutive fan_out steps"));
+ }
+ if (j >= steps.size() || !(steps.get(j).mode() instanceof StepMode.Collect)) {
+ errors.add(CompileError.step(groupStart, "step.fan_out.no_terminating_collect",
+ "fan_out group starting at step '" + steps.get(groupStart).name()
+ + "' must be terminated by a collect step"));
+ }
+ i = j;
+ continue;
+ }
+ if (m instanceof StepMode.Collect) {
+ if (i == 0 || !(steps.get(i - 1).mode() instanceof StepMode.FanOut)) {
+ errors.add(CompileError.step(i, "step.collect.no_preceding_fan_out",
+ "collect step must follow a fan_out group"));
+ }
+ }
+ i++;
+ }
+ }
+
+ /** Always false in v0 — placeholder for future composite-mode awareness. */
+ private static boolean containsAwaitApproval(WorkflowStep step) {
+ return step.mode() instanceof StepMode.AwaitApproval;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/ErrorMode.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/ErrorMode.java
new file mode 100644
index 00000000..67b4b8f1
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/ErrorMode.java
@@ -0,0 +1,24 @@
+package vip.mate.workflow.compiler.ir;
+
+/**
+ * Per-step error policy. {@code Retry} carries the retry budget; {@code Fail}
+ * propagates the error to the run; {@code Skip} marks the step succeeded with
+ * no output (downstream steps that referenced its outputVar see the previous
+ * variable value, mirroring the conditional-false rule).
+ */
+public sealed interface ErrorMode {
+
+ String typeName();
+
+ record Fail() implements ErrorMode {
+ @Override public String typeName() { return "fail"; }
+ }
+
+ record Skip() implements ErrorMode {
+ @Override public String typeName() { return "skip"; }
+ }
+
+ record Retry(int maxRetries) implements ErrorMode {
+ @Override public String typeName() { return "retry"; }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/StepMode.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/StepMode.java
new file mode 100644
index 00000000..2ad53cee
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/StepMode.java
@@ -0,0 +1,64 @@
+package vip.mate.workflow.compiler.ir;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Tagged record describing the control-flow mode of a single workflow step.
+ * v0 supports four base modes (sequential / fan_out / collect / conditional)
+ * and three MateClaw-specific modes (await_approval / dispatch_channel /
+ * write_memory). loop and invoke_skill are deferred to v1.
+ */
+public sealed interface StepMode {
+
+ String typeName();
+
+ /** Sequential — runs after the previous step, threads its output forward. */
+ record Sequential() implements StepMode {
+ @Override public String typeName() { return "sequential"; }
+ }
+
+ /** Fan-out — schedules in parallel with adjacent fan_out steps. */
+ record FanOut() implements StepMode {
+ @Override public String typeName() { return "fan_out"; }
+ }
+
+ /** Collect — joins the most recent fan_out group. */
+ record Collect() implements StepMode {
+ @Override public String typeName() { return "collect"; }
+ }
+
+ /** Conditional — runs only when the Pebble expression evaluates true. */
+ record Conditional(String expression) implements StepMode {
+ @Override public String typeName() { return "conditional"; }
+ }
+
+ /** Await approval — pauses the run until the approval row resolves. */
+ record AwaitApproval(
+ String approvalKind,
+ List approverChannels,
+ String approvalMessage,
+ Integer timeoutSecs
+ ) implements StepMode {
+ @Override public String typeName() { return "await_approval"; }
+ }
+
+ /** Dispatch channel — fan out a payload to one or more configured channels. */
+ record DispatchChannel(
+ List channels,
+ Map targets,
+ String content
+ ) implements StepMode {
+ @Override public String typeName() { return "dispatch_channel"; }
+ }
+
+ /** Write memory — apply a merge strategy to an employee's memory file. */
+ record WriteMemory(
+ String employeeId,
+ String file,
+ String mergeStrategy,
+ String content
+ ) implements StepMode {
+ @Override public String typeName() { return "write_memory"; }
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowGraph.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowGraph.java
new file mode 100644
index 00000000..2a47f922
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowGraph.java
@@ -0,0 +1,19 @@
+package vip.mate.workflow.compiler.ir;
+
+import java.util.List;
+
+/**
+ * Immutable in-memory representation of a parsed workflow definition. The
+ * compiler operates exclusively on this IR; the original JSON is the wire
+ * format and is not retained past the parse stage.
+ */
+public record WorkflowGraph(
+ String schemaVersion,
+ List inputs,
+ List steps
+) {
+ public WorkflowGraph {
+ inputs = inputs == null ? List.of() : List.copyOf(inputs);
+ steps = steps == null ? List.of() : List.copyOf(steps);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowInput.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowInput.java
new file mode 100644
index 00000000..06f2fdf7
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowInput.java
@@ -0,0 +1,5 @@
+package vip.mate.workflow.compiler.ir;
+
+/** Declared workflow input. Type values are advisory: {@code text|json|number|boolean}. */
+public record WorkflowInput(String name, String type) {
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowStep.java b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowStep.java
new file mode 100644
index 00000000..363bdc9f
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/workflow/compiler/ir/WorkflowStep.java
@@ -0,0 +1,26 @@
+package vip.mate.workflow.compiler.ir;
+
+/**
+ * Single step in a workflow's linear step array. {@code mode} holds the
+ * type-specific configuration; common fields like timeout / retry policy /
+ * outputVar live here so they apply to every mode without duplication.
+ */
+public record WorkflowStep(
+ String name,
+ String agentName,
+ Long agentId,
+ String promptTemplate,
+ StepMode mode,
+ Integer timeoutSecs,
+ ErrorMode errorMode,
+ String outputVar,
+ String outputContentType
+) {
+
+ /** Resolved content type, defaulting to {@code text} when unspecified. */
+ public String effectiveOutputContentType() {
+ return outputContentType == null || outputContentType.isBlank()
+ ? "text"
+ : outputContentType;
+ }
+}