From 32d4b20d80a6fe416bc58dab8d3149e00338b9fe Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Mon, 17 Aug 2026 00:44:53 +0000 Subject: [PATCH 1/7] build: install actionlint through mise, and verify tools can actually run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in this repository installed actionlint. Not a devcontainer Feature, not mise.toml, not any script under .devcontainer/. The binary that answered on PATH had been hand-placed into the container, was built for x86-64, and died with `exec format error` on an aarch64 host — so `task check:workflow` could not run, `task check` could not complete, and the pre-push hook had to be bypassed. Three changes, and the third is the one that matters. **mise.toml pins actionlint at 1.7.11**, matching the version ci.yml installs. mise resolves it per-architecture through the aqua backend and verifies the release attestation, which is exactly what a hand-fetched binary does not do. The file's header comment scoped it to "npm-distributed" CLIs; devcontainer.json designates it for any tool with no Feature, so the comment is widened to say so and to record that ci.yml carries the same version independently. **Taskfile.yml drops LEFTHOOK_VERSION and ACTIONLINT_VERSION.** Neither was read by any task, script or workflow. ACTIONLINT_VERSION was a declared intent with nothing enforcing it — the version now lives in mise.toml, where something acts on it. **verify_tools now checks that a tool runs, not just that it resolves.** The old version logged `$("$cmd" --version || echo installed)`, which swallowed the failure and reported a broken binary as present — it would have called the x86-64 actionlint installed. Being on the PATH is not the property worth checking; being runnable is. Every tool it verifies exits 0 on `--version`, so a non-zero status means the binary cannot execute, and that now fails the build where it belongs rather than at someone's first push. actionlint and shellcheck join the verified list, since `task check` runs both. Refs #20 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Justin Merrell --- .devcontainer/mise.toml | 12 +++++++++--- .devcontainer/scripts/lib/base-setup.sh | 6 +++++- .devcontainer/scripts/lib/common.sh | 24 +++++++++++++++++++----- Taskfile.yml | 2 -- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/.devcontainer/mise.toml b/.devcontainer/mise.toml index a1f60e5..806addc 100644 --- a/.devcontainer/mise.toml +++ b/.devcontainer/mise.toml @@ -1,13 +1,19 @@ # Developer tools with no devcontainer Feature. # # Everything that has a Feature is pinned in devcontainer.json and baked into -# the image. The CLIs below are npm-distributed with no Feature, so mise -# installs and version-pins them. Claude Code is the exception — it self-updates -# through its own installer (scripts/lib/base-setup.sh). +# the image. Everything else is pinned here, whatever it is distributed as — +# mise resolves each tool for the container's own CPU architecture, which is +# what a hand-placed release binary does not do. Claude Code is the exception: +# it self-updates through its own installer (scripts/lib/base-setup.sh). +# +# actionlint must stay in step with .github/workflows/ci.yml, which installs the +# same version through the upstream download script. Two environments, one +# version — CI is not a mise host and does not read this file. # # Change a version here, then rebuild the container or run `mise install`. # Docs: https://mise.jdx.dev [tools] +"actionlint" = "1.7.11" "npm:@openai/codex" = "0.143.0" "npm:lefthook" = "2.1.10" diff --git a/.devcontainer/scripts/lib/base-setup.sh b/.devcontainer/scripts/lib/base-setup.sh index 7bfe5f2..4573143 100644 --- a/.devcontainer/scripts/lib/base-setup.sh +++ b/.devcontainer/scripts/lib/base-setup.sh @@ -140,12 +140,16 @@ base_install_claude() { # Verifies the CLIs this script installs (plus a couple of key Feature tools) # are on PATH. Runtimes are validated by the container build itself. # +# actionlint and shellcheck are here because `task check` runs both, and a tool +# that is absent or built for the wrong CPU architecture should fail loudly at +# container build rather than at the first push. +# # Outputs: # Writes tool status to stderr via log() # Returns: # 0 if all tools found, 1 if any are missing base_verify_tools() { - verify_tools gh task codex lefthook claude + verify_tools gh task codex lefthook claude actionlint shellcheck } # --- Orchestrator --- diff --git a/.devcontainer/scripts/lib/common.sh b/.devcontainer/scripts/lib/common.sh index b11149b..e6bb13c 100644 --- a/.devcontainer/scripts/lib/common.sh +++ b/.devcontainer/scripts/lib/common.sh @@ -139,23 +139,37 @@ install_npm_cli() { # --- Verification --- -# Verifies that a list of commands are available on the PATH. +# Verifies that a list of commands are available on the PATH and can run. +# +# Being on the PATH is not enough. A release binary fetched for the wrong CPU +# architecture resolves fine and then dies with `exec format error` (exit 126) +# the first time anything invokes it, which is a failure worth catching at +# container build rather than mid-task. Every tool checked here exits 0 on +# `--version`, so a non-zero status means the binary is not runnable. # # Arguments: # $@ — command names to check # Outputs: # Writes status of each tool to stderr via log() # Returns: -# 0 if all tools found, 1 if any are missing +# 0 if all tools found and runnable, 1 otherwise verify_tools() { log "Verifying installed tools..." local all_ok=true + local version for cmd in "$@"; do - if has_cmd "$cmd"; then - log " ✓ ${cmd}: $("$cmd" --version 2>/dev/null || echo 'installed')" - else + if ! has_cmd "$cmd"; then log " ✗ ${cmd}: MISSING" all_ok=false + elif ! version="$("$cmd" --version 2>/dev/null)"; then + log " ✗ ${cmd}: on PATH at $(command -v "$cmd") but does not run" + all_ok=false + else + # First line only — shellcheck and actionlint both print a banner, and the + # status line is meant to be one line per tool. Trimmed after the status + # check rather than by piping through `head`, which would report the + # pipeline's exit code instead of the tool's. + log " ✓ ${cmd}: ${version%%$'\n'*}" fi done [[ "${all_ok}" == true ]] diff --git a/Taskfile.yml b/Taskfile.yml index add3bd7..e7c780b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -7,8 +7,6 @@ version: '3' vars: TOOLS_DIR: tools SITE_DIR: site - LEFTHOOK_VERSION: v2.1.10 - ACTIONLINT_VERSION: v1.7.11 env: # Keep bun's cache inside the workspace-visible cache dir the dev container From c5c7c767d671a84b60f4852c9fc711d47ece6071 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:13:35 +0000 Subject: [PATCH 2/7] ci: Bump go-task/setup-task from 2.1.0 to 2.2.0 Bumps [go-task/setup-task](https://github.com/go-task/setup-task) from 2.1.0 to 2.2.0. - [Release notes](https://github.com/go-task/setup-task/releases) - [Changelog](https://github.com/go-task/setup-task/blob/main/CHANGELOG.md) - [Commits](https://github.com/go-task/setup-task/compare/01a4adf9db2d14c1de7a560f09170b6e0df736aa...a00fbb05ce67b35648be3c78cbc9fd85354c757e) --- updated-dependencies: - dependency-name: go-task/setup-task dependency-version: 2.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] (cherry picked from commit cce11df661f70bb904e4e78bfc87de5c989dafca) --- .github/workflows/ci.yml | 6 +++--- .github/workflows/devcontainer.yml | 2 +- .github/workflows/pages.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e42a499..5ac05f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: with: bun-version: 1.3.14 - - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} @@ -62,7 +62,7 @@ jobs: with: bun-version: 1.3.14 - - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} @@ -100,7 +100,7 @@ jobs: with: bun-version: 1.3.14 - - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml index d9370df..79c1b76 100644 --- a/.github/workflows/devcontainer.yml +++ b/.github/workflows/devcontainer.yml @@ -41,7 +41,7 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 21ec0e8..25fffd2 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -33,7 +33,7 @@ jobs: with: bun-version: 1.3.14 - - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + - uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 with: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} From 35ea2a2019c14518ba519d65d12347c1851c3abc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:13:38 +0000 Subject: [PATCH 3/7] chore: Bump @biomejs/biome in /tools in the tooling group Bumps the tooling group in /tools with 1 update: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome). Updates `@biomejs/biome` from 2.5.7 to 2.5.8 - [Release notes](https://github.com/biomejs/biome/releases) - [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md) - [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.5.8/packages/@biomejs/biome) --- updated-dependencies: - dependency-name: "@biomejs/biome" dependency-version: 2.5.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: tooling ... Signed-off-by: dependabot[bot] (cherry picked from commit 7a580d5754a171881968645240a825a8c5502b74) --- tools/bun.lock | 20 ++++++++++---------- tools/package.json | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tools/bun.lock b/tools/bun.lock index 5e09c41..85bebd5 100644 --- a/tools/bun.lock +++ b/tools/bun.lock @@ -9,30 +9,30 @@ "yaml": "2.9.0", }, "devDependencies": { - "@biomejs/biome": "2.5.7", + "@biomejs/biome": "2.5.8", "@types/bun": "1.3.14", "typescript": "7.0.2", }, }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.5.7", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.7", "@biomejs/cli-darwin-x64": "2.5.7", "@biomejs/cli-linux-arm64": "2.5.7", "@biomejs/cli-linux-arm64-musl": "2.5.7", "@biomejs/cli-linux-x64": "2.5.7", "@biomejs/cli-linux-x64-musl": "2.5.7", "@biomejs/cli-win32-arm64": "2.5.7", "@biomejs/cli-win32-x64": "2.5.7" }, "bin": { "biome": "bin/biome" } }, "sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw=="], + "@biomejs/biome": ["@biomejs/biome@2.5.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.8", "@biomejs/cli-darwin-x64": "2.5.8", "@biomejs/cli-linux-arm64": "2.5.8", "@biomejs/cli-linux-arm64-musl": "2.5.8", "@biomejs/cli-linux-x64": "2.5.8", "@biomejs/cli-linux-x64-musl": "2.5.8", "@biomejs/cli-win32-arm64": "2.5.8", "@biomejs/cli-win32-x64": "2.5.8" }, "bin": { "biome": "bin/biome" } }, "sha512-aeAeeJB9fSDc7Gq+2GqpQxA0qBj6gj1k2R6L1cYqGePKP/baIq1WX8y6B+D+nRsO5ViQL22K/8IwbqERW0q1nw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mk1QON9PHllvvLN5gU3f4rMxeh4syK5p9OvKyWH6/W8ueh04uaC8TUXXByhGufWf/y5mQc03ZLM45zU+cmqMjA=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-bsGwFMBNyHPyiLSsQcZJxdoRrg1V4JL+d7wEsvUBczlP9U9lwM+7mzQHxI4o1mhBsTmdOBbAb6fHU3Z3snN45w=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-XmFiA0WPYFC+uiUDC8WRFzAIH9bo7vwQLav38Uoq4ETC+T/+uBi0TsYGJECkugY3r8USl3jc+Ae2/irAF6F2lQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcJNbstduTHx83NGAdhp78/JOcP45BZHXL7yNsfI1uGzdUgegAz2s+mSoT7wK6PBNzLoqG0zDOXaz/RQYVtSiw=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-S5wcm9OBDvLHodD4PUaN488hCpco9QD/9ZxuYJiw4euWtr/oQvLR72z2ixItH8Wd5BCm6FZaeb+YNvOoM1xHtQ=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-kKmiyokeISRGq2FLwvr+TzsgBusfxaZ0FZNLcOYOpCK/78tRrEjeEBLvq3xLZMpqbANgJdRPI7vZX8ZL37u9/w=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-nILH0mzm3Hi3iEdd7o7GpB8kBR/mSQwfQG/tyBqyNrY2GFtcgwfV9nV8xLmbtUpMNY/Oi0Ml1XgfR4flOdq+AA=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.8", "", { "os": "win32", "cpu": "x64" }, "sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], diff --git a/tools/package.json b/tools/package.json index c42645d..63fc50f 100644 --- a/tools/package.json +++ b/tools/package.json @@ -24,7 +24,7 @@ "yaml": "2.9.0" }, "devDependencies": { - "@biomejs/biome": "2.5.7", + "@biomejs/biome": "2.5.8", "@types/bun": "1.3.14", "typescript": "7.0.2" } From 24e4199a1bd0a676b284919c6c403dfaa48794f9 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Mon, 17 Aug 2026 00:54:48 +0000 Subject: [PATCH 4/7] chore(tools): migrate the biome config to 2.5.8 The bump changed the CLI but not the $schema URL the config pins, so `biome ci` reported a version mismatch on every run. `biome migrate` closes it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Justin Merrell --- tools/biome.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/biome.json b/tools/biome.json index 22c2efd..26b9944 100644 --- a/tools/biome.json +++ b/tools/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.7/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json", "vcs": { "enabled": false }, From a599f130b4702aa24266cb9c716e6bd844b97a4c Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Mon, 17 Aug 2026 00:54:56 +0000 Subject: [PATCH 5/7] feat(blueprint): specify how an authored parameter binds to component inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §5 settled that a non-empty `spec.parameters` is "an authored override, used in place of derivation rather than merged with it", and §5.1 and §5.2 then specified derivation in full — which inputs are derived, in what order, and what happens when two nodes disagree. The override path had none of that, and §5.2's own closing line recommends it. The specification recommended a path it did not define. New §5.3 defines it. Binding is by key, and the key is the whole of the correspondence: a parameter carries no `suppliedBy`, no node name and no `target`, so there is nothing else to bind on. That is also what lets one parameter serve two components, which is the whole of why §5.2 sends an author here. Three semantic rules follow, each needing the referenced component documents. **A parameter MUST cover something** — ERR_UNBOUND_PARAMETER, anchored at /spec/parameters/. The install form asks a deploying user for a value and nothing reads it. §3 already makes this argument about an unreferenced component document: permitted, these accumulate, and nothing says which entries are live. **An input the deploying user must supply MUST be covered** — ERR_UNCOVERED_REQUIRED_INPUT, anchored at /spec/parameters. An input is in scope when it is USER-supplied, required, and carries no generator, no platformDefault and no schema default: nothing else can supply it, so if the form does not ask, the workload starts without it. This is the mirror of ERR_UNWIRED_REQUIRED_INPUT on the CONNECTION side. Coverage tests what a parameter *guarantees*, not which keys it names, and that is load-bearing rather than fussy. `isRequired` defaults to **true** on a component input and to **false** on a blueprint parameter, so an override that copies a required key and says nothing else has quietly made it optional. Tested by key alone the rule would be evaded by satisfying it. A parameter covers such an input only if it declares `isRequired: true`, carries a generator, or declares a schema default. **`type` MUST agree** — ERR_INCOMPATIBLE_PARAMETER_TYPE. Exactly the axis §4.2 chose for connections, and nothing else compared. The user is validated against the parameter's schema and the component then receives the result against its own; §5.3 records format, enum, pattern, default, isSensitive and ui as silences in the same words §4.2 uses. `semanticType` is not compared because a parameter has none — an install form is not where a value acquires a backing-service tag. **A generated parameter is secret material.** Component §6.1 obliges a generated input to declare `isSensitive: true`, and a derived parameter takes the input's schema unchanged, so the marking is guaranteed on the derivation path. It was not on the authored one — and isSensitive defaults to false, so losing it took no more than not mentioning it. Now structural, both halves: an absent isSensitive is ERR_MISSING_FIELD and one written false is ERR_INVALID_VALUE, the same dual phrasing §4 uses for spec.components. **An override suppresses the merge, and this fixes a live inconsistency.** §5 says derivation does not run; if it does not run there is nothing to merge and nothing to conflict. `semantic.ts` never read spec.parameters, so ERR_CONFLICTING_INPUT_SCHEMA fired regardless — meaning §5.2's own recommended remedy did not remedy. `checkGraphAgainstItem` now takes one path or the other. semantic-020 is the regression pin: two nodes declaring one key with differing patterns, reconciled by an override, which fails against the previous implementation and passes now. It also shows the limit of the remedy — an override reconciles a disagreement below `type`, not one about `type`. Seven fixtures, 107 -> 114 cases, 0 skipped. No new UNCOVERED entry: every code here is semantic or structural and therefore testable. Closes #20 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Justin Merrell --- conformance/blueprint/v1/cases.json | 35 +++++ .../diagnostics.json | 6 + .../metadata.json | 8 ++ .../tree/acme-wiki/blueprint.yaml | 25 ++++ .../tree/acme-wiki/components/api.yaml | 26 ++++ .../tree/acme-wiki/components/postgres.yaml | 26 ++++ .../tree/acme-wiki/listing.yaml | 11 ++ .../diagnostics.json | 6 + .../metadata.json | 8 ++ .../tree/acme-wiki/blueprint.yaml | 26 ++++ .../tree/acme-wiki/components/api.yaml | 26 ++++ .../tree/acme-wiki/components/postgres.yaml | 25 ++++ .../tree/acme-wiki/listing.yaml | 11 ++ .../diagnostics.json | 6 + .../metadata.json | 8 ++ .../tree/acme-wiki/blueprint.yaml | 27 ++++ .../tree/acme-wiki/components/api.yaml | 26 ++++ .../tree/acme-wiki/components/postgres.yaml | 25 ++++ .../tree/acme-wiki/listing.yaml | 11 ++ .../diagnostics.json | 6 + .../metadata.json | 8 ++ .../tree/acme-wiki/blueprint.yaml | 27 ++++ .../tree/acme-wiki/components/api.yaml | 26 ++++ .../tree/acme-wiki/components/postgres.yaml | 25 ++++ .../tree/acme-wiki/listing.yaml | 11 ++ .../metadata.json | 8 ++ .../tree/acme-wiki/blueprint.yaml | 38 ++++++ .../tree/acme-wiki/components/api.yaml | 26 ++++ .../tree/acme-wiki/components/postgres.yaml | 26 ++++ .../tree/acme-wiki/listing.yaml | 11 ++ .../case.yaml | 26 ++++ .../diagnostics.json | 6 + .../metadata.json | 7 + .../case.yaml | 25 ++++ .../diagnostics.json | 6 + .../metadata.json | 7 + .../v1/schemas/dist/blueprint.schema.json | 27 ++++ .../v1/schemas/src/blueprint.schema.json | 27 ++++ specifications/blueprint/v1/spec.md | 119 +++++++++++++++-- tools/src/semantic.ts | 126 +++++++++++++++++- 40 files changed, 918 insertions(+), 12 deletions(-) create mode 100644 conformance/blueprint/v1/semantic/016-parameter-covers-no-input/diagnostics.json create mode 100644 conformance/blueprint/v1/semantic/016-parameter-covers-no-input/metadata.json create mode 100644 conformance/blueprint/v1/semantic/016-parameter-covers-no-input/tree/acme-wiki/blueprint.yaml create mode 100644 conformance/blueprint/v1/semantic/016-parameter-covers-no-input/tree/acme-wiki/components/api.yaml create mode 100644 conformance/blueprint/v1/semantic/016-parameter-covers-no-input/tree/acme-wiki/components/postgres.yaml create mode 100644 conformance/blueprint/v1/semantic/016-parameter-covers-no-input/tree/acme-wiki/listing.yaml create mode 100644 conformance/blueprint/v1/semantic/017-required-input-uncovered/diagnostics.json create mode 100644 conformance/blueprint/v1/semantic/017-required-input-uncovered/metadata.json create mode 100644 conformance/blueprint/v1/semantic/017-required-input-uncovered/tree/acme-wiki/blueprint.yaml create mode 100644 conformance/blueprint/v1/semantic/017-required-input-uncovered/tree/acme-wiki/components/api.yaml create mode 100644 conformance/blueprint/v1/semantic/017-required-input-uncovered/tree/acme-wiki/components/postgres.yaml create mode 100644 conformance/blueprint/v1/semantic/017-required-input-uncovered/tree/acme-wiki/listing.yaml create mode 100644 conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/diagnostics.json create mode 100644 conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/metadata.json create mode 100644 conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/tree/acme-wiki/blueprint.yaml create mode 100644 conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/tree/acme-wiki/components/api.yaml create mode 100644 conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/tree/acme-wiki/components/postgres.yaml create mode 100644 conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/tree/acme-wiki/listing.yaml create mode 100644 conformance/blueprint/v1/semantic/019-parameter-type-disagrees/diagnostics.json create mode 100644 conformance/blueprint/v1/semantic/019-parameter-type-disagrees/metadata.json create mode 100644 conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/blueprint.yaml create mode 100644 conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/components/api.yaml create mode 100644 conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/components/postgres.yaml create mode 100644 conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/listing.yaml create mode 100644 conformance/blueprint/v1/semantic/020-authored-override-well-formed/metadata.json create mode 100644 conformance/blueprint/v1/semantic/020-authored-override-well-formed/tree/acme-wiki/blueprint.yaml create mode 100644 conformance/blueprint/v1/semantic/020-authored-override-well-formed/tree/acme-wiki/components/api.yaml create mode 100644 conformance/blueprint/v1/semantic/020-authored-override-well-formed/tree/acme-wiki/components/postgres.yaml create mode 100644 conformance/blueprint/v1/semantic/020-authored-override-well-formed/tree/acme-wiki/listing.yaml create mode 100644 conformance/blueprint/v1/structural/022-generated-parameter-not-sensitive/case.yaml create mode 100644 conformance/blueprint/v1/structural/022-generated-parameter-not-sensitive/diagnostics.json create mode 100644 conformance/blueprint/v1/structural/022-generated-parameter-not-sensitive/metadata.json create mode 100644 conformance/blueprint/v1/structural/023-generated-parameter-marked-insensitive/case.yaml create mode 100644 conformance/blueprint/v1/structural/023-generated-parameter-marked-insensitive/diagnostics.json create mode 100644 conformance/blueprint/v1/structural/023-generated-parameter-marked-insensitive/metadata.json diff --git a/conformance/blueprint/v1/cases.json b/conformance/blueprint/v1/cases.json index c772fc9..e8245f8 100644 --- a/conformance/blueprint/v1/cases.json +++ b/conformance/blueprint/v1/cases.json @@ -127,6 +127,16 @@ "phase": "structural", "path": "structural/021-unknown-cpu-dedication" }, + { + "id": "structural-022-generated-parameter-not-sensitive", + "phase": "structural", + "path": "structural/022-generated-parameter-not-sensitive" + }, + { + "id": "structural-023-generated-parameter-marked-insensitive", + "phase": "structural", + "path": "structural/023-generated-parameter-marked-insensitive" + }, { "id": "semantic-001-connection-names-unknown-role", "phase": "semantic", @@ -201,6 +211,31 @@ "id": "semantic-015-required-connection-input-unwired", "phase": "semantic", "path": "semantic/015-required-connection-input-unwired" + }, + { + "id": "semantic-016-parameter-covers-no-input", + "phase": "semantic", + "path": "semantic/016-parameter-covers-no-input" + }, + { + "id": "semantic-017-required-input-uncovered", + "phase": "semantic", + "path": "semantic/017-required-input-uncovered" + }, + { + "id": "semantic-018-optional-parameter-does-not-cover", + "phase": "semantic", + "path": "semantic/018-optional-parameter-does-not-cover" + }, + { + "id": "semantic-019-parameter-type-disagrees", + "phase": "semantic", + "path": "semantic/019-parameter-type-disagrees" + }, + { + "id": "semantic-020-authored-override-well-formed", + "phase": "semantic", + "path": "semantic/020-authored-override-well-formed" } ] } diff --git a/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/diagnostics.json b/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/diagnostics.json new file mode 100644 index 0000000..d95a557 --- /dev/null +++ b/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_UNBOUND_PARAMETER", + "path": "/spec/parameters/legacyMode" + } +] diff --git a/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/metadata.json b/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/metadata.json new file mode 100644 index 0000000..669185d --- /dev/null +++ b/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "semantic-016-parameter-covers-no-input", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#authored-parameters", + "summary": "An authored parameter whose key names no USER input is rejected.", + "document": "acme-wiki/blueprint.yaml" +} diff --git a/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/tree/acme-wiki/blueprint.yaml b/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/tree/acme-wiki/blueprint.yaml new file mode 100644 index 0000000..f83de03 --- /dev/null +++ b/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/tree/acme-wiki/blueprint.yaml @@ -0,0 +1,25 @@ +# `legacyMode` is asked of the deploying user and read by nothing: neither +# component declares an input of that name. Binding is by key and there is no +# other correspondence to fall back on, so the value is collected and dropped. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + components: + api: + component: ./components/api.yaml + size: general.standard.small + connections: {} + db: + component: ./components/postgres.yaml + size: general.standard.small + connections: {} + parameters: + legacyMode: + schema: + type: BOOLEAN + isRequired: true + ui: + label: Legacy mode diff --git a/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/tree/acme-wiki/components/api.yaml b/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/tree/acme-wiki/components/api.yaml new file mode 100644 index 0000000..246f568 --- /dev/null +++ b/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/tree/acme-wiki/components/api.yaml @@ -0,0 +1,26 @@ +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + endpoints: + primary: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE + contract: + inputs: + siteTitle: + schema: + type: STRING + default: Acme Wiki + isRequired: false + suppliedBy: USER + ui: + label: Site title + outputs: {} diff --git a/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/tree/acme-wiki/components/postgres.yaml b/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/tree/acme-wiki/components/postgres.yaml new file mode 100644 index 0000000..246f568 --- /dev/null +++ b/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/tree/acme-wiki/components/postgres.yaml @@ -0,0 +1,26 @@ +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + endpoints: + primary: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE + contract: + inputs: + siteTitle: + schema: + type: STRING + default: Acme Wiki + isRequired: false + suppliedBy: USER + ui: + label: Site title + outputs: {} diff --git a/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/tree/acme-wiki/listing.yaml b/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/tree/acme-wiki/listing.yaml new file mode 100644 index 0000000..da5eed4 --- /dev/null +++ b/conformance/blueprint/v1/semantic/016-parameter-covers-no-input/tree/acme-wiki/listing.yaml @@ -0,0 +1,11 @@ +specVersion: v1 +kind: LISTING +metadata: + slug: acme-wiki + version: 1 +spec: + listingKind: BLUEPRINT + displayName: Acme Wiki + summary: A wiki backed by PostgreSQL + category: PRODUCTIVITY + lifecycleStage: STABLE diff --git a/conformance/blueprint/v1/semantic/017-required-input-uncovered/diagnostics.json b/conformance/blueprint/v1/semantic/017-required-input-uncovered/diagnostics.json new file mode 100644 index 0000000..3449796 --- /dev/null +++ b/conformance/blueprint/v1/semantic/017-required-input-uncovered/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_UNCOVERED_REQUIRED_INPUT", + "path": "/spec/parameters" + } +] diff --git a/conformance/blueprint/v1/semantic/017-required-input-uncovered/metadata.json b/conformance/blueprint/v1/semantic/017-required-input-uncovered/metadata.json new file mode 100644 index 0000000..667de13 --- /dev/null +++ b/conformance/blueprint/v1/semantic/017-required-input-uncovered/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "semantic-017-required-input-uncovered", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#authored-parameters", + "summary": "An input the deploying user must supply that no parameter covers is rejected.", + "document": "acme-wiki/blueprint.yaml" +} diff --git a/conformance/blueprint/v1/semantic/017-required-input-uncovered/tree/acme-wiki/blueprint.yaml b/conformance/blueprint/v1/semantic/017-required-input-uncovered/tree/acme-wiki/blueprint.yaml new file mode 100644 index 0000000..1fc1f02 --- /dev/null +++ b/conformance/blueprint/v1/semantic/017-required-input-uncovered/tree/acme-wiki/blueprint.yaml @@ -0,0 +1,26 @@ +# `postgres` requires an `adminPassword` the deploying user has to type: no +# generator mints it, no platform default derives it, and its schema carries no +# default. The override covers `siteTitle` and stops there, so the form never +# asks and the workload starts without it. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + components: + api: + component: ./components/api.yaml + size: general.standard.small + connections: {} + db: + component: ./components/postgres.yaml + size: general.standard.small + connections: {} + parameters: + siteTitle: + schema: + type: STRING + default: Acme Wiki + ui: + label: Site title diff --git a/conformance/blueprint/v1/semantic/017-required-input-uncovered/tree/acme-wiki/components/api.yaml b/conformance/blueprint/v1/semantic/017-required-input-uncovered/tree/acme-wiki/components/api.yaml new file mode 100644 index 0000000..246f568 --- /dev/null +++ b/conformance/blueprint/v1/semantic/017-required-input-uncovered/tree/acme-wiki/components/api.yaml @@ -0,0 +1,26 @@ +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + endpoints: + primary: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE + contract: + inputs: + siteTitle: + schema: + type: STRING + default: Acme Wiki + isRequired: false + suppliedBy: USER + ui: + label: Site title + outputs: {} diff --git a/conformance/blueprint/v1/semantic/017-required-input-uncovered/tree/acme-wiki/components/postgres.yaml b/conformance/blueprint/v1/semantic/017-required-input-uncovered/tree/acme-wiki/components/postgres.yaml new file mode 100644 index 0000000..2c794ed --- /dev/null +++ b/conformance/blueprint/v1/semantic/017-required-input-uncovered/tree/acme-wiki/components/postgres.yaml @@ -0,0 +1,25 @@ +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + endpoints: + primary: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE + contract: + inputs: + adminPassword: + schema: + type: STRING + isSensitive: true + suppliedBy: USER + ui: + label: Administrator password + outputs: {} diff --git a/conformance/blueprint/v1/semantic/017-required-input-uncovered/tree/acme-wiki/listing.yaml b/conformance/blueprint/v1/semantic/017-required-input-uncovered/tree/acme-wiki/listing.yaml new file mode 100644 index 0000000..da5eed4 --- /dev/null +++ b/conformance/blueprint/v1/semantic/017-required-input-uncovered/tree/acme-wiki/listing.yaml @@ -0,0 +1,11 @@ +specVersion: v1 +kind: LISTING +metadata: + slug: acme-wiki + version: 1 +spec: + listingKind: BLUEPRINT + displayName: Acme Wiki + summary: A wiki backed by PostgreSQL + category: PRODUCTIVITY + lifecycleStage: STABLE diff --git a/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/diagnostics.json b/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/diagnostics.json new file mode 100644 index 0000000..3449796 --- /dev/null +++ b/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_UNCOVERED_REQUIRED_INPUT", + "path": "/spec/parameters" + } +] diff --git a/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/metadata.json b/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/metadata.json new file mode 100644 index 0000000..886d66e --- /dev/null +++ b/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "semantic-018-optional-parameter-does-not-cover", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#authored-parameters", + "summary": "A parameter that names a required input's key but guarantees no value does not cover it.", + "document": "acme-wiki/blueprint.yaml" +} diff --git a/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/tree/acme-wiki/blueprint.yaml b/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/tree/acme-wiki/blueprint.yaml new file mode 100644 index 0000000..c18ed6b --- /dev/null +++ b/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/tree/acme-wiki/blueprint.yaml @@ -0,0 +1,27 @@ +# The override names `adminPassword`, so it is covered by key — and it still +# does not guarantee a value. `isRequired` defaults to true on a component +# input and to false on a blueprint parameter, so copying the key and saying +# nothing else turns a required value into one the user may skip. Naming the +# key is not the test; asking for the value is. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + components: + api: + component: ./components/api.yaml + size: general.standard.small + connections: {} + db: + component: ./components/postgres.yaml + size: general.standard.small + connections: {} + parameters: + adminPassword: + schema: + type: STRING + isSensitive: true + ui: + label: Administrator password diff --git a/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/tree/acme-wiki/components/api.yaml b/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/tree/acme-wiki/components/api.yaml new file mode 100644 index 0000000..246f568 --- /dev/null +++ b/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/tree/acme-wiki/components/api.yaml @@ -0,0 +1,26 @@ +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + endpoints: + primary: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE + contract: + inputs: + siteTitle: + schema: + type: STRING + default: Acme Wiki + isRequired: false + suppliedBy: USER + ui: + label: Site title + outputs: {} diff --git a/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/tree/acme-wiki/components/postgres.yaml b/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/tree/acme-wiki/components/postgres.yaml new file mode 100644 index 0000000..2c794ed --- /dev/null +++ b/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/tree/acme-wiki/components/postgres.yaml @@ -0,0 +1,25 @@ +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + endpoints: + primary: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE + contract: + inputs: + adminPassword: + schema: + type: STRING + isSensitive: true + suppliedBy: USER + ui: + label: Administrator password + outputs: {} diff --git a/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/tree/acme-wiki/listing.yaml b/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/tree/acme-wiki/listing.yaml new file mode 100644 index 0000000..da5eed4 --- /dev/null +++ b/conformance/blueprint/v1/semantic/018-optional-parameter-does-not-cover/tree/acme-wiki/listing.yaml @@ -0,0 +1,11 @@ +specVersion: v1 +kind: LISTING +metadata: + slug: acme-wiki + version: 1 +spec: + listingKind: BLUEPRINT + displayName: Acme Wiki + summary: A wiki backed by PostgreSQL + category: PRODUCTIVITY + lifecycleStage: STABLE diff --git a/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/diagnostics.json b/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/diagnostics.json new file mode 100644 index 0000000..63d1b6b --- /dev/null +++ b/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INCOMPATIBLE_PARAMETER_TYPE", + "path": "/spec/parameters/adminPassword/schema/type" + } +] diff --git a/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/metadata.json b/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/metadata.json new file mode 100644 index 0000000..c37f901 --- /dev/null +++ b/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "semantic-019-parameter-type-disagrees", + "phase": "semantic", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#authored-parameters", + "summary": "A parameter whose schema.type differs from an input it covers is rejected.", + "document": "acme-wiki/blueprint.yaml" +} diff --git a/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/blueprint.yaml b/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/blueprint.yaml new file mode 100644 index 0000000..cbe8d4a --- /dev/null +++ b/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/blueprint.yaml @@ -0,0 +1,27 @@ +# The parameter asks for an INTEGER; the input it covers declares a STRING. +# The deploying user is validated against the parameter's schema and the +# component then receives the result against its own, so nothing fails until the +# workload starts. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + components: + api: + component: ./components/api.yaml + size: general.standard.small + connections: {} + db: + component: ./components/postgres.yaml + size: general.standard.small + connections: {} + parameters: + adminPassword: + schema: + type: INTEGER + isSensitive: true + isRequired: true + ui: + label: Administrator password diff --git a/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/components/api.yaml b/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/components/api.yaml new file mode 100644 index 0000000..246f568 --- /dev/null +++ b/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/components/api.yaml @@ -0,0 +1,26 @@ +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + endpoints: + primary: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE + contract: + inputs: + siteTitle: + schema: + type: STRING + default: Acme Wiki + isRequired: false + suppliedBy: USER + ui: + label: Site title + outputs: {} diff --git a/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/components/postgres.yaml b/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/components/postgres.yaml new file mode 100644 index 0000000..2c794ed --- /dev/null +++ b/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/components/postgres.yaml @@ -0,0 +1,25 @@ +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + endpoints: + primary: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE + contract: + inputs: + adminPassword: + schema: + type: STRING + isSensitive: true + suppliedBy: USER + ui: + label: Administrator password + outputs: {} diff --git a/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/listing.yaml b/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/listing.yaml new file mode 100644 index 0000000..da5eed4 --- /dev/null +++ b/conformance/blueprint/v1/semantic/019-parameter-type-disagrees/tree/acme-wiki/listing.yaml @@ -0,0 +1,11 @@ +specVersion: v1 +kind: LISTING +metadata: + slug: acme-wiki + version: 1 +spec: + listingKind: BLUEPRINT + displayName: Acme Wiki + summary: A wiki backed by PostgreSQL + category: PRODUCTIVITY + lifecycleStage: STABLE diff --git a/conformance/blueprint/v1/semantic/020-authored-override-well-formed/metadata.json b/conformance/blueprint/v1/semantic/020-authored-override-well-formed/metadata.json new file mode 100644 index 0000000..835edf5 --- /dev/null +++ b/conformance/blueprint/v1/semantic/020-authored-override-well-formed/metadata.json @@ -0,0 +1,8 @@ +{ + "id": "semantic-020-authored-override-well-formed", + "phase": "semantic", + "expected": "pass", + "clause": "specifications/blueprint/v1/spec.md#authored-parameters", + "summary": "An authored override reconciles two nodes that disagree below type, and suppresses the merge.", + "document": "acme-wiki/blueprint.yaml" +} diff --git a/conformance/blueprint/v1/semantic/020-authored-override-well-formed/tree/acme-wiki/blueprint.yaml b/conformance/blueprint/v1/semantic/020-authored-override-well-formed/tree/acme-wiki/blueprint.yaml new file mode 100644 index 0000000..82a4655 --- /dev/null +++ b/conformance/blueprint/v1/semantic/020-authored-override-well-formed/tree/acme-wiki/blueprint.yaml @@ -0,0 +1,38 @@ +# The regression pin for §5.2's remedy. +# +# Both components declare `adminPassword` and their schemas differ — `api` +# admits eight characters, `db` demands twelve. On the derivation path that is +# ERR_CONFLICTING_INPUT_SCHEMA, and §5.2 sends the author here to resolve it. +# The resolution only works if writing the override actually stops the merge +# running, which is what this case pins: an override is used in place of +# derivation, so there is nothing left to conflict. +# +# The override then answers to §5.3 instead, and satisfies all three rules. The +# key names an input both nodes declare, so it is not unbound. It declares +# `isRequired: true`, so it guarantees the value neither node can do without. +# And its `type` agrees with both — the disagreement is over `pattern`, which +# §5.3 does not compare, and which is why one parameter can reconcile the two. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + components: + api: + component: ./components/api.yaml + size: general.standard.small + connections: {} + db: + component: ./components/postgres.yaml + size: general.standard.small + connections: {} + parameters: + adminPassword: + schema: + type: STRING + pattern: ^.{12,}$ + isSensitive: true + isRequired: true + ui: + label: Administrator password diff --git a/conformance/blueprint/v1/semantic/020-authored-override-well-formed/tree/acme-wiki/components/api.yaml b/conformance/blueprint/v1/semantic/020-authored-override-well-formed/tree/acme-wiki/components/api.yaml new file mode 100644 index 0000000..0fe3bcc --- /dev/null +++ b/conformance/blueprint/v1/semantic/020-authored-override-well-formed/tree/acme-wiki/components/api.yaml @@ -0,0 +1,26 @@ +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + endpoints: + primary: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE + contract: + inputs: + adminPassword: + schema: + type: STRING + pattern: ^.{8,}$ + isSensitive: true + suppliedBy: USER + ui: + label: Administrator password + outputs: {} diff --git a/conformance/blueprint/v1/semantic/020-authored-override-well-formed/tree/acme-wiki/components/postgres.yaml b/conformance/blueprint/v1/semantic/020-authored-override-well-formed/tree/acme-wiki/components/postgres.yaml new file mode 100644 index 0000000..8263a86 --- /dev/null +++ b/conformance/blueprint/v1/semantic/020-authored-override-well-formed/tree/acme-wiki/components/postgres.yaml @@ -0,0 +1,26 @@ +specVersion: v1 +kind: COMPONENT +metadata: + version: 1 +spec: + workload: + kind: SERVICE + source: + type: IMAGE + ref: postgres:17.10-alpine + endpoints: + primary: + containerPort: 5432 + protocol: TCP + visibility: PRIVATE + contract: + inputs: + adminPassword: + schema: + type: STRING + pattern: ^.{12,}$ + isSensitive: true + suppliedBy: USER + ui: + label: Administrator password + outputs: {} diff --git a/conformance/blueprint/v1/semantic/020-authored-override-well-formed/tree/acme-wiki/listing.yaml b/conformance/blueprint/v1/semantic/020-authored-override-well-formed/tree/acme-wiki/listing.yaml new file mode 100644 index 0000000..da5eed4 --- /dev/null +++ b/conformance/blueprint/v1/semantic/020-authored-override-well-formed/tree/acme-wiki/listing.yaml @@ -0,0 +1,11 @@ +specVersion: v1 +kind: LISTING +metadata: + slug: acme-wiki + version: 1 +spec: + listingKind: BLUEPRINT + displayName: Acme Wiki + summary: A wiki backed by PostgreSQL + category: PRODUCTIVITY + lifecycleStage: STABLE diff --git a/conformance/blueprint/v1/structural/022-generated-parameter-not-sensitive/case.yaml b/conformance/blueprint/v1/structural/022-generated-parameter-not-sensitive/case.yaml new file mode 100644 index 0000000..ccb0feb --- /dev/null +++ b/conformance/blueprint/v1/structural/022-generated-parameter-not-sensitive/case.yaml @@ -0,0 +1,26 @@ +# A generated value is secret material. Component §6.1 obliges a generated input +# to declare isSensitive: true, and a derived parameter inherits that schema — so +# the marking is guaranteed on the derivation path. Without the same rule here, +# moving the secret onto the override path is enough to lose it, and isSensitive +# defaults to false, so losing it takes no more than not mentioning it. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + components: + db: + component: ./components/postgres.yaml + size: general.standard.small + connections: {} + parameters: + adminPassword: + schema: + type: STRING + generator: + byteLength: 32 + encoding: HEX + isRequired: true + ui: + label: Administrator password diff --git a/conformance/blueprint/v1/structural/022-generated-parameter-not-sensitive/diagnostics.json b/conformance/blueprint/v1/structural/022-generated-parameter-not-sensitive/diagnostics.json new file mode 100644 index 0000000..ea04308 --- /dev/null +++ b/conformance/blueprint/v1/structural/022-generated-parameter-not-sensitive/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_MISSING_FIELD", + "path": "/spec/parameters/adminPassword/schema" + } +] diff --git a/conformance/blueprint/v1/structural/022-generated-parameter-not-sensitive/metadata.json b/conformance/blueprint/v1/structural/022-generated-parameter-not-sensitive/metadata.json new file mode 100644 index 0000000..90be8cd --- /dev/null +++ b/conformance/blueprint/v1/structural/022-generated-parameter-not-sensitive/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-022-generated-parameter-not-sensitive", + "phase": "structural", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#authored-parameters", + "summary": "An authored parameter carrying a generator must declare isSensitive rather than default it." +} diff --git a/conformance/blueprint/v1/structural/023-generated-parameter-marked-insensitive/case.yaml b/conformance/blueprint/v1/structural/023-generated-parameter-marked-insensitive/case.yaml new file mode 100644 index 0000000..1976871 --- /dev/null +++ b/conformance/blueprint/v1/structural/023-generated-parameter-marked-insensitive/case.yaml @@ -0,0 +1,25 @@ +# The other half of the rule. 022 omits isSensitive and takes the false default; +# this one says false outright. Both leave a platform-minted secret unmarked, so +# both are rejected — the first for not answering, the second for the answer. +specVersion: v1 +kind: BLUEPRINT +metadata: + slug: acme-wiki + version: 1 +spec: + components: + db: + component: ./components/postgres.yaml + size: general.standard.small + connections: {} + parameters: + adminPassword: + schema: + type: STRING + isSensitive: false + generator: + byteLength: 32 + encoding: HEX + isRequired: true + ui: + label: Administrator password diff --git a/conformance/blueprint/v1/structural/023-generated-parameter-marked-insensitive/diagnostics.json b/conformance/blueprint/v1/structural/023-generated-parameter-marked-insensitive/diagnostics.json new file mode 100644 index 0000000..eb09187 --- /dev/null +++ b/conformance/blueprint/v1/structural/023-generated-parameter-marked-insensitive/diagnostics.json @@ -0,0 +1,6 @@ +[ + { + "code": "ERR_INVALID_VALUE", + "path": "/spec/parameters/adminPassword/schema/isSensitive" + } +] diff --git a/conformance/blueprint/v1/structural/023-generated-parameter-marked-insensitive/metadata.json b/conformance/blueprint/v1/structural/023-generated-parameter-marked-insensitive/metadata.json new file mode 100644 index 0000000..f6b9cf1 --- /dev/null +++ b/conformance/blueprint/v1/structural/023-generated-parameter-marked-insensitive/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "structural-023-generated-parameter-marked-insensitive", + "phase": "structural", + "expected": "fail", + "clause": "specifications/blueprint/v1/spec.md#authored-parameters", + "summary": "An authored parameter carrying a generator may not declare isSensitive false." +} diff --git a/specifications/blueprint/v1/schemas/dist/blueprint.schema.json b/specifications/blueprint/v1/schemas/dist/blueprint.schema.json index a5b759a..7402cca 100644 --- a/specifications/blueprint/v1/schemas/dist/blueprint.schema.json +++ b/specifications/blueprint/v1/schemas/dist/blueprint.schema.json @@ -278,7 +278,20 @@ }, "BlueprintParameter": { "description": "An install-form parameter composed at the graph layer (map value; keyed by key).", + "$comment": "spec.md §5.3: a generated value is secret material, and isSensitive is required explicitly because its default is false — the wrong answer here. The derived path inherits the marking from the input, which component §6.1 already obliges; this keeps the authored path from being the way to lose it. Mirrors ComponentInput's generator branch.", "additionalProperties": false, + "if": { + "properties": { + "generator": { + "not": { + "type": "null" + } + } + }, + "required": [ + "generator" + ] + }, "properties": { "description": { "description": "Help text shown beneath the field, or null when none is set.", @@ -322,6 +335,20 @@ "schema", "ui" ], + "then": { + "properties": { + "schema": { + "properties": { + "isSensitive": { + "const": true + } + }, + "required": [ + "isSensitive" + ] + } + } + }, "type": "object", "x-additionalPropertiesName": "parameterKey" }, diff --git a/specifications/blueprint/v1/schemas/src/blueprint.schema.json b/specifications/blueprint/v1/schemas/src/blueprint.schema.json index dee5af2..a3c0911 100644 --- a/specifications/blueprint/v1/schemas/src/blueprint.schema.json +++ b/specifications/blueprint/v1/schemas/src/blueprint.schema.json @@ -277,8 +277,35 @@ "type": "object" }, "BlueprintParameter": { + "$comment": "spec.md §5.3: a generated value is secret material, and isSensitive is required explicitly because its default is false — the wrong answer here. The derived path inherits the marking from the input, which component §6.1 already obliges; this keeps the authored path from being the way to lose it. Mirrors ComponentInput's generator branch.", "additionalProperties": false, "description": "An install-form parameter composed at the graph layer (map value; keyed by key).", + "if": { + "properties": { + "generator": { + "not": { + "type": "null" + } + } + }, + "required": [ + "generator" + ] + }, + "then": { + "properties": { + "schema": { + "properties": { + "isSensitive": { + "const": true + } + }, + "required": [ + "isSensitive" + ] + } + } + }, "properties": { "description": { "anyOf": [ diff --git a/specifications/blueprint/v1/spec.md b/specifications/blueprint/v1/spec.md index a59ec56..5de3baa 100644 --- a/specifications/blueprint/v1/spec.md +++ b/specifications/blueprint/v1/spec.md @@ -523,10 +523,107 @@ documents that disagreed and with nothing pointing back at them. An author who wants one shared value across two components says so by writing `spec.parameters` outright, which is what an authored override is for. -> **TODO** — How an authored parameter binds to the component inputs it -> satisfies. Derivation makes that correspondence by key; an override is used -> verbatim, which does not say what happens to a parameter key matching no -> input, or to a `USER` input no parameter covers. +**This rule belongs to the derivation path.** [§5](#parameters) makes an +authored override something used in place of derivation rather than merged with +it, so where `parameters` is non-empty the merge above does not run and there is +nothing left to conflict. That is what makes the remedy in the previous +paragraph a remedy: two components that disagree are reconciled by the author +naming the value once, rather than left in conflict beside the reconciliation. +What an authored parameter has to satisfy instead is +[§5.3](#authored-parameters). + +### 5.3 Authored parameters + +An authored override replaces the derived parameter set outright. It is the +path [§5.2](#merge) sends an author to, and it carries obligations derivation +met for free. + +**Binding is by key.** A parameter key is an input key: the parameter called +`adminPassword` supplies every `USER` input called `adminPassword`, in every +node that declares one. Nothing else is available to make the correspondence — a +parameter carries no `suppliedBy`, no node name and no `target` — so the key is +not one signal among several but the whole of it. That is also what lets one +parameter serve two components, which is the whole of why [§5.2](#merge) sends +an author here. + +A parameter **covers** an input when their keys are equal. Three rules follow. +All are `semantic`, and all need the component documents the graph references, +which a repo-local reference makes readable without a network. + +**A parameter MUST cover something.** A key matching no `USER` input of any node +is rejected with `ERR_UNBOUND_PARAMETER`, anchored at `/spec/parameters/`. +The install form asks a deploying user for a value and nothing in the +composition ever reads it. Permitted, these accumulate exactly as +[§3](#identity) says an unreferenced component document does — last release's +`legacyMode` still on the form beside the parameters that do something, with +nothing in the document saying which is which. + +**An input the deploying user must supply MUST be covered**, and covered by a +parameter that will actually ask for it. Otherwise the component requires a +value, the install form never offers one, and the workload starts without it. +The blueprint is rejected with `ERR_UNCOVERED_REQUIRED_INPUT`, anchored at +`/spec/parameters` — the mapping that should have named it, since a JSON Pointer +addresses this document and the input it is complaining about is not in it. + +An input must be covered when every one of these holds: + +| Property | Value | Because | +|---|---|---| +| `suppliedBy` | `USER` | A `CONNECTION` input is satisfied by a wire ([§4.2](#connections)). | +| `isRequired` | true, its default | Nothing has to supply an optional input. | +| `generator` | absent | The platform mints the value. | +| `platformDefault` | absent | The platform derives it from the node's own addressing. | +| `schema.default` | absent | The component document already supplies it. | + +A parameter covers such an input only if it **guarantees a value**: it declares +`isRequired: true`, or it carries a `generator`, or its `schema` declares a +`default`. A parameter that names the key and leaves the value optional has +moved the omission rather than closed it. + +**`isRequired` reads in opposite directions on the two documents**, and this is +the rule where that bites. It defaults to `true` on a component input and to +`false` on a blueprint parameter, so an override that copies a required input's +key and says nothing else has quietly made it optional. The defaults are +defensible on each side alone — an input declares a need, a parameter declares a +question — but the asymmetry is a trap, and it is why the rule above tests what +a parameter guarantees rather than only which keys it names. + +**`type` MUST agree.** Where a parameter covers an input, its `schema.type` MUST +equal that input's, and a mismatch is `ERR_INCOMPATIBLE_PARAMETER_TYPE`, +anchored at `/spec/parameters//schema/type`. The deploying user is +validated against the parameter's schema and the component then receives the +result against its own: a `STRING` accepted at the form where the workload +expects an `INTEGER` is the failure [§4.2](#connections) rejected for +connections and [§5.2](#merge) rejected for merging, arriving through a third +door. + +**A generated parameter is secret material.** A parameter carrying a `generator` +MUST declare `schema.isSensitive: true`, and MUST declare it rather than leave +it to a default. That is a shape rather than a relationship, so both halves are +`structural`: an absent `isSensitive` is `ERR_MISSING_FIELD` and one written +`false` is `ERR_INVALID_VALUE`. +[Component §6.1](../../component/v1/spec.md#inputs) requires exactly this of a +generated input, and a derived parameter takes the input's `schema` unchanged +([§5.1](#derivation)), so the marking is guaranteed on the derivation path +already. Without the same rule here, moving a generated secret onto the override +path is enough to lose it — and `isSensitive` defaults to `false`, so losing it +takes no more than not mentioning it. + +**What v1 does not compare.** `format`, `enum`, `pattern`, `default`, +`isSensitive` and `ui` take no part in whether a parameter covers an input. A +parameter whose `pattern` admits more than the input's does is accepted, and so +is one that asks for a value the input would reject. `semanticType` is not +compared because a parameter has none to compare: it tags the backing service a +value addresses ([§4.2](#connections)), and an install form is not where a value +acquires one. Those silences are gaps rather than considered permissions, +recorded here so a reader can tell the two apart; closing any of them rejects +compositions that validate today. + +**None of this reaches a published reference.** All three rules read the +referenced component's inputs, so a node naming its component by UUID +contributes none of them ([§4.1](#component-reference)). A blueprint mixing the +two forms is checked against the repo-local half and no further, and an +implementation MUST NOT report an input it was never given the means to read. ## 6. Validation layers @@ -561,6 +658,9 @@ family adds: | `ERR_VERSION_MISMATCH` | `semantic` | `metadata.version` disagrees with the sibling listing document. | | `ERR_UNREFERENCED_COMPONENT` | `semantic` | A component document in the item is referenced by no node. | | `ERR_CONFLICTING_INPUT_SCHEMA` | `semantic` | Two nodes declare the same input key with different schemas. | +| `ERR_UNBOUND_PARAMETER` | `semantic` | An authored parameter's key names no `USER` input of any node. | +| `ERR_UNCOVERED_REQUIRED_INPUT` | `semantic` | An input the deploying user must supply is guaranteed a value by no authored parameter. | +| `ERR_INCOMPATIBLE_PARAMETER_TYPE` | `semantic` | An authored parameter and an input it covers declare different `schema.type`s. | ## 8. Conformance @@ -573,8 +673,11 @@ Seeded from the platform's generated schema. The naming that arrived with it 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 +No section of this document is marked TODO any longer. The last of them — how an +authored parameter binds to the component inputs it satisfies — is answered by +[§5.3](#authored-parameters). + +What remains is not a gap in the prose but a vocabulary nothing publishes yet: +[§4.4](#advanced-constraints)'s compute-constraint pins, recorded there as a gap +rather than described as a decision. See also [component §10](../../component/v1/spec.md#known-debt). diff --git a/tools/src/semantic.ts b/tools/src/semantic.ts index 3911448..d7e98bc 100644 --- a/tools/src/semantic.ts +++ b/tools/src/semantic.ts @@ -637,9 +637,9 @@ function checkIdentity(family: Family, document: Json, itemRoot: string, out: Di } /** - * Blueprint §4.1 and §4.2, plus §3's unreferenced-document rule and §5.2's - * merge conflict. All four need the component documents the graph names, which - * is what makes them item-scoped. + * Blueprint §4.1 and §4.2, plus §3's unreferenced-document rule and §5's two + * parameter paths. All of them need the component documents the graph names, + * which is what makes them item-scoped. */ function checkGraphAgainstItem( document: Json, @@ -648,6 +648,7 @@ function checkGraphAgainstItem( out: Diagnostic[], ): void { const components = child(child(document, 'spec'), 'components') + const parameters = child(child(document, 'spec'), 'parameters') const baseDir = dirname(documentPath) const referenced = new Set() const resolved = new Map() @@ -686,7 +687,17 @@ function checkGraphAgainstItem( checkConnectionInputs(components, resolved, out) checkRequiredConnections(components, resolved, out) checkConnectionCompatibility(components, resolved, out) - checkInputMerge(components, resolved, out) + + // §5's two paths are exclusive. An authored override is used in place of + // derivation rather than merged with it (§5), so where one is written the + // merge does not run and §5.2's conflict has nothing to conflict — which is + // what makes §5.2's own remedy a remedy. §5.3 is what the override answers to + // instead. + if (keysOf(parameters).length === 0) { + checkInputMerge(components, resolved, out) + } else { + checkParameterBinding(parameters, components, resolved, out) + } } /** Blueprint §3 — every component document in the item MUST be referenced. */ @@ -914,6 +925,113 @@ function checkInputMerge( } } +/** Present and not null. An optional property spelled `null` sets nothing. */ +function isSet(value: Json | undefined): boolean { + return value !== undefined && value !== null +} + +/** + * Blueprint §5.3 — the input side of the coverage test. An input has to be + * covered only when nothing else can supply it: a wire, a minted secret, a + * platform-derived address and a declared default each take it out of scope. + */ +function mustBeSupplied(input: Json | undefined): boolean { + if (child(input, 'suppliedBy') === 'CONNECTION') return false + // `isRequired` defaults to true on a component input, so an absent key is a + // required one — hence `=== false` rather than `!== true`. + if (child(input, 'isRequired') === false) return false + if (isSet(child(input, 'generator'))) return false + if (isSet(child(input, 'platformDefault'))) return false + return !isSet(child(child(input, 'schema'), 'default')) +} + +/** + * Blueprint §5.3 — the parameter side. Naming the key is not enough; the + * parameter has to actually ask for a value. + * + * `isRequired` defaults to **false** here, the opposite of a component input, + * which is why this tests `=== true` where `mustBeSupplied` tests `=== false`. + * An override that copies a required input's key and says nothing else has made + * it optional, and that is the case this catches. + */ +function guaranteesValue(parameter: Json | undefined): boolean { + if (child(parameter, 'isRequired') === true) return true + if (isSet(child(parameter, 'generator'))) return true + return isSet(child(child(parameter, 'schema'), 'default')) +} + +/** + * Blueprint §5.3 — an authored override binds to inputs by key, and the key is + * the whole of the correspondence: a parameter carries no `suppliedBy`, no node + * name and no `target`. + * + * Only reached when `parameters` is non-empty. The derived set is built from + * the inputs themselves, so none of these three rules can fail on that path. + */ +function checkParameterBinding( + parameters: Json | undefined, + components: Json | undefined, + resolved: Map, + out: Diagnostic[], +): void { + // Input key → every USER declaration of it, in canonical node order. One key + // may be declared by several nodes; a parameter covers all of them. + const declared = new Map() + + for (const node of keysOf(components).sort()) { + const component = resolved.get(node) + // A published reference resolves in the capability phase, so its inputs are + // unreadable here. §5.3: an implementation MUST NOT report an input it was + // never given the means to read. + if (component === undefined) continue + + const inputs = inputsOf(component) + for (const key of keysOf(inputs)) { + const input = child(inputs, key) + if (input === undefined) continue + if (child(input, 'suppliedBy') === 'CONNECTION') continue + const seen = declared.get(key) + if (seen === undefined) declared.set(key, [input]) + else seen.push(input) + } + } + + for (const key of keysOf(parameters)) { + const pointer = `/spec/parameters/${token(key)}` + const covered = declared.get(key) + if (covered === undefined) { + out.push({ + code: 'ERR_UNBOUND_PARAMETER', + path: pointer, + message: `parameter "${key}" names no USER input of any node`, + }) + continue + } + + // Only `type` is compared. §5.3 records the rest as silences, and `type` is + // REQUIRED on both sides, so this needs no defaulting pass. + const type = child(child(child(parameters, key), 'schema'), 'type') + const mismatch = covered.find((input) => child(child(input, 'schema'), 'type') !== type) + if (mismatch === undefined) continue + out.push({ + code: 'ERR_INCOMPATIBLE_PARAMETER_TYPE', + path: `${pointer}/schema/type`, + message: `parameter "${key}" declares ${String(type)} where an input it covers declares ${String(child(child(mismatch, 'schema'), 'type'))}`, + }) + } + + for (const key of declared.keys()) { + const inputs = declared.get(key) ?? [] + if (!inputs.some(mustBeSupplied)) continue + if (guaranteesValue(child(parameters, key))) continue + out.push({ + code: 'ERR_UNCOVERED_REQUIRED_INPUT', + path: '/spec/parameters', + message: `input "${key}" must be supplied by the deploying user, and no parameter guarantees it a value`, + }) + } +} + // =========================================================================== // entry point // =========================================================================== From 400d179f3e1a369e90f61c9f78d935ee140fbfbb Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Mon, 17 Aug 2026 00:55:17 +0000 Subject: [PATCH 6/7] docs(blueprint): explain the authored override in the worked example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `web-and-database.yaml` is the only published example that authors `spec.parameters`, and the block arrived unexplained — a reader met the feature §5.2 recommends with nothing saying how the key reaches an input, or that overriding forgoes the derived set entirely. Now commented: binding is by key, the key is the whole of the correspondence, and the override is a commitment rather than an addition. It also accounts for why `siteTitle` is the only entry, which is the question §5.3's coverage rule makes a reader ask. Nothing checks it either way. `examples/` carries no item root by design (tools/src/validator.ts), so §5.3's rules cannot fire there — which is exactly why the example has to teach the rule in prose rather than by passing. CLAUDE.md's Known debt section said TODO sections remain in blueprint and listing. Blueprint has none left. Refs #20 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Justin Merrell --- CLAUDE.md | 4 ++-- .../blueprint/v1/examples/web-and-database.yaml | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 99b02e0..22ea178 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,5 +83,5 @@ now rejects both, but the seeding still shows in the prose: some `description` 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. +published, per docs/adr/0003. Only `listing/v1/spec.md` still carries `TODO` +sections — they are what keeps v1 pre-stable. diff --git a/specifications/blueprint/v1/examples/web-and-database.yaml b/specifications/blueprint/v1/examples/web-and-database.yaml index 45e5cb1..f9a25ff 100644 --- a/specifications/blueprint/v1/examples/web-and-database.yaml +++ b/specifications/blueprint/v1/examples/web-and-database.yaml @@ -33,6 +33,17 @@ spec: DATABASE_URL: fromRole: db fromOutput: connectionString + # A non-empty `parameters` is an authored override: it replaces the form that + # would otherwise be derived from the graph's USER inputs, rather than adding + # to it. Binding is by key and the key is the whole of the correspondence — a + # parameter names no node and no target — so `siteTitle` here supplies the + # `siteTitle` input of every component that declares one, which is what lets + # one parameter serve two components. + # + # Overriding is a commitment: the derived set is not consulted, so every input + # the deploying user would have had to type has to be named here. `siteTitle` + # is the only one — the database password is generated, and `DATABASE_URL` + # arrives over the wire above rather than through the form. parameters: siteTitle: schema: From 4016abb3c943e05f5f1cb002201e4bb04e909811 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Mon, 17 Aug 2026 01:01:32 +0000 Subject: [PATCH 7/7] fix(devcontainer): let the env template declare no keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `task env:check` failed against an unmodified checkout, and the Dev Container workflow's Env Template Sync job failed with it. Not drift — it failed when the two files were byte-identical, because copying `.env.example` to `.env` is exactly what the job does first. `env_check_keys` ran `grep | sed | awk` under `set -o pipefail`. grep exits 1 when it matches nothing, so a template declaring no keys aborted the function, and `set -e` took the whole check down before it could conclude there was no drift. A file with no keys is not an error here — it is this repository's documented state: ".env.example" says outright that "there is nothing required here", because the repo runs no services and needs no credentials. The match is now captured before the pipeline, so an empty key set stays a result instead of becoming a failure. `env_check_drift` also drops blank lines from comm's output, since an empty set echoes as one blank line that would otherwise read as a key named "". Pre-existing on main, and only surfaced now because the workflow is path filtered to `.devcontainer/**` and nothing had touched that directory since the template lost its last key. Verified both directions: byte-identical files exit 0, and adding a key to the template alone still reports it and fails. Refs #20 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Justin Merrell --- .devcontainer/scripts/lib/env-check.sh | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.devcontainer/scripts/lib/env-check.sh b/.devcontainer/scripts/lib/env-check.sh index 6879c73..d08d321 100644 --- a/.devcontainer/scripts/lib/env-check.sh +++ b/.devcontainer/scripts/lib/env-check.sh @@ -15,6 +15,12 @@ set -euo pipefail # Recognizes lines that start with an uppercase identifier followed by '='. # Skips blanks, comments (`#`), and commented-out overrides. # +# A file declaring no keys is a legitimate state, not an error: this +# repository's template is entirely commented defaults by design. grep exits 1 +# when it matches nothing, and `set -o pipefail` above would turn that into a +# failure of the whole check — so the match is captured before the pipeline +# rather than inside it. +# # Arguments: # $1 — path to env file # Outputs: @@ -22,7 +28,11 @@ set -euo pipefail env_check_keys() { local file="${1:?usage: env_check_keys }" [[ -f "${file}" ]] || return 0 - grep -E '^[A-Z_][A-Z0-9_]*=' "${file}" | sed 's/=.*//' | awk '!seen[$0]++' + + local declarations + declarations="$(grep -E '^[A-Z_][A-Z0-9_]*=' "${file}" || true)" + [[ -n "${declarations}" ]] || return 0 + printf '%s\n' "${declarations}" | sed 's/=.*//' | awk '!seen[$0]++' } # Reports keys present in but missing from . @@ -42,7 +52,9 @@ env_check_drift() { local expected actual missing expected="$(env_check_keys "${example_file}" | sort -u)" actual="$(env_check_keys "${env_file}" | sort -u)" - missing="$(comm -23 <(echo "${expected}") <(echo "${actual}"))" + # An empty key set echoes as one blank line, which comm would read as a key + # named "". Dropping blanks keeps an empty set genuinely empty. + missing="$(comm -23 <(echo "${expected}") <(echo "${actual}") | grep -v '^$' || true)" if [[ -n "${missing}" ]]; then echo "${missing}" >&2