blue/.blue/docs/rfcs/0028-dialogue-format-contract.draft.md
Eric Garcia 0fea499957 feat: lifecycle suffixes for all document states + resolve all clippy warnings
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>
2026-01-26 12:19:46 -05:00

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:

  1. Agent header order — generator writes ### {Name} {Emoji}, linter regex expects either order
  2. Perspective ID width — generator uses P{:02} (zero-padded), linter regex accepts P\d+ (any width)
  3. Judge assessment section — generator emits ## 💙 Judge:, linter doesn't recognize it as a valid section
  4. Round numbering — generator started at Round 1, protocol instructed Round 0
  5. Scoreboard bold totals — generator wraps totals in **, linter regex doesn't require it
  6. 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 in blue-core
  • The dependency arrow is blue-mcp → blue-core, never reversed
  • AlignmentDialogue struct (the dialogue state model) already lives in blue-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 P1 or P01, normalize to P01 on 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:desc and P01: desc both 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_hint strings updated to reference contract types

Phase 2 — Strict mode:

  • Remove all regex from dialogue_lint.rs
  • Replace parse_dialogue() with DialogueFormat::parse()
  • Replace check_markers_parseable() (currently regex-scans content twice) with single parse call

Phase 3 — Fourth parser migration:

  • Replace alignment.rs::extract_marker() with DialogueFormat::parse_markers()
  • Replace parse_expert_response's line.contains() checks with DialogueLine::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 duplicated parse_dialogue logic.
  • 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 DialogueLine enum with 8 variants
  • Implement DialogueLine::classify() using string methods only
  • Define MarkerType and Marker enums
  • Implement DialogueFormat::parse_markers() — replaces extract_marker()
  • Unit tests: classify every line type, round-trip property tests

Phase 2: Generator Migration

  • Implement DialogueFormat::render()
  • Replace hardcoded format!() strings in dialogue.rs:806+ with render calls
  • Implement DialogueFormat::specification_markdown()
  • Update build_judge_protocol to embed specification
  • Integration tests: render then parse round-trips to same structure

Phase 3: Linter Migration

  • Implement DialogueFormat::parse() returning Result<ParsedDialogue, Vec<LintError>>
  • Run in compat mode: both regex and struct parser, compare results
  • Replace parse_dialogue() in dialogue_lint.rs with DialogueFormat::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's line.contains() checks with DialogueLine::classify()
  • Replace extract_marker() with DialogueFormat::parse_markers()
  • Delete extract_marker() function from alignment.rs
  • Alignment tests: parse existing expert responses, verify identical output

Test Plan

  • DialogueLine::classify() correctly classifies all 8 line types
  • DialogueLine::classify() handles whitespace tolerance (extra spaces, tabs)
  • DialogueFormat::render() produces valid markdown that parse() accepts
  • DialogueFormat::parse() correctly parses all existing dialogue files in .blue/docs/dialogues/
  • DialogueFormat::parse_markers() produces identical output to current extract_marker() for all test cases
  • Zero regex patterns remain in dialogue_lint.rs after 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