---
summary: "How a binding becomes the install record \u2014 scope precedence, the lockfile,\
  \ pending grants, and the per-turn resolve."
title: Bindings and resolution
path: concepts/bindings-and-resolution
status: published
---

# Bindings and resolution

A binding is the authoritative install of one skill version into one scope. Everything the runtime sees flows through bindings: which skills are active, what they resolve to today, whose permission gate is still pending, what dependencies are pinned. If a skill isn't reachable through a binding, it doesn't exist as far as the LLM is concerned.

## What a binding stores

A row in `mod_scaiskills_bindings` carries:

- `skill_id` and `skill_version_ref` — the skill and the original ref as submitted (`<id>@0.1.0`, `<id>@latest`, `<id>@^0.1`).
- `resolved_version` — the concrete semver the ref currently points at. For pinned refs this is the ref's own version. For floating refs it is the highest matching published, non-yanked version at bind time.
- `scope_type` and `scope_id` — `workspace`, `channel`, `user`, or `core` and the corresponding id.
- `enabled` — administrative on/off, flippable without losing the lockfile.
- `pending_grants` — true if any declared permission is ungranted or any required secret is unmapped. Filtered out of resolve while true.
- `resolved_deps_json` — the lockfile: a topologically-ordered list of `{skill_id, slug, version}` entries for every transitive dependency.
- `secret_mappings_json` — the `secret_name → vault_path` map supplied at bind time.

The lockfile is computed once, at bind time, and frozen for the binding's lifetime. There is no transitive re-resolution. The only way to pick up a new transitive dependency is to delete the binding and re-create it.

## The four scope types

Scopes correspond to the runtime contexts ScaiSkills knows about:

- **`workspace`** — applies to every agent in the tenant. The broad default for a team-wide style guide or a shared tool wrapper.
- **`channel`** — applies inside a specific ScaiWave channel. Channel-specific expertise, channel-specific tone.
- **`user`** — applies for a specific end user across whatever they're doing. Personal preferences, per-user A/B.
- **`core`** — applies inside a specific ScaiCore agent. Loaded once at Core startup, pinned in memory for the Core's lifetime.

A single `POST /resolve` can carry several scope ids at once. The resolver merges them, applies precedence, and deduplicates.

## Precedence and deduplication

When multiple bindings exist for the same `skill_id` across overlapping scopes, the resolver keeps exactly one. The precedence is:

```
core (4)  >  user (3)  >  channel (2)  >  workspace (1)
```

Concretely: a workspace-bound `summarise@1.0.0` is shadowed by a user-bound `summarise@2.0.0` for that user; the channel-bound version, if any, sits between them. Different `skill_id`s never collide — precedence only applies when the same skill appears at more than one scope.

Bindings with `enabled = false` or `pending_grants = true` are filtered out entirely. They don't compete for precedence; they just aren't in the candidate set.

## Pending grants

A skill's manifest can declare two gates:

- **`permissions:`** — a list of permission strings the skill needs at runtime (network allow-list entries, MCP tool invocations, ScaiDrive paths, etc.).
- **`secrets:`** — a list of named secret slots with a `required` flag. Each requires a `secret_mappings` entry naming a vault path.

At bind time, ScaiSkills checks:

1. Every declared permission has a matching row in `mod_scaiskills_permission_grants` for this binding.
2. Every secret with `required: true` has an entry in `secret_mappings_json`.

If anything is missing, `pending_grants` is set to `true`. The binding is created but not eligible for resolution. An admin with `scaiskills:grant` clears the gate one permission at a time via `POST /bindings/{id}/permissions/grant`; mapping a missed required secret needs a fresh bind. The flag flips back to `false` when both axes are satisfied.

This is the supply-chain mitigation. When a `^1.0` binding rolls forward to a new version that adds a permission, the new version's declared list won't match the existing grants, the flag flips back to `true` on the next resolve, and the binding falls out of results until an admin re-approves.

## Version refs

A binding's `version` field accepts three shapes:

- **Exact** — `0.1.0`. Pinned. The lockfile root never changes.
- **Floating semver** — `^1.2` (compatible-with), `~1.2` (patch-only), `>=1.0` (open range). Resolves to the highest published, non-yanked version that matches.
- **`latest`** — equivalent to "highest published, non-yanked version, any range". Convenient for development bindings, dangerous in production.

The leading `@` is optional and stripped. `@latest`, `latest`, `@^1.2`, and `^1.2` are equivalent.

For floating refs, `resolved_version` reflects the version at bind time. The resolver does not re-resolve floating refs during a binding's lifetime — to roll forward, delete and re-bind. This keeps the per-turn resolve hot path off the version table and out of any retry storm if a publish goes sideways.

## The Redis cache

`POST /resolve` is on the hot path: ScaiWave calls it before every LLM turn. The result is cached in Redis at `scaiskills:resolve:{scope_type}:{scope_id}` for 60 seconds and the cache key is tracked in `mod_scaiskills_cache_keys` so binding writes can invalidate without scanning.

Every write to the binding table — create, delete, grant — calls `ResolverService.invalidate(scope_type, scope_id)` after committing. The very next resolve repopulates the cache from MariaDB. The TTL is a backstop, not the primary correctness mechanism.

If Redis is unavailable, resolution still works — it just hits MariaDB every call. The cache key tracking still tries to write; failure there is benign.

## What resolve actually returns

The runtime gets the lightweight projection:

```json
{
  "skills": [
    {
      "slug": "summarise",
      "version": "1.0.0",
      "description": "Summarise a passage in three sentences.",
      "triggers": ["summarise", "tl;dr"]
    }
  ],
  "cache_ttl_ms": 60000
}
```

That's all. The full `SKILL.md` body and `references/` directory are loaded only when the model decides to call `skills.view(slug)`. The point is to keep the per-turn context budget independent of the number of bound skills.

## The cache key tracking table

`mod_scaiskills_cache_keys` records every Redis key the resolver has written. When a binding is created, deleted, or has its grants changed, the write path doesn't need to scan Redis to find affected entries — it queries this table for the matching `(scope_type, scope_id)`, deletes the keys, and clears the rows. Without it, invalidation would either over-delete (drop unrelated caches) or under-delete (leave stale entries until TTL). The table is purely operational; it carries no semantic information not already derivable from the Redis key.

## The lockfile in detail

`resolved_deps_json` is a flat, topologically ordered list of `{skill_id, slug, version}` entries — one per transitive dependency, not including the root. The root is identified by the binding row's own `skill_id` and `resolved_version`. The list is sorted so a consumer reading it top-down can install or wire each dependency before it would be referenced by anything later in the list.

Cycles are detected during the walk via a visiting/visited set: if a dependency points back at an ancestor in the current depth-first chain, the bind fails with `SCAISKILLS_DEPENDENCY_CYCLE`. A skill can legally appear multiple times in the dependency graph (e.g. two siblings both depend on `core-utilities`), but only once in the lockfile — the second visit short-circuits because the node has already been resolved.

Skills declare dependencies as `<slug>@<version-spec>` strings under `manifest.requires.skills`. Each ref is resolved the same way the binding's top-level ref is — against published, non-yanked versions, honouring exact pins or floating ranges. There is no global dependency namespace and no version unification across bindings. Each binding's lockfile is its own independent graph; binding `summarise` into one scope and binding it into another may resolve to different versions if their refs differ.

## What this means for callers

- Treat the binding row as the source of truth for "is this skill active right now". `pending_grants` and `enabled` matter; `resolved_version` is what's loaded.
- Don't poll resolve from inside the runtime — the 60 s cache is the contract. Re-resolve on cache TTL or on receipt of a binding-change event, not every operation.
- For ScaiCore, pin specific versions at bind time. Floating refs in long-lived agent contexts surprise you when a publish lands.
- Wire one admin role to `scaiskills:grant` and keep it small. The pending-grants flag is the only gate stopping a freshly-published version with new declared permissions from going live.
- Inspect `resolved_deps_json` when debugging "why is this dependency version pinned" — it's the authoritative record of what bind time decided, even if the underlying skill catalog has moved on.
