Every document filename now mirrors its lifecycle state with a status suffix (e.g., .draft.md, .wip.md, .accepted.md). No more bare .md for tracked document types. Also renamed all from_str methods to parse to avoid FromStr trait confusion, introduced StagingDeploymentParams struct, and fixed all 19 clippy warnings across the codebase. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
11 KiB
RFC 0028: Dialogue Format Contract
| Status | Draft |
| Date | 2026-01-26 |
| Source Spike | dialogue-generation-linter-mismatch |
| Alignment Dialogue | dialogue-format-contract-rfc-design |
| Alignment Dialogue | file-based-subagent-output-and-dialogue-format-contract-rfc-design |
| Downstream | RFC 0029 depends on this RFC |
Summary
Four independent components parse or produce dialogue markdown using independent format assumptions — regex patterns, ad-hoc line.contains() checks, and hardcoded strings. This causes 6+ mismatches between what gets generated and what gets validated. This RFC introduces a shared format contract module in blue-core with a DialogueLine enum and render/parse pair that eliminates all regex from dialogue handling.
Problem
The source spike identified six format mismatches:
- Agent header order — generator writes
### {Name} {Emoji}, linter regex expects either order - Perspective ID width — generator uses
P{:02}(zero-padded), linter regex acceptsP\d+(any width) - Judge assessment section — generator emits
## 💙 Judge:, linter doesn't recognize it as a valid section - Round numbering — generator started at Round 1, protocol instructed Round 0
- Scoreboard bold totals — generator wraps totals in
**, linter regex doesn't require it - No shared format contract — root cause of all five above
Root cause: Three components (generator, linter, Judge protocol) encode format assumptions independently. A fourth component (alignment.rs::parse_expert_response) was identified during the alignment dialogue — it uses line.contains("[PERSPECTIVE") and extract_marker() with its own string-slicing logic.
Four Consumers
| Consumer | Location | Current Approach |
|---|---|---|
| Generator | blue-mcp/src/handlers/dialogue.rs:806 |
Hardcoded format!() strings |
| Linter | blue-mcp/src/handlers/dialogue_lint.rs |
16+ compiled regex patterns |
| Judge Protocol | blue-mcp/src/handlers/dialogue.rs:887 |
Prose template with format assumptions |
| Alignment Parser | blue-core/src/alignment.rs:927 |
line.contains() + extract_marker() |
Design
Constraint: No Regex
The user constraint is explicit: no regex in the solution. All 16+ regex patterns in dialogue_lint.rs are replaced by structural parsing using starts_with, split, trim, and parse. This is not a limitation — regex was the wrong tool. Markdown lines have structural regularity (headings start with #, tables start with |, markers start with [) that string methods handle cleanly.
Architecture: blue-core::dialogue_format Module
The format contract lives in blue-core, not blue-mcp. Rationale:
alignment.rs::parse_expert_response(a consumer) already lives inblue-core- The dependency arrow is
blue-mcp → blue-core, never reversed AlignmentDialoguestruct (the dialogue state model) already lives inblue-core::alignment- Placing format types alongside the state model is natural — schema next to data
Core Type: DialogueLine Enum
Every line in a dialogue document classifies into exactly one of 8 variants:
/// A classified line from a dialogue markdown document.
pub enum DialogueLine {
/// `# Title`
Heading1(String),
/// `**Key**: Value` metadata fields
Metadata { key: String, value: String },
/// `## Section Name` (e.g., "Expert Panel", "Alignment Scoreboard")
SectionHeading(String),
/// `## Round N: Label`
RoundHeading { number: u32, label: String },
/// `### Agent Name Emoji`
AgentHeading { name: String, emoji: String },
/// `| cell | cell | cell |`
TableRow(Vec<String>),
/// `[MARKER_TYPE ID: description]`
MarkerLine { marker_type: MarkerType, id: String, description: String },
/// Everything else — prose, blank lines, code blocks
Content(String),
}
pub enum MarkerType {
Perspective,
Tension,
Refinement,
Concession,
Resolved,
}
Classification uses only starts_with, split, trim, and parse:
impl DialogueLine {
pub fn classify(line: &str) -> Self {
let trimmed = line.trim();
if trimmed.starts_with("# ") && !trimmed.starts_with("## ") {
Self::Heading1(trimmed[2..].trim().to_string())
} else if trimmed.starts_with("## Round ") {
// parse "## Round N: Label"
// split on ':', parse number from first part
...
} else if trimmed.starts_with("## ") {
Self::SectionHeading(trimmed[3..].trim().to_string())
} else if trimmed.starts_with("### ") {
// parse "### Name Emoji" — name is all words before the emoji
...
} else if trimmed.starts_with("| ") {
// split by '|', trim cells
...
} else if trimmed.starts_with("[PERSPECTIVE") || trimmed.starts_with("[TENSION")
|| trimmed.starts_with("[REFINEMENT") || trimmed.starts_with("[CONCESSION")
|| trimmed.starts_with("[RESOLVED") {
// extract marker type, ID, and description
...
} else if trimmed.starts_with("**") && trimmed.contains("**:") {
// Metadata field
...
} else {
Self::Content(trimmed.to_string())
}
}
}
Interface: DialogueFormat
Four methods serve four consumers:
pub struct DialogueFormat;
impl DialogueFormat {
/// Generator calls this to produce dialogue markdown.
pub fn render(dialogue: &AlignmentDialogue) -> String { ... }
/// Linter calls this to parse and validate a dialogue file.
/// Returns structured errors instead of boolean checks.
pub fn parse(markdown: &str) -> Result<ParsedDialogue, Vec<LintError>> { ... }
/// Alignment parser calls this to extract markers from agent output.
/// Replaces `parse_expert_response`'s ad-hoc `extract_marker()`.
pub fn parse_markers(agent_output: &str) -> Vec<Marker> { ... }
/// Judge protocol embeds this as format instructions for agents.
/// Generated from the same types — agents read the spec, not code.
pub fn specification_markdown() -> String { ... }
}
The Marker type replaces the current stringly-typed marker extraction:
pub enum Marker {
Perspective { id: String, description: String },
Tension { id: String, description: String },
Refinement(String),
Concession(String),
Resolved(String),
}
Tolerance Policy
Strict where structure matters:
## Round— capital R, space required### {agent_name}— must match a name from the expert panel| {cell} |— pipe-delimited, column count must match header[PERSPECTIVE P— capital P, ID required before colon- Perspective IDs: accept
P1orP01, normalize toP01on parse
Lenient where voice matters:
- Marker descriptions: any text after the colon
- Content blocks: any markdown
- Whitespace: leading/trailing trimmed, multiple spaces collapsed
- Colon spacing in markers:
P01:descandP01: descboth parse
Migration
Phase 1 — Compat mode (default for one release cycle):
- New struct-based parser runs alongside existing regex linter
- Warnings emitted when formats diverge
fix_hintstrings updated to reference contract types
Phase 2 — Strict mode:
- Remove all regex from
dialogue_lint.rs - Replace
parse_dialogue()withDialogueFormat::parse() - Replace
check_markers_parseable()(currently regex-scans content twice) with single parse call
Phase 3 — Fourth parser migration:
- Replace
alignment.rs::extract_marker()withDialogueFormat::parse_markers() - Replace
parse_expert_response'sline.contains()checks withDialogueLine::classify() - Delete
extract_marker()function
ADR Alignment
- ADR 5 (Single Source): One format contract, four consumers. Markdown is the single source of document state. The struct is the schema (constraint definition), not a second copy of data.
- ADR 10 (No Dead Code): Migration plan deletes
extract_marker(), 16+ regex patterns, and the duplicatedparse_dialoguelogic. - ADR 11 (Freedom Through Constraint): The typed enum constrains what's valid while giving agents freedom in content and descriptions.
Phases
Phase 1: Contract Module
- Create
blue-core/src/dialogue_format.rs - Define
DialogueLineenum with 8 variants - Implement
DialogueLine::classify()using string methods only - Define
MarkerTypeandMarkerenums - Implement
DialogueFormat::parse_markers()— replacesextract_marker() - Unit tests: classify every line type, round-trip property tests
Phase 2: Generator Migration
- Implement
DialogueFormat::render() - Replace hardcoded
format!()strings indialogue.rs:806+with render calls - Implement
DialogueFormat::specification_markdown() - Update
build_judge_protocolto embed specification - Integration tests: render then parse round-trips to same structure
Phase 3: Linter Migration
- Implement
DialogueFormat::parse()returningResult<ParsedDialogue, Vec<LintError>> - Run in compat mode: both regex and struct parser, compare results
- Replace
parse_dialogue()indialogue_lint.rswithDialogueFormat::parse() - Remove all
Regex::new()calls from dialogue lint - Lint tests: validate all existing dialogue files pass
Phase 4: Alignment Parser Migration
- Replace
parse_expert_response'sline.contains()checks withDialogueLine::classify() - Replace
extract_marker()withDialogueFormat::parse_markers() - Delete
extract_marker()function fromalignment.rs - Alignment tests: parse existing expert responses, verify identical output
Test Plan
DialogueLine::classify()correctly classifies all 8 line typesDialogueLine::classify()handles whitespace tolerance (extra spaces, tabs)DialogueFormat::render()produces valid markdown thatparse()acceptsDialogueFormat::parse()correctly parses all existing dialogue files in.blue/docs/dialogues/DialogueFormat::parse_markers()produces identical output to currentextract_marker()for all test cases- Zero regex patterns remain in
dialogue_lint.rsafter Phase 3 extract_marker()deleted after Phase 4- Round-trip property:
parse(render(dialogue))recovers the original structure - Compat mode: struct parser and regex parser agree on all existing dialogues
"Right then. Let's get to it."
— Blue