Slots & bindings
How a way stays tool-agnostic — capability slots, Binding profiles, and the project binding map.
A portable way can't say "use GitHub" — that would only serve teams that use GitHub. Instead it declares capability slots: abstract needs the consumer fills with their own tools. This is the mechanism that lets one development way serve a Jira+Sentry+GitHub shop and a Linear+Rollbar+GitLab shop.
The three decoupled layers
| Layer | Artifact | Answers |
|---|---|---|
| Interface | kind: SlotContract | what does any vcs-host guarantee? |
| Implementation | kind: Binding | to satisfy vcs-host with github, what's required? |
| Selection | ways.yaml → bindings: | this repo binds vcs-host → github |
Where slots come from — the derived model
A way's slot set isn't a hand-maintained list; it's derived from what each skill declares it uses, then optionally enriched. Two sources, unioned by slot id:
spec:
skills:
- path: skills/incu-way-development
uses: [vcs-host] # this skill needs a vcs-host — that IS a slot
needs: # optional: enrich a derived slot, or declare a way-level one
- id: vcs-host
displayName: Version-control host
description: Hosts the repo and PRs — where this way opens and gates pull requests.
contract: ways.dev/vcs-host@1 # SlotContract reference, id@major
required: true
options: [github, gitlab, bitbucket] # wizard suggestions
defaults:
bindings:
vcs-host: github # a pre-fill, never a lock- A slot id that appears only in
skills[].usesbecomes a slot with defaults{ contract: ways.dev/{id}@1, required: true, cardinality: single }— a simple way can declare slots without aneedssection at all. - A
needs[]entry enriches or overrides the derived slot for its id: humandisplayNameanddescription, suggestedoptions, a softenedrequired, a default binding. spec.needsis the current name;capabilitySlotsis its deprecated alias — still valid for the lifetime ofways/v1alpha1, but conformance nudges the rename, and declaring both is a schema error.
Because slots carry provenance, every surface — the bind wizard, ways bind, doctor, and
ways list --components — leads with the human name and description and says why the slot exists
(needed by: <skill> or declared by the way), with the id·contract·required as dimmed technical
detail.
A Binding profile maps {contract, impl} → requires{} — it's what doctor expands to know
that choosing github means checking the gh CLI and a GH_TOKEN. Its identity is the pair
{contract, impl}, not its name (one impl like snyk can satisfy several contracts). A way can
ship profiles inside its bundle, or they can be resolved from a shared catalog — see
Resolving contracts below.
Binding
npx ways.sh bind # wizard: walks unbound + default-resolved slots
npx ways.sh bind incu/dev vcs-host github # scripted, idempotent
npx ways.sh bind incu/dev # list slots + provenance
npx ways.sh unbind incu/dev vcs-host # back to the way's default (or unbound)Your selection lands in the repo's ways.yaml — the composition root — and is committable, so
the whole team and CI validate against the same tools:
ways:
incu/dev:
source: github:incu-tech/incuway
bindings:
vcs-host: githubPrecedence: project > way defaults. A default is a pre-fill, not a lock — your binding always
wins, and unbind falls back. (Team profiles and user-global tiers are reserved in the contract
for later.)
Resolving contracts — catalogs
A slot's contract (ways.dev/vcs-host@1) used to be a string nobody resolved, and its Binding
profile existed only if the way happened to bundle one. As of 0.5.0 a contract's namespace
declares where its catalog comes from — an acquirable, pinned collection of SlotContract +
Binding artifacts, in the shape of Terraform's required_providers:
# in a way — the ways.dev namespace has a built-in default source, so a way that only uses
# ways.dev/* contracts can omit this block entirely.
spec:
contracts:
ways.dev: { source: github:incu-tech/ways-contracts, version: ^1.0.0 }
needs:
- { id: sast, contract: ways.dev/sast@1 }# in a project's ways.yaml — highest precedence: register an in-house impl as a first-class
# option without forking any way.
contracts:
acme.internal: { source: ../our-catalog }A catalog holds SlotContract definitions (what vcs-host guarantees) and Binding profiles
(what satisfying it with github requires). Layout is convention (contracts/, bindings/);
resolution is by content — contracts key by metadata.name@<major>, bindings by
{contract, impl}.
Binding profiles resolve across three tiers
ways.yaml → contracts:In-house impls you register for this repo. Highest precedence — no need to fork a way.bindings/ inside the wayWhat the way author shipped. Anything bundled today keeps winning over a shared catalog.spec.contracts namespace sourceThe acquirable, pinned collection a namespace points at (e.g. ways.dev). The default source.Anything a way bundles today keeps winning over a shared catalog, so nothing existing changes; the project catalog is the escape hatch for an in-house impl.
Pinned, approved, validated
- Pinned & reproducible. Resolved catalogs land in
ways.lockunder acontractssection (source,ref/sha,digest);ways installreproduces them offline. - Executable surface → approved. A Binding contributes
requires.cli[].checkcommandsdoctorruns, so a catalog is trusted content:ways addlists the catalogs in its approval plan (attributed to catalog + impl) and re-asks when a digest changes. Read-only commands (doctor,bind,ways contracts) are cache-only and never fetch — a catalog only enters through an explicit approval.WAYS_NO_CATALOGS=1disables resolution entirely. - Real validation.
doctornow reports the operations a bound impl'scoversOperationsomits (advisory ⚠ by default, a failure under--strict), and an unresolvable contract asunknownwith the reason.conformancefails a contract reference that doesn't exist in its catalog, and names the near-miss (the typo lint). - Inspect what resolved with
ways contracts: the namespaces (source, pin, digest, origin), the contracts each provides, and the impls available per contract with coverage and provenance.
Required means required
required is the way author's contract, and the consumer can't demote it:
- You can bind a required slot to any impl — required constrains that it's filled, not with what.
- The wizard lets you skip a required slot only after an explicit confirmation — and
doctorwill keep failing it (exit 1) until it's bound. There is no consumer-side flag to silence it.
A bound slot whose Binding profile can't be found reports unknown in doctor — recorded, honest,
but unverifiable. See Doctor & trust.