@warble/ir-spec 0.6.0 → 0.8.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 CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  The Warble IR (`warble_ir_version`) as a resolvable npm package.
4
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
5
+ **This package's version *is* the payload.** IR `x.y` publishes as npm version `x.y.0`, with the
6
+ patch component always zero so this package's own version on the registry tells you which
7
+ `warble_ir_version` it describes, and the bundled `ir-schema.md` is that IR's spec as it stood. Everything else in
7
8
  the package — the bundled [`ir-schema.md`](./ir-schema.md) (a copy of
8
9
  [`docs/spec/ir-schema.md`](https://github.com/Canner/Warble/blob/main/docs/spec/ir-schema.md)) and
9
10
  the `IR_VERSION` constant exported from `index.js` — is documentation, not enforcement.
@@ -12,8 +13,8 @@ the `IR_VERSION` constant exported from `index.js` — is documentation, not enf
12
13
 
13
14
  Before this package, the IR version a published dispatcher (`@warble/claude-agent-sdk`,
14
15
  `@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
16
+ declare `@warble/ir-spec` as a `peerDependencies` range (`x.y.x`) plus an advisory
17
+ `"warble": { "irVersion": "x.y" }` field, so a consumer — or npm's own resolver — can see which IR a
17
18
  dispatcher speaks without opening it.
18
19
 
19
20
  **This package is not meant to be imported.** A `peerDependency` is a declaration, not a dependency
package/index.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * `docs/spec/ir-schema.md` (bundled here as `ir-schema.md`) for why this is exported without being
4
4
  * meant to be imported by a dispatcher.
5
5
  */
6
- export declare const IR_VERSION: "0.6";
6
+ export declare const IR_VERSION: "0.8";
7
7
 
8
- declare const _default: { IR_VERSION: "0.6" };
8
+ declare const _default: { IR_VERSION: "0.8" };
9
9
  export default _default;
package/index.js CHANGED
@@ -8,6 +8,6 @@
8
8
  * exported for tooling that wants the value without re-parsing `package.json` (e.g. a script
9
9
  * checking a dispatcher's declared peer range against the spec it names).
10
10
  */
11
- export const IR_VERSION = "0.6";
11
+ export const IR_VERSION = "0.8";
12
12
 
13
13
  export default { IR_VERSION };
package/ir-schema.md CHANGED
@@ -1,4 +1,4 @@
1
- # Warble IR — the compile contract (`warble_ir_version: 0.6`)
1
+ # Warble IR — the compile contract (`warble_ir_version: 0.8`)
2
2
 
3
3
  The IR is the **language-neutral seam** between the Warble front-end (`warble compile`) and any
4
4
  back-end. The v1 reference back-end is the Claude Code CLI target (`warble dispatch`, Rust); other
@@ -6,26 +6,33 @@ runtimes are other thin back-ends. Both sides depend only on this document — n
6
6
  internals.
7
7
 
8
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
9
+ context binding) and emits **one** IR JSON document with `"warble_ir_version": "0.8"` — the
10
10
  current, live contract the compiler emits today. (Earlier drafts of this doc kept the per-step-tier
11
11
  shape in a separate "v0.2 (proposed)" section; that has been folded into the contract below now
12
12
  that it is implemented and wired into the built core/dispatcher.) The shape below is what the
13
13
  dispatcher consumes.
14
14
 
15
+ > **Composition boundary:** v0.8 adds the resolved `components[].entrypoint` and optional
16
+ > `llm_calls[].component_calls` facets specified by
17
+ > [`component-composition.md`](./component-composition.md). Every shipped reader retains them. The
18
+ > Agent SDK and composed Codex local orchestrate targets realize narrow slices with dispatcher-owned
19
+ > isolated child runs and their documented runtime budgets; unsupported shapes and other targets preflight wall-hit instead of
20
+ > dropping or inlining an edge.
21
+
15
22
  > Scope note (v0.3+): context binding is **fine-grained**. The host injects a `ContextLoader`
16
23
  > selected for the binding kind, and the compiler **evaluates** every `context_precondition`
17
24
  > 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
25
+ > checks in `precondition_result.checks`; a `prepared` binding also fills
26
+ > `context_binding.resolved` with metrics/dimensions/grains and lineage, while a raw-source binding
27
+ > answers the constitutive probes and an external one emits no resolved block. A precondition
28
+ > that is answerable-and-false, or that the loader **cannot answer** at all, is a
22
29
  > loud compile-time fail — so an emitted IR only ever contains passing checks. See
23
30
  > [`context_precondition`](#context_precondition-closed-predicate-vocabulary) and the
24
31
  > [v0.3 binding](#v03--fine-grained-context-binding) section below.
25
32
  >
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.
33
+ > The coarse `context_binding.project` locator is **retained**: a back-end uses it to name the layer
34
+ > it queries, and each binding kind gives it its own meaning. Fine-grained binding is additive, not a
35
+ > replacement.
29
36
 
30
37
  ---
31
38
 
@@ -41,11 +48,11 @@ version on anything else — there is no best-effort or partial parse of an unre
41
48
 
42
49
  | Consumer | Accepted `warble_ir_version` | Where the accepted version is declared |
43
50
  | --- | --- | --- |
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` |
51
+ | `core` (`warble compile`) | emits `0.8` | the `"warble_ir_version"` literal in `core/src/compile.rs` |
52
+ | `dispatcher/claude-code-cli` | `0.8` | `SUPPORTED_IR_VERSION` in `dispatcher/claude-code-cli/src/ir.rs` |
53
+ | `dispatcher/vercel` | `0.8` | `SUPPORTED_IR_VERSION` in `dispatcher/vercel/src/emit.rs` |
54
+ | `dispatcher/claude-agent-sdk` | `0.8` | `SUPPORTED_IR_VERSIONS` in `dispatcher/claude-agent-sdk/src/ir.ts` |
55
+ | `dispatcher/codex-local` | `0.8` | `SUPPORTED_IR_VERSION` in `dispatcher/codex-local/src/ir.ts` |
49
56
 
50
57
  Each back-end copies this value rather than importing it from `core` or from another back-end: a
51
58
  back-end shouldn't need a Rust dependency edge just to know a version string, and independent copies
@@ -62,7 +69,7 @@ is informational, not itself an input enforcement check.
62
69
  `@warble/claude-agent-sdk` and `@warble/codex-local` additionally each declare a `peerDependencies`
63
70
  entry on [`@warble/ir-spec`](../../packages/ir-spec) — a dedicated npm package whose own version *is*
64
71
  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
72
+ plus an advisory `"warble": { "irVersion": "0.8" }` field in the same `package.json`. This makes the
66
73
  IR version a dispatcher speaks visible in the npm dependency graph without opening the package.
67
74
  **Neither dispatcher imports `@warble/ir-spec`** — the peer is a declaration, not a dependency edge,
68
75
  and each dispatcher keeps enforcing its own copy of `SUPPORTED_IR_VERSION`(S) above. `@warble/ir-spec`
@@ -70,8 +77,12 @@ also bundles this document itself (as `ir-schema.md`, alongside `index.js`/`inde
70
77
  snapshot for the IR version it publishes — a published npm version is immutable, so a snapshot as of
71
78
  that version is worth more than a live link back to this file on `main`, which points at whatever the
72
79
  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
80
+ ever diverge, since a publish is exactly the point that drift becomes irreversible. Keeping it in
81
+ sync is not a manual step: `npm run gen:reference` in `docs/site/` writes it from this document
82
+ alongside the generated reference pages, and the docs-site CI drift check covers it on the same
83
+ footing, so editing this document and regenerating is all there is to it. The snapshot has to be a
84
+ real committed file rather than a symlink — `npm pack` does not dereference one, it silently omits
85
+ the file and ships a package with no spec in it. Counting the
75
86
  producer (what `core` actually emits) alongside every independent consumer/advisory copy, the
76
87
  `@warble/ir-spec` package's own version, its `index.js` and `index.d.ts` version constants/literals,
77
88
  both dispatchers' peer declarations, both dispatchers' advisory `warble.irVersion` fields, and the
@@ -88,7 +99,7 @@ spec title, there are **eighteen** locations that must agree:
88
99
  | 7 | `dispatcher/vercel/src/emit.rs` `MAX_SUPPORTED_IR_VERSION` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
89
100
  | 8 | `dispatcher/claude-agent-sdk/src/manifest.ts` `MIN_SUPPORTED_IR_VERSION` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
90
101
  | 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` |
102
+ | 10 | This document's title (`warble_ir_version: 0.8`) | Spec | `core/tests/ir_version_lockstep_tests.rs` |
92
103
  | 11 | `packages/ir-spec/package.json` `"version"` (mapped `x.y` -> `x.y.0`) | Producer (npm) | `core/tests/ir_version_lockstep_tests.rs` |
93
104
  | 12 | `packages/ir-spec/index.js` `IR_VERSION` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
94
105
  | 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` |
@@ -101,14 +112,14 @@ spec title, there are **eighteen** locations that must agree:
101
112
  This table's scope is contract-bearing declarations — constants and literals something actually
102
113
  compares against — not every place `warble_ir_version` appears in prose. Each back-end's `ir` module
103
114
  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
115
+ the Warble IR (warble_ir_version: 0.7)`); nothing checks those comments, and a version bump can leave
105
116
  them stale without breaking anything. They are deliberately not extra rows — update them as a
106
117
  courtesy to the reader, not because a test requires it.
107
118
 
108
119
  `core/tests/ir_version_lockstep_tests.rs` is the sole cross-target lockstep owner: it text-parses
109
120
  every contract-bearing declaration above and asserts that each equals the version core emits (rows
110
- 1115 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
121
+ 11, 13, and 15 first pass the emitted version through the mapping below, since they are npm
122
+ identifiers, not copies of the raw `x.y` string; the table marks which ones). The target-local `ir_version_tests.rs` files only exercise their own
112
123
  unsupported-version rejection and no-partial-output behavior; they do not scrape other targets'
113
124
  sources. (`core/src/lib.rs`'s doctest and `core/tests/compile_tests.rs` also assert row 1's literal
114
125
  directly, but aren't listed as separate lockstep-tested locations — they self-guard, failing the
@@ -123,8 +134,8 @@ only when `warble_ir_version` moves, on its own release line. The mapping from I
123
134
  version is fixed and mechanical:
124
135
 
125
136
  - 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`.
137
+ - A dispatcher's `peerDependencies["@warble/ir-spec"]` range is `x.y.x` — e.g. IR `0.7` is npm
138
+ version `0.7.0` and peer range `0.7.x`.
128
139
  - An IR version with anything other than exactly two dot-separated numeric components (a three-part
129
140
  `x.y.z`, or a non-numeric component) has **no defined mapping** and is rejected by
130
141
  `core/tests/ir_version_lockstep_tests.rs` at the point it tries to compute rows 11, 13, and 15 — it
@@ -165,7 +176,7 @@ back-end accepts, and must be regenerated rather than merely re-read.
165
176
 
166
177
  ```jsonc
167
178
  {
168
- "warble_ir_version": "0.6",
179
+ "warble_ir_version": "0.8",
169
180
  "profile": "orders-analytics", // profile.yml `profile:`
170
181
  "context_binding": { // resolved from profile `context:` + context/binding.yml
171
182
  "project": "examples/jaffle-wren", // coarse path to a wren project (retained for back-ends)
@@ -179,7 +190,7 @@ back-end accepts, and must be regenerated rather than merely re-read.
179
190
  "dimensions": [ { "name": "status", "temporal": false }, { "name": "order_date", "temporal": true } ],
180
191
  "time_dimensions": ["order_date"],
181
192
  "models": ["customers", "orders", /* … */],
182
- "lineage": { "nodes": 15, "edges": 12, "resolvable": true } // summary only; full DAG stays in the adapter
193
+ "lineage": { "nodes": 15, "edges": 12, "resolvable": true } // summary only; the full DAG stays in the bound document
183
194
  // when the project carries consumer artifacts, `lineage` additionally reports
184
195
  // "consumers": { "queries": 2, "dashboards": 1 } // query:/dashboard: node counts
185
196
  // and, when construction had to degrade (e.g. a consumer's SQL didn't parse),
@@ -188,15 +199,20 @@ back-end accepts, and must be regenerated rather than merely re-read.
188
199
  // so pre-consumer IRs are byte-identical.
189
200
  }
190
201
  },
191
- "config": {}, // reserved profile-level config block; no fields today
202
+ "config": {}, // reserved profile-level config block; optionally carries `capability_ceiling`
203
+ "slots": [ // additive; present only when the PROFILE declares slots — see `slots` below
204
+ { "name": "plan_mode", "default": "off", "present_when": "plan_mode_available",
205
+ "variants": { "on": "…rendered…", "off": "…rendered…" } }
206
+ ],
192
207
  "components": [ /* one resolved component node, see below */ ]
193
208
  }
194
209
  ```
195
210
 
196
- ### `config` — emptied in 0.6
211
+ ### `config` — one optional field in 0.7
197
212
 
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 `{}`.
213
+ `config` carried one field, `tier_policy`, from the first IR through `0.5`. `0.6` removed it, and
214
+ the block was emitted as `{}` until the addition documented below gave it its first surviving
215
+ field.
200
216
 
201
217
  `tier_policy` was a profile-wide tier stance (`cost_sensitive`) that the compiler was meant to
202
218
  resolve into per-step tiers. Only the field ever landed. No back-end read it, its value was never
@@ -214,11 +230,40 @@ control (see [`profile-schema.md`](./authoring.md#61-tiers-not-model-names)).
214
230
  The block itself stays so that profile-level config which *can* be honored is an additive change
215
231
  rather than the reintroduction of a removed key.
216
232
 
233
+ #### `capability_ceiling` (additive since v0.7)
234
+
235
+ When a profile declares `capability_ceiling` — a list of capability strings — the compiler carries
236
+ it into `config` verbatim:
237
+
238
+ ```jsonc
239
+ "config": {
240
+ "capability_ceiling": ["sql_execution", "chart_rendering"]
241
+ }
242
+ ```
243
+
244
+ A profile that omits `capability_ceiling` compiles exactly as before this field existed: `config`
245
+ stays `{}`.
246
+
247
+ The ceiling is a compile-time *authorization* check ("may a component of this profile require
248
+ this capability at all"), not the dispatch-time capability *resolution* every component's
249
+ `required_capabilities` already goes through against a target's profile (native / realize-via /
250
+ degrade / fail — see the compile-time checks table below). The two gates are independent: a
251
+ capability can pass the ceiling and still fail resolution against a given target, and the ceiling
252
+ is evaluated once at compile time regardless of which target the IR is later dispatched against.
253
+ A component whose `required_capabilities` names anything outside the profile's declared ceiling
254
+ fails compile before resolution is ever attempted.
255
+
256
+ Containment is exact string-set matching — no hierarchy or prefix inference on the `:` qualifier
257
+ some capability names use. A ceiling of `sql_execution` does **not** admit a component requiring
258
+ `sql_execution:read_only`; the qualifier is not an ordering, and a profile that means to allow the
259
+ narrower capability must list it explicitly alongside (or instead of) the broader one.
260
+
217
261
  ## Component node (resolved: component fields ⊕ supported profile mount fields)
218
262
 
219
263
  ```jsonc
220
264
  {
221
265
  "id": "generate_dashboard",
266
+ "entrypoint": true, // resolved mount eligibility; defaults true in authoring
222
267
  "verb": "generate_dashboard",
223
268
  "type": "analytical", // analytical | assertive | mutating | constitutive | orchestrating
224
269
  "realization_kind": "skill", // required in component.yml; a profile mount may replace it
@@ -229,47 +274,49 @@ rather than the reintroduction of a removed key.
229
274
  "context_requirements": [ // human-readable shape strings — always emitted, may be []
230
275
  "a wren project (semantic layer) to build dashboards over"
231
276
  ],
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
- ],
277
+ "context_precondition": [], // dashboard delegates panel data-shape checks to answer_query
239
278
  "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
279
+ { "name": "topic_default", "bind": "optional", "default": "overview" } // profile-bound (bind)
242
280
  ],
243
281
  "binds": { // additive; present only when >=1 bind-family param has a value
244
282
  "topic_default": "overview" // mount didn't supply one, so this is the declared default
245
283
  },
246
284
  "precondition_result": { // per-predicate evaluation outcome (v0.3, see §checks)
247
285
  "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
- ]
286
+ "checks": [] // one entry per declared context_precondition, in order
252
287
  },
253
288
  "brief": "…shared framing for every step, placeholders substituted…", // additive; present only when authored — see below
289
+ "slots": [ // additive; present only when the component declares slots — see below
290
+ { "name": "verification", "default": "base",
291
+ "variants": { "base": "…rendered…", "terse": "…rendered…" } }
292
+ ],
293
+ "assets": [ // additive; present only when the component declares assets — see below
294
+ { "path": "themes/dark.css", "hash": "sha256:9869fecc…", "bytes": 22 }
295
+ ],
254
296
  "prompt_fragment": "…rendered skill instructions…", // see §prompt rendering
255
297
  "llm_calls": [ // per-step tier, order preserved from component llm_steps
256
298
  { "name": "plan_dashboard", "tier": "strong", "conditional": false, "when": null,
257
- "consumes": [], "produces": "query_plan",
299
+ "consumes": [], "produces": "dashboard_plan",
258
300
  "prompt": "<plan_dashboard.md rendered, placeholders substituted, no ## header>" },
259
301
  { "name": "compose_layout", "tier": "cheap", "conditional": false, "when": null,
260
- "consumes": ["query_plan"], "produces": "dashboard_summary",
302
+ "consumes": ["dashboard_plan"], "produces": "dashboard",
303
+ "component_calls": [{ "alias": "answer", "component": "answer_query" }],
261
304
  "prompt": "<compose_layout.md rendered>" }
262
305
  // a conditional step instead carries e.g. "conditional": true, "when": { "guard": "on_failure", "target": "generate_sql" }
263
306
  // — see `llm_calls[].when` below
307
+ // a step may also narrow its tool boundary and/or mark its artifact exclusive, e.g.
308
+ // "capabilities": ["sql_execution:read_only"], "produces_exclusive": true — both additive,
309
+ // omitted here because neither step below declares them; see `llm_calls[].capabilities` /
310
+ // `llm_calls[].produces_exclusive` below
264
311
  ],
265
312
  "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": "." }
313
+ { "name": "read_only_execution", "locked": true },
314
+ { "name": "artifact_write", "locked": true, "scope": "." }
269
315
  ],
270
316
  "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"
317
+ "required_capabilities": [ // declarations plus shape-implied requirements
318
+ "render_contract", "artifact_write", "llm:per_step_tier", "llm:strong", "llm:cheap",
319
+ "component_invocation"
273
320
  ],
274
321
  "borrowed_actions": [],
275
322
  "eval_ref": "generate_dashboard.eval", // legacy reference string; retained for back-compat
@@ -279,7 +326,10 @@ rather than the reintroduction of a removed key.
279
326
  },
280
327
  "effect": {
281
328
  "render_blocks": [
282
- { "type": "chart", "fields": {} }, { "type": "table", "fields": {} }, { "type": "kpi_card", "fields": {} }
329
+ { "type": "kpi_card", "fields": { "label": "string", "value": "number|string", "unit": "string?", "delta": "number?" } },
330
+ { "type": "table", "fields": { "columns": "string[]", "rows": "row[]" } },
331
+ { "type": "chart", "fields": { "chart_type": "bar|line|pie|area|scatter", "x": "string", "series": "string[]", "rows": "row[]" } },
332
+ { "type": "definition", "fields": { "sql": "string", "source_tables": "string[]", "filters": "string[]" } }
283
333
  ],
284
334
  "outcome": {
285
335
  "kind": "none" // none | assertion | mutation | dispatch — stays this 4-value union
@@ -451,6 +501,70 @@ an IR version bump under the policy above. As of this writing:
451
501
  A back-end that ignores `when` must do so as a documented, deliberate choice (as `claude-code-cli`
452
502
  does above) — never as a silent fallback for a guard shape it was simply never taught to recognize.
453
503
 
504
+ #### `llm_calls[].capabilities` (additive)
505
+
506
+ `string[]`, an exact-string subset of the component's own `required_capabilities` — **omitted
507
+ entirely (not present as a key, not `null`) unless the step declares it**. A step that narrows its
508
+ capabilities compiles to `"capabilities": [...]` on that call's IR entry; a step that doesn't
509
+ compiles to exactly the IR it produced before this field existed. Compile validates the subset
510
+ relationship (exact string containment, no hierarchy) against the component's declared
511
+ `required_capabilities` and loud-fails, naming both the step and the offending capability, when a
512
+ step names one outside that set. This is a compile-time declaration of what a step is allowed to
513
+ need, not a runtime identity or actor concept: how a back-end scopes a step's own call is already a
514
+ per-back-end mechanism this field says nothing about.
515
+
516
+ ```jsonc
517
+ { "name": "plan_query", "tier": "strong", "conditional": false, "when": null,
518
+ "consumes": [], "produces": "query_plan",
519
+ "capabilities": ["render_contract"],
520
+ "prompt": "…" }
521
+ ```
522
+
523
+ #### `llm_calls[].produces_exclusive` (additive)
524
+
525
+ `bool` — **omitted entirely unless `true`.** A step's plain `produces` name is unchanged; this is a
526
+ separate provenance marker on that same artifact, meaning only the producing step is expected to
527
+ write it. Kept as an additive boolean rather than reshaping `produces` into an object. Like
528
+ `capabilities` above, it says nothing about *who* enforces exclusivity — that is whatever already
529
+ scopes a step's own call at the back-end.
530
+
531
+ ```jsonc
532
+ { "name": "run_query", "tier": "cheap", "conditional": false, "when": null,
533
+ "consumes": ["query_plan"], "produces": "query_result",
534
+ "produces_exclusive": true,
535
+ "prompt": "…" }
536
+ ```
537
+
538
+ #### `entrypoint` and `llm_calls[].component_calls` (since v0.8)
539
+
540
+ The composition contract adds two protocol fields:
541
+
542
+ ```jsonc
543
+ {
544
+ "id": "answer_query",
545
+ "entrypoint": false,
546
+ "llm_calls": [
547
+ {
548
+ "name": "compose_dashboard",
549
+ "component_calls": [
550
+ { "alias": "answer", "component": "answer_query" }
551
+ ]
552
+ }
553
+ ]
554
+ }
555
+ ```
556
+
557
+ `entrypoint` is a required resolved mount property, defaulting to `true` at the authoring boundary
558
+ for profiles written before it existed. `component_calls` is omitted when empty and otherwise is a
559
+ step-local static allowlist; its aliases resolve to unique mounted component identities after
560
+ overlays. A non-empty list also adds the required runtime-provided `component_invocation`
561
+ capability to the component and the declaring step's effective requirements.
562
+
563
+ The compiler rejects duplicate mounted identities, invalid or duplicate aliases, missing targets,
564
+ self-calls, and cycles before emitting the IR. See
565
+ [`component-composition.md`](./component-composition.md) for the authoritative validation,
566
+ preparation, runtime, envelope, and compatibility rules.
567
+
454
568
  #### `guardrails[].scope` / `threshold` and the `locked`/`overridable` normalization
455
569
 
456
570
  `scope` and `threshold` are passthrough fields — each is present in the resolved IR **only when
@@ -504,6 +618,14 @@ Optional free-form text, authored on the component (or replaced wholesale by a p
504
618
  same `{{project}}` / `{{project_name}}` placeholder substitution as step prompts (§Prompt
505
619
  rendering). Emitted **once, on the node itself** — never per-step — and present in the IR only when
506
620
  authored; a component with no `brief` produces IR byte-identical to before this field existed.
621
+
622
+ The emitted value may be a **merge of two authored layers**: a profile's
623
+ [`system_prompt`](./authoring.md#system_prompt--profile-level-framing-for-every-component) — shared
624
+ by every component that profile mounts — followed by a blank line and the component's own effective
625
+ brief. Either layer may be absent; with neither, the key is absent as described above. The merge
626
+ happens at compile time and deliberately adds no IR field, so a back-end that already reads `brief`
627
+ needs no change and the wire contract is unversioned by it. The trade-off is that the IR does not
628
+ record which half came from where.
507
629
  Every back-end that assembles a system prompt places it in the same position: after the
508
630
  machine-generated preamble, before the body, on both the driver and every subagent. See
509
631
  [`profile-schema.md`](./authoring.md#brief--authored-framing-shared-across-every-step) for the
@@ -534,6 +656,165 @@ from the node's shape instead. See
534
656
 
535
657
  ---
536
658
 
659
+ #### `slots` (additive since v0.7)
660
+
661
+ `object[]` — **omitted entirely (not present as a key, not `[]`) unless the component declares
662
+ `slots:`.** A component without any compiles to exactly the IR it did before this field existed.
663
+
664
+ Each entry carries a `name`, a required `default`, a `variants` object, and `present_when` only when
665
+ authored:
666
+
667
+ ```jsonc
668
+ "slots": [
669
+ {
670
+ "name": "verification",
671
+ "default": "base", // always present; names a key of "variants"
672
+ "present_when": "verification_enabled", // omitted entirely unless authored
673
+ "variants": {
674
+ "base": "…rendered text, placeholders substituted…",
675
+ "terse": "…rendered text…"
676
+ }
677
+ }
678
+ ]
679
+ ```
680
+
681
+ **Every variant is carried; none is selected.** This is the deliberate division of labour: compile
682
+ *transports* the alternatives, dispatch *chooses* between them. The consequence is that the IR stays
683
+ a complete description of what the model could be told — a reader can enumerate every wording without
684
+ running anything — at the cost of a larger document. Selecting at compile would have been smaller and
685
+ would have made the IR silent about the paths not taken.
686
+
687
+ **Variant keys are opaque.** Warble does not interpret, validate, or order them; the mapping from a
688
+ bound model (or any other runtime fact) to a key belongs to the host's dispatch fragment. A
689
+ consumer that hard-codes key names is coupling itself to one project's convention, not to this
690
+ schema.
691
+
692
+ **A slot is referenced from prompt text as `{{ slot.<name> }}`.** Deliberately a different syntax
693
+ from the `{{project}}` / `{{project_name}}` value substitutions, so a slot reference and a mistyped
694
+ placeholder are distinguishable; it is also Jinja-native attribute access, so the renderer can be
695
+ replaced without touching authored files. Variant text goes through the same substitution as a step
696
+ body or a `brief`.
697
+
698
+ **Names are unique project-wide** — a profile-level slot may not reuse a component-level name — so a
699
+ consumer can resolve `{{ slot.<name> }}` against a single flat name space rather than having to know
700
+ which layer the surrounding text came from.
701
+
702
+ **The same shape appears at two levels.** A component's slots are on its component node and belong
703
+ to that component's own prompt text (its steps and `brief`); a profile's slots are the **top-level
704
+ `slots` array** and belong to the profile's `system_prompt`. They are not merged, and a profile's
705
+ slots are deliberately not copied onto each component node — the flat name space is what makes that
706
+ unnecessary. Compile refuses a name declared at both levels rather than letting one shadow the
707
+ other, so a consumer never has to implement a precedence rule.
708
+
709
+ **`present_when` is a condition on the slot's presence, unrelated to `llm_calls[].when`.** When it
710
+ does not hold, the slot is removed rather than filled with any variant: an instruction describing a
711
+ withheld capability is worse than no instruction. Evaluating it is the host's job; compile only
712
+ carries it.
713
+
714
+ **Who resolves a slot.** Compile never picks a variant — every one travels in the IR — so a consumer
715
+ must substitute before a prompt is sent. Two back-ends do: the Agent SDK back-end takes the host's
716
+ table through its dispatch input, and the `warble` CLI resolves the IR document once, before any
717
+ target-specific type deserializes it, from repeated `--slot NAME=VARIANT` flags (`--slot NAME=`
718
+ removes a slot whose condition does not hold). A back-end that cannot yet resolve refuses an IR
719
+ declaring slots rather than emitting the placeholder.
720
+
721
+ **A slot nobody answers takes its `default` — with one exception.** A slot carrying `present_when`
722
+ and no answer is a loud failure, not a default: `default` covers "no opinion on the wording", and it
723
+ cannot cover "no opinion on whether this exists at all". Defaulting there is precisely the failure
724
+ the field exists to prevent.
725
+
726
+ **A consumer that records an IR hash to identify a run now records something narrower than it
727
+ looks.** Before slots, compile finished every string, so an IR hash fixed the prompt as well. Now
728
+ two runs can share one and have sent different prompts, so anything that needs to identify what a
729
+ model was told has to fingerprint the assembled prompts instead — and each back-end has to produce
730
+ it, since only dispatch knows the answer.
731
+
732
+ The contract is: **a digest per named prompt surface, plus a total over all of them**, taken as late
733
+ as possible — over the text that actually reaches the runtime, not over the plan a host may then
734
+ rebuild. The question is excluded (it is the caller's text and varies per turn by design, so
735
+ including it would make every turn unique and answer nothing). Per-turn content supply is excluded
736
+ too, and deliberately: it is not an IR construct, and when it lands it needs its own decision about
737
+ whether it joins the digest.
738
+
739
+ The surfaces, per back-end:
740
+
741
+ | Back-end | Surfaces |
742
+ | --- | --- |
743
+ | Agent SDK, from a plan | the driver's `systemPrompt` and each named subagent's prompt — what the single and split paths send as built. On the single-tier collapse path a component's `prompt_fragment` is folded into `systemPrompt` and `llm_calls[].prompt` is not read, so a one-step component's text is covered through the driver surface. |
744
+ | Agent SDK, per turn at run time | the `systemPrompt` and subagent prompts of every turn the runtime sends, reported one fingerprint per turn, plus — for a step bound to a local OpenAI-compatible endpoint — the role/content messages it posts, keyed by position and role, since such a step builds no SDK options at all. This is the only truthful source for the staged and hybrid-tool paths: a staged step's options carry a runtime preamble ahead of the step prompt, and the hybrid-tool driver composes a prompt from the step list that appears in no plan field. A plan-derived digest **must not** claim those. |
745
+ | Claude Code CLI | not yet produced — filed as a follow-up |
746
+ | vercel | not yet produced — filed as a follow-up |
747
+ | codex-local | not yet produced — filed as a follow-up |
748
+
749
+ **Take it from a plan only when the host sends that plan's options as built.** Otherwise take it at
750
+ the point of send. A host that rebuilds its options — replacing the system prompt, say — and then
751
+ records a plan-derived digest has recorded a prompt nobody received, which is worse than recording
752
+ nothing: it reads as evidence.
753
+
754
+ A back-end that grows a new prompt-carrying surface and does not add it to its digest narrows the
755
+ fingerprint silently. That is the same shape as an unresolved slot placeholder, one layer out, which
756
+ is why the surfaces are listed here rather than left implicit in each implementation.
757
+
758
+ **Display paths differ, and the two manifests differ from each other.** The rule above protects a
759
+ model; a reader is not one, so a display renders an unanswered condition's default rather than
760
+ refusing — what it shows is what the default binding would say, never a promise about what will be
761
+ sent. Whether a display needs resolving at all depends on its schema: the `warble manifest` output
762
+ omits prompt text structurally and so needs none, while the Agent SDK back-end's own manifest
763
+ carries each step's prompt and therefore resolves like any other consumer of that text.
764
+
765
+ #### `assets` (additive since v0.7)
766
+
767
+ `object[]` — **omitted entirely (not present as a key, not `[]`) unless the component declares
768
+ `assets:`.** Each entry is exactly `{path, hash, bytes}`:
769
+
770
+ ```jsonc
771
+ "assets": [
772
+ { "path": "themes/dark.css", "hash": "sha256:9869fecc…", "bytes": 22 }
773
+ ]
774
+ ```
775
+
776
+ `path` is as authored, relative to the component directory. `hash` is `sha256:<hex>` over the
777
+ file's bytes and `bytes` its length; **both are computed at compile and neither is authorable** — a
778
+ `hash:` or `bytes:` written in `component.yml` is a parse error. An author-supplied fingerprint
779
+ could only rot, and silently replacing one would leave the author trusting a field that means
780
+ nothing.
781
+
782
+ **An IR's assets travel beside it.** Because content is not carried (below) and no consumer can
783
+ re-read a component directory — there is none at dispatch, and a Hub component was resolved over the
784
+ network at compile — `warble compile` writes each component's asset content into a sibling directory
785
+ of the IR it emits: for `<dir>/ir.json`, into `<dir>/ir.assets/<component-id>/<authored path>`.
786
+ Nothing is created for a project that declares no assets.
787
+
788
+ Dispatch lands them at the authored relative path inside the agent's working directory and verifies
789
+ every file against its `hash`. **A manifest path is re-validated on the way in, twice.** Absolute
790
+ paths and `..` segments are refused as text; then the resolved location is checked against the
791
+ canonicalized root, so a syntactically clean path that reaches outside through a symlinked directory
792
+ is refused too — on the write side and on the read side. Compile checks what an author wrote against
793
+ the component directory, but an IR is a document that can arrive from anywhere, and the working
794
+ directory it lands in may be a real project somebody else has written to. Without both checks a
795
+ manifest is an arbitrary file write. **Both failure modes are loud**: a manifest entry with no file in the
796
+ travelling directory, and one whose content no longer hashes to the recorded value, each stop the
797
+ dispatch, and nothing is landed at all rather than part of a component's set. Silence there is the
798
+ defect this closes — a component that declared its files and received none.
799
+
800
+ The consequence to know: **the artifact that travels is the IR plus that directory.** Copying an
801
+ `ir.json` on its own and dispatching it fails loudly rather than running without the files, which is
802
+ the right direction but is a new failure to recognise.
803
+
804
+ **Content is not carried.** This is the deliberate contrast with [`slots`](#slots-additive-since-v07)
805
+ above, and the two together define the line: a slot variant is prompt text, so its content is read
806
+ into the IR and composed into a prompt; an asset is a file that must be present on disk when the
807
+ component runs, so only its identity travels. Embedding binaries — or dozens of stylesheets — in
808
+ every IR is not worth the size, and `bytes` exists so a consumer can price landing them first.
809
+
810
+ **Landing is the target's business.** Copy, symlink, or pre-bake into an image: the IR says what is
811
+ required and how to recognise it, never how to provide it. A consumer that finds an asset missing
812
+ or hash-mismatched at dispatch time has a real failure to report, but that check is not compile's.
813
+
814
+ **The hash is content identity, not a version number.** It supports "is this the same file", never
815
+ "is this newer" — deliberately the same stance freshness takes elsewhere in this spec. Do not infer
816
+ ordering from it.
817
+
537
818
  ## Resolution rules (front-end `warble compile` must implement)
538
819
 
539
820
  1. **Parse** `profile.yml`, each mounted `components/<id>/component.yml`, and `context/binding.yml`.
@@ -595,9 +876,36 @@ from the node's shape instead. See
595
876
  | `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
877
  | 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
878
  | `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 …` |
879
+ | duplicate step name | two `llm_steps[]` entries share the same `name` | `duplicate step name '<name>' on component '<id>' — step names must be unique within a component` |
880
+ | duplicate `produces` artifact | more than one `llm_steps[]` entry declares the same `produces` name | `duplicate 'produces' artifact '<name>' on component '<id>' — more than one step declares it, most recently '<step>'; each artifact must have exactly one producer` |
881
+ | step consumes its own `produces` | a step's `consumes[]` entry equals that same step's own `produces` | `step '<step>' on component '<id>' consumes '<name>', its own 'produces' artifact — a step cannot consume what it produces; 'consumes' must name an earlier step's output` |
882
+ | step consumes an artifact no earlier step produces | a step's `consumes[]` entry is not the `produces` of any strictly-earlier step in `llm_steps[]` (covers both "no step produces it" and "only a later step produces it") | `step '<step>' on component '<id>' consumes '<name>', which no earlier step produces — add a preceding step with 'produces: <name>', or remove it from 'consumes'` |
883
+ | `on_failure` guard targets a non-earlier step | `when.guard: on_failure` and `when.target` does not name a strictly-earlier step in `llm_steps[]` (the target namespace is step names, not artifacts) | `guard 'on_failure' in step '<step>' of component '<id>' targets step '<target>', which is not a strictly-earlier step of this component — 'on_failure' can only observe the outcome of a step that has already run` |
884
+ | `on_missing` guard targets an unproduced artifact | `when.guard: on_missing` and `when.target` is not the `produces` of any strictly-earlier step (the target namespace is artifact/`produces` names, not step names) | `guard 'on_missing' in step '<step>' of component '<id>' targets artifact '<target>', which no earlier step produces` |
885
+ | `on_flag` guard targets an unproduced artifact | `when.guard: on_flag` and the artifact segment (before the first `.`) of the dotted `when.target` is not the `produces` of any strictly-earlier step | `guard 'on_flag' in step '<step>' of component '<id>' targets '<target>', but artifact '<artifact>' is not produced by any earlier step` |
886
+ | capability outside ceiling | profile declares `config.capability_ceiling` and a mounted component's `required_capabilities` names a capability not in that set (exact-string containment, no hierarchy inference) | `component '<id>' requires capability '<capability>', which is outside the profile's capability_ceiling (<declared set>)` |
887
+ | slot referenced but not declared | prompt text contains `{{ slot.<name> }}` and the component declares no such slot (including the case where it declares none at all) | `component '<id>' references slot '<name>' in its prompt text, which it does not declare (declared: <names>)` / `… but declares no slots` |
888
+ | slot declared but not referenced | a `slots[]` entry no prompt text of that component references | `component '<id>' declares slot '<name>' but no prompt text references '{{ slot.<name> }}'` |
889
+ | slot name unusable in a reference | a `slots[]` entry's `name` does not match `[a-z_][a-z0-9_]*`, so no `{{ slot.<name> }}` could ever resolve to it | `<owner> declares slot '<name>', which is not a usable slot name — a name must match [a-z_][a-z0-9_]* …` |
890
+ | `produces_exclusive` with no artifact | `llm_steps[].produces_exclusive: true` on a step that declares no `produces` | `step '<step>' on component '<id>' declares 'produces_exclusive' but produces no artifact …` |
891
+ | slot with no variants | a `slots[]` entry whose `variants` map is empty | `<owner> declares slot '<name>' with no variants` |
892
+ | slot `default` outside its variants | `slots[].default` names a key absent from that slot's `variants` | `<owner> declares slot '<name>' with default '<key>', which is not one of its variants (<keys>)` |
893
+ | duplicate slot name | two `slots[]` entries on the same component share a `name` | `component '<id>' declares slot '<name>' more than once` |
894
+ | unrecognised `{{ … }}` in prompt text | a step body, `brief`, mount `brief`, `system_prompt` or slot variant contains a `{{ … }}` that is not `project`, `project_name`, or `slot.<name>` | `unrecognised template syntax in <surface>: '{{ … }}' is not a known placeholder … A single brace needs no escaping …` |
895
+ | template statement or comment delimiter | the same surfaces contain `{%` or `{#` | `unrecognised template syntax in <surface>: '{%' is a template statement or comment delimiter, which Warble does not support …` |
896
+ | profile/component slot name collision | a `profile.yml` `slots[]` entry shares a `name` with any mounted component's `slots[]` entry | `profile '<profile>' declares slot '<name>', which component '<id>' also declares — slot names are shared across the whole project, so rename one of them` |
897
+ | profile slot referenced but not declared | `system_prompt` contains `{{ slot.<name> }}` and the profile declares no such slot | `profile '<profile>' references slot '<name>' in its system_prompt, which it does not declare (declared: <names>)` |
898
+ | profile slot declared but not referenced | a profile `slots[]` entry its `system_prompt` never references | `profile '<profile>' declares slot '<name>' but its system_prompt does not reference '{{ slot.<name> }}'` |
899
+ | duplicate profile slot name | two profile `slots[]` entries share a `name` | `profile '<profile>' declares slot '<name>' more than once` |
900
+ | asset path escapes its directory / is missing | an `assets[].path` is absolute, contains `..`, or names no existing file (the shared file-reference rule) | `asset '<path>' must be a relative path inside its own directory …` / `… does not exist: <path>` |
901
+ | authored asset `hash` / `bytes` | `component.yml` supplies either field on an `assets[]` entry; both are compile-computed and not authorable | `unknown field 'hash'` / `unknown field 'bytes'` (serde `deny_unknown_fields`) |
902
+ | slot variant reference escapes its directory / is missing | a `slots[].variants` value is absolute, contains `..`, or names no existing file (the shared file-reference rule, as for `prompt_ref`) | `slot '<name>' variant '<key>' must be a relative path inside its own directory …` / `… does not exist: <path>` |
598
903
 
599
904
  `required_capabilities` is **declared only** in this POC (not enforced by the compiler;
600
- enforcement is the dispatcher/runtime's job).
905
+ enforcement is the dispatcher/runtime's job) — except against a profile's `config.capability_ceiling`,
906
+ which the compiler does check at compile time (see [`capability_ceiling`](#capability_ceiling-additive-since-v07)
907
+ above). The ceiling check is an authorization gate on what a component may declare; it never
908
+ substitutes for, and is unaffected by, the dispatch-time resolution that still happens later.
601
909
 
602
910
  ## Prompt rendering
603
911
 
@@ -608,6 +916,16 @@ named by step, with placeholders substituted from coarse context:
608
916
 
609
917
  - `{{project}}` → `context_binding.project`
610
918
  - `{{project_name}}` → basename of the project path
919
+ - `{{ slot.<name> }}` → **not** substituted here; carried through for dispatch to fill from that
920
+ slot's variants (see [`slots`](#slots-additive-since-v07))
921
+
922
+ Surrounding whitespace inside the braces is ignored. **Any other `{{ … }}`, and any `{%` or `{#`,
923
+ is a compile error** in every prompt-text surface — step bodies, a component or mount `brief`, the
924
+ profile `system_prompt`, and slot variants. Single braces are untouched, so a prompt teaching a
925
+ model to emit JSON needs no escaping. There is deliberately no escape sequence for a literal `{{`:
926
+ the substitution is plain string replacement, not a template engine, so there is nothing to escape
927
+ for — and refusing the unrecognised forms is what lets a real engine replace it later without
928
+ migrating any authored file.
611
929
 
612
930
  Each `llm_calls[]` entry also carries its own **per-step rendered `prompt`** — the same
613
931
  substitution as `prompt_fragment`, but rendered **per step and without** the `## <name>` header.
@@ -657,7 +975,7 @@ Warble differentiator.
657
975
  `warble compile ./examples/demo-agent -o ir.json` against the demo project in this repo must produce an
658
976
  IR equal to `examples/demo-agent/ir.golden.json` (committed alongside, used as the core's fixture test).
659
977
  `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:
978
+ `examples/render-demo/ir.golden.json`. Both goldens use the current v0.8 contract:
661
979
  `context_requirements`, `context_precondition`, and `params` are always present (possibly `[]`, as
662
980
  on `dashboard`), while `eval` appears only on `generate_dashboard` and `scope: "."` appears only on
663
981
  render-demo's authored `artifact_write` guardrail.
@@ -668,8 +986,9 @@ render-demo's authored `artifact_write` guardrail.
668
986
 
669
987
  Where v0.2 carried a coarse project path and *declared* preconditions, v0.3 made the front-end
670
988
  **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.
989
+ Warble ships `PreparedContext` which reads a projection the layer's own owner wrote, so any
990
+ semantic format binds without Warble speaking it — plus `RawSourceContext` for raw constitutive
991
+ input and `ExternalContext` for a layer held elsewhere; a host may supply its own loader.
673
992
 
674
993
  ## What lands in the IR
675
994
  - For a Wren project, `context_binding.resolved` carries the compiler's introspection result: `metrics`
@@ -692,13 +1011,14 @@ answerable only over a declared metric (see the `context_precondition` section a
692
1011
  source through `RawSourceContext`; an MDL-only adapter returns unanswerable for both.
693
1012
 
694
1013
  ## `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.
1014
+ The bound layer's owner builds the lineage DAG (`model → relationship / cube → metric / dimension`,
1015
+ plus view references) **and** computes each node's downstream closure with a severity **rank** on its
1016
+ own scale, supplying both in the prepared-context document. Warble computes neither: it compares a
1017
+ supplied rank against an authored ceiling and decides `allow` / `escalate` / `block`, refusing
1018
+ outright when no analysis was supplied. That gate is exposed as read-only analysis on the read path
1019
+ and as the enforcement gate for *mutating* applies. It remains the one `provided_by: warble`
1020
+ capability, on those grounds rather than on computing the closure — see `capability-model.md` §6/§7.1
1021
+ and `blast-radius.md`.
702
1022
 
703
1023
  ---
704
1024
 
@@ -772,6 +1092,15 @@ instead of trusting the prose alone:
772
1092
  Both are additive optional fields on the existing envelope/block shapes above, not a new block type;
773
1093
  a renderer or consumer that doesn't recognize them ignores them.
774
1094
 
1095
+ When a render envelope crosses a future same-profile component-call boundary, it is wrapped as the
1096
+ `kind: render` success variant and validated against the **callee's** block contract. A
1097
+ non-rendering terminal JSON value (including the current tabular
1098
+ `{columns, rows, summary, verified, definition}` shape) is wrapped as `kind: value`. The child does
1099
+ not render or persist either form; only the root invocation owns those effects. The normalized
1100
+ wrapper and failure vocabulary are specified in
1101
+ [`component-composition.md`](./component-composition.md#6-target-neutral-request-and-result-envelopes),
1102
+ and are not part of the resolved v0.8 IR.
1103
+
775
1104
  ## 3. Renderer registry — `render(target, blocks[]) → artifact`
776
1105
  Warble owns the **contract + a reference renderer (HTML)**; runtimes register/override per target.
777
1106
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warble/ir-spec",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
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.",