Wire skills into agent runs: org-scoped, published-only, org-preferred resolution

ISkillCatalog.GetByKeysAsync now takes the org id and resolves each key within that org's namespace
only — the org's own published skill, else a shared builtin (null org), never another org's. Org-owned
is preferred over the builtin; only Published (golden-tested) skills are injected; the resolved
skill@version is recorded in the prompt heading and run trace. AgentRunExecutor threads
context.OrganizationId. SeatsPage now loads the org library (builtins + authored + installed), dedupes
to one entry per key, and flags drafts (won't run until published).

Verified: ArchitectureTests 8/8, IntegrationTests 48/48 (new SkillRunScopingTests: a run assembles the
org's own skill over the builtin of the same key, and another org's same-key skill never leaks in),
client build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-13 13:35:53 +03:30
parent cca7c68da3
commit 2ebe2808be
6 changed files with 216 additions and 9 deletions
@@ -9,6 +9,7 @@ namespace TeamUp.Modules.Skills.Catalog;
internal sealed class SkillCatalog(SkillsDbContext db) : ISkillCatalog
{
public async Task<IReadOnlyList<SkillPrompt>> GetByKeysAsync(
Guid organizationId,
IReadOnlyCollection<string> keys,
CancellationToken cancellationToken = default)
{
@@ -18,17 +19,28 @@ internal sealed class SkillCatalog(SkillsDbContext db) : ISkillCatalog
}
var wanted = keys.ToHashSet();
var skills = await db.Skills.Where(s => wanted.Contains(s.SkillKey)).ToListAsync(cancellationToken);
// Org-scoped: only this org's own skills or shared builtins (null org) — never another org's.
// Published-only, so a run never executes an ungated (Draft) skill.
var skills = await db.Skills
.Where(s => wanted.Contains(s.SkillKey)
&& s.Status == SkillStatus.Published
&& (s.OrganizationId == organizationId || s.OrganizationId == null))
.ToListAsync(cancellationToken);
return skills
.GroupBy(s => s.SkillKey)
.Select(group => group.OrderByDescending(s => s.Version, StringComparer.Ordinal).First())
.Select(group => group
.OrderByDescending(s => s.OrganizationId == organizationId) // a forked/authored org skill beats the builtin
.ThenByDescending(s => s.Version, StringComparer.Ordinal) // then the latest version in that scope
.First())
.Select(s =>
{
var primary = s.Actions.Count > 0 ? s.Actions[0] : null;
return new SkillPrompt(
s.SkillKey,
s.Name,
s.Version,
s.Body,
primary?.Name ?? "respond",
(primary?.Risk ?? ActionRisk.Draft).ToString(),