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/.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 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 }} 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/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 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/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: 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/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 }, 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" } 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 // ===========================================================================