Skip to content

feat(blueprint): specify how an authored parameter binds to component inputs - #36

Merged
justinmerrell merged 7 commits into
mainfrom
spec/authored-parameter-binding-issue-20
Aug 17, 2026
Merged

feat(blueprint): specify how an authored parameter binds to component inputs#36
justinmerrell merged 7 commits into
mainfrom
spec/authored-parameter-binding-issue-20

Conversation

@justinmerrell

Copy link
Copy Markdown
Contributor

What changes

Blueprint §5 defined derivation in full and left the override path — the one §5.2 recommends — undefined. New §5.3 Authored parameters defines it: binding is by key, and three semantic rules follow. Along the way it fixes a live inconsistency that made §5.2's recommended remedy not actually remedy.

Also folds in the dev-container tooling fix that blocked task check locally, plus the two open dependabot bumps.

Why

Closes #20. Supersedes #33 and #34 (both cherry-picked here with dependabot's authorship intact).

The mechanism was already settled — by the platform, not by us

ParameterBindings.values is documented as "Keyed by the input key of each USER ComponentInput on the template", and one flat mapping is handed to every node, each taking the keys it recognises (blueprint/acl/component/adapter.py). Binding is by key, in one namespace shared across the graph. The seed importer confirms the override is wholesale — parameters = manifest.blueprint.parameters or _derived_parameters(manifest) — and performs no correspondence check at all.

So both of the issue's questions are genuine gaps in both projects rather than undocumented behaviour. This is the specification deciding, not transcribing.

§5.3 — three rules

A parameter MUST cover somethingERR_UNBOUND_PARAMETER at /spec/parameters/<key>. The install form asks a deploying user for a value and nothing in the composition reads it. §3 already makes exactly 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 coveredERR_UNCOVERED_REQUIRED_INPUT at /spec/parameters. In scope when the input is USER-supplied, required, and carries no generator, no platformDefault and no schema.default — i.e. when nothing but a person can supply it. The mirror of ERR_UNWIRED_REQUIRED_INPUT on the CONNECTION side, and the same argument: no later chance to supply it.

type MUST agreeERR_INCOMPATIBLE_PARAMETER_TYPE. Exactly the axis §4.2 chose for connections, and nothing else compared. format, enum, pattern, default, isSensitive and ui are recorded as silences in §4.2's own words. semanticType is not compared because a parameter has none — an install form is not where a value acquires a backing-service tag.

The isRequired trap, and why coverage tests guarantees rather than keys

isRequired defaults to true on a component input and to false on a blueprint parameter. An override that copies a required input's key and says nothing else has quietly made it optional.

Tested by key alone, the second rule would be evaded by the act of satisfying it: name the key, leave it optional, and the form still never compels the value. So a parameter covers such an input only if it guarantees a valueisRequired: true, or a generator, or a schema.default. semantic-018 is that case, and it is the one worth reviewing hardest.

A hole on the authored path that the derived path did not have

Component §6.1 obliges a generated input to declare schema.isSensitive: true, and §5.1 says a derived parameter takes the input's schema unchanged — so on the derivation path the marking is guaranteed. On the authored path it was not, 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 already uses for spec.components.

Three findings worth a reviewer's attention

1. §5.2's recommended remedy did not work, and this PR is what makes it true. §5.2 tells an author that two components disagreeing on an input schema is resolved by writing spec.parameters outright. tools/src/semantic.ts never read spec.parameters, so checkInputMerge ran regardless and ERR_CONFLICTING_INPUT_SCHEMA fired anyway. §5 already said an override is "used in place of derivation rather than merged with it" — if derivation does not run, there is nothing to conflict. 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. It fails against the previous implementation and passes now — verified, not asserted.

2. semantic-020 also shows the limit of the remedy. The two nodes disagree on pattern, not on type. An override can reconcile a disagreement below type; it cannot reconcile one about type, because rule three would then reject whichever half the parameter picked. That is the right boundary — a STRING and an INTEGER are not one value asked for twice — but it is a narrower remedy than §5.2's sentence implies on its own, so §5.3 states it.

3. The only published example of an authored override is unverifiable, and now says so. examples/web-and-database.yaml authors siteTitle against component documents that deliberately do not exist — examples/ carries no item root by design, so §5.3's rules cannot fire there. The block arrived with no comment at all. It now explains the by-key correspondence and why siteTitle is the only entry, so it teaches the rule rather than sitting beside it unexplained.

Tooling, in the same PR

actionlint was orphaned. Nothing in the repo installed it — not a Feature, not mise.toml, not any .devcontainer script. The binary answering on PATH was hand-placed, built for x86-64, and died with exec format error on this aarch64 host, so task check could not complete and #35 had to be pushed with --no-verify. It is now pinned in .devcontainer/mise.toml at 1.7.11 (matching ci.yml), resolved per-architecture and attestation-verified by mise.

verify_tools was checking the wrong property. It logged $("$cmd" --version || echo installed), which swallowed the failure — it would have called the broken binary installed. Being on the PATH is not what matters; being runnable is. It now fails on a non-zero --version, and actionlint and shellcheck join the verified list since task check runs both. Confirmed against a deliberately unrunnable binary.

Taskfile.yml also drops LEFTHOOK_VERSION and ACTIONLINT_VERSION, both dead — the latter a declared intent with nothing enforcing it.

Dependabot #33 and #34 are cherry-picked with -x and their original authorship. One loose end they left: the biome bump changed the CLI but not the $schema URL in tools/biome.json, so every biome ci run reported a version mismatch. biome migrate closes it in a separate commit.

Compatibility

  • Breaking — a previously valid document now fails.

Three new semantic codes and one structural narrowing.

No new v<N> directory, and the reason is that v1 has never been published. git tag -l is empty and .github/release-please/manifest.json reads 0.0.0 for all three families, so component §3's guarantee has no released v1.0.0 to run from. This is #35's argument and #31's before it, with the same expiry: it dies the moment release PRs #1, #2 or #3 merge. This needs to land before them.

Corpus impact — checked, not assumed

Only two authored parameters blocks exist in the entire repository, and neither is affected:

  • examples/web-and-database.yaml — validated with no item root, so no semantic rule reaches it.
  • structural/005-nested-unknown-field — a case.yaml, so no item root either.

Every other occurrence in the corpus is the empty literal parameters: {}, which takes the derivation path and is untouched. semantic-009-conflicting-input-schema is empty too, so gating the merge leaves it green.

One thing to note downstream: the platform runs this corpus as a contract test (tests/contract/spec_conformance/test_blueprint_corpus.py) with a DivergencePin mechanism, so these three codes will surface there as new divergences until it implements them — relevant to open issue #24.

Checklist

  • task check passes locally — including check:workflow, which is the point of the tooling half
  • schemas/dist/ regenerated with task bundle and committed (never edited by hand)
  • Conformance fixtures added for every behavioural change, each citing a clause
  • Normative prose updated in the affected spec.md — schema descriptions are explanatory, not normative
  • Commit messages are Conventional and correctly scoped (the scope drives release-please)
  • Commits are DCO signed off (git commit -s)

7 new fixtures, 107 → 114 cases, 0 skipped. No new UNCOVERED entry — every code here is semantic or structural and therefore testable, unlike #35's capability code. Each negative fixture was run against the pre-change tree and confirmed to report "expected to fail but validated cleanly"; semantic-020 was confirmed to fail in the opposite direction.

Blueprint spec.md now carries no TODO sections at all. Only listing still does.

🤖 Generated with Claude Code

justinmerrell and others added 7 commits August 17, 2026 00:44
… run

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) <noreply@anthropic.com>
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
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](go-task/setup-task@01a4adf...a00fbb0)

---
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] <support@github.com>
(cherry picked from commit cce11df)
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] <support@github.com>
(cherry picked from commit 7a580d5)
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) <noreply@anthropic.com>
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
… inputs

§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/<key>. 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) <noreply@anthropic.com>
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
`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) <noreply@anthropic.com>
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
`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) <noreply@anthropic.com>
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
@justinmerrell
justinmerrell merged commit 311be77 into main Aug 17, 2026
7 checks passed
@justinmerrell
justinmerrell deleted the spec/authored-parameter-binding-issue-20 branch August 17, 2026 01:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

blueprint §5.2: how an authored parameter binds to the component inputs it satisfies

1 participant