fix: unify $status to const-only, drop enum support (#123)
CI / check (pull_request) Successful in 1m43s

- Validator: hasStatusConst/getConstStatuses replace enum checks
- enum in $status is now rejected with clear error message
- All docs/examples/tests migrated from enum to const/oneOf
- bootstrap hello.yaml updated

Fixes #123
This commit is contained in:
2026-06-05 23:31:56 +00:00
parent 1a37928bb9
commit 68079cc003
11 changed files with 116 additions and 104 deletions
+10 -5
View File
@@ -1,10 +1,15 @@
--- ---
"@united-workforce/cli": patch
"@united-workforce/util": patch "@united-workforce/util": patch
--- ---
fix: workflow-authoring docs — add type:object to oneOf examples, clarify const vs enum rules (#123) fix: unify $status to const-only, drop enum support (#123)
- All frontmatter examples include `type: object` (both flat and oneOf) Breaking: `$status` in frontmatter now requires `const` everywhere.
- Restructure $status section: "Multi-exit (oneOf)" vs "Single-exit (flat schema)" `enum` is no longer accepted and will be rejected by the validator.
- Add "Important rules" box: type:object required, const only in oneOf, enum in flat
- Restore "Custom Fields" subsection - Validator: `hasStatusConst()` / `getConstStatuses()` replace enum-based checks
- Error message: "must define $status as const (or oneOf with const)"
- workflow-authoring docs: all examples use `const`, enum explicitly noted as unsupported
- bootstrap hello.yaml: `$status: { const: done }`
- All test fixtures migrated from enum to const/oneOf
@@ -28,9 +28,13 @@ roles:
$status: "ready" $status: "ready"
frontmatter: frontmatter:
type: object type: object
oneOf:
- properties:
$status: { const: "ready" }
required: ["$status"]
- properties:
$status: { const: "not-ready" }
required: ["$status"] required: ["$status"]
properties:
$status: { type: string, enum: ["ready", "not-ready"] }
roleB: roleB:
description: Second role description: Second role
goal: Do B goal: Do B
@@ -42,7 +46,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string, enum: ["done"] } $status: { const: "done" }
graph: graph:
$START: $START:
new: new:
@@ -82,9 +86,13 @@ roles:
$status: "pass" $status: "pass"
frontmatter: frontmatter:
type: object type: object
oneOf:
- properties:
$status: { const: "pass" }
required: ["$status"]
- properties:
$status: { const: "fail" }
required: ["$status"] required: ["$status"]
properties:
$status: { type: string, enum: ["pass", "fail"] }
roleB: roleB:
description: Pass role description: Pass role
goal: Do B goal: Do B
@@ -96,7 +104,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string, enum: ["done"] } $status: { const: "done" }
roleC: roleC:
description: Fail role description: Fail role
goal: Do C goal: Do C
@@ -108,7 +116,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string, enum: ["done"] } $status: { const: "done" }
graph: graph:
$START: $START:
new: new:
@@ -155,7 +163,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string, enum: ["done"] } $status: { const: "done" }
graph: graph:
$START: $START:
new: new:
@@ -54,7 +54,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string, enum: ["ready"] } $status: { const: "ready" }
graph: graph:
$START: $START:
new: new:
@@ -114,7 +114,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string, enum: ["ready"] } $status: { const: "ready" }
graph: graph:
$START: $START:
new: new:
@@ -161,7 +161,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string, enum: ["ready"] } $status: { const: "ready" }
graph: graph:
$START: $START:
new: new:
@@ -31,7 +31,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string, enum: ["ready"] } $status: { const: "ready" }
graph: graph:
$START: $START:
new: new:
@@ -54,7 +54,7 @@ roles:
type: object type: object
required: ["$status"] required: ["$status"]
properties: properties:
$status: { type: string, enum: ["ready"] } $status: { const: "ready" }
graph: graph:
$START: $START:
new: new:
@@ -17,7 +17,7 @@ function makeWorkflow(overrides?: Partial<WorkflowPayload>): WorkflowPayload {
frontmatter: { frontmatter: {
type: "object", type: "object",
properties: { properties: {
$status: { enum: ["done"] }, $status: { const: "done" },
plan: { type: "string" }, plan: { type: "string" },
}, },
required: ["$status", "plan"], required: ["$status", "plan"],
@@ -85,7 +85,7 @@ describe("Suite 1: Role Reference Integrity", () => {
output: "None", output: "None",
frontmatter: { frontmatter: {
type: "object", type: "object",
properties: { $status: { enum: ["done"] } }, properties: { $status: { const: "done" } },
required: ["$status"], required: ["$status"],
} as unknown as string, } as unknown as string,
}; };
@@ -187,7 +187,7 @@ describe("Suite 2: Graph Structure", () => {
output: "Isolated", output: "Isolated",
frontmatter: { frontmatter: {
type: "object", type: "object",
properties: { $status: { enum: ["done"] } }, properties: { $status: { const: "done" } },
required: ["$status"], required: ["$status"],
} as unknown as string, } as unknown as string,
}; };
@@ -272,8 +272,8 @@ describe("Suite 3: Status-Edge Consistency", () => {
}); });
}); });
describe("Suite 3b: Enum-Based Multi-Exit", () => { describe("Suite 3b: Enum-Based $status is Rejected", () => {
test("3b.1 enum multi-exit passes with matching graph keys", () => { test("3b.1 enum multi-exit is rejected (must use oneOf + const)", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.roles.reviewer = { wf.roles.reviewer = {
...wf.roles.reviewer, ...wf.roles.reviewer,
@@ -291,52 +291,10 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null }, rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null },
}; };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors).toEqual([]); expect(errors.some((e) => e.includes("must define") && e.includes("const"))).toBe(true);
}); });
test("3b.2 enum multi-exit with extra graph key", () => { test("3b.2 enum single-exit is rejected (must use const)", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
frontmatter: {
type: "object",
properties: {
$status: { enum: ["approved", "rejected"] },
comments: { type: "string" },
},
required: ["$status", "comments"],
} as unknown as string,
};
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done", location: null },
rejected: { role: "writer", prompt: "Fix", location: null },
timeout: { role: "$END", prompt: "Timed out", location: null },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("extra status keys: timeout"))).toBe(true);
});
test("3b.3 enum multi-exit with missing graph key", () => {
const wf = makeWorkflow();
wf.roles.reviewer = {
...wf.roles.reviewer,
frontmatter: {
type: "object",
properties: {
$status: { enum: ["approved", "rejected"] },
comments: { type: "string" },
},
required: ["$status", "comments"],
} as unknown as string,
};
wf.graph.reviewer = {
approved: { role: "$END", prompt: "Done", location: null },
};
const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("missing status keys: rejected"))).toBe(true);
});
test("3b.4 enum with single explicit value passes", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.roles.writer = { wf.roles.writer = {
...wf.roles.writer, ...wf.roles.writer,
@@ -351,28 +309,71 @@ describe("Suite 3b: Enum-Based Multi-Exit", () => {
}; };
wf.graph.writer = { ready: { role: "reviewer", prompt: "Review: {{{plan}}}", location: null } }; wf.graph.writer = { ready: { role: "reviewer", prompt: "Review: {{{plan}}}", location: null } };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors).toEqual([]); expect(errors.some((e) => e.includes("must define") && e.includes("const"))).toBe(true);
}); });
});
test("3b.5 enum multi-exit mustache var not in frontmatter", () => { describe("Suite 3c: Const-Based Flat Schema", () => {
test("3c.1 flat schema with const $status passes validation", () => {
const wf = makeWorkflow(); const wf = makeWorkflow();
wf.roles.reviewer = { wf.roles.writer = {
...wf.roles.reviewer, ...wf.roles.writer,
frontmatter: { frontmatter: {
type: "object", type: "object",
properties: { properties: {
$status: { enum: ["approved", "rejected"] }, $status: { const: "done" },
comments: { type: "string" }, plan: { type: "string" },
}, },
required: ["$status", "comments"], required: ["$status", "plan"],
} as unknown as string, } as unknown as string,
}; };
wf.graph.reviewer = { const errors = validateWorkflow(wf);
approved: { role: "$END", prompt: "Done: {{{nonexistent}}}", location: null }, expect(errors).toEqual([]);
rejected: { role: "writer", prompt: "Fix: {{{comments}}}", location: null }, });
test("3c.2 flat schema with const $status detects extra graph key", () => {
const wf = makeWorkflow();
wf.roles.writer = {
...wf.roles.writer,
frontmatter: {
type: "object",
properties: {
$status: { const: "done" },
plan: { type: "string" },
},
required: ["$status", "plan"],
} as unknown as string,
};
wf.graph.writer = {
done: { role: "reviewer", prompt: "Review.", location: null },
extra: { role: "$END", prompt: "Nope.", location: null },
}; };
const errors = validateWorkflow(wf); const errors = validateWorkflow(wf);
expect(errors.some((e) => e.includes("nonexistent") && e.includes("not found"))).toBe(true); expect(errors.some((e) => e.includes("extra status keys") && e.includes("extra"))).toBe(true);
});
test("3c.3 flat schema with const $status validates mustache vars", () => {
const wf = makeWorkflow();
wf.roles.writer = {
...wf.roles.writer,
frontmatter: {
type: "object",
properties: {
$status: { const: "done" },
plan: { type: "string" },
},
required: ["$status", "plan"],
} as unknown as string,
};
wf.graph.writer = {
done: { role: "reviewer", prompt: "Review: {{{nonexistent}}}", location: null },
};
const errors = validateWorkflow(wf);
expect(
errors.some(
(e) => e.includes('prompt variable "nonexistent"') && e.includes('role "writer"'),
),
).toBe(true);
}); });
}); });
@@ -480,7 +481,7 @@ describe("Suite 6: Multiple Errors Collection", () => {
output: "None", output: "None",
frontmatter: { frontmatter: {
type: "object", type: "object",
properties: { $status: { enum: ["done"] } }, properties: { $status: { const: "done" } },
required: ["$status"], required: ["$status"],
} as unknown as string, } as unknown as string,
}; };
@@ -31,7 +31,7 @@ function makeMinimalPayload(name: string, description: string): WorkflowPayload
frontmatter: { frontmatter: {
type: "object", type: "object",
properties: { properties: {
$status: { type: "string", enum: ["done"] }, $status: { const: "done" },
}, },
required: ["$status"], required: ["$status"],
} as unknown as CasRef, } as unknown as CasRef,
+1 -1
View File
@@ -219,7 +219,7 @@ roles:
frontmatter: frontmatter:
type: object type: object
properties: properties:
$status: { enum: [done] } $status: { const: done }
message: { type: string } message: { type: string }
required: [$status, message] required: [$status, message]
graph: graph:
+13 -13
View File
@@ -24,22 +24,22 @@ function isOneOfSchema(fm: unknown): fm is SchemaObj & { oneOf: SchemaObj[] } {
return Array.isArray(obj.oneOf); return Array.isArray(obj.oneOf);
} }
/** Check if a frontmatter schema declares "$status" as an enum (the required form for user roles). */ /** Check if a frontmatter schema declares "$status" as const (flat schema form). */
function hasStatusEnum(fm: unknown): boolean { function hasStatusConst(fm: unknown): boolean {
if (typeof fm !== "object" || fm === null) return false; if (typeof fm !== "object" || fm === null) return false;
const obj = fm as SchemaObj; const obj = fm as SchemaObj;
const props = obj.properties as Record<string, SchemaObj> | undefined; const props = obj.properties as Record<string, SchemaObj> | undefined;
if (!props?.$status) return false; if (!props?.$status) return false;
return Array.isArray(props.$status.enum); return typeof props.$status.const === "string";
} }
/** Extract status values from an enum-based $status field. */ /** Extract status values from a const-based $status field. */
function getEnumStatuses(fm: SchemaObj): string[] { function getConstStatuses(fm: SchemaObj): string[] {
const props = fm.properties as Record<string, SchemaObj> | undefined; const props = fm.properties as Record<string, SchemaObj> | undefined;
if (!props?.$status) return []; if (!props?.$status) return [];
const statusDef = props.$status; const statusDef = props.$status;
if (!Array.isArray(statusDef.enum)) return []; if (typeof statusDef.const === "string") return [statusDef.const];
return statusDef.enum as string[]; return [];
} }
/** Get property names from a schema object. */ /** Get property names from a schema object. */
@@ -248,21 +248,21 @@ function checkRoleConsistency(payload: WorkflowPayload, errors: string[]): void
checkOneOfDiscriminant(roleName, variants, statuses, errors); checkOneOfDiscriminant(roleName, variants, statuses, errors);
checkStatusEdges(roleName, graphKeys, new Set(statuses), errors); checkStatusEdges(roleName, graphKeys, new Set(statuses), errors);
checkMultiExitMustache(roleName, graphEntry, variants, errors); checkMultiExitMustache(roleName, graphEntry, variants, errors);
} else if (hasStatusEnum(fm)) { } else if (hasStatusConst(fm)) {
const statuses = getEnumStatuses(fm as SchemaObj); const statuses = getConstStatuses(fm as SchemaObj);
checkStatusEdges(roleName, graphKeys, new Set(statuses), errors); checkStatusEdges(roleName, graphKeys, new Set(statuses), errors);
// For enum-based schemas, mustache vars come from the flat properties // For const-based flat schemas, mustache vars come from the flat properties
checkEnumMustache(roleName, graphEntry, fm as SchemaObj, errors); checkFlatMustache(roleName, graphEntry, fm as SchemaObj, errors);
} else { } else {
errors.push( errors.push(
`role "${roleName}" must define "$status" as an enum (or oneOf const) in frontmatter`, `role "${roleName}" must define "$status" as const (or oneOf with const) in frontmatter`,
); );
} }
} }
} }
/** Check mustache vars in all edge prompts against flat schema properties. */ /** Check mustache vars in all edge prompts against flat schema properties. */
function checkEnumMustache( function checkFlatMustache(
roleName: string, roleName: string,
graphEntry: Record<string, { role: string; prompt: string }>, graphEntry: Record<string, { role: string; prompt: string }>,
fm: SchemaObj, fm: SchemaObj,
@@ -143,7 +143,7 @@ describe("buildOutputFormatInstruction", () => {
{ {
type: "object", type: "object",
properties: { properties: {
$status: { type: "string", enum: ["approved"] }, $status: { const: "approved" },
branch: { type: "string" }, branch: { type: "string" },
}, },
required: ["$status"], required: ["$status"],
@@ -151,7 +151,7 @@ describe("buildOutputFormatInstruction", () => {
{ {
type: "object", type: "object",
properties: { properties: {
$status: { type: "string", enum: ["rejected"] }, $status: { const: "rejected" },
comments: { type: "string" }, comments: { type: "string" },
}, },
required: ["$status"], required: ["$status"],
@@ -90,23 +90,21 @@ frontmatter:
required: [$status, error] required: [$status, error]
\`\`\` \`\`\`
**Single-exit (flat schema)** — use \`enum\` (not \`const\`): **Single-exit (flat schema)** — same syntax, just no \`oneOf\` wrapper:
\`\`\`yaml \`\`\`yaml
frontmatter: frontmatter:
type: object type: object
properties: properties:
$status: $status: { const: "done" }
type: string
enum: [done]
summary: { type: string } summary: { type: string }
required: [$status, summary] required: [$status, summary]
\`\`\` \`\`\`
**Important rules:** **Important rules:**
- \`type: object\` is **required** at the top level of frontmatter (both flat and oneOf) - \`type: object\` is **required** at the top level of frontmatter (both flat and oneOf)
- In flat schemas, \`$status\` must use \`enum: [value]\`, NOT \`const: "value"\` — the validator rejects \`const\` outside of \`oneOf\` variants - \`$status\` always uses \`const: "value"\` — simple and consistent
- In \`oneOf\` schemas, each variant's \`$status\` must use \`const: "value"\` - \`enum\` is **not supported** for \`$status\` — the validator will reject it
### Custom Fields ### Custom Fields