use super::gate::{GateKind, RenderGate};
use super::support::{find_guardrail, DEFAULT_ARTIFACT_SCOPE};
use super::types::RenderFlavor;
use crate::ir::ComponentNode;
fn format_render_block(block: &crate::ir::RenderBlock) -> String {
let fields = block
.fields
.iter()
.map(|(k, v)| format!("{k}: {v}"))
.collect::<Vec<_>>()
.join(", ");
format!("- `{}`: {{ {fields} }}", block.block_type)
}
const ENVELOPE_EXAMPLE: &str = r#"```json
{
"blocks": [
{ "type": "kpi_card", "label": "Total revenue", "value": 1672.4, "unit": "USD" },
{ "type": "table", "columns": ["status", "orders"], "rows": [["completed", 67], ["shipped", 32]] },
{ "type": "chart", "chart_type": "bar", "x": "status", "series": ["orders"],
"rows": [["completed", 67], ["shipped", 32]] },
{ "type": "definition", "sql": "SELECT status, count(*) AS orders FROM orders GROUP BY status",
"source_tables": ["orders"], "filters": [] }
],
"verified": true,
"summary": "One or two sentences of prose (optional)."
}
```"#;
const VERIFY_DEFINITION_CONTRACT: &str = "Before you answer you MUST verify (per-answer verify, \
required): actually execute the query through `wren`, then validate the result set is legitimate \
(non-empty where a value is expected, types/units sane, grain matches the question). If it is not, \
repair the query and re-run; if it still cannot be validated, REFUSE — say so plainly and do not \
fabricate a number. Set the envelope's top-level `\"verified\": true` ONLY when a query ran and its \
result set passed validation. Always include one `definition` block — the shallow \"how this was \
computed\" card: the exact `sql` you ran, the `source_tables` it read, and the `filters` you \
applied. This is run-level provenance only; do not invent unit/owner/formal-metric lineage (that \
is Phase 2).";
pub(super) fn build_render_section(node: &ComponentNode, gate: &RenderGate) -> Option<String> {
match gate.kind {
GateKind::Realize => Some(if gate.flavor == Some(RenderFlavor::Prompt) {
build_prompt_render_section(node, gate)
} else {
build_programmatic_render_section(node)
}),
GateKind::Degrade => Some(
[
"## Render output",
"",
"This target has no artifact-write surface for render output: render the results \
as a markdown table plus a short prose summary instead. Do not write any files.",
]
.join("\n"),
),
GateKind::None => None,
}
}
fn build_programmatic_render_section(node: &ComponentNode) -> String {
let mut lines: Vec<String> = vec![
"## Render output".to_string(),
String::new(),
"Block contract (produce data matching these shapes, not prose):".to_string(),
String::new(),
];
lines.extend(node.effect.render_blocks.iter().map(format_render_block));
lines.push(String::new());
lines.push(
"Do NOT write any files and do NOT format the answer as prose or markdown. After \
gathering the data via `wren`, your FINAL message must be a SINGLE JSON object — the render \
envelope — and nothing else: a `blocks` array of instances conforming to the contract above, \
plus an optional `summary` string. A downstream renderer turns this envelope into the dashboard \
deterministically; you stay read-only."
.to_string(),
);
lines.push(String::new());
lines.push(VERIFY_DEFINITION_CONTRACT.to_string());
lines.push(String::new());
lines.push("Envelope shape:".to_string());
lines.push(String::new());
lines.push(ENVELOPE_EXAMPLE.to_string());
lines.join("\n")
}
fn build_prompt_render_section(node: &ComponentNode, gate: &RenderGate) -> String {
let scope = gate
.scope
.clone()
.unwrap_or_else(|| DEFAULT_ARTIFACT_SCOPE.to_string());
let mut lines: Vec<String> = vec![
"## Render output".to_string(),
String::new(),
"Block contract (produce data matching these shapes, not prose):".to_string(),
String::new(),
];
lines.extend(node.effect.render_blocks.iter().map(format_render_block));
lines.push(String::new());
lines.push(format!(
"After gathering the data via `wren`, write a SINGLE self-contained `dashboard.html` file \
into the artifact-write scope directory (`{scope}`), rendering the blocks above: KPI cards, an \
HTML table, and a simple chart (inline SVG or a CDN-loaded chart library — no build step). Also \
render a `✓ Verified` pill next to the title and a \"how this was computed\" definition panel (the \
SQL you ran, source tables, filters). End your reply stating the path of the file you wrote."
));
lines.push(String::new());
lines.push(VERIFY_DEFINITION_CONTRACT.to_string());
lines.join("\n")
}
const VERDICT_ENVELOPE_EXAMPLE: &str = r#"```json
{
"blocks": [
{ "type": "status", "state": "stale", "label": "orders freshness",
"detail": "max(order_date) is 51h old; expected within 24h", "severity": "critical" }
],
"verdict": { "type": "freshness_verdict", "fresh": false, "observed_lag_hours": 51, "expected_cadence": "24h" },
"emitted": ["freshness_breach"],
"verified": true
}
```"#;
pub(super) fn build_assertion_section(node: &ComponentNode) -> String {
let outcome = &node.effect.outcome;
let verdict_type = outcome.verdict_type.as_deref().unwrap_or("verdict");
let emits = outcome.emits.clone().unwrap_or_default();
let emits_line = if emits.is_empty() {
"This assertion emits no signals.".to_string()
} else {
format!(
"On breach, list the emitted signal name(s) in the envelope's `emitted` array: [{}]. \
The runtime routes those signals to the borrowed on-breach actions ({}) over the notify channel — \
Warble declares the wiring (signal ↔ action); the transport (Slack / Jira / MCP) is borrowed, not \
owned by this agent.",
emits
.iter()
.map(|e| format!("`{e}`"))
.collect::<Vec<_>>()
.join(", "),
if node.borrowed_actions.is_empty() {
"a runtime notify channel".to_string()
} else {
node.borrowed_actions
.iter()
.map(|a| format!("`{a}`"))
.collect::<Vec<_>>()
.join(", ")
}
)
};
let mut lines: Vec<String> = vec![
"## Assertion output".to_string(),
String::new(),
format!(
"This is an **assertive** component (outcome: assertion, verdict_type `{verdict_type}`). \
Its core is a DETERMINISTIC check, not a judgment call: run the freshness assert through `wren` — \
`SELECT max(<timestamp column>)` on the bound model — and compare the observed lag against the \
expected cadence (`expected_cadence` param, or the MDL's declared cadence). Fresh iff the newest \
row is within the cadence; stale otherwise. Do NOT ask an LLM to decide fresh-vs-stale — that is a \
SQL comparison and must be reproducible."
),
String::new(),
"Only when the data is STALE do you use judgment, via the `assess_severity` step, to \
classify how bad it is (e.g. warn vs critical) from the lag magnitude and history. When fresh, \
there is no severity to assess."
.to_string(),
String::new(),
"Verdict block contract (produce data matching these shapes, not prose):".to_string(),
String::new(),
];
lines.extend(node.effect.render_blocks.iter().map(format_render_block));
lines.push(String::new());
lines.push(
"Stay strictly read-only: only `SELECT` through `wren`, never write to the warehouse and \
never write any files. Your FINAL message MUST be a SINGLE JSON object — the verdict envelope — \
and nothing else: a `blocks` array (the `status` block above), a `verdict` object \
(`{ type, fresh, ... }`), and, on breach, an `emitted` array. A downstream renderer turns the \
`status` block into HTML deterministically; you stay read-only. Set the top-level `\"verified\": \
true` only when the assert query actually ran and its result was validated."
.to_string(),
);
lines.push(String::new());
lines.push(emits_line);
lines.push(String::new());
lines.push("Envelope shape:".to_string());
lines.push(String::new());
lines.push(VERDICT_ENVELOPE_EXAMPLE.to_string());
lines.join("\n")
}
const MUTATION_DIFF_ENVELOPE_EXAMPLE: &str = r#"```json
{
"blocks": [
{ "type": "diff", "path": "models/orders.yml",
"diff": "--- a/models/orders.yml\n+++ b/models/orders.yml\n@@ -3,6 +3,7 @@\n columns:\n - name: order_id\n+ - name: customer_id\n" }
],
"verified": true
}
```"#;
pub(super) fn build_mutation_section(node: &ComponentNode) -> String {
if node.effect.outcome.target.as_deref() == Some("context") {
return build_context_mutation_section(node);
}
let outcome = &node.effect.outcome;
let target = outcome.target.as_deref().unwrap_or("data");
let change_type_note = outcome
.change_type
.as_deref()
.map(|c| format!(", change_type `{c}`"))
.unwrap_or_default();
let dry_run_guardrail = find_guardrail(&node.guardrails, "must_dry_run");
let blast_guardrail = find_guardrail(&node.guardrails, "blast_radius_limit");
let threshold_note = blast_guardrail
.and_then(|g| g.threshold.as_ref())
.map(|t| format!(" (threshold: {t})"))
.unwrap_or_default();
let lines: Vec<String> = vec![
"## Mutation lifecycle".to_string(),
String::new(),
format!(
"This is a **mutating** component (outcome: mutation, target `{target}`{change_type_note}). \
Applying a change here is a two-phase GATED lifecycle: propose, then gate, and only then apply. \
None of the four phases below is optional and none may be skipped by this agent."
),
String::new(),
format!(
"1. **Dry-run first**{}: propose the edit as a DIFF only — do NOT apply it yet. Render \
the proposed change via a `diff` render block (`path` + the unified diff text); this is a preview, \
never a write.",
if dry_run_guardrail.is_some() {
" (guardrail `must_dry_run`, locked)"
} else {
""
}
),
format!(
"2. **Blast-radius gate**{}: run `warble blast-radius {} --node <the lineage node id of \
the target you are editing, e.g. `model:orders` / `metric:revenue.total_revenue`>` to compute the \
downstream impact of the proposed change before it may be applied. The `--node` seed is the node \
BEING EDITED (identified in the dry-run), never this component's id. An empty radius auto-allows; a \
radius exceeding the guardrail's threshold{threshold_note} escalates to human approval; a radius \
touching a protected asset blocks the change outright.",
if blast_guardrail.is_some() {
" (guardrail `blast_radius_limit`)"
} else {
""
},
node.context_binding.project
),
"3. **Human approval**: the apply step is gated on EXPLICIT human approval delivered over \
the runtime's approval channel (guardrail `human_approval`, locked). Headless mode has no human in \
the loop, so this component honestly CANNOT run headless — that capability edge is not worked \
around."
.to_string(),
"4. **Apply + rollback**: only once approved do you apply the edit. A version-control (git) \
checkpoint is taken first — BORROWED from the runtime, not built by this agent — so the change can \
be rolled back if it turns out wrong (guardrail `rollback_available`)."
.to_string(),
String::new(),
"Your dry-run message MUST be a SINGLE JSON object — the diff envelope — containing a \
`diff`-typed block with the proposed change and nothing else. Do not proceed past dry-run until \
the blast-radius gate and human approval have both cleared; do not apply, and do not write any \
file yourself outside of that gated apply step."
.to_string(),
String::new(),
"Mutation diff envelope example:".to_string(),
String::new(),
MUTATION_DIFF_ENVELOPE_EXAMPLE.to_string(),
];
lines.join("\n")
}
fn build_context_mutation_section(node: &ComponentNode) -> String {
let outcome = &node.effect.outcome;
let change_type_note = outcome
.change_type
.as_deref()
.map(|c| format!(", change_type `{c}`"))
.unwrap_or_default();
let context_guardrail = find_guardrail(&node.guardrails, "context_write_authz");
let scope = context_guardrail
.and_then(|g| g.scope.as_deref())
.unwrap_or(DEFAULT_ARTIFACT_SCOPE);
let lines: Vec<String> = vec![
"## Mutation lifecycle".to_string(),
String::new(),
format!(
"This is a **constitutive** component (outcome: mutation, target `context`{change_type_note}). \
Applying a change here is a two-phase GATED lifecycle: propose, then gate, and only then apply. \
None of the four phases below is optional and none may be skipped by this agent."
),
String::new(),
"1. **Dry-run first** (guardrail `must_dry_run`, locked): propose the edit as a DIFF only — \
do NOT apply it yet. Render the proposed change via a `diff` render block (`path` + the unified \
diff text); this is a preview, never a write."
.to_string(),
format!(
"2. **Context-write gate** (guardrail `context_write_authz`, locked, scope `{scope}`): \
this is a scoped PATH-AUTHORIZATION check, NOT a downstream-lineage impact computation — the \
proposed write must resolve to a path inside the `{scope}` scope or it is denied outright. Writing \
outside this scope (the models/metrics/knowledge structure this component owns) is never \
permitted, however small the change."
),
"3. **Human approval**: the apply step is gated on EXPLICIT human approval delivered over \
the runtime's approval channel (guardrail `human_approval`, locked). Headless mode has no human in \
the loop, so this component honestly CANNOT run headless — that capability edge is not worked \
around."
.to_string(),
"4. **Apply + rollback**: only once approved do you apply the edit. A version-control (git) \
checkpoint is taken first — BORROWED from the runtime, not built by this agent — so the change can \
be rolled back if it turns out wrong (guardrail `rollback_available`)."
.to_string(),
String::new(),
"Your dry-run message MUST be a SINGLE JSON object — the diff envelope — containing a \
`diff`-typed block with the proposed change and nothing else. Do not proceed past dry-run until \
the context-write gate and human approval have both cleared; do not apply, and do not write any \
file yourself outside of that gated apply step, or outside the scoped path above."
.to_string(),
String::new(),
"Mutation diff envelope example:".to_string(),
String::new(),
MUTATION_DIFF_ENVELOPE_EXAMPLE.to_string(),
];
lines.join("\n")
}