diff --git a/CLAUDE.md b/CLAUDE.md
index 8d9f854..99b02e0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -80,7 +80,8 @@ an unscoped `feat:` releases nothing.
The three schemas were seeded from the platform's Pydantic-generated catalog
schemas. The naming and the generated titles have been cleaned and `lint.ts`
now rejects both, but the seeding still shows in the prose: some `description`
-text uses platform vocabulary (`snapshot compute`, Compute Profile slugs) that
-a reader outside `musher-dev/platform` cannot resolve. The `TODO` sections in
-each `spec.md` are the larger remaining gap — they are what keeps v1
-pre-stable.
+text uses platform vocabulary (`snapshot compute`) that a reader outside
+`musher-dev/platform` cannot resolve. Compute Profile slugs used to be on that
+list; blueprint §4.3 now carries the grammar and names where the vocabulary is
+published, per docs/adr/0003. The `TODO` sections remaining in `blueprint` and
+`listing` `spec.md` are the larger gap — they are what keeps v1 pre-stable.
diff --git a/conformance/README.md b/conformance/README.md
index a7e3549..7b2870c 100644
--- a/conformance/README.md
+++ b/conformance/README.md
@@ -163,13 +163,15 @@ later-phase diagnostic before the earlier phases pass.
## Coverage status
`parser`, `structural` and `semantic` are covered. Every diagnostic code the
-three `spec.md` files declare is exercised by at least one case, with two
-exceptions, both `capability`:
+three `spec.md` files declare is exercised by at least one case, with four
+exceptions, all `capability`:
| Code | Why it has no case |
|---|---|
| `ERR_UNKNOWN_COMPONENT` | `capability` — resolving a published reference needs the catalog, and no phase a client runs may reach the network |
| `ERR_VERSION_NOT_MONOTONIC` | `capability` — comparing a version against the lineage it extends needs the catalog, and a fixture is one document with no previous release to be greater than |
+| `ERR_COMPONENT_NOT_PUBLISHED` | `capability` — only the registry holds publication state, and a fixture is a tree of files none of which has one |
+| `ERR_UNKNOWN_COMPUTE_PROFILE` | `capability` — the slug grammar is fixtured, but which profiles are offered changes when the platform gains hardware to back a tier, not when this repository releases |
That table is not prose anyone has to remember to update.
`task check:conformance` derives it: every `ERR_*` row in a family's own
diff --git a/conformance/blueprint/v1/cases.json b/conformance/blueprint/v1/cases.json
index 50a986b..c772fc9 100644
--- a/conformance/blueprint/v1/cases.json
+++ b/conformance/blueprint/v1/cases.json
@@ -92,6 +92,41 @@
"phase": "structural",
"path": "structural/014-missing-component-graph"
},
+ {
+ "id": "structural-015-legacy-compute-size-slug",
+ "phase": "structural",
+ "path": "structural/015-legacy-compute-size-slug"
+ },
+ {
+ "id": "structural-016-unknown-compute-profile-family",
+ "phase": "structural",
+ "path": "structural/016-unknown-compute-profile-family"
+ },
+ {
+ "id": "structural-017-versioned-compute-profile-slug",
+ "phase": "structural",
+ "path": "structural/017-versioned-compute-profile-slug"
+ },
+ {
+ "id": "structural-018-reserved-compute-profile-slug",
+ "phase": "structural",
+ "path": "structural/018-reserved-compute-profile-slug"
+ },
+ {
+ "id": "structural-019-advanced-compute-constraints",
+ "phase": "structural",
+ "path": "structural/019-advanced-compute-constraints"
+ },
+ {
+ "id": "structural-020-constraint-term-not-a-token",
+ "phase": "structural",
+ "path": "structural/020-constraint-term-not-a-token"
+ },
+ {
+ "id": "structural-021-unknown-cpu-dedication",
+ "phase": "structural",
+ "path": "structural/021-unknown-cpu-dedication"
+ },
{
"id": "semantic-001-connection-names-unknown-role",
"phase": "semantic",
diff --git a/conformance/blueprint/v1/structural/015-legacy-compute-size-slug/case.yaml b/conformance/blueprint/v1/structural/015-legacy-compute-size-slug/case.yaml
new file mode 100644
index 0000000..0513c65
--- /dev/null
+++ b/conformance/blueprint/v1/structural/015-legacy-compute-size-slug/case.yaml
@@ -0,0 +1,15 @@
+# A raw-resource name. It says how much silicon, which is the promise §4.3 says
+# a profile slug exists not to make — the same nominal vCPU count means a
+# burstable shared core on one host and a dedicated modern one on another.
+specVersion: v1
+kind: BLUEPRINT
+metadata:
+ slug: postgres
+ version: 1
+spec:
+ components:
+ db:
+ component: ./components/postgres.yaml
+ size: 1vcpu-1gb
+ connections: {}
+ parameters: {}
diff --git a/conformance/blueprint/v1/structural/015-legacy-compute-size-slug/diagnostics.json b/conformance/blueprint/v1/structural/015-legacy-compute-size-slug/diagnostics.json
new file mode 100644
index 0000000..5bf6053
--- /dev/null
+++ b/conformance/blueprint/v1/structural/015-legacy-compute-size-slug/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_VALUE",
+ "path": "/spec/components/db/size"
+ }
+]
diff --git a/conformance/blueprint/v1/structural/015-legacy-compute-size-slug/metadata.json b/conformance/blueprint/v1/structural/015-legacy-compute-size-slug/metadata.json
new file mode 100644
index 0000000..dd51f7c
--- /dev/null
+++ b/conformance/blueprint/v1/structural/015-legacy-compute-size-slug/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-015-legacy-compute-size-slug",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/blueprint/v1/spec.md#node-compute",
+ "summary": "A raw-resource size slug is not a Compute Profile and is rejected."
+}
diff --git a/conformance/blueprint/v1/structural/016-unknown-compute-profile-family/case.yaml b/conformance/blueprint/v1/structural/016-unknown-compute-profile-family/case.yaml
new file mode 100644
index 0000000..4dbc2db
--- /dev/null
+++ b/conformance/blueprint/v1/structural/016-unknown-compute-profile-family/case.yaml
@@ -0,0 +1,15 @@
+# Three segments, correct shape, invented family. The grammar is closed on all
+# three segments, so this fails offline rather than travelling to the catalog to
+# be told the same thing.
+specVersion: v1
+kind: BLUEPRINT
+metadata:
+ slug: postgres
+ version: 1
+spec:
+ components:
+ db:
+ component: ./components/postgres.yaml
+ size: tiny.standard.small
+ connections: {}
+ parameters: {}
diff --git a/conformance/blueprint/v1/structural/016-unknown-compute-profile-family/diagnostics.json b/conformance/blueprint/v1/structural/016-unknown-compute-profile-family/diagnostics.json
new file mode 100644
index 0000000..5bf6053
--- /dev/null
+++ b/conformance/blueprint/v1/structural/016-unknown-compute-profile-family/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_VALUE",
+ "path": "/spec/components/db/size"
+ }
+]
diff --git a/conformance/blueprint/v1/structural/016-unknown-compute-profile-family/metadata.json b/conformance/blueprint/v1/structural/016-unknown-compute-profile-family/metadata.json
new file mode 100644
index 0000000..b3f34d8
--- /dev/null
+++ b/conformance/blueprint/v1/structural/016-unknown-compute-profile-family/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-016-unknown-compute-profile-family",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/blueprint/v1/spec.md#node-compute",
+ "summary": "A slug whose family segment is outside the closed set is rejected."
+}
diff --git a/conformance/blueprint/v1/structural/017-versioned-compute-profile-slug/case.yaml b/conformance/blueprint/v1/structural/017-versioned-compute-profile-slug/case.yaml
new file mode 100644
index 0000000..c857c2a
--- /dev/null
+++ b/conformance/blueprint/v1/structural/017-versioned-compute-profile-slug/case.yaml
@@ -0,0 +1,15 @@
+# The profile is versioned where it is published, and a node does not name that
+# version: it names the tier and gets the current version of it. The grammar has
+# three segments, so a fourth is rejected without needing a rule of its own.
+specVersion: v1
+kind: BLUEPRINT
+metadata:
+ slug: postgres
+ version: 1
+spec:
+ components:
+ db:
+ component: ./components/postgres.yaml
+ size: general.standard.small.v1
+ connections: {}
+ parameters: {}
diff --git a/conformance/blueprint/v1/structural/017-versioned-compute-profile-slug/diagnostics.json b/conformance/blueprint/v1/structural/017-versioned-compute-profile-slug/diagnostics.json
new file mode 100644
index 0000000..5bf6053
--- /dev/null
+++ b/conformance/blueprint/v1/structural/017-versioned-compute-profile-slug/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_VALUE",
+ "path": "/spec/components/db/size"
+ }
+]
diff --git a/conformance/blueprint/v1/structural/017-versioned-compute-profile-slug/metadata.json b/conformance/blueprint/v1/structural/017-versioned-compute-profile-slug/metadata.json
new file mode 100644
index 0000000..f41c620
--- /dev/null
+++ b/conformance/blueprint/v1/structural/017-versioned-compute-profile-slug/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-017-versioned-compute-profile-slug",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/blueprint/v1/spec.md#node-compute",
+ "summary": "A node names a bare profile slug, not the versioned form."
+}
diff --git a/conformance/blueprint/v1/structural/018-reserved-compute-profile-slug/case.yaml b/conformance/blueprint/v1/structural/018-reserved-compute-profile-slug/case.yaml
new file mode 100644
index 0000000..6b05307
--- /dev/null
+++ b/conformance/blueprint/v1/structural/018-reserved-compute-profile-slug/case.yaml
@@ -0,0 +1,17 @@
+# This is the case that pins the split. The slug is grammatical, so the
+# structural phase accepts it; whether the profile can actually be deployed is
+# a capability question, and an offline validator has not been given the means
+# to answer it. A schema that enumerated the offering would reject this
+# document today and accept it next quarter, having changed nothing.
+specVersion: v1
+kind: BLUEPRINT
+metadata:
+ slug: inference
+ version: 1
+spec:
+ components:
+ api:
+ component: ./components/api.yaml
+ size: gpu.premium.xlarge
+ connections: {}
+ parameters: {}
diff --git a/conformance/blueprint/v1/structural/018-reserved-compute-profile-slug/metadata.json b/conformance/blueprint/v1/structural/018-reserved-compute-profile-slug/metadata.json
new file mode 100644
index 0000000..f5093e0
--- /dev/null
+++ b/conformance/blueprint/v1/structural/018-reserved-compute-profile-slug/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-018-reserved-compute-profile-slug",
+ "phase": "structural",
+ "expected": "pass",
+ "clause": "specifications/blueprint/v1/spec.md#node-compute",
+ "summary": "A grammatical slug validates whether or not the profile is currently offered."
+}
diff --git a/conformance/blueprint/v1/structural/019-advanced-compute-constraints/case.yaml b/conformance/blueprint/v1/structural/019-advanced-compute-constraints/case.yaml
new file mode 100644
index 0000000..7294fd2
--- /dev/null
+++ b/conformance/blueprint/v1/structural/019-advanced-compute-constraints/case.yaml
@@ -0,0 +1,25 @@
+# Every one of the nine pins, each a lowercase token or a positive integer.
+# The pins narrow which host may run the node; none of them changes the compute
+# `size` names.
+specVersion: v1
+kind: BLUEPRINT
+metadata:
+ slug: inference
+ version: 1
+spec:
+ components:
+ api:
+ component: ./components/api.yaml
+ size: gpu.performance.large
+ advanced:
+ cpuArchitecture: [x86_64, arm64]
+ cpuDedication: dedicated
+ acceleratorMinVramGb: 24
+ acceleratorRuntime: [cuda]
+ acceleratorInterconnect: nvlink
+ acceleratorSkuClass: h100-sxm
+ storageClass: local-nvme
+ storageMinIops: 20000
+ networkClass: standard
+ connections: {}
+ parameters: {}
diff --git a/conformance/blueprint/v1/structural/019-advanced-compute-constraints/metadata.json b/conformance/blueprint/v1/structural/019-advanced-compute-constraints/metadata.json
new file mode 100644
index 0000000..f63fc64
--- /dev/null
+++ b/conformance/blueprint/v1/structural/019-advanced-compute-constraints/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-019-advanced-compute-constraints",
+ "phase": "structural",
+ "expected": "pass",
+ "clause": "specifications/blueprint/v1/spec.md#advanced-constraints",
+ "summary": "A node carrying every advanced compute pin validates."
+}
diff --git a/conformance/blueprint/v1/structural/020-constraint-term-not-a-token/case.yaml b/conformance/blueprint/v1/structural/020-constraint-term-not-a-token/case.yaml
new file mode 100644
index 0000000..97ea1a9
--- /dev/null
+++ b/conformance/blueprint/v1/structural/020-constraint-term-not-a-token/case.yaml
@@ -0,0 +1,17 @@
+# This contract does not say which storage classes exist, but it does say what
+# one looks like. A display string is not a term, and two documents spelling the
+# same class differently could not be compared.
+specVersion: v1
+kind: BLUEPRINT
+metadata:
+ slug: postgres
+ version: 1
+spec:
+ components:
+ db:
+ component: ./components/postgres.yaml
+ size: storage.standard.large
+ advanced:
+ storageClass: Local NVMe
+ connections: {}
+ parameters: {}
diff --git a/conformance/blueprint/v1/structural/020-constraint-term-not-a-token/diagnostics.json b/conformance/blueprint/v1/structural/020-constraint-term-not-a-token/diagnostics.json
new file mode 100644
index 0000000..388c8e8
--- /dev/null
+++ b/conformance/blueprint/v1/structural/020-constraint-term-not-a-token/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_VALUE",
+ "path": "/spec/components/db/advanced/storageClass"
+ }
+]
diff --git a/conformance/blueprint/v1/structural/020-constraint-term-not-a-token/metadata.json b/conformance/blueprint/v1/structural/020-constraint-term-not-a-token/metadata.json
new file mode 100644
index 0000000..86a4615
--- /dev/null
+++ b/conformance/blueprint/v1/structural/020-constraint-term-not-a-token/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-020-constraint-term-not-a-token",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/blueprint/v1/spec.md#advanced-constraints",
+ "summary": "An advanced pin whose value is not a lowercase token is rejected."
+}
diff --git a/conformance/blueprint/v1/structural/021-unknown-cpu-dedication/case.yaml b/conformance/blueprint/v1/structural/021-unknown-cpu-dedication/case.yaml
new file mode 100644
index 0000000..50b0ce8
--- /dev/null
+++ b/conformance/blueprint/v1/structural/021-unknown-cpu-dedication/case.yaml
@@ -0,0 +1,16 @@
+# The one pin whose vocabulary this contract closes. A vCPU is shared or it is
+# not; a third term would be a different concept wearing this one's name.
+specVersion: v1
+kind: BLUEPRINT
+metadata:
+ slug: postgres
+ version: 1
+spec:
+ components:
+ db:
+ component: ./components/postgres.yaml
+ size: general.standard.small
+ advanced:
+ cpuDedication: exclusive
+ connections: {}
+ parameters: {}
diff --git a/conformance/blueprint/v1/structural/021-unknown-cpu-dedication/diagnostics.json b/conformance/blueprint/v1/structural/021-unknown-cpu-dedication/diagnostics.json
new file mode 100644
index 0000000..26edbbc
--- /dev/null
+++ b/conformance/blueprint/v1/structural/021-unknown-cpu-dedication/diagnostics.json
@@ -0,0 +1,6 @@
+[
+ {
+ "code": "ERR_INVALID_VALUE",
+ "path": "/spec/components/db/advanced/cpuDedication"
+ }
+]
diff --git a/conformance/blueprint/v1/structural/021-unknown-cpu-dedication/metadata.json b/conformance/blueprint/v1/structural/021-unknown-cpu-dedication/metadata.json
new file mode 100644
index 0000000..fef95ec
--- /dev/null
+++ b/conformance/blueprint/v1/structural/021-unknown-cpu-dedication/metadata.json
@@ -0,0 +1,7 @@
+{
+ "id": "structural-021-unknown-cpu-dedication",
+ "phase": "structural",
+ "expected": "fail",
+ "clause": "specifications/blueprint/v1/spec.md#advanced-constraints",
+ "summary": "cpuDedication is closed to shared and dedicated."
+}
diff --git a/docs/adr/0003-controlled-vocabulary-placement.md b/docs/adr/0003-controlled-vocabulary-placement.md
new file mode 100644
index 0000000..635a8ad
--- /dev/null
+++ b/docs/adr/0003-controlled-vocabulary-placement.md
@@ -0,0 +1,143 @@
+# ADR 0003: Where a controlled vocabulary is published
+
+- **Status:** Accepted
+- **Date:** 2026-08-17
+- **Extends:** [ADR 0001](0001-canonical-repository-architecture.md) §6
+
+## Context
+
+Several fields across the three families take their value from a controlled
+vocabulary rather than from a grammar alone. The specifications have handled
+them inconsistently, because nothing said how to decide.
+
+| Field | Today |
+|---|---|
+| listing `category`, `lifecycleStage` | Closed `enum` in the schema |
+| blueprint `size` | A length-bounded string, and a `TODO` where the rule should be |
+| blueprint `advanced` pins | Free-form strings, no prose at all |
+
+`size` is what forced the question. It names a **Compute Profile** — a
+provider-neutral machine tier — and the platform publishes both a slug grammar
+and a catalog of the profiles it currently offers. Those two things have
+different lifetimes. The grammar is fixed by contract. The catalog changes when
+an operator enables a tier, which is neither a release of this repository nor
+anything this repository can observe.
+
+Committing the catalog here would make it a second source of truth, and the one
+that is wrong: a released artifact is immutable
+([GOVERNANCE.md](../../GOVERNANCE.md) §Release process), so a list frozen at
+release time drifts the moment the offering changes, and a reader who trusted it
+would be told a tier exists that cannot be deployed, or told one does not exist
+that can.
+
+Leaving the vocabulary unnamed is not the alternative it appears to be. It is
+what the repository does today, and
+[component §10](../../specifications/component/v1/spec.md#known-debt) records
+the cost: a reader outside `musher-dev/platform` is shown
+`general.standard.small` in three examples and has nowhere to look it up.
+
+## Decision
+
+### 1. Three placements, decided by what fixes the vocabulary
+
+| The vocabulary is… | Where it lives | Example |
+|---|---|---|
+| Fixed by this contract | A closed `enum` in the schema | listing `category`, `lifecycleStage` |
+| Grammar fixed here, membership fixed elsewhere | A `pattern` in the schema; membership named as an external publication surface and checked in the `capability` phase | blueprint `size` |
+| Not fixed anywhere yet | Shape only, and the silence recorded in prose as a gap | blueprint `advanced` pins |
+
+The test is not how large the vocabulary is or how often it changes. It is **who
+decides membership**. A term this specification could add or remove by releasing
+belongs in the schema. A term that becomes real when something outside this
+repository is provisioned does not, however stable the list looks on any given
+day.
+
+**Rejected:** deciding by volatility. `lifecycleStage` will change rarely and
+`category` may change often, but both are decided here, and a rule keyed on
+expected churn would move a field between placements without anything about the
+contract having changed.
+
+### 2. A named surface, never a mirrored copy
+
+Where membership is fixed elsewhere, the specification MUST name where the
+vocabulary is published, precisely enough to fetch: a URL a reader can open
+without an account. It MUST NOT restate the members, not even informatively and
+not even dated.
+
+This repository therefore does not become a vocabulary publisher, and
+GOVERNANCE.md §Scope is not extended. The reasoning is ADR 0001 §6's, applied to
+data rather than to code: a copy shipped from here acquires the authority of the
+thing it copies, and the first time the two disagree, implementations will have
+followed the one that was easier to reach rather than the one that was right.
+
+**Rejected:** a dated, explicitly non-normative snapshot committed alongside the
+prose. It reads as a convenience and behaves as a trap — a reader who finds a
+list in the normative repository has already stopped looking, and the date they
+were counting on to warn them is the one thing they will not check.
+
+### 3. Membership is a `capability` failure
+
+An externally fixed vocabulary cannot be resolved without reaching the surface
+that publishes it, and
+[component §7](../../specifications/component/v1/spec.md#validation-layers)
+forbids the `parser`, `structural` and `semantic` phases from requiring network
+access. Membership is therefore a `capability` rule: server-side, and an offline
+implementation MUST NOT report it.
+
+This is the same line
+[blueprint §4.1](../../specifications/blueprint/v1/spec.md#component-reference)
+already draws for a published component reference, and it costs the same thing —
+the diagnostic gets no fixture, and appears in the runner's `UNCOVERED` list with
+a reason.
+
+**Rejected:** treating membership as `semantic` on the grounds that a client
+could cache the vocabulary. A cache turns a rule about what the platform offers
+into a rule about what a client last downloaded, and two implementations would
+disagree on a valid document for reasons neither could see.
+
+### 4. The grammar goes in the schema, and it may only grow
+
+A vocabulary whose membership lives elsewhere still has a shape, and the shape is
+this repository's to fix. Carrying it as a `pattern` is what makes a typo fail
+offline instead of on a round trip to the platform.
+
+Growing that grammar later — a new segment value, a new permitted token — admits
+documents that were previously rejected, which is a relaxation and therefore a
+minor release under
+[component §3](../../specifications/component/v1/spec.md#compatibility).
+Narrowing it is major. The asymmetry is what makes the placement safe:
+[component §5.1](../../specifications/component/v1/spec.md#source) keeps its
+floating-tag list *out* of the schema for the mirror-image reason, because that
+list is a blocklist and growing it is a narrowing.
+
+## Consequences
+
+**Positive**
+
+- `size` gets an answer, and blueprint `spec.md` loses the largest of the
+ seeding-debt `TODO`s that keep v1 pre-stable.
+- A reader outside the platform can resolve every value the examples show them.
+- Open questions of the same shape — `category` and `lifecycleStage` governance —
+ are decided by applying this test rather than re-argued per field.
+- A typo in an externally-fixed vocabulary still fails offline, because the
+ grammar stayed here.
+
+**Negative**
+
+- The specification now carries URLs it does not control. A surface that moves
+ breaks a normative citation, and nothing in CI can tell.
+- Placement two means a document can pass every phase this repository can run and
+ still be undeployable. That is honest about where the knowledge lives, but it
+ is a weaker offline guarantee than an `enum` would have given.
+- Placement three — shape only — is a stated gap rather than a rule. It is the
+ right record of an undecided contract, and it is still a field an author can
+ write that nothing checks.
+
+## Follow-ups
+
+1. Apply the test to listing `category` and `lifecycleStage`, which are
+ placement one today and should say so rather than being placement one by
+ default.
+2. Revisit blueprint's `advanced` pins when the platform publishes a vocabulary
+ for them; they move from placement three to placement two at that point,
+ and gain a `capability` diagnostic.
diff --git a/specifications/blueprint/v1/examples/web-and-database.yaml b/specifications/blueprint/v1/examples/web-and-database.yaml
index 6fd0e67..45e5cb1 100644
--- a/specifications/blueprint/v1/examples/web-and-database.yaml
+++ b/specifications/blueprint/v1/examples/web-and-database.yaml
@@ -6,6 +6,10 @@
#
# The two nodes show that a repo-local reference does not mandate a directory
# layout — `db` names a document under `components/`, `web` a flat sibling.
+#
+# They also size independently. Compute is chosen per node rather than baked
+# into the component, so the database gets more of it than the web server
+# without either document changing.
specVersion: v1
kind: BLUEPRINT
metadata:
@@ -15,7 +19,12 @@ spec:
components:
db:
component: ./components/postgres.yaml
- size: general.standard.small
+ size: general.standard.medium
+ # Optional pins narrowing which host may run this node. They never change
+ # the compute `size` names — this asks for the profile's vCPU on a host
+ # that dedicates it, not for more of it.
+ advanced:
+ cpuDedication: dedicated
connections: {}
web:
component: ./component-web-server.yaml
diff --git a/specifications/blueprint/v1/schemas/dist/blueprint.schema.json b/specifications/blueprint/v1/schemas/dist/blueprint.schema.json
index 5c6fdb8..a5b759a 100644
--- a/specifications/blueprint/v1/schemas/dist/blueprint.schema.json
+++ b/specifications/blueprint/v1/schemas/dist/blueprint.schema.json
@@ -34,13 +34,16 @@
"type": "object",
"$defs": {
"BlueprintComputeConstraints": {
- "description": "Advanced, optional compute pins honored when the node is placed on a host.",
+ "description": "Advanced, optional compute pins that narrow the hosts a node may be placed on. A pin never changes the compute the node's size names. See spec.md §4.4.",
+ "$comment": "Every term pin is a lowercase token rather than a member of a closed set: the grammar is fixed by this contract, the vocabularies are not published anywhere yet, and spec.md §4.4 records that as a gap. cpuDedication is the exception — two values are the whole concept.",
"additionalProperties": false,
"properties": {
"acceleratorInterconnect": {
- "description": "Required interconnect such as 'nvlink' or 'pcie', or null for any.",
+ "description": "Required accelerator interconnect, such as nvlink or pcie. Null pins nothing. The permitted terms are not a vocabulary this contract defines — see spec.md §4.4.",
"anyOf": [
{
+ "maxLength": 64,
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
"type": "string"
},
{
@@ -50,7 +53,7 @@
"default": null
},
"acceleratorMinVramGb": {
- "description": "Minimum accelerator VRAM in GiB, or null for no floor.",
+ "description": "Minimum accelerator VRAM in GiB. Null pins no floor.",
"anyOf": [
{
"minimum": 1,
@@ -63,16 +66,21 @@
"default": null
},
"acceleratorRuntime": {
- "description": "Required accelerator runtimes, e.g. ['cuda']. Empty means any.",
+ "description": "Required accelerator runtimes, such as cuda. An empty array pins nothing, and a term may not repeat. The permitted terms are not a vocabulary this contract defines — see spec.md §4.4.",
"items": {
+ "maxLength": 64,
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
"type": "string"
},
- "type": "array"
+ "type": "array",
+ "uniqueItems": true
},
"acceleratorSkuClass": {
- "description": "Exact accelerator SKU class to pin, or null.",
+ "description": "Exact accelerator SKU class to pin. Null pins nothing. The permitted terms are not a vocabulary this contract defines — see spec.md §4.4.",
"anyOf": [
{
+ "maxLength": 64,
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
"type": "string"
},
{
@@ -82,16 +90,23 @@
"default": null
},
"cpuArchitecture": {
- "description": "Allowed CPU architectures, e.g. ['x86_64', 'arm64']. Empty means any.",
+ "description": "Permitted CPU architectures, such as x86_64 or arm64. An empty array pins nothing, and a term may not repeat. The permitted terms are not a vocabulary this contract defines — see spec.md §4.4.",
"items": {
+ "maxLength": 64,
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
"type": "string"
},
- "type": "array"
+ "type": "array",
+ "uniqueItems": true
},
"cpuDedication": {
- "description": "CPU dedication: 'shared' or 'dedicated', or null for the platform default.",
+ "description": "Whether the node's vCPU must be dedicated rather than shared. Null pins nothing. This is the one pin whose vocabulary this contract closes — see spec.md §4.4.",
"anyOf": [
{
+ "enum": [
+ "shared",
+ "dedicated"
+ ],
"type": "string"
},
{
@@ -101,9 +116,11 @@
"default": null
},
"networkClass": {
- "description": "Required network class such as 'standard', or null for the default.",
+ "description": "Required network class. Null pins nothing. The permitted terms are not a vocabulary this contract defines — see spec.md §4.4.",
"anyOf": [
{
+ "maxLength": 64,
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
"type": "string"
},
{
@@ -113,9 +130,11 @@
"default": null
},
"storageClass": {
- "description": "Required storage class such as 'local-nvme', or null for the default.",
+ "description": "Required storage class, such as local-nvme. Null pins nothing. The permitted terms are not a vocabulary this contract defines — see spec.md §4.4.",
"anyOf": [
{
+ "maxLength": 64,
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
"type": "string"
},
{
@@ -125,7 +144,7 @@
"default": null
},
"storageMinIops": {
- "description": "Minimum provisioned IOPS, or null for no floor.",
+ "description": "Minimum provisioned IOPS. Null pins no floor.",
"anyOf": [
{
"minimum": 1,
@@ -201,7 +220,7 @@
},
"properties": {
"advanced": {
- "description": "Optional advanced compute pins that narrow the eligible-host set at schedule time.",
+ "description": "Optional advanced compute pins that narrow the eligible-host set at schedule time. Absent, null, and a block with no pin set all mean the same thing. See spec.md §4.4.",
"anyOf": [
{
"$ref": "#/$defs/BlueprintComputeConstraints"
@@ -239,9 +258,10 @@
"type": "object"
},
"size": {
- "description": "Compute Profile slug bound to this node, such as general.standard.small. Required per node — a component document carries no compute of its own.",
- "maxLength": 128,
- "minLength": 1,
+ "description": "Compute Profile slug bound to this node, in family.tier.size form — for example general.standard.small. Required per node, because a component document carries no compute of its own. The grammar is fixed here; the profiles actually on offer are published at https://api.musher.dev/v1/reference/compute-profiles.",
+ "$comment": "Lookahead-free, so it compiles under RE2 as well as ECMA-262. Only the grammar is here. Which profiles are actually offered changes when the platform gains hardware to back a tier, not when this schema is released, so membership is a capability check rather than an enum. See spec.md §4.3.",
+ "maxLength": 64,
+ "pattern": "^(general|compute|memory|storage|gpu|accelerator)\\.(economy|standard|performance|premium)\\.(nano|small|medium|large|xlarge)$",
"type": "string"
}
},
diff --git a/specifications/blueprint/v1/schemas/src/blueprint.schema.json b/specifications/blueprint/v1/schemas/src/blueprint.schema.json
index ef689f1..dee5af2 100644
--- a/specifications/blueprint/v1/schemas/src/blueprint.schema.json
+++ b/specifications/blueprint/v1/schemas/src/blueprint.schema.json
@@ -34,12 +34,15 @@
"type": "object",
"$defs": {
"BlueprintComputeConstraints": {
+ "$comment": "Every term pin is a lowercase token rather than a member of a closed set: the grammar is fixed by this contract, the vocabularies are not published anywhere yet, and spec.md §4.4 records that as a gap. cpuDedication is the exception — two values are the whole concept.",
"additionalProperties": false,
- "description": "Advanced, optional compute pins honored when the node is placed on a host.",
+ "description": "Advanced, optional compute pins that narrow the hosts a node may be placed on. A pin never changes the compute the node's size names. See spec.md §4.4.",
"properties": {
"acceleratorInterconnect": {
"anyOf": [
{
+ "maxLength": 64,
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
"type": "string"
},
{
@@ -47,7 +50,7 @@
}
],
"default": null,
- "description": "Required interconnect such as 'nvlink' or 'pcie', or null for any."
+ "description": "Required accelerator interconnect, such as nvlink or pcie. Null pins nothing. The permitted terms are not a vocabulary this contract defines — see spec.md §4.4."
},
"acceleratorMinVramGb": {
"anyOf": [
@@ -60,18 +63,23 @@
}
],
"default": null,
- "description": "Minimum accelerator VRAM in GiB, or null for no floor."
+ "description": "Minimum accelerator VRAM in GiB. Null pins no floor."
},
"acceleratorRuntime": {
- "description": "Required accelerator runtimes, e.g. ['cuda']. Empty means any.",
+ "description": "Required accelerator runtimes, such as cuda. An empty array pins nothing, and a term may not repeat. The permitted terms are not a vocabulary this contract defines — see spec.md §4.4.",
"items": {
+ "maxLength": 64,
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
"type": "string"
},
- "type": "array"
+ "type": "array",
+ "uniqueItems": true
},
"acceleratorSkuClass": {
"anyOf": [
{
+ "maxLength": 64,
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
"type": "string"
},
{
@@ -79,18 +87,25 @@
}
],
"default": null,
- "description": "Exact accelerator SKU class to pin, or null."
+ "description": "Exact accelerator SKU class to pin. Null pins nothing. The permitted terms are not a vocabulary this contract defines — see spec.md §4.4."
},
"cpuArchitecture": {
- "description": "Allowed CPU architectures, e.g. ['x86_64', 'arm64']. Empty means any.",
+ "description": "Permitted CPU architectures, such as x86_64 or arm64. An empty array pins nothing, and a term may not repeat. The permitted terms are not a vocabulary this contract defines — see spec.md §4.4.",
"items": {
+ "maxLength": 64,
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
"type": "string"
},
- "type": "array"
+ "type": "array",
+ "uniqueItems": true
},
"cpuDedication": {
"anyOf": [
{
+ "enum": [
+ "shared",
+ "dedicated"
+ ],
"type": "string"
},
{
@@ -98,11 +113,13 @@
}
],
"default": null,
- "description": "CPU dedication: 'shared' or 'dedicated', or null for the platform default."
+ "description": "Whether the node's vCPU must be dedicated rather than shared. Null pins nothing. This is the one pin whose vocabulary this contract closes — see spec.md §4.4."
},
"networkClass": {
"anyOf": [
{
+ "maxLength": 64,
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
"type": "string"
},
{
@@ -110,11 +127,13 @@
}
],
"default": null,
- "description": "Required network class such as 'standard', or null for the default."
+ "description": "Required network class. Null pins nothing. The permitted terms are not a vocabulary this contract defines — see spec.md §4.4."
},
"storageClass": {
"anyOf": [
{
+ "maxLength": 64,
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
"type": "string"
},
{
@@ -122,7 +141,7 @@
}
],
"default": null,
- "description": "Required storage class such as 'local-nvme', or null for the default."
+ "description": "Required storage class, such as local-nvme. Null pins nothing. The permitted terms are not a vocabulary this contract defines — see spec.md §4.4."
},
"storageMinIops": {
"anyOf": [
@@ -135,7 +154,7 @@
}
],
"default": null,
- "description": "Minimum provisioned IOPS, or null for no floor."
+ "description": "Minimum provisioned IOPS. Null pins no floor."
}
},
"type": "object"
@@ -215,7 +234,7 @@
}
],
"default": null,
- "description": "Optional advanced compute pins that narrow the eligible-host set at schedule time."
+ "description": "Optional advanced compute pins that narrow the eligible-host set at schedule time. Absent, null, and a block with no pin set all mean the same thing. See spec.md §4.4."
},
"component": {
"anyOf": [
@@ -244,9 +263,10 @@
"type": "object"
},
"size": {
- "description": "Compute Profile slug bound to this node, such as general.standard.small. Required per node — a component document carries no compute of its own.",
- "maxLength": 128,
- "minLength": 1,
+ "$comment": "Lookahead-free, so it compiles under RE2 as well as ECMA-262. Only the grammar is here. Which profiles are actually offered changes when the platform gains hardware to back a tier, not when this schema is released, so membership is a capability check rather than an enum. See spec.md §4.3.",
+ "description": "Compute Profile slug bound to this node, in family.tier.size form — for example general.standard.small. Required per node, because a component document carries no compute of its own. The grammar is fixed here; the profiles actually on offer are published at https://api.musher.dev/v1/reference/compute-profiles.",
+ "maxLength": 64,
+ "pattern": "^(general|compute|memory|storage|gpu|accelerator)\\.(economy|standard|performance|premium)\\.(nano|small|medium|large|xlarge)$",
"type": "string"
}
},
diff --git a/specifications/blueprint/v1/spec.md b/specifications/blueprint/v1/spec.md
index 2d0de61..a59ec56 100644
--- a/specifications/blueprint/v1/spec.md
+++ b/specifications/blueprint/v1/spec.md
@@ -127,11 +127,9 @@ document. Two blueprints MAY each declare a node called `db` and neither is
the other's. Its one job is to be what [§4.2](#connections) `fromRole` names,
and what [§5.2](#merge) orders the graph by.
-> **TODO** — `size` MUST name a Compute Profile in `family.tier.size` form.
-> State whether an unknown profile is a `semantic` or a `capability` failure.
-> The profile vocabulary itself is not defined in this repository and a reader
-> outside the platform cannot resolve a slug like `general.standard.small`;
-> naming where it is published is part of closing this.
+A node carries three things: the component it deploys ([§4.1](#component-reference)),
+the wires feeding it ([§4.2](#connections)), and the compute it runs on
+([§4.3](#node-compute)).
### 4.1 Component reference
@@ -335,6 +333,123 @@ what makes the node names comparable across a conformance corpus instead of an
artifact of whichever node the traversal happened to start from. The
diagnostic anchors at `/spec/components//connections`.
+### 4.3 Node compute
+
+`size` is REQUIRED on every node and names the **Compute Profile** it runs on —
+a provider-neutral machine tier, not a raw resource request. A component
+document carries no compute of its own
+([component §5](../../component/v1/spec.md#workload)), so the node is the only
+place it can be said, and two blueprints MAY run the same component version at
+different sizes without forking it.
+
+```yaml
+db:
+ component: ./components/postgres.yaml
+ size: general.standard.medium
+ connections: {}
+```
+
+**A profile slug has three segments, `family.tier.size`.** Two layers enforce
+it, and the split between them is deliberate.
+
+The three segments are drawn from closed sets. A value outside them is rejected
+in the `structural` phase with `ERR_INVALID_VALUE`.
+
+| Segment | Members |
+|---|---|
+| family | `general`, `compute`, `memory`, `storage`, `gpu`, `accelerator` |
+| tier | `economy`, `standard`, `performance`, `premium` |
+| size | `nano`, `small`, `medium`, `large`, `xlarge` |
+
+That is a grammar, so the schema carries it. The last segment shares the field's
+name and is not the field: `size` holds the whole slug, and `small` is one third
+of one.
+
+A tier names a capability band rather than a workload — for the `gpu` and
+`accelerator` families, `economy` through `premium` run entry inference to
+frontier training. The exact accelerator is not encoded in the slug; it is
+pinned, if at all, by [§4.4](#advanced-constraints).
+
+**A grammatical slug is not necessarily an offered one.** Which profiles are
+actually available is not a property of this document, and a node naming one
+that is not is rejected with `ERR_UNKNOWN_COMPUTE_PROFILE` in the `capability`
+phase. Deciding it needs the catalog, which needs the network, which
+[§6](#validation-layers) forbids the earlier phases from reaching — so an
+offline implementation MUST NOT report it, on the same grounds
+[§4.1](#component-reference) gives for a published component reference. It has
+not been given the means to check.
+
+**Where the vocabulary is published.** The profiles on offer are served,
+unauthenticated, at
+`https://api.musher.dev/v1/reference/compute-profiles`, and rendered for a
+reader at . The endpoint
+lists what can be deployed now; a slug the grammar admits and the endpoint does
+not name is reserved rather than available.
+
+**Why the vocabulary is not an `enum`.** The grammar is settled and the
+membership is not: a profile becomes available when the platform has hardware
+to back it, which is not an event this specification can observe and not one a
+release of it coincides with. A schema that enumerated the offering would be
+wrong in both directions between releases — naming tiers that cannot yet be
+deployed, and rejecting ones that can. Growing the *grammar* is safe by
+contrast, because an allowlist admitting more is a relaxation and ships in a
+minor release ([component §3](../../component/v1/spec.md#compatibility));
+narrowing one would be major. That is the mirror of
+[component §5.1](../../component/v1/spec.md#source), where the floating-tag
+blocklist stays out of the schema precisely because growing a *blocklist* is a
+narrowing.
+
+**A bare slug, and never a versioned one.** A profile is versioned where it is
+published, and a slug carrying that version is not a value this field takes —
+the grammar has three segments and rejects a fourth. A node names the profile
+and gets the current version of it, which means the vCPU and memory behind a
+slug MAY differ between two deployments of an unchanged document. That is the
+point of naming a tier rather than a machine: a name like `4vcpu-16gb` fixes
+numbers it cannot keep, promising identical silicon across hardware generations
+that do not deliver it.
+
+### 4.4 Advanced constraints
+
+`advanced` is OPTIONAL and narrows the hosts a node may be placed on. Absent,
+`null`, and a block whose every pin is unset all mean the same thing: no
+constraints. A pin whose value is an array means "any" when the array is empty,
+and MUST NOT repeat a term.
+
+| Pin | Narrows |
+|---|---|
+| `cpuArchitecture` | Permitted CPU architectures |
+| `cpuDedication` | `shared` or `dedicated` |
+| `acceleratorMinVramGb` | Accelerator VRAM floor |
+| `acceleratorRuntime` | Required accelerator runtimes |
+| `acceleratorInterconnect` | Required accelerator interconnect |
+| `acceleratorSkuClass` | Exact accelerator SKU class |
+| `storageClass` | Required storage class |
+| `storageMinIops` | Provisioned IOPS floor |
+| `networkClass` | Required network class |
+
+**A pin narrows placement and nothing else.** `size` remains what the node runs
+as; a pin only reduces the set of hosts that may run it. A node pinning
+`cpuDedication: dedicated` gets the vCPU and memory its profile names, on a host
+that dedicates them — never more compute than it asked for, and never a
+substitute profile.
+
+**A pin term is a lowercase token**, `^[a-z0-9][a-z0-9_-]*$`, which admits
+`x86_64`, `arm64`, `local-nvme`, `nvlink` and `cuda`. Numeric pins are positive
+integers. Both are grammar, both are `structural`, and a violation is
+`ERR_INVALID_VALUE`. `cpuDedication` is the one pin whose vocabulary this
+contract closes, because `shared` and `dedicated` are the whole of the concept
+and a third value would be a different one.
+
+**What v1 does not constrain.** No vocabulary is published for the other eight
+pins — not by this repository and not, today, by the platform. `local-nvme` is
+an example rather than a member, and nothing here says what the permitted
+storage classes are. Nor does anything say what happens when no host satisfies a
+pin: it is not decidable offline, and it carries no diagnostic in v1. Those are
+gaps rather than considered permissions, recorded here so a reader can tell the
+two apart. Naming a code for them would claim an implementation reports
+something none does; closing them properly means publishing the terms first, at
+which point they take the same shape `size` has above.
+
## 5. Parameters
`spec.parameters` is the install form: what a deploying user is asked for once,
@@ -438,6 +553,7 @@ family adds:
| `ERR_UNKNOWN_INPUT` | `semantic` | A connection's map key names no input of the consuming node's component. |
| `ERR_UNWIRED_REQUIRED_INPUT` | `semantic` | A node's required `CONNECTION` input is satisfied by no connection. |
| `ERR_COMPONENT_NOT_PUBLISHED` | `capability` | A published `component` reference resolves to a component that is not in a published state. |
+| `ERR_UNKNOWN_COMPUTE_PROFILE` | `capability` | A node's `size` names a Compute Profile the catalog does not offer. |
| `ERR_INCOMPATIBLE_TYPE` | `semantic` | A connection joins an output and an input whose `schema.type`s differ. |
| `ERR_INCOMPATIBLE_SEMANTIC_TYPE` | `semantic` | A connection joins an output and an input whose `schema.semanticType`s disagree. |
| `ERR_DEPENDENCY_CYCLE` | `semantic` | The connection graph contains a cycle. |
@@ -454,5 +570,11 @@ fixture in [`conformance/blueprint/v1/`](../../../conformance/blueprint/v1/).
## 9. Known debt
Seeded from the platform's generated schema. The naming that arrived with it
-has been cleaned; the sections above marked TODO have not. See
+has been cleaned, and [§4.3](#node-compute) now names where the Compute Profile
+vocabulary is published rather than assuming a reader can already resolve it.
+
+One TODO remains, in [§5.2](#merge): how an authored parameter binds to the
+component inputs it satisfies. [§4.4](#advanced-constraints) is not marked TODO
+but carries the nearest thing to one — a pin vocabulary nothing publishes yet,
+recorded there as a gap. See also
[component §10](../../component/v1/spec.md#known-debt).
diff --git a/specifications/component/v1/spec.md b/specifications/component/v1/spec.md
index 2e31655..2d6cd37 100644
--- a/specifications/component/v1/spec.md
+++ b/specifications/component/v1/spec.md
@@ -684,8 +684,14 @@ implements the prose rather than standing in for it.
One debt remains, and it MUST be resolved before v1 is declared stable. Some
schema `description` fields still speak the platform's vocabulary rather than
this contract's — "resolved server-side at snapshot compute" names a pipeline
-stage a reader outside `musher-dev/platform` cannot look up, and `size` values
-like `general.standard.small` name a Compute Profile vocabulary this repository
-does not publish. Descriptions are explanatory rather than normative, so nothing
-in this document turns on them; a reader who cannot resolve the words is still
-being told to go somewhere they cannot reach.
+stage a reader outside `musher-dev/platform` cannot look up. Descriptions are
+explanatory rather than normative, so nothing in this document turns on them; a
+reader who cannot resolve the words is still being told to go somewhere they
+cannot reach.
+
+The Compute Profile half of that debt is closed.
+[Blueprint §4.3](../../blueprint/v1/spec.md#node-compute) now carries the slug
+grammar and names where the offered profiles are published, so a slug like
+`general.standard.small` resolves for a reader outside the platform.
+[ADR 0003](../../../docs/adr/0003-controlled-vocabulary-placement.md) records
+the rule that decided it.
diff --git a/tools/src/conformance.ts b/tools/src/conformance.ts
index e5d3094..7fbe654 100644
--- a/tools/src/conformance.ts
+++ b/tools/src/conformance.ts
@@ -441,6 +441,10 @@ const UNCOVERED: ReadonlyMap = new Map([
'ERR_COMPONENT_NOT_PUBLISHED',
'capability — only the registry holds publication state, so deciding it needs the catalog, and a fixture is a tree of files none of which has one',
],
+ [
+ 'ERR_UNKNOWN_COMPUTE_PROFILE',
+ 'capability — the grammar is fixtured (structural/015 through 018), but which profiles are offered changes when the platform gains hardware to back a tier, so deciding membership needs the catalog',
+ ],
])
/**