@warble/ir-spec 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # `@warble/ir-spec`
2
+
3
+ The Warble IR (`warble_ir_version`) as a resolvable npm package.
4
+
5
+ **This package's version *is* the payload.** `warble_ir_version 0.6` publishes as npm version
6
+ `0.6.0` — IR `x.y` maps to npm `x.y.0`, with the patch component always zero. Everything else in
7
+ the package — the bundled [`ir-schema.md`](./ir-schema.md) (a copy of
8
+ [`docs/spec/ir-schema.md`](https://github.com/Canner/Warble/blob/main/docs/spec/ir-schema.md)) and
9
+ the `IR_VERSION` constant exported from `index.js` — is documentation, not enforcement.
10
+
11
+ ## Why this package exists
12
+
13
+ Before this package, the IR version a published dispatcher (`@warble/claude-agent-sdk`,
14
+ `@warble/codex-local`) accepts was discoverable only by reading its source. Both dispatchers now
15
+ declare `@warble/ir-spec` as a `peerDependencies` range (`0.6.x` today) plus an advisory
16
+ `"warble": { "irVersion": "0.6" }` field, so a consumer — or npm's own resolver — can see which IR a
17
+ dispatcher speaks without opening it.
18
+
19
+ **This package is not meant to be imported.** A `peerDependency` is a declaration, not a dependency
20
+ edge: each dispatcher keeps its own independently declared `SUPPORTED_IR_VERSION` (or
21
+ `SUPPORTED_IR_VERSIONS`) constant rather than importing this package's `IR_VERSION`. See
22
+ [`ir-schema.md`](./ir-schema.md#ir-version-compatibility) for why independent copies are what make
23
+ the core-owned lockstep test a real check rather than a formality.
24
+
25
+ ## What this package does *not* do
26
+
27
+ - It does not ship a machine-readable IR JSON Schema — `ir-schema.md` is prose. See
28
+ `docs/spec/ir-schema.md` in the main repository for the rationale.
29
+ - It does not, by itself, reject a mismatched dispatcher/IR pair at install time. A missing peer
30
+ (this package absent from the registry at the named version) fails installation under every
31
+ package manager's default configuration; a *mismatched* peer range across two npm packages is
32
+ enforced only once a second party (e.g. a future `@warble/cli`) also declares the peer.
33
+ - It is not locked to the Warble workspace/crate version (`0.4.0` and friends). It moves only when
34
+ the IR moves, on its own release line.
package/index.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The Warble IR version this package's own npm version encodes. See `index.js` and
3
+ * `docs/spec/ir-schema.md` (bundled here as `ir-schema.md`) for why this is exported without being
4
+ * meant to be imported by a dispatcher.
5
+ */
6
+ export declare const IR_VERSION: "0.6";
7
+
8
+ declare const _default: { IR_VERSION: "0.6" };
9
+ export default _default;
package/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The Warble IR version this package's own npm version encodes.
3
+ *
4
+ * This package is not meant to be imported by a dispatcher — see `docs/spec/ir-schema.md` (bundled
5
+ * here as `ir-schema.md`) for why: a dispatcher keeps its own independently declared
6
+ * `SUPPORTED_IR_VERSION`(S) constant instead, so this package's role is to exist as a resolvable
7
+ * npm node whose *version* is the contract, not to be depended on at runtime. This constant is
8
+ * exported for tooling that wants the value without re-parsing `package.json` (e.g. a script
9
+ * checking a dispatcher's declared peer range against the spec it names).
10
+ */
11
+ export const IR_VERSION = "0.6";
12
+
13
+ export default { IR_VERSION };
package/ir-schema.md ADDED
@@ -0,0 +1,820 @@
1
+ # Warble IR — the compile contract (`warble_ir_version: 0.6`)
2
+
3
+ The IR is the **language-neutral seam** between the Warble front-end (`warble compile`) and any
4
+ back-end. The v1 reference back-end is the Claude Code CLI target (`warble dispatch`, Rust); other
5
+ runtimes are other thin back-ends. Both sides depend only on this document — not on each other's
6
+ internals.
7
+
8
+ `warble compile <project-dir> -o ir.json` reads a Warble project (profile + components +
9
+ context binding) and emits **one** IR JSON document with `"warble_ir_version": "0.6"` — the
10
+ current, live contract the compiler emits today. (Earlier drafts of this doc kept the per-step-tier
11
+ shape in a separate "v0.2 (proposed)" section; that has been folded into the contract below now
12
+ that it is implemented and wired into the built core/dispatcher.) The shape below is what the
13
+ dispatcher consumes.
14
+
15
+ > Scope note (v0.3+): context binding is **fine-grained**. The host injects a `ContextLoader`
16
+ > selected for the binding kind, and the compiler **evaluates** every `context_precondition`
17
+ > against that bound context — not merely validates vocabulary membership. The IR records passing
18
+ > checks in `precondition_result.checks`; a Wren-project adapter also fills
19
+ > `context_binding.resolved` with metrics/dimensions/grains and lineage, while a raw-source adapter
20
+ > answers the constitutive probes and an external adapter emits no resolved block. A precondition
21
+ > that is answerable-and-false, or that the adapter **cannot answer** at all, is a
22
+ > loud compile-time fail — so an emitted IR only ever contains passing checks. See
23
+ > [`context_precondition`](#context_precondition-closed-predicate-vocabulary) and the
24
+ > [v0.3 binding](#v03--fine-grained-context-binding) section below.
25
+ >
26
+ > The coarse `context_binding.project` locator is **retained**: Wren-project back-ends use it to run
27
+ > `wren`, while other binding kinds give it adapter-specific meaning. Fine-grained binding is
28
+ > additive, not a replacement.
29
+
30
+ ---
31
+
32
+ > **Umbrella model:** how the IR's declared capabilities are matched against a target runtime at
33
+ > dispatch (native / realize-via / degrade / fail) is defined in `capability-model.md`. The v0.3
34
+ > section below is a specific capability resolved under that model.
35
+
36
+ ## IR version compatibility
37
+
38
+ `warble_ir_version` is a closed, exact-match contract, not a semver range: every back-end accepts
39
+ **only** the version(s) listed below and loud-fails naming both the rejected and the supported
40
+ version on anything else — there is no best-effort or partial parse of an unrecognized version.
41
+
42
+ | Consumer | Accepted `warble_ir_version` | Where the accepted version is declared |
43
+ | --- | --- | --- |
44
+ | `core` (`warble compile`) | emits `0.6` | the `"warble_ir_version"` literal in `core/src/compile.rs` |
45
+ | `dispatcher/claude-code-cli` | `0.6` | `SUPPORTED_IR_VERSION` in `dispatcher/claude-code-cli/src/ir.rs` |
46
+ | `dispatcher/vercel` | `0.6` | `SUPPORTED_IR_VERSION` in `dispatcher/vercel/src/emit.rs` |
47
+ | `dispatcher/claude-agent-sdk` | `0.6` | `SUPPORTED_IR_VERSIONS` in `dispatcher/claude-agent-sdk/src/ir.ts` |
48
+ | `dispatcher/codex-local` | `0.6` | `SUPPORTED_IR_VERSION` in `dispatcher/codex-local/src/ir.ts` |
49
+
50
+ Each back-end copies this value rather than importing it from `core` or from another back-end: a
51
+ back-end shouldn't need a Rust dependency edge just to know a version string, and independent copies
52
+ are what make the core-owned lockstep test below a meaningful check rather than a formality. (This is in the
53
+ same spirit as invariant 2 (zero-wren) in
54
+ [`CONTRIBUTING.md`](../../CONTRIBUTING.md#invariants--preserve-these), though that invariant itself
55
+ only constrains what `core` and the components may depend on, not what depends on them.) Three of
56
+ the four back-ends (`vercel`, `claude-agent-sdk`, `codex-local`) also stamp the version onto emitted
57
+ artifacts as advisory `min`/`max` metadata — for example, the `vercel` bundle's own
58
+ `compat.min_ir_version` / `compat.max_ir_version`. The Codex manifest derives that advisory pair
59
+ directly from its enforcement constant instead of copying the value again. Artifact compatibility
60
+ is informational, not itself an input enforcement check.
61
+
62
+ `@warble/claude-agent-sdk` and `@warble/codex-local` additionally each declare a `peerDependencies`
63
+ entry on [`@warble/ir-spec`](../../packages/ir-spec) — a dedicated npm package whose own version *is*
64
+ the IR version (see [IR version to npm version mapping](#ir-version-to-npm-version-mapping) below) —
65
+ plus an advisory `"warble": { "irVersion": "0.6" }` field in the same `package.json`. This makes the
66
+ IR version a dispatcher speaks visible in the npm dependency graph without opening the package.
67
+ **Neither dispatcher imports `@warble/ir-spec`** — the peer is a declaration, not a dependency edge,
68
+ and each dispatcher keeps enforcing its own copy of `SUPPORTED_IR_VERSION`(S) above. `@warble/ir-spec`
69
+ also bundles this document itself (as `ir-schema.md`, alongside `index.js`/`index.d.ts`) as a frozen
70
+ snapshot for the IR version it publishes — a published npm version is immutable, so a snapshot as of
71
+ that version is worth more than a live link back to this file on `main`, which points at whatever the
72
+ spec later became. `just publish-check` fails if `packages/ir-spec/ir-schema.md` and this document
73
+ ever diverge, since a publish is exactly the point that drift becomes irreversible; re-sync it with
74
+ `cp docs/spec/ir-schema.md packages/ir-spec/ir-schema.md` whenever this document changes. Counting the
75
+ producer (what `core` actually emits) alongside every independent consumer/advisory copy, the
76
+ `@warble/ir-spec` package's own version, its `index.js` and `index.d.ts` version constants/literals,
77
+ both dispatchers' peer declarations, both dispatchers' advisory `warble.irVersion` fields, and the
78
+ spec title, there are **eighteen** locations that must agree:
79
+
80
+ | # | Location | Kind | Checked by |
81
+ | --- | --- | --- | --- |
82
+ | 1 | `core/src/compile.rs` — the `"warble_ir_version"` literal it emits | Producer | `core/tests/ir_version_lockstep_tests.rs` |
83
+ | 2 | `dispatcher/claude-code-cli/src/ir.rs` `SUPPORTED_IR_VERSION` | Enforcement | `core/tests/ir_version_lockstep_tests.rs` |
84
+ | 3 | `dispatcher/vercel/src/emit.rs` `SUPPORTED_IR_VERSION` | Enforcement | `core/tests/ir_version_lockstep_tests.rs` |
85
+ | 4 | `dispatcher/claude-agent-sdk/src/ir.ts` `SUPPORTED_IR_VERSIONS` | Enforcement | `core/tests/ir_version_lockstep_tests.rs` |
86
+ | 5 | `dispatcher/codex-local/src/ir.ts` `SUPPORTED_IR_VERSION` | Enforcement + manifest advisory source | `core/tests/ir_version_lockstep_tests.rs` |
87
+ | 6 | `dispatcher/vercel/src/emit.rs` `MIN_SUPPORTED_IR_VERSION` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
88
+ | 7 | `dispatcher/vercel/src/emit.rs` `MAX_SUPPORTED_IR_VERSION` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
89
+ | 8 | `dispatcher/claude-agent-sdk/src/manifest.ts` `MIN_SUPPORTED_IR_VERSION` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
90
+ | 9 | `dispatcher/claude-agent-sdk/src/manifest.ts` `MAX_SUPPORTED_IR_VERSION` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
91
+ | 10 | This document's title (`warble_ir_version: 0.6`) | Spec | `core/tests/ir_version_lockstep_tests.rs` |
92
+ | 11 | `packages/ir-spec/package.json` `"version"` (mapped `x.y` -> `x.y.0`) | Producer (npm) | `core/tests/ir_version_lockstep_tests.rs` |
93
+ | 12 | `packages/ir-spec/index.js` `IR_VERSION` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
94
+ | 13 | `dispatcher/claude-agent-sdk/package.json` `peerDependencies["@warble/ir-spec"]` (mapped `x.y` -> `x.y.x`) | Declaration | `core/tests/ir_version_lockstep_tests.rs` |
95
+ | 14 | `dispatcher/claude-agent-sdk/package.json` `warble.irVersion` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
96
+ | 15 | `dispatcher/codex-local/package.json` `peerDependencies["@warble/ir-spec"]` (mapped `x.y` -> `x.y.x`) | Declaration | `core/tests/ir_version_lockstep_tests.rs` |
97
+ | 16 | `dispatcher/codex-local/package.json` `warble.irVersion` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
98
+ | 17 | `packages/ir-spec/index.d.ts` `export declare const IR_VERSION` type literal | Advisory (type) | `core/tests/ir_version_lockstep_tests.rs` |
99
+ | 18 | `packages/ir-spec/index.d.ts` default-export `IR_VERSION` type literal | Advisory (type) | `core/tests/ir_version_lockstep_tests.rs` |
100
+
101
+ This table's scope is contract-bearing declarations — constants and literals something actually
102
+ compares against — not every place `warble_ir_version` appears in prose. Each back-end's `ir` module
103
+ doc comment also mentions the current version for a human skimming the file (e.g. `//! Typed view of
104
+ the Warble IR (warble_ir_version: 0.6)`); nothing checks those comments, and a version bump can leave
105
+ them stale without breaking anything. They are deliberately not extra rows — update them as a
106
+ courtesy to the reader, not because a test requires it.
107
+
108
+ `core/tests/ir_version_lockstep_tests.rs` is the sole cross-target lockstep owner: it text-parses
109
+ every contract-bearing declaration above and asserts that each equals the version core emits (rows
110
+ 11–15 first pass the emitted version through the mapping below, since they are npm identifiers, not
111
+ copies of the raw `x.y` string). The target-local `ir_version_tests.rs` files only exercise their own
112
+ unsupported-version rejection and no-partial-output behavior; they do not scrape other targets'
113
+ sources. (`core/src/lib.rs`'s doctest and `core/tests/compile_tests.rs` also assert row 1's literal
114
+ directly, but aren't listed as separate lockstep-tested locations — they self-guard, failing the
115
+ moment `compile.rs` changes without a matching update there.) When `warble_ir_version` changes,
116
+ update all eighteen rows in the same change.
117
+
118
+ ### IR version to npm version mapping
119
+
120
+ `@warble/ir-spec` is **not** version-locked to the Cargo workspace version (`0.4.0` and friends,
121
+ tracked separately — see [`RELEASING.md`](../../RELEASING.md#ir-version-vs-crate-version)); it moves
122
+ only when `warble_ir_version` moves, on its own release line. The mapping from IR version to npm
123
+ version is fixed and mechanical:
124
+
125
+ - IR version `x.y` maps to npm version `x.y.0` — the patch component is **always** zero.
126
+ - A dispatcher's `peerDependencies["@warble/ir-spec"]` range is `x.y.x` — e.g. IR `0.6` is npm
127
+ version `0.6.0` and peer range `0.6.x`.
128
+ - An IR version with anything other than exactly two dot-separated numeric components (a three-part
129
+ `x.y.z`, or a non-numeric component) has **no defined mapping** and is rejected by
130
+ `core/tests/ir_version_lockstep_tests.rs` at the point it tries to compute rows 11, 13, and 15 — it
131
+ panics rather than guessing a truncated or extended version.
132
+
133
+ This mapping is what a future releaser bumping `warble_ir_version` needs to reproduce by hand when
134
+ publishing the next `@warble/ir-spec` version (row 11) and updating both dispatchers' peer ranges
135
+ (rows 13 and 15) — see the bump procedure in [`RELEASING.md`](../../RELEASING.md).
136
+
137
+ ### When `warble_ir_version` must change
138
+
139
+ Any change to the IR shape requires a version bump — **including a purely additive one** (a new
140
+ optional field, a new enum arm nothing yet emits, a key that quietly defaults when absent). This is
141
+ not a formality; it is the precondition for the exact-match policy above to mean anything.
142
+
143
+ An additive field shipped without a version bump is invisible to enforcement. A back-end built
144
+ against the previous shape keeps declaring the same `warble_ir_version`, so the version check passes
145
+ and the back-end never even notices a new field exists; an unrecognized key is dropped during
146
+ deserialization and the consumer behaves exactly as it did before. Nothing fails, loud or otherwise,
147
+ and there is no signal anywhere that the wire contract moved. The version bump *is* the mechanism
148
+ that turns "the IR grew" into an observable event. Skip it, and additive growth becomes undetectable
149
+ growth — the exact-match check has nothing left to compare against, because both sides still agree on
150
+ a version number that no longer describes the same contract.
151
+
152
+ Bumping unconditionally also keeps a retreat path open. Widening the accepted-version policy later —
153
+ for example, moving from a single exact version to an accepted range — is a non-breaking relaxation
154
+ of what is enforced today. Starting from a wide range and later narrowing it is a breaking change for
155
+ whoever came to depend on the wider behavior in the meantime. Exact-match is the strictest available
156
+ starting point, so keeping it strict now is what preserves the option to loosen it later without
157
+ having already given up the ability to say no.
158
+
159
+ None of this is free: a bump touches all eighteen places above — held together by the core-owned Rust
160
+ lockstep test — *and* it invalidates any artifact a consumer has already stored from a previous IR version — a
161
+ committed bundle or compiled snapshot built against the old version now names a version no current
162
+ back-end accepts, and must be regenerated rather than merely re-read.
163
+
164
+ ## Top-level shape
165
+
166
+ ```jsonc
167
+ {
168
+ "warble_ir_version": "0.6",
169
+ "profile": "orders-analytics", // profile.yml `profile:`
170
+ "context_binding": { // resolved from profile `context:` + context/binding.yml
171
+ "project": "examples/jaffle-wren", // coarse path to a wren project (retained for back-ends)
172
+ "binding_mode": "runtime_selected",
173
+ "resolved": { // v0.3 fine-grained binding — what the ContextLoader learned
174
+ "metrics": [ // declared cube measures + implicit numeric columns
175
+ { "name": "total_revenue", "declared": true, "additivity": "additive" },
176
+ { "name": "avg_order_value", "declared": true, "additivity": "non_additive" },
177
+ { "name": "amount", "declared": false } // implicit column: additivity not expressible
178
+ ],
179
+ "dimensions": [ { "name": "status", "temporal": false }, { "name": "order_date", "temporal": true } ],
180
+ "time_dimensions": ["order_date"],
181
+ "models": ["customers", "orders", /* … */],
182
+ "lineage": { "nodes": 15, "edges": 12, "resolvable": true } // summary only; full DAG stays in the adapter
183
+ // when the project carries consumer artifacts, `lineage` additionally reports
184
+ // "consumers": { "queries": 2, "dashboards": 1 } // query:/dashboard: node counts
185
+ // and, when construction had to degrade (e.g. a consumer's SQL didn't parse),
186
+ // "diagnostics": ["query:broken: statement did not parse as SQL; …"] // no silent caps
187
+ // — both keys are ABSENT (not empty) on a project without consumers/degradations,
188
+ // so pre-consumer IRs are byte-identical.
189
+ }
190
+ },
191
+ "config": {}, // reserved profile-level config block; no fields today
192
+ "components": [ /* one resolved component node, see below */ ]
193
+ }
194
+ ```
195
+
196
+ ### `config` — emptied in 0.6
197
+
198
+ `config` carried one field, `tier_policy`, from the first IR through `0.5`. `0.6` removes it, and
199
+ the block is now emitted as `{}`.
200
+
201
+ `tier_policy` was a profile-wide tier stance (`cost_sensitive`) that the compiler was meant to
202
+ resolve into per-step tiers. Only the field ever landed. No back-end read it, its value was never
203
+ validated against any vocabulary, and compiling the same profile with `cost_sensitive`, `null`, or
204
+ an invented string produced byte-identical dispatch output — so a profile declaring it advertised
205
+ cost control it did not have.
206
+
207
+ It was removed rather than implemented because the rule it needs does not exist and the obvious
208
+ rule is measurably wrong: eval puts a blanket downgrade of `answer_query` at no accuracy cost and
209
+ ~3× cheaper on a clean schema, and at 0.93 → 0.60 execution accuracy on a messy one. Which steps
210
+ are safe to downgrade is a property of the **bound context**, not of the profile, so a static
211
+ profile-level string cannot express it. Per-step `tier_overrides` on a mount remains the honest
212
+ control (see [`profile-schema.md`](./authoring.md#61-tiers-not-model-names)).
213
+
214
+ The block itself stays so that profile-level config which *can* be honored is an additive change
215
+ rather than the reintroduction of a removed key.
216
+
217
+ ## Component node (resolved: component fields ⊕ supported profile mount fields)
218
+
219
+ ```jsonc
220
+ {
221
+ "id": "generate_dashboard",
222
+ "verb": "generate_dashboard",
223
+ "type": "analytical", // analytical | assertive | mutating | constitutive | orchestrating
224
+ "realization_kind": "skill", // required in component.yml; a profile mount may replace it
225
+ "context_binding": { // per-node; POC = same coarse project as top-level
226
+ "project": "examples/jaffle-wren",
227
+ "binding_mode": "runtime_selected"
228
+ },
229
+ "context_requirements": [ // human-readable shape strings — always emitted, may be []
230
+ "a wren project (semantic layer) to build dashboards over"
231
+ ],
232
+ "context_precondition": [ // structured predicates — always emitted, may be []
233
+ { "predicate": "has_metric" },
234
+ { "predicate": "has_groupable_dimension" }
235
+ // "args" is optional per entry, e.g. { "predicate": "has_metric", "args": { "name": "revenue" } }
236
+ // predicate must be from the closed vocabulary — see below. Compile validates membership AND
237
+ // evaluates each predicate against the bound context via the injected ContextLoader.
238
+ ],
239
+ "params": [ // always emitted, may be []
240
+ { "name": "topic_default", "bind": "optional", "default": "overview" }, // profile-bound (bind)
241
+ { "name": "connection", "source": "runtime-injected" } // runtime-injected, not in git
242
+ ],
243
+ "binds": { // additive; present only when >=1 bind-family param has a value
244
+ "topic_default": "overview" // mount didn't supply one, so this is the declared default
245
+ },
246
+ "precondition_result": { // per-predicate evaluation outcome (v0.3, see §checks)
247
+ "status": "pass", // always "pass" in emitted IR — a failing predicate loud-fails
248
+ "checks": [ // one entry per declared context_precondition, in order
249
+ { "predicate": "has_metric", "outcome": "pass" },
250
+ { "predicate": "has_groupable_dimension", "outcome": "pass" }
251
+ ]
252
+ },
253
+ "brief": "…shared framing for every step, placeholders substituted…", // additive; present only when authored — see below
254
+ "prompt_fragment": "…rendered skill instructions…", // see §prompt rendering
255
+ "llm_calls": [ // per-step tier, order preserved from component llm_steps
256
+ { "name": "plan_dashboard", "tier": "strong", "conditional": false, "when": null,
257
+ "consumes": [], "produces": "query_plan",
258
+ "prompt": "<plan_dashboard.md rendered, placeholders substituted, no ## header>" },
259
+ { "name": "compose_layout", "tier": "cheap", "conditional": false, "when": null,
260
+ "consumes": ["query_plan"], "produces": "dashboard_summary",
261
+ "prompt": "<compose_layout.md rendered>" }
262
+ // a conditional step instead carries e.g. "conditional": true, "when": { "guard": "on_failure", "target": "generate_sql" }
263
+ // — see `llm_calls[].when` below
264
+ ],
265
+ "guardrails": [ // resolved; `locked` is the normalized lock-state
266
+ { "name": "read_only_execution", "locked": true }
267
+ // `scope`/`threshold` appear only when authored; their meaning is guardrail/target-specific,
268
+ // e.g. another component may emit { "name": "artifact_write", "locked": true, "scope": "." }
269
+ ],
270
+ "trigger": { "kind": "one_shot" }, // one_shot | scheduled | event
271
+ "required_capabilities": [ // union of component declarations
272
+ "sql_execution:read_only", "genbi_build", "llm:strong", "llm:cheap"
273
+ ],
274
+ "borrowed_actions": [],
275
+ "eval_ref": "generate_dashboard.eval", // legacy reference string; retained for back-compat
276
+ "eval": { // structured form; present only when authored
277
+ "template_ref": "eval/",
278
+ "metrics": ["answer_relevance", "chart_appropriateness"]
279
+ },
280
+ "effect": {
281
+ "render_blocks": [
282
+ { "type": "chart", "fields": {} }, { "type": "table", "fields": {} }, { "type": "kpi_card", "fields": {} }
283
+ ],
284
+ "outcome": {
285
+ "kind": "none" // none | assertion | mutation | dispatch — stays this 4-value union
286
+ // optional facets below are parsed today but not yet consumed by the MVP (analytical) back-ends
287
+ // — forward-declared, not silently dropped. See §effect.outcome facets.
288
+ // "verdict_type", "emits" (assertive)
289
+ // "target", "change_type" (mutating)
290
+ // "routable_scope" (orchestrating)
291
+ }
292
+ }
293
+ }
294
+ ```
295
+
296
+ ### New/expanded fields, one by one
297
+
298
+ #### `context_requirements`
299
+
300
+ An array of human-readable shape strings — what "shape" of context this component needs, in
301
+ prose (not a binding). **Always emitted, may be `[]`** (as it is on the `dashboard` component in
302
+ `examples/render-demo/ir.golden.json`, which declares no requirements). Not machine-checked today; it exists
303
+ for discoverability (Hub listings, docs) alongside the machine-checked `context_precondition`.
304
+
305
+ #### `context_precondition` (closed predicate vocabulary)
306
+
307
+ An array of structured predicates, each `{ "predicate": <name>, "args"?: {…} }`. **Always emitted,
308
+ may be `[]`.** `predicate` must be one of exactly eleven names:
309
+
310
+ | Predicate |
311
+ | --- |
312
+ | `mdl_parseable` |
313
+ | `has_metric` |
314
+ | `has_queryable_dimension` |
315
+ | `has_time_dimension` |
316
+ | `has_groupable_dimension` |
317
+ | `metric_additive` |
318
+ | `model_has_timestamp` |
319
+ | `lineage_resolvable` |
320
+ | `wren_project_exists` |
321
+ | `source_introspectable` |
322
+ | `raw_docs_readable` |
323
+
324
+ `args` is optional per entry (predicate-specific, e.g. a metric/dimension name to check). An arg
325
+ value may be the literal to check, or a **bind reference** `"$param:<name>"` naming one of the
326
+ component's own `params[]` entries — compile substitutes it with that param's *effective* value
327
+ (the profile mount's supplied bind, or else the param's declared `default`; see
328
+ [`params`](#params) and [Resolution rules](#resolution-rules)) before evaluating the predicate.
329
+ `$param:<name>` naming a param the component doesn't declare is a loud compile-time fail (an
330
+ authoring bug, caught immediately rather than surfacing later as a confusing "unanswerable").
331
+ A declared param with no effective value (an unsupplied `bind: optional` param with no `default`)
332
+ makes the referencing precondition **unanswerable** — the same refuse-rather-than-guess outcome as
333
+ an unanswerable predicate, not a silent skip. The IR always carries the **resolved** value in
334
+ `context_precondition[].args`, never the unresolved `"$param:<name>"` template — a back-end reading
335
+ the IR never needs to know binding happened. Compile validates vocabulary membership (an unknown
336
+ predicate name is a loud fail) **and evaluates each predicate against the bound context** through
337
+ the injected `ContextLoader`. Evaluation has three outcomes:
338
+
339
+ - **pass** — the predicate holds; recorded in `precondition_result.checks`.
340
+ - **fail (answerable-and-false)** — the predicate is answerable but does not hold → loud compile
341
+ fail (`… not satisfied by the bound semantic layer`).
342
+ - **unanswerable** — the active adapter cannot express the answer → a *different* loud fail
343
+ (`… cannot be evaluated …`), never a silent false. `metric_additive` is unanswerable without a
344
+ declared metric; a pinned `model_has_timestamp` is unanswerable when its model is undeclared; and
345
+ the two raw-shape predicates are unanswerable on adapters that cannot probe raw input.
346
+
347
+ `metric_additive` is the one semantic predicate. **Existential by default** (no `args`): it passes
348
+ iff the layer declares at least one additive metric, fails if declared metrics exist but none are
349
+ additive, and is unanswerable if no declared metric exists at all. **Pinned** (`args: { metric:
350
+ <name> }`): the named metric must be a declared measure — additive → pass, non-additive → fail, not
351
+ declared → unanswerable. The per-metric decision a general component needs at *run* time (which
352
+ metric did the user pick?) stays a runtime guard; compile time proves a valid target exists and that
353
+ additivity is decidable in this Context.
354
+
355
+ `model_has_timestamp` follows the same existential/pinned shape. **Existential by default** (no
356
+ `args`): passes iff *any* declared model has a timestamp column, never unanswerable (a project with
357
+ zero models still has a well-defined — false — answer). **Pinned** (`args: { model: <name> }`,
358
+ typically via `$param:` against a `bind`-family param naming a model): the named model must be
359
+ declared — has a timestamp → pass, no timestamp → fail, not a declared model at all →
360
+ unanswerable. This is what makes `binding_mode: pinned` meaningful for a component like
361
+ `monitor_freshness`: binding the component to a specific, timestampless model is caught at compile
362
+ time instead of failing confusingly at run time.
363
+
364
+ `source_introspectable` and `raw_docs_readable` are the constitutive raw-shape predicates. A
365
+ `RawSourceContext` answers them with `Some(true)` (pass) or `Some(false)` (answerable fail);
366
+ MDL-only and external adapters return `None` (unanswerable), which is a loud compile failure rather
367
+ than a guessed false. They are used with a `kind: raw_source` binding before an MDL exists.
368
+
369
+ #### `params`
370
+
371
+ An array, **always emitted, may be `[]`.** Each entry is exactly one of two shapes:
372
+
373
+ - **Profile-bound**: `{ "name", "bind": "required" | "optional", "default"? }` — supplied (or
374
+ defaulted) by the profile at compile time; see [Resolution rules](#resolution-rules).
375
+ - **Runtime-injected**: `{ "name", "source": "runtime-injected" }` — supplied by the runtime at
376
+ dispatch/run time, never committed to git (e.g. a database `connection`, or `model_binding`,
377
+ the tier→concrete-model binding).
378
+
379
+ An entry must declare **exactly one** of `bind` or `source` — declaring both, or neither, is a loud
380
+ compile-time fail. The only accepted `source` value today is `"runtime-injected"`; any other value
381
+ is also a loud compile-time fail.
382
+
383
+ #### `binds`
384
+
385
+ An object, **additive: present only when the component has at least one `bind`-family param with
386
+ an effective value; omitted entirely otherwise** (no empty `{}`). Keys are param names; values are
387
+ each param's *effective* value — the profile mount's supplied bind, or else the param's declared
388
+ `default` when the mount didn't supply one. `source: runtime-injected` params are never included
389
+ (their value doesn't exist until dispatch/run time). This is the one place in the IR a back-end (or
390
+ a human reading `ir.json`) can see, without cross-referencing `profile.yml`, exactly what a
391
+ component was bound to at compile time — the same map compile itself uses to resolve `$param:`
392
+ references in `context_precondition[].args` (see above), so the two are always consistent with each
393
+ other by construction.
394
+
395
+ ```jsonc
396
+ "params": [
397
+ { "name": "model", "bind": "required" },
398
+ { "name": "expected_cadence", "bind": "optional", "default": "24h" }
399
+ ],
400
+ "binds": { "model": "orders", "expected_cadence": "24h" }
401
+ // "model" came from the profile mount's `bind:`; "expected_cadence" fell back to its default
402
+ // because the mount didn't supply one.
403
+ ```
404
+
405
+ #### `llm_calls[].conditional`
406
+
407
+ A boolean, **always emitted, defaults to `false`.** It marks a step that only runs sometimes; *why*
408
+ it's conditional is carried separately in `when` (below), not in this flag. The composition layer
409
+ stays declarative: `conditional`/`when` name a closed-vocabulary guard, never a condition
410
+ expression — the actual mechanics of what to do (retry, skip, escalate) live inside the step's own
411
+ hook/prompt, never in the profile/composition layer. This keeps invariant #3 (the composition layer
412
+ never grows a data-flow DSL) intact.
413
+
414
+ #### `llm_calls[].when` (closed guard vocabulary, additive since v0.3)
415
+
416
+ `{ "guard": <name>, "target": <string> }`, or `null` — **always emitted as a key** (present with a
417
+ `null` value when the step isn't conditional, mirroring `produces`'s always-present-key style; never
418
+ omitted). `guard` must be one of exactly three names:
419
+
420
+ | `guard` | `target` | Meaning |
421
+ | --- | --- | --- |
422
+ | `on_failure` | an upstream step name | Runs only if that step failed |
423
+ | `on_flag` | a dotted `artifact.field` | Runs only if that boolean field on a produced artifact is true |
424
+ | `on_missing` | an artifact name | Runs only if that artifact was not produced |
425
+
426
+ Compile enforces the full `(conditional, when)` matrix as a loud fail:
427
+
428
+ - `conditional: true` with no `when` — refused; bare `conditional: true` no longer implies a
429
+ condition.
430
+ - `when` present without `conditional: true` — refused; a guard with nothing to guard is refused
431
+ rather than silently ignored.
432
+ - an unknown `guard` name, an empty `target`, or an `on_flag` target with no `.` — all refused.
433
+
434
+ This shape was introduced as part of the `0.3` IR contract. Whether — and how — a back-end realizes
435
+ `when` is a per-back-end decision, not a schema requirement; any future shape change still requires
436
+ an IR version bump under the policy above. As of this writing:
437
+
438
+ - `dispatcher/vercel` realizes it as one of two well-defined shapes: an `on_failure` guard
439
+ targeting the immediately-preceding call folds into that call's own bounded repair loop; every
440
+ other guard in the closed vocabulary is an independent step whose guard the bundle *consumer*
441
+ evaluates deterministically at runtime (see `dispatcher/vercel/src/classify.rs`). A `when`/
442
+ `conditional` shape outside the closed vocabulary — an unrecognized guard name, or `conditional`
443
+ and `when` disagreeing about whether a guard exists — fails loudly at emit time rather than
444
+ being silently folded into either shape.
445
+ - `dispatcher/claude-agent-sdk` realizes it at runtime via a hybrid-staged executor (see
446
+ `dispatcher/claude-agent-sdk/src/run.ts` / `conditional.ts`).
447
+ - `dispatcher/claude-code-cli` does not yet realize it — this back-end has no deterministic
448
+ runtime to evaluate a guard against, so it still treats `conditional` as an opaque flag and
449
+ tolerates `when` without acting on it (see `dispatcher/claude-code-cli/src/ir.rs`).
450
+
451
+ A back-end that ignores `when` must do so as a documented, deliberate choice (as `claude-code-cli`
452
+ does above) — never as a silent fallback for a guard shape it was simply never taught to recognize.
453
+
454
+ #### `guardrails[].scope` / `threshold` and the `locked`/`overridable` normalization
455
+
456
+ `scope` and `threshold` are passthrough fields — each is present in the resolved IR **only when
457
+ authored** on the component. Their meaning is selected by `name` and the target: for example,
458
+ `artifact_write.scope` and `context_write_authz.scope` define distinct path boundaries, while a
459
+ threshold is guardrail-specific structured policy. The compiler preserves these values but does
460
+ not itself validate path containment or assign a universal meaning to every scope; a target that
461
+ claims support must consume the relevant field at its enforcement seam. Dropping a scope from a
462
+ scoped write guardrail widens the represented write boundary and is not a semantics-preserving
463
+ fallback. Both fields are omitted when not authored.
464
+
465
+ Authoring may declare `locked` and/or `overridable` on a guardrail, but the **IR only ever emits
466
+ `locked`** as its normalized lock-state. It is the single source of truth for whether a guardrail
467
+ is locked, while downstream consumers may also inspect the guardrail's `name`, `scope`, and
468
+ `threshold` to select and configure enforcement. At compile:
469
+
470
+ - authoring may declare exactly **one** of `locked` or `overridable` (`overridable: true` normalizes
471
+ to `locked: false`);
472
+ - declaring **both**, if they agree (`locked: true` + `overridable: false`, or `locked: false` +
473
+ `overridable: true`), is accepted and normalized the same way;
474
+ - declaring **both** with a contradiction, or declaring **neither**, is a loud compile-time fail.
475
+
476
+ #### `eval` and `eval_ref`
477
+
478
+ `eval_ref` is the legacy synthesized reference string (`"<id>.eval"`) — kept for back-compat with
479
+ tooling that only needs a pointer. `eval` is the newer **structured form**, `{ "template_ref",
480
+ "metrics": [...] }`, present **only when authored** on the component (see `dashboard` in
481
+ `examples/render-demo/ir.golden.json`, which has no `eval` block and only `eval_ref`, vs. `generate_dashboard`
482
+ in `examples/demo-agent/ir.golden.json`, which has both). `eval` is what can actually drive an eval loop
483
+ (concrete metrics + template); `eval_ref` remains only a reference string.
484
+
485
+ #### `effect.outcome` facets
486
+
487
+ `outcome.kind` stays the stable 4-value union (`none | assertion | mutation | dispatch`) — the spine
488
+ does not grow a new arm. On top of `kind`, authoring may declare type-specific facets that are
489
+ **parsed and passed through the IR today but not yet consumed by the MVP (analytical) back-ends**:
490
+ they are forward-declared, not silently dropped.
491
+
492
+ | Facet | For `type` | Meaning |
493
+ | --- | --- | --- |
494
+ | `verdict_type` | `assertive` | the shape of the assertion's verdict |
495
+ | `emits` | `assertive` | events this outcome may publish (routing) |
496
+ | `target` | `mutating` | what's being mutated, e.g. `data` vs `context` |
497
+ | `change_type` | `mutating` | the kind of mutation |
498
+ | `routable_scope` | `orchestrating` | what this dispatch may route to |
499
+
500
+ #### `brief` (additive since v0.5)
501
+
502
+ Optional free-form text, authored on the component (or replaced wholesale by a profile mount, see
503
+ [`profile-schema.md`](./authoring.md#3-profile--bind-a-harness-to-a-context)) and rendered with the
504
+ same `{{project}}` / `{{project_name}}` placeholder substitution as step prompts (§Prompt
505
+ rendering). Emitted **once, on the node itself** — never per-step — and present in the IR only when
506
+ authored; a component with no `brief` produces IR byte-identical to before this field existed.
507
+ Every back-end that assembles a system prompt places it in the same position: after the
508
+ machine-generated preamble, before the body, on both the driver and every subagent. See
509
+ [`profile-schema.md`](./authoring.md#brief--authored-framing-shared-across-every-step) for the
510
+ authoring rule, the token-cost note, and the eval-invalidation note.
511
+
512
+ #### `description` / `examples` (additive, optional)
513
+
514
+ `description` is a string and `examples` a list of strings, both authored on the component and both
515
+ emitted onto the node only when authored and non-empty. They are part of the first released `0.5`
516
+ contract; a component with neither omits both fields. That omission preserves the compact serialized
517
+ shape, but does not relax the rule above: any future change to these fields' IR shape requires a
518
+ version bump.
519
+
520
+ Unlike `brief` they take **no placeholder substitution**: they describe the component to whoever is
521
+ choosing between components, and a description that only reads correctly once a project is bound
522
+ cannot serve a skill list published to another agent.
523
+
524
+ Both are validated at compile time rather than shipped broken: a `{{...}}` placeholder in either
525
+ field, or `examples` with no `description` (which every consumer reaches *through* the description),
526
+ is a loud failure.
527
+
528
+ Consumers are selectors, never the running agent's behavior: a back-end applies `description` to the
529
+ agent that *is* the component (including a per-step-split driver, whose internal subdivision is not
530
+ what the component is for) and leaves everything inside the component — per-step subagents and a
531
+ context-isolation child alike — its own internally-scoped line. Absent, a back-end synthesizes a line
532
+ from the node's shape instead. See
533
+ [`profile-schema.md`](./authoring.md#description--examples--what-the-component-is-for).
534
+
535
+ ---
536
+
537
+ ## Resolution rules (front-end `warble compile` must implement)
538
+
539
+ 1. **Parse** `profile.yml`, each mounted `components/<id>/component.yml`, and `context/binding.yml`.
540
+ Each `component.yml` is checked against `deny_unknown_fields` (applies to `component.yml` only,
541
+ not `profile.yml` / `context/binding.yml`): an authoring field the schema
542
+ does not recognize is a loud compile-time fail (never silently ignored).
543
+ 2. **Merge** `IR.node = component fields ⊕ supported profile mount fields`:
544
+ - `profile.components[].config` is accepted by the parser but ignored by the compiler; it does
545
+ not override defaults, cadence, thresholds, or any other behavior.
546
+ - `profile.components[].tier_overrides.{step}` overrides that step's `tier` in `llm_calls`.
547
+ - `realization_kind`: the component's required authored value unless the profile mount replaces it.
548
+ 3. **Fill required binds**: every component `params[].bind: required` must be supplied by
549
+ `profile.components[].bind`. Missing → **compile error** (loud fail). Then **resolve effective
550
+ binds**: for every `bind`-family param (required or optional), its effective value is the
551
+ mount-supplied bind, or else the param's declared `default`, or else absent (only possible for
552
+ `bind: optional` with no `default`). This effective-binds map feeds both the IR's additive
553
+ `binds` facet (§`binds`, emitted only when non-empty) and the next step.
554
+ 4. **Resolve `$param:<name>` references and evaluate `context_precondition`**: every entry's
555
+ `predicate` must be a member of the closed eleven-name vocabulary (unknown → loud fail). Before
556
+ evaluation, any `args` value of the form `"$param:<name>"` is substituted with that param's
557
+ effective value from step 3 — `<name>` not naming a declared param → **compile error** (loud
558
+ fail); naming a declared param with no effective value → the precondition is **unanswerable**
559
+ (below), not silently evaluated against a missing value. The IR's `context_precondition[].args`
560
+ always carries the **resolved** value, never the `"$param:<name>"` template. The predicate is
561
+ then **evaluated** against the bound context via the injected `ContextLoader`:
562
+ answerable-and-false → loud fail; unanswerable (`can_answer=false`) → a distinct loud fail; pass
563
+ → recorded in `precondition_result.checks`.
564
+ 5. **Validate `params` shape**: each entry must declare exactly one of `bind`/`source`; `source`, if
565
+ present, must be `"runtime-injected"`. Violations → **compile error** (loud fail).
566
+ 6. **Normalize `guardrails[].locked`**: resolve authored `locked`/`overridable` down to a single
567
+ `locked` boolean per the rule above; contradictory or absent declarations → **compile error**
568
+ (loud fail).
569
+ 7. **context_binding**: `project` = resolved path from `context/binding.yml` `project:`
570
+ (kept as-authored: relative paths stay relative to the project-dir). `binding_mode` from component.
571
+ (v0.3) `resolved` = the fine-grained block the `ContextLoader` produces from MDL introspection
572
+ (metrics/dimensions/grains + lineage summary). The coarse `project` path is retained alongside it.
573
+ 8. **prompt rendering** (see below) → `prompt_fragment` and per-step `llm_calls[].prompt`.
574
+ 9. **tier**: carry the step's tier **name** as a string in `llm_calls` (the standard core is
575
+ `strong`/`cheap`, but the vocabulary is open — a component may use custom tier names); do **not**
576
+ resolve it to a concrete model (that is the dispatcher's runtime-injected job — see the
577
+ `ModelConfig` / `warble dispatch --models-config` binding in `authoring.md` §6.1.1).
578
+
579
+ ## Compile-time checks — all loud-fail (non-zero exit + clear message)
580
+
581
+ | Check | Trigger | Error |
582
+ | --- | --- | --- |
583
+ | bind-required | a `params[].bind: required` not supplied by profile | `missing required bind '<name>' for component '<id>'` |
584
+ | locked-guardrail override | profile tries to remove/weaken a `guardrails[].locked: true` | `cannot override locked guardrail '<name>' on component '<id>'` |
585
+ | unparseable context | the bound project does not assemble/parse (coarse floor) | `context precondition failed: bound project '<path>' is not a parseable wren project …` |
586
+ | unknown precondition predicate | `context_precondition[].predicate` not in the closed 11-name vocabulary | `unknown context_precondition predicate '<name>' on component '<id>' …` |
587
+ | precondition not satisfied | a predicate is answerable but evaluates false against the bound context | `context precondition '<name>' not satisfied by the bound semantic layer for component '<id>'` |
588
+ | precondition unanswerable | the adapter cannot express the answer (e.g. `metric_additive` with no declared metric, a raw-shape predicate on an MDL-only adapter, or a `$param:` reference with no effective value) | `context precondition '<name>' … cannot be evaluated … Refusing rather than answering wrongly.` |
589
+ | `$param:` references an undeclared param | a `context_precondition[].args` value is `"$param:<name>"` and `<name>` is not one of the component's own `params[]` | `precondition arg '<key>' on component '<id>' references '$param:<name>', but '<name>' is not a declared param of this component` |
590
+ | param bind/source exclusion | a `params[]` entry declares both `bind` and `source`, or neither | `param '<name>' must declare exactly one of 'bind' or 'source' for component '<id>'` |
591
+ | unknown param source | `params[].source` present but not `"runtime-injected"` | `unknown param source '<value>' for param '<name>' on component '<id>'` |
592
+ | contradictory/absent guardrail lock state | a `guardrails[]` entry declares neither `locked` nor `overridable`, or declares both with conflicting values | `guardrail '<name>' on component '<id>' must declare exactly one (agreeing) of 'locked'/'overridable'` |
593
+ | unknown authoring field | `component.yml` (the `ComponentFile` and its nested structs) contains a field the schema does not recognize | `unknown field '<name>'` (serde `deny_unknown_fields`) — note: applies to `component.yml` only in this phase, not `profile.yml` / `context/binding.yml` |
594
+ | conditional step missing `when` | `llm_steps[].conditional: true` with no `when` | `conditional step '<name>' on component '<id>' has no 'when' guard — …` |
595
+ | `when` guard without `conditional` | `llm_steps[].when` present but `conditional` is not `true` | `step '<name>' on component '<id>' declares a 'when' guard but is not 'conditional: true' …` |
596
+ | unknown `when` guard name | `llm_steps[].when.guard` not in the closed 3-name vocabulary (`on_failure`/`on_flag`/`on_missing`) | `unknown guard '<name>' in step '<step>' of component '<id>' …` |
597
+ | `when` guard invalid target | `llm_steps[].when.target` is empty, or `guard: on_flag` with a non-dotted target | `guard '<name>' in step '<step>' of component '<id>' has an empty target` / `… expects a dotted 'artifact.field' target …` |
598
+
599
+ `required_capabilities` is **declared only** in this POC (not enforced by the compiler;
600
+ enforcement is the dispatcher/runtime's job).
601
+
602
+ ## Prompt rendering
603
+
604
+ For `realization_kind: skill`, `prompt_fragment` is a single instruction block the dispatcher
605
+ drops into the agent's system prompt. The front-end builds it by rendering each
606
+ `llm_steps[].prompt_ref` markdown file **in declared order**, joined under `##`-level headers
607
+ named by step, with placeholders substituted from coarse context:
608
+
609
+ - `{{project}}` → `context_binding.project`
610
+ - `{{project_name}}` → basename of the project path
611
+
612
+ Each `llm_calls[]` entry also carries its own **per-step rendered `prompt`** — the same
613
+ substitution as `prompt_fragment`, but rendered **per step and without** the `## <name>` header.
614
+ This exists because a step must be realizable in isolation on any runtime: isolation severs shared
615
+ context, so both the step's own instructions and its named `consumes`/`produces` I/O slots must be
616
+ explicit on the step itself. `consumes`/`produces` are named slots only — no conditionals/loops
617
+ (that composition-level restraint is what `llm_calls[].conditional`, above, exists to cover without
618
+ growing a data-flow DSL). Absent `consumes` → `[]`; absent `produces` → `null`.
619
+
620
+ **Two realizations of the same IR:**
621
+
622
+ - `prompt_fragment` (joined) is what an in-loop runtime uses — the driver runs every step against
623
+ one model in one context.
624
+ - `llm_calls[].prompt` (per-step) is what a runtime that splits into isolated calls uses — each
625
+ step's prompt plus its `consumes`/`produces` slots is enough to realize it as a standalone
626
+ invocation.
627
+
628
+ One IR feeds both realizations; nothing here is runtime-specific. `required_capabilities` carries
629
+ `llm:per_step_tier` — the generic requirement "every LLM call must run at its declared tier" — and
630
+ never names a mechanism.
631
+
632
+ ### Compile-time resolution against a target runtime (loud-fail, per the checks table above)
633
+
634
+ | Runtime supports… | Realization |
635
+ | --- | --- |
636
+ | per-step tier natively (in-loop model switch) | run steps in-loop; I/O contract unused |
637
+ | only `isolated_invocation` (tier-bound sub-call) | realize each divergent-tier step as an isolated call; marshal via `consumes`/`produces` |
638
+ | neither, and the component has heterogeneous tiers | **compile-time loud fail** |
639
+
640
+ ### Runtime-general realization (same IR, borrowed mechanisms)
641
+
642
+ | Runtime | Satisfies `llm:per_step_tier` by | Needs I/O contract |
643
+ | --- | --- | --- |
644
+ | Claude Code CLI (static files) | one **subagent** per divergent-tier step (own `model:`); driver marshals | ✅ |
645
+ | Claude Agent SDK (programmatic) | `query({options})` picks the model per step — in-loop | ❌ |
646
+ | LangGraph | bind each node to its own LLM | ❌ (edges carry it) |
647
+ | Omnigent / meta-harness | a tier-bound sub-component it manages | ✅ |
648
+
649
+ The dispatcher stays enum-keyed and thin: it reads `llm:per_step_tier` + the I/O contract and
650
+ maps to whatever its runtime provides. Mechanism (spawn/collect/marshal) is **borrowed**, never a
651
+ Warble differentiator.
652
+
653
+ ---
654
+
655
+ ## Golden example
656
+
657
+ `warble compile ./examples/demo-agent -o ir.json` against the demo project in this repo must produce an
658
+ IR equal to `examples/demo-agent/ir.golden.json` (committed alongside, used as the core's fixture test).
659
+ `warble compile ./examples/render-demo -o ir.json` similarly must equal
660
+ `examples/render-demo/ir.golden.json`. Both goldens use the current v0.6 contract:
661
+ `context_requirements`, `context_precondition`, and `params` are always present (possibly `[]`, as
662
+ on `dashboard`), while `eval` appears only on `generate_dashboard` and `scope: "."` appears only on
663
+ render-demo's authored `artifact_write` guardrail.
664
+
665
+ ---
666
+
667
+ ## v0.3 — fine-grained context binding
668
+
669
+ Where v0.2 carried a coarse project path and *declared* preconditions, v0.3 made the front-end
670
+ **probe the bound context**. A host injects a `ContextLoader` (the trait lives in core, sans-IO).
671
+ The same binding crate now supplies `MdlContext` for Wren projects and `RawSourceContext` for raw
672
+ constitutive input; hosts may supply other adapters.
673
+
674
+ ## What lands in the IR
675
+ - For a Wren project, `context_binding.resolved` carries the compiler's introspection result: `metrics`
676
+ (`{name, declared, additivity?}` — a declared cube measure carries inferred additivity; an
677
+ implicit numeric column does not), `dimensions` (`{name, temporal}`), `time_dimensions`, `models`,
678
+ and a `lineage` summary (`{nodes, edges, resolvable}`, plus optional `consumers` counts and
679
+ `diagnostics` — see `blast-radius.md` §3; both keys are omitted when empty). The full lineage DAG
680
+ stays in the adapter; the IR carries only the summary. A raw-source adapter emits an empty
681
+ semantic inventory while answering its raw-shape probes; an external adapter omits `resolved`.
682
+ - `precondition_result.checks` — one `{predicate, outcome}` per declared precondition, all `pass`
683
+ (a non-pass loud-fails before emit).
684
+
685
+ ## Predicate evaluation
686
+ The eleven predicates evaluate **loose for existence, strict for semantics**: `has_metric` /
687
+ `has_*_dimension` / `model_has_timestamp` are satisfied by a matching cube member *or* a plain model
688
+ column (so a cube-less project can still answer data questions), while `metric_additive` is
689
+ answerable only over a declared metric (see the `context_precondition` section above). This is why
690
+ `examples/jaffle-wren` gained a `revenue` cube — it gives the layer a declared, additive metric so
691
+ `metric_additive` is decidable. `source_introspectable` and `raw_docs_readable` instead probe a raw
692
+ source through `RawSourceContext`; an MDL-only adapter returns unanswerable for both.
693
+
694
+ ## `blast_radius` (read path)
695
+ The adapter self-builds a lineage DAG (`model → relationship / cube → metric / dimension`, plus view
696
+ references), and core computes `LineageGraph::blast_radius(node)` = the transitive downstream closure
697
+ + worst `Severity` (`Semantic > Structural > Compatibility > None`). This is exposed as read-only
698
+ analysis on the read path, and the same query also serves as an enforcement gate for *mutating*
699
+ applies. This is the one `provided_by: warble`
700
+ capability — see `capability-model.md` §6/§7.1, whose coarse-binding loud-fail is now lifted because
701
+ fine-grained binding exists.
702
+
703
+ ---
704
+
705
+ ## v0.3 — render contract (typed blocks + renderer registry)
706
+
707
+ > Closes wall-hit #2 (render blocks) the same way #1 was closed: the IR declares a
708
+ > **runtime-agnostic typed-output contract**; each runtime supplies (or overrides) a **renderer**;
709
+ > Warble ships a **default reference renderer (HTML)** so there is an out-of-box result even on a
710
+ > plain runtime. **Status:** both render flavors are implemented in the dispatcher (see §4) — the
711
+ > IR-side typed `render_blocks` contract is consumed today; the front-end `warble compile` still
712
+ > emits `render_blocks` as coarse type names + field schema (no per-runtime renderer selection at
713
+ > compile time — that is the dispatcher's job).
714
+
715
+ ## The gap today
716
+ `effect.render_blocks` is just type names (`["chart","table","kpi_card"]`), and the agent returns
717
+ prose. Nothing downstream can render typed blocks. Two things are missing: (a) a **data contract**
718
+ per block type so the agent emits *structured* blocks, and (b) a **renderer** that turns those
719
+ blocks into an artifact.
720
+
721
+ ## 1. Typed block contract (Warble stdlib, extensible)
722
+ `effect.render_blocks` becomes typed entries carrying each block's field schema. Warble ships a
723
+ small stdlib of block types; components may extend it.
724
+
725
+ ```jsonc
726
+ "render_blocks": [
727
+ { "type": "kpi_card", "fields": { "label": "string", "value": "number|string", "unit": "string?", "delta": "number?" } },
728
+ { "type": "table", "fields": { "columns": "string[]", "rows": "row[]" } },
729
+ { "type": "chart", "fields": { "chart_type": "bar|line|pie|area|scatter", "x": "string", "series": "string[]", "rows": "row[]" } },
730
+ { "type": "narrative", "fields": { "title": "string?", "text": "string" } },
731
+ { "type": "diff", "fields": { "path": "string?", "diff": "string" } }
732
+ ]
733
+ ```
734
+
735
+ The `diff` block is the stdlib block for a **mutating** component's dry-run proposal (added for
736
+ `edit_pipeline`): the target `path` and the raw unified-`diff` text, rendered HTML-escaped
737
+ inside a `<pre>` (never re-parsed as markup). It is the presentational facet of the change a reviewer
738
+ approves — it does not itself apply anything.
739
+
740
+ The `narrative` block is the stdlib text/prose block (added for `explain_change`, whose output is a
741
+ data-native explanation, not a chart). The reference renderer emits an optional `title` heading plus
742
+ the escaped `text` body (blank lines → paragraphs); it is deliberately minimal — prose, not a
743
+ rich-markdown surface. A component whose output is an explanation declares `render_blocks:
744
+ [narrative]`; because both back-ends render through `warble render`, no per-back-end renderer change
745
+ is needed.
746
+
747
+ ## 2. Agent output envelope (runtime-agnostic, structured — not prose)
748
+ The renderable component returns a structured envelope of **block instances** conforming to the
749
+ contract, plus optional prose:
750
+ ```jsonc
751
+ { "blocks": [
752
+ { "type": "kpi_card", "label": "Total customers", "value": 100 },
753
+ { "type": "table", "columns": ["status","orders","revenue"], "rows": [["completed",67,1103], ...] }
754
+ ],
755
+ "summary": "…prose…" }
756
+ ```
757
+
758
+ ### Provenance: `verified` and per-block `definition`
759
+
760
+ Two optional additions to the envelope let a reviewer check a rendered answer against its source
761
+ instead of trusting the prose alone:
762
+
763
+ - **`verified` (envelope-level boolean, optional)** — whether the agent actually ran the query it is
764
+ reporting on (via the `wren` CLI) versus recalling/estimating a figure. Absent means unknown, not
765
+ false; a component whose steps always execute before rendering may set it unconditionally.
766
+ - **`definition` (per-block, optional)** — attached to a data-bearing block (typically `kpi_card` or
767
+ `table`) to carry how the number was produced: `{ "sql": "...", "source_tables": ["..."],
768
+ "filters": ["..."] }`. This is presentational provenance for the renderer to show alongside the
769
+ block (e.g. an expandable "how was this computed" panel) — it does not feed back into computation
770
+ and is not itself re-executed.
771
+
772
+ Both are additive optional fields on the existing envelope/block shapes above, not a new block type;
773
+ a renderer or consumer that doesn't recognize them ignores them.
774
+
775
+ ## 3. Renderer registry — `render(target, blocks[]) → artifact`
776
+ Warble owns the **contract + a reference renderer (HTML)**; runtimes register/override per target.
777
+
778
+ | target | how blocks render | provider |
779
+ | --- | --- | --- |
780
+ | `html` (**default**) | one self-contained `dashboard.html` (KPI cards, HTML tables, JS charts) | Warble reference renderer |
781
+ | `markdown` | markdown tables + text (the plain-CLI degrade) | Warble |
782
+ | `wren-genbi` | delegate to `wren genbi build/verify/open` | borrowed (wren) |
783
+ | `react` / IDE / web host (future) | native components | that runtime |
784
+
785
+ ## 4. Two renderer flavors (default programmatic, prompt fallback) — **implemented**
786
+ Selected at dispatch via `warble dispatch … --render-flavor <programmatic|prompt>` (default
787
+ `programmatic`). The IR is flavor-agnostic; the flavor lives in the back-end.
788
+
789
+ - **programmatic (default)** — ✅ implemented: the emitted agent stays **fully read-only** (no
790
+ `Write` tool) and is instructed to emit the `{ blocks, summary }` envelope as its final message.
791
+ The back-end ships a **reference renderer** (`warble render`, `dispatcher/claude-code-cli/src/render.rs`) that
792
+ turns that envelope → a self-contained `dashboard.html` **deterministically** (inline SVG charts,
793
+ no clock/RNG, no external assets → same envelope ⇒ identical bytes). The two-step run is documented
794
+ in the emitted `RUN.md`:
795
+ ```sh
796
+ claude -p "<data question>" --agent dashboard --output-format json > result.json
797
+ warble render result.json --out dashboard.html
798
+ ```
799
+ `warble render` also unwraps the `--output-format json` result object and tolerates the model
800
+ fencing/prose-wrapping the envelope (see `parseEnvelope`).
801
+ - **prompt fallback** — ✅ implemented (`--render-flavor prompt`): for when there is no post-step to
802
+ run the renderer, the dispatcher bakes the block contract + "write `dashboard.html`" instruction
803
+ into the prompt and grants the agent **scoped artifact-write**. Works in the pure file model; HTML
804
+ is LLM-produced → non-deterministic.
805
+
806
+ ## 5. Guardrail split this forces (data-write ≠ artifact-write)
807
+ Rendering writes a file, but the component is `read_only_execution`. These must be **separate
808
+ enforcement points** (see [`enforcement-seam.md`](./enforcement-seam.md)):
809
+ - `data:read_only` — never mutate the warehouse (wren `strict_mode`). Unchanged.
810
+ - `artifact:write(scoped)` — may write only the output dir (the HTML). Needed **only** on the
811
+ prompt-fallback path; the programmatic path keeps the agent read-only entirely.
812
+
813
+ ## 6. Capability + loud-fail
814
+ - Component declares `required_capabilities: [render_contract]` (generic: "the declared blocks must
815
+ be renderable"). Never names `html`/`react` — that is the target's realization.
816
+ - Compile against a target: has a renderer for these block types → render; else can degrade to
817
+ `markdown` → warn; else → loud-fail. Same pattern as §5 / per-step-tier.
818
+
819
+ The IR names **types + data contract + the requirement** only; *how* a chart becomes pixels is the
820
+ renderer's job (Warble default HTML, or a runtime override). Contract owned; renderer borrowed/pluggable.
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@warble/ir-spec",
3
+ "version": "0.6.0",
4
+ "license": "Apache-2.0",
5
+ "type": "module",
6
+ "description": "The Warble IR (warble_ir_version) as a resolvable npm package: a version constant plus the compile-contract schema document. Not a runtime dependency — its version IS the contract.",
7
+ "homepage": "https://github.com/Canner/Warble/tree/main/packages/ir-spec#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/Canner/Warble",
11
+ "directory": "packages/ir-spec"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/Canner/Warble/issues"
15
+ },
16
+ "keywords": ["warble", "ir", "schema", "spec"],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "main": "./index.js",
24
+ "module": "./index.js",
25
+ "types": "./index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./index.d.ts",
29
+ "import": "./index.js"
30
+ },
31
+ "./package.json": "./package.json"
32
+ },
33
+ "files": ["index.js", "index.d.ts", "ir-schema.md"]
34
+ }