@warble/ir-spec 0.6.0 → 0.7.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.7";
7
7
 
8
- declare const _default: { IR_VERSION: "0.6" };
8
+ declare const _default: { IR_VERSION: "0.7" };
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.7";
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.7`)
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,7 +6,7 @@ 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.7"` — 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
@@ -41,11 +41,11 @@ version on anything else — there is no best-effort or partial parse of an unre
41
41
 
42
42
  | Consumer | Accepted `warble_ir_version` | Where the accepted version is declared |
43
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` |
44
+ | `core` (`warble compile`) | emits `0.7` | the `"warble_ir_version"` literal in `core/src/compile.rs` |
45
+ | `dispatcher/claude-code-cli` | `0.7` | `SUPPORTED_IR_VERSION` in `dispatcher/claude-code-cli/src/ir.rs` |
46
+ | `dispatcher/vercel` | `0.7` | `SUPPORTED_IR_VERSION` in `dispatcher/vercel/src/emit.rs` |
47
+ | `dispatcher/claude-agent-sdk` | `0.7` | `SUPPORTED_IR_VERSIONS` in `dispatcher/claude-agent-sdk/src/ir.ts` |
48
+ | `dispatcher/codex-local` | `0.7` | `SUPPORTED_IR_VERSION` in `dispatcher/codex-local/src/ir.ts` |
49
49
 
50
50
  Each back-end copies this value rather than importing it from `core` or from another back-end: a
51
51
  back-end shouldn't need a Rust dependency edge just to know a version string, and independent copies
@@ -62,7 +62,7 @@ is informational, not itself an input enforcement check.
62
62
  `@warble/claude-agent-sdk` and `@warble/codex-local` additionally each declare a `peerDependencies`
63
63
  entry on [`@warble/ir-spec`](../../packages/ir-spec) — a dedicated npm package whose own version *is*
64
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
65
+ plus an advisory `"warble": { "irVersion": "0.7" }` field in the same `package.json`. This makes the
66
66
  IR version a dispatcher speaks visible in the npm dependency graph without opening the package.
67
67
  **Neither dispatcher imports `@warble/ir-spec`** — the peer is a declaration, not a dependency edge,
68
68
  and each dispatcher keeps enforcing its own copy of `SUPPORTED_IR_VERSION`(S) above. `@warble/ir-spec`
@@ -70,8 +70,12 @@ also bundles this document itself (as `ir-schema.md`, alongside `index.js`/`inde
70
70
  snapshot for the IR version it publishes — a published npm version is immutable, so a snapshot as of
71
71
  that version is worth more than a live link back to this file on `main`, which points at whatever the
72
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
73
+ ever diverge, since a publish is exactly the point that drift becomes irreversible. Keeping it in
74
+ sync is not a manual step: `npm run gen:reference` in `docs/site/` writes it from this document
75
+ alongside the generated reference pages, and the docs-site CI drift check covers it on the same
76
+ footing, so editing this document and regenerating is all there is to it. The snapshot has to be a
77
+ real committed file rather than a symlink — `npm pack` does not dereference one, it silently omits
78
+ the file and ships a package with no spec in it. Counting the
75
79
  producer (what `core` actually emits) alongside every independent consumer/advisory copy, the
76
80
  `@warble/ir-spec` package's own version, its `index.js` and `index.d.ts` version constants/literals,
77
81
  both dispatchers' peer declarations, both dispatchers' advisory `warble.irVersion` fields, and the
@@ -88,7 +92,7 @@ spec title, there are **eighteen** locations that must agree:
88
92
  | 7 | `dispatcher/vercel/src/emit.rs` `MAX_SUPPORTED_IR_VERSION` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
89
93
  | 8 | `dispatcher/claude-agent-sdk/src/manifest.ts` `MIN_SUPPORTED_IR_VERSION` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
90
94
  | 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` |
95
+ | 10 | This document's title (`warble_ir_version: 0.7`) | Spec | `core/tests/ir_version_lockstep_tests.rs` |
92
96
  | 11 | `packages/ir-spec/package.json` `"version"` (mapped `x.y` -> `x.y.0`) | Producer (npm) | `core/tests/ir_version_lockstep_tests.rs` |
93
97
  | 12 | `packages/ir-spec/index.js` `IR_VERSION` | Advisory | `core/tests/ir_version_lockstep_tests.rs` |
94
98
  | 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 +105,14 @@ spec title, there are **eighteen** locations that must agree:
101
105
  This table's scope is contract-bearing declarations — constants and literals something actually
102
106
  compares against — not every place `warble_ir_version` appears in prose. Each back-end's `ir` module
103
107
  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
108
+ the Warble IR (warble_ir_version: 0.7)`); nothing checks those comments, and a version bump can leave
105
109
  them stale without breaking anything. They are deliberately not extra rows — update them as a
106
110
  courtesy to the reader, not because a test requires it.
107
111
 
108
112
  `core/tests/ir_version_lockstep_tests.rs` is the sole cross-target lockstep owner: it text-parses
109
113
  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
114
+ 11, 13, and 15 first pass the emitted version through the mapping below, since they are npm
115
+ 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
116
  unsupported-version rejection and no-partial-output behavior; they do not scrape other targets'
113
117
  sources. (`core/src/lib.rs`'s doctest and `core/tests/compile_tests.rs` also assert row 1's literal
114
118
  directly, but aren't listed as separate lockstep-tested locations — they self-guard, failing the
@@ -123,8 +127,8 @@ only when `warble_ir_version` moves, on its own release line. The mapping from I
123
127
  version is fixed and mechanical:
124
128
 
125
129
  - 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`.
130
+ - A dispatcher's `peerDependencies["@warble/ir-spec"]` range is `x.y.x` — e.g. IR `0.7` is npm
131
+ version `0.7.0` and peer range `0.7.x`.
128
132
  - An IR version with anything other than exactly two dot-separated numeric components (a three-part
129
133
  `x.y.z`, or a non-numeric component) has **no defined mapping** and is rejected by
130
134
  `core/tests/ir_version_lockstep_tests.rs` at the point it tries to compute rows 11, 13, and 15 — it
@@ -165,7 +169,7 @@ back-end accepts, and must be regenerated rather than merely re-read.
165
169
 
166
170
  ```jsonc
167
171
  {
168
- "warble_ir_version": "0.6",
172
+ "warble_ir_version": "0.7",
169
173
  "profile": "orders-analytics", // profile.yml `profile:`
170
174
  "context_binding": { // resolved from profile `context:` + context/binding.yml
171
175
  "project": "examples/jaffle-wren", // coarse path to a wren project (retained for back-ends)
@@ -188,15 +192,20 @@ back-end accepts, and must be regenerated rather than merely re-read.
188
192
  // so pre-consumer IRs are byte-identical.
189
193
  }
190
194
  },
191
- "config": {}, // reserved profile-level config block; no fields today
195
+ "config": {}, // reserved profile-level config block; optionally carries `capability_ceiling`
196
+ "slots": [ // additive; present only when the PROFILE declares slots — see `slots` below
197
+ { "name": "plan_mode", "default": "off", "present_when": "plan_mode_available",
198
+ "variants": { "on": "…rendered…", "off": "…rendered…" } }
199
+ ],
192
200
  "components": [ /* one resolved component node, see below */ ]
193
201
  }
194
202
  ```
195
203
 
196
- ### `config` — emptied in 0.6
204
+ ### `config` — one optional field in 0.7
197
205
 
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 `{}`.
206
+ `config` carried one field, `tier_policy`, from the first IR through `0.5`. `0.6` removed it, and
207
+ the block was emitted as `{}` until the addition documented below gave it its first surviving
208
+ field.
200
209
 
201
210
  `tier_policy` was a profile-wide tier stance (`cost_sensitive`) that the compiler was meant to
202
211
  resolve into per-step tiers. Only the field ever landed. No back-end read it, its value was never
@@ -214,6 +223,34 @@ control (see [`profile-schema.md`](./authoring.md#61-tiers-not-model-names)).
214
223
  The block itself stays so that profile-level config which *can* be honored is an additive change
215
224
  rather than the reintroduction of a removed key.
216
225
 
226
+ #### `capability_ceiling` (additive since v0.7)
227
+
228
+ When a profile declares `capability_ceiling` — a list of capability strings — the compiler carries
229
+ it into `config` verbatim:
230
+
231
+ ```jsonc
232
+ "config": {
233
+ "capability_ceiling": ["sql_execution", "chart_rendering"]
234
+ }
235
+ ```
236
+
237
+ A profile that omits `capability_ceiling` compiles exactly as before this field existed: `config`
238
+ stays `{}`.
239
+
240
+ The ceiling is a compile-time *authorization* check ("may a component of this profile require
241
+ this capability at all"), not the dispatch-time capability *resolution* every component's
242
+ `required_capabilities` already goes through against a target's profile (native / realize-via /
243
+ degrade / fail — see the compile-time checks table below). The two gates are independent: a
244
+ capability can pass the ceiling and still fail resolution against a given target, and the ceiling
245
+ is evaluated once at compile time regardless of which target the IR is later dispatched against.
246
+ A component whose `required_capabilities` names anything outside the profile's declared ceiling
247
+ fails compile before resolution is ever attempted.
248
+
249
+ Containment is exact string-set matching — no hierarchy or prefix inference on the `:` qualifier
250
+ some capability names use. A ceiling of `sql_execution` does **not** admit a component requiring
251
+ `sql_execution:read_only`; the qualifier is not an ordering, and a profile that means to allow the
252
+ narrower capability must list it explicitly alongside (or instead of) the broader one.
253
+
217
254
  ## Component node (resolved: component fields ⊕ supported profile mount fields)
218
255
 
219
256
  ```jsonc
@@ -251,6 +288,13 @@ rather than the reintroduction of a removed key.
251
288
  ]
252
289
  },
253
290
  "brief": "…shared framing for every step, placeholders substituted…", // additive; present only when authored — see below
291
+ "slots": [ // additive; present only when the component declares slots — see below
292
+ { "name": "verification", "default": "base",
293
+ "variants": { "base": "…rendered…", "terse": "…rendered…" } }
294
+ ],
295
+ "assets": [ // additive; present only when the component declares assets — see below
296
+ { "path": "themes/dark.css", "hash": "sha256:9869fecc…", "bytes": 22 }
297
+ ],
254
298
  "prompt_fragment": "…rendered skill instructions…", // see §prompt rendering
255
299
  "llm_calls": [ // per-step tier, order preserved from component llm_steps
256
300
  { "name": "plan_dashboard", "tier": "strong", "conditional": false, "when": null,
@@ -261,6 +305,10 @@ rather than the reintroduction of a removed key.
261
305
  "prompt": "<compose_layout.md rendered>" }
262
306
  // a conditional step instead carries e.g. "conditional": true, "when": { "guard": "on_failure", "target": "generate_sql" }
263
307
  // — see `llm_calls[].when` below
308
+ // a step may also narrow its tool boundary and/or mark its artifact exclusive, e.g.
309
+ // "capabilities": ["sql_execution:read_only"], "produces_exclusive": true — both additive,
310
+ // omitted here because neither step below declares them; see `llm_calls[].capabilities` /
311
+ // `llm_calls[].produces_exclusive` below
264
312
  ],
265
313
  "guardrails": [ // resolved; `locked` is the normalized lock-state
266
314
  { "name": "read_only_execution", "locked": true }
@@ -451,6 +499,40 @@ an IR version bump under the policy above. As of this writing:
451
499
  A back-end that ignores `when` must do so as a documented, deliberate choice (as `claude-code-cli`
452
500
  does above) — never as a silent fallback for a guard shape it was simply never taught to recognize.
453
501
 
502
+ #### `llm_calls[].capabilities` (additive)
503
+
504
+ `string[]`, an exact-string subset of the component's own `required_capabilities` — **omitted
505
+ entirely (not present as a key, not `null`) unless the step declares it**. A step that narrows its
506
+ capabilities compiles to `"capabilities": [...]` on that call's IR entry; a step that doesn't
507
+ compiles to exactly the IR it produced before this field existed. Compile validates the subset
508
+ relationship (exact string containment, no hierarchy) against the component's declared
509
+ `required_capabilities` and loud-fails, naming both the step and the offending capability, when a
510
+ step names one outside that set. This is a compile-time declaration of what a step is allowed to
511
+ need, not a runtime identity or actor concept: how a back-end scopes a step's own call is already a
512
+ per-back-end mechanism this field says nothing about.
513
+
514
+ ```jsonc
515
+ { "name": "plan_query", "tier": "strong", "conditional": false, "when": null,
516
+ "consumes": [], "produces": "query_plan",
517
+ "capabilities": ["render_contract"],
518
+ "prompt": "…" }
519
+ ```
520
+
521
+ #### `llm_calls[].produces_exclusive` (additive)
522
+
523
+ `bool` — **omitted entirely unless `true`.** A step's plain `produces` name is unchanged; this is a
524
+ separate provenance marker on that same artifact, meaning only the producing step is expected to
525
+ write it. Kept as an additive boolean rather than reshaping `produces` into an object. Like
526
+ `capabilities` above, it says nothing about *who* enforces exclusivity — that is whatever already
527
+ scopes a step's own call at the back-end.
528
+
529
+ ```jsonc
530
+ { "name": "run_query", "tier": "cheap", "conditional": false, "when": null,
531
+ "consumes": ["query_plan"], "produces": "query_result",
532
+ "produces_exclusive": true,
533
+ "prompt": "…" }
534
+ ```
535
+
454
536
  #### `guardrails[].scope` / `threshold` and the `locked`/`overridable` normalization
455
537
 
456
538
  `scope` and `threshold` are passthrough fields — each is present in the resolved IR **only when
@@ -504,6 +586,14 @@ Optional free-form text, authored on the component (or replaced wholesale by a p
504
586
  same `{{project}}` / `{{project_name}}` placeholder substitution as step prompts (§Prompt
505
587
  rendering). Emitted **once, on the node itself** — never per-step — and present in the IR only when
506
588
  authored; a component with no `brief` produces IR byte-identical to before this field existed.
589
+
590
+ The emitted value may be a **merge of two authored layers**: a profile's
591
+ [`system_prompt`](./authoring.md#system_prompt--profile-level-framing-for-every-component) — shared
592
+ by every component that profile mounts — followed by a blank line and the component's own effective
593
+ brief. Either layer may be absent; with neither, the key is absent as described above. The merge
594
+ happens at compile time and deliberately adds no IR field, so a back-end that already reads `brief`
595
+ needs no change and the wire contract is unversioned by it. The trade-off is that the IR does not
596
+ record which half came from where.
507
597
  Every back-end that assembles a system prompt places it in the same position: after the
508
598
  machine-generated preamble, before the body, on both the driver and every subagent. See
509
599
  [`profile-schema.md`](./authoring.md#brief--authored-framing-shared-across-every-step) for the
@@ -534,6 +624,92 @@ from the node's shape instead. See
534
624
 
535
625
  ---
536
626
 
627
+ #### `slots` (additive since v0.7)
628
+
629
+ `object[]` — **omitted entirely (not present as a key, not `[]`) unless the component declares
630
+ `slots:`.** A component without any compiles to exactly the IR it did before this field existed.
631
+
632
+ Each entry carries a `name`, a required `default`, a `variants` object, and `present_when` only when
633
+ authored:
634
+
635
+ ```jsonc
636
+ "slots": [
637
+ {
638
+ "name": "verification",
639
+ "default": "base", // always present; names a key of "variants"
640
+ "present_when": "verification_enabled", // omitted entirely unless authored
641
+ "variants": {
642
+ "base": "…rendered text, placeholders substituted…",
643
+ "terse": "…rendered text…"
644
+ }
645
+ }
646
+ ]
647
+ ```
648
+
649
+ **Every variant is carried; none is selected.** This is the deliberate division of labour: compile
650
+ *transports* the alternatives, dispatch *chooses* between them. The consequence is that the IR stays
651
+ a complete description of what the model could be told — a reader can enumerate every wording without
652
+ running anything — at the cost of a larger document. Selecting at compile would have been smaller and
653
+ would have made the IR silent about the paths not taken.
654
+
655
+ **Variant keys are opaque.** Warble does not interpret, validate, or order them; the mapping from a
656
+ bound model (or any other runtime fact) to a key belongs to the host's dispatch fragment. A
657
+ consumer that hard-codes key names is coupling itself to one project's convention, not to this
658
+ schema.
659
+
660
+ **A slot is referenced from prompt text as `{{ slot.<name> }}`.** Deliberately a different syntax
661
+ from the `{{project}}` / `{{project_name}}` value substitutions, so a slot reference and a mistyped
662
+ placeholder are distinguishable; it is also Jinja-native attribute access, so the renderer can be
663
+ replaced without touching authored files. Variant text goes through the same substitution as a step
664
+ body or a `brief`.
665
+
666
+ **Names are unique project-wide** — a profile-level slot may not reuse a component-level name — so a
667
+ consumer can resolve `{{ slot.<name> }}` against a single flat name space rather than having to know
668
+ which layer the surrounding text came from.
669
+
670
+ **The same shape appears at two levels.** A component's slots are on its component node and belong
671
+ to that component's own prompt text (its steps and `brief`); a profile's slots are the **top-level
672
+ `slots` array** and belong to the profile's `system_prompt`. They are not merged, and a profile's
673
+ slots are deliberately not copied onto each component node — the flat name space is what makes that
674
+ unnecessary. Compile refuses a name declared at both levels rather than letting one shadow the
675
+ other, so a consumer never has to implement a precedence rule.
676
+
677
+ **`present_when` is a condition on the slot's presence, unrelated to `llm_calls[].when`.** When it
678
+ does not hold, the slot is removed rather than filled with any variant: an instruction describing a
679
+ withheld capability is worse than no instruction. Evaluating it is the host's job; compile only
680
+ carries it.
681
+
682
+ #### `assets` (additive since v0.7)
683
+
684
+ `object[]` — **omitted entirely (not present as a key, not `[]`) unless the component declares
685
+ `assets:`.** Each entry is exactly `{path, hash, bytes}`:
686
+
687
+ ```jsonc
688
+ "assets": [
689
+ { "path": "themes/dark.css", "hash": "sha256:9869fecc…", "bytes": 22 }
690
+ ]
691
+ ```
692
+
693
+ `path` is as authored, relative to the component directory. `hash` is `sha256:<hex>` over the
694
+ file's bytes and `bytes` its length; **both are computed at compile and neither is authorable** — a
695
+ `hash:` or `bytes:` written in `component.yml` is a parse error. An author-supplied fingerprint
696
+ could only rot, and silently replacing one would leave the author trusting a field that means
697
+ nothing.
698
+
699
+ **Content is not carried.** This is the deliberate contrast with [`slots`](#slots-additive-since-v07)
700
+ above, and the two together define the line: a slot variant is prompt text, so its content is read
701
+ into the IR and composed into a prompt; an asset is a file that must be present on disk when the
702
+ component runs, so only its identity travels. Embedding binaries — or dozens of stylesheets — in
703
+ every IR is not worth the size, and `bytes` exists so a consumer can price landing them first.
704
+
705
+ **Landing is the target's business.** Copy, symlink, or pre-bake into an image: the IR says what is
706
+ required and how to recognise it, never how to provide it. A consumer that finds an asset missing
707
+ or hash-mismatched at dispatch time has a real failure to report, but that check is not compile's.
708
+
709
+ **The hash is content identity, not a version number.** It supports "is this the same file", never
710
+ "is this newer" — deliberately the same stance freshness takes elsewhere in this spec. Do not infer
711
+ ordering from it.
712
+
537
713
  ## Resolution rules (front-end `warble compile` must implement)
538
714
 
539
715
  1. **Parse** `profile.yml`, each mounted `components/<id>/component.yml`, and `context/binding.yml`.
@@ -595,9 +771,36 @@ from the node's shape instead. See
595
771
  | `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
772
  | 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
773
  | `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 …` |
774
+ | 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` |
775
+ | 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` |
776
+ | 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` |
777
+ | 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'` |
778
+ | `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` |
779
+ | `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` |
780
+ | `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` |
781
+ | 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>)` |
782
+ | 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` |
783
+ | 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> }}'` |
784
+ | 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_]* …` |
785
+ | `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 …` |
786
+ | slot with no variants | a `slots[]` entry whose `variants` map is empty | `<owner> declares slot '<name>' with no variants` |
787
+ | 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>)` |
788
+ | duplicate slot name | two `slots[]` entries on the same component share a `name` | `component '<id>' declares slot '<name>' more than once` |
789
+ | 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 …` |
790
+ | 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 …` |
791
+ | 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` |
792
+ | 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>)` |
793
+ | 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> }}'` |
794
+ | duplicate profile slot name | two profile `slots[]` entries share a `name` | `profile '<profile>' declares slot '<name>' more than once` |
795
+ | 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>` |
796
+ | 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`) |
797
+ | 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
798
 
599
799
  `required_capabilities` is **declared only** in this POC (not enforced by the compiler;
600
- enforcement is the dispatcher/runtime's job).
800
+ enforcement is the dispatcher/runtime's job) — except against a profile's `config.capability_ceiling`,
801
+ which the compiler does check at compile time (see [`capability_ceiling`](#capability_ceiling-additive-since-v07)
802
+ above). The ceiling check is an authorization gate on what a component may declare; it never
803
+ substitutes for, and is unaffected by, the dispatch-time resolution that still happens later.
601
804
 
602
805
  ## Prompt rendering
603
806
 
@@ -608,6 +811,16 @@ named by step, with placeholders substituted from coarse context:
608
811
 
609
812
  - `{{project}}` → `context_binding.project`
610
813
  - `{{project_name}}` → basename of the project path
814
+ - `{{ slot.<name> }}` → **not** substituted here; carried through for dispatch to fill from that
815
+ slot's variants (see [`slots`](#slots-additive-since-v07))
816
+
817
+ Surrounding whitespace inside the braces is ignored. **Any other `{{ … }}`, and any `{%` or `{#`,
818
+ is a compile error** in every prompt-text surface — step bodies, a component or mount `brief`, the
819
+ profile `system_prompt`, and slot variants. Single braces are untouched, so a prompt teaching a
820
+ model to emit JSON needs no escaping. There is deliberately no escape sequence for a literal `{{`:
821
+ the substitution is plain string replacement, not a template engine, so there is nothing to escape
822
+ for — and refusing the unrecognised forms is what lets a real engine replace it later without
823
+ migrating any authored file.
611
824
 
612
825
  Each `llm_calls[]` entry also carries its own **per-step rendered `prompt`** — the same
613
826
  substitution as `prompt_fragment`, but rendered **per step and without** the `## <name>` header.
@@ -657,7 +870,7 @@ Warble differentiator.
657
870
  `warble compile ./examples/demo-agent -o ir.json` against the demo project in this repo must produce an
658
871
  IR equal to `examples/demo-agent/ir.golden.json` (committed alongside, used as the core's fixture test).
659
872
  `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:
873
+ `examples/render-demo/ir.golden.json`. Both goldens use the current v0.7 contract:
661
874
  `context_requirements`, `context_precondition`, and `params` are always present (possibly `[]`, as
662
875
  on `dashboard`), while `eval` appears only on `generate_dashboard` and `scope: "."` appears only on
663
876
  render-demo's authored `artifact_write` guardrail.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@warble/ir-spec",
3
- "version": "0.6.0",
3
+ "version": "0.7.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.",