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', + ], ]) /**