Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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_idworkspace, 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:

scdoc
1
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_ids 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:

  • Exact0.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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "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.
Updated 2026-05-18 15:01:32 View source (.md) rev 12