feat(#577): scope-ordered skill resolution with shadowing (P2) - #768
Merged
Conversation
Adds the model layer for scope-owned, tamper-evident skills: - migrations/0040_skill_ownership_lifecycle.sql: owner_scope, lifecycle_status (draft/reviewed/published/archived), manifest_signature, manifest_signed_at columns on the existing skills table (0003). - src/services/skillLifecycle.ts: pure model — SkillOwnerScope (ScopeId restricted to personal/group/org), the lifecycle transition matrix, requiredCapabilities parsing from frontmatter (throws SkillManifestError with a field-naming message on malformed input, never silently drops — #690 guard), canonical manifest serialization + HMAC-SHA256 sign/verify. - src/services/skillLifecycleStore.ts: thin Postgres store over the new columns (assignPersonalOwner, transition), standalone from AgentGraphStore to keep this phase's surface to src/services/skill*. - test/skillLifecycle.test.ts (30 tests): exhaustive 4x4 transition matrix, byte-exact canonical manifest lock, capability-order independence, capability case-sensitivity (mirrors #724's role-key precedent), tamper detection, malformed-signature handling. - test/skillOwnershipLifecycleStore.pg.test.ts (6 tests, pg-gated): full draft->reviewed->published->archived flow against real Postgres, publish-gate rejection, re-signing on every transition.
Adds resolveSkillByName in src/services/skillResolver.ts: given a skill name and a requester's scope, pick the single winning skill row across personal -> shared -> team -> org, in that strict order (#577 Kernkonzept #4). Pure and synchronous, layered on P1's ScopeId/lifecycle model (#767) without touching GrantStore directly — membership/sharing are passed in pre-resolved (SkillResolutionContext), same seam P1 used for capability grants. Resolves by `name`, not `slug`: skills.slug is globally UNIQUE (0003), so no schema change is needed for two scopes to own same-named skills. Two invariants get explicit, separately-mutation-tested coverage: - precedence order (personal beats org even at equal names -- the 'non-empty result from the wrong level' danger named in the phase spec, parallel to the quorum='all' fail-open lesson from #726) - the lifecycle gate: only 'published' skills are eligible candidates, filtered BEFORE bucketing, so a draft at a higher-precedence level can never outrank a published skill lower down. A third case is guarded structurally: two candidates tying within one bucket return an explicit 'ambiguous' result (level + full candidate list) rather than an arbitrary pick -- same 'absence/uncertainty is a type' posture as resolveCapabilities (#575) and RoleSourceRegistry (#333). 15 tests in test/skillResolver.test.ts, all pure (no pg gate needed). Mutation-tested: reversing bucket precedence order fails 3 tests; dropping the published-status filter fails 3 tests.
This was referenced Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 2 of #577 (skills as scope-owned, shareable artifacts). Adds the scope-ordered resolver with shadowing — no sharing/promotion/cron-guard (P3), no admin UI (P4).
Base:
feat/577-skill-artifacts(P1, #767), NOTmain. Stacked per the phase-cut plan — this PR should be merged after #767, or #767's diff will show up here until then. GitHub will resolve the base automatically once #767 merges intomainand this branch is rebased/retargeted, but for review right now the "Files changed" tab includes P1's 5 files plus this PR's 2 new ones.What's in this PR
src/services/skillResolver.ts—resolveSkillByName(name, candidates, ctx), pure and synchronous:name, notslug—skills.slugis globallyUNIQUE(migration0003), so no schema change was needed: two scopes can already own same-named skills as long as their slugs differ (which the existinguniqueSlugdisambiguation inskillImport.tsalready guarantees on insert).personal → shared → team → org(Skills as scope-owned, shareable artifacts (grants, org promotion, git skill packs) #577 Kernkonzept chore(deps,docker,web-dev): Bump node from 20-slim to 26-slim in /web-dev #4), evaluated in that order, stopping at the first non-empty bucket.SkillResolutionContext.memberTeams/.orgId/.sharedSkillIds) — this module never talks toGrantStoredirectly, same seam P1'smissingRequiredCapabilitiesused for capability grants. P3 is the phase that wiresGrantStoreto producesharedSkillIds.lifecycleStatus === 'published'candidates are eligible, filtered before bucketing.{ ok: false, reason: 'ambiguous', level, candidates }rather than an arbitrary pick.The two dangerous cases (both explicitly named in the phase-cut plan, both separately tested + mutated)
personal beats org even at equal names. Mutated by reversing the bucket evaluation order → 3 tests fail (including the ambiguity-short-circuit test, which also depends on precedence order).a draft at a higher-precedence level does NOT win over a published skill at a lower level. Mutated by dropping thelifecycleStatus === 'published'filter → 3 tests fail.A third, related failure mode — silent tie-breaking within one bucket — is guarded structurally (
ambiguousresult) and covered by 2 tests (two org skills with the same name are ambiguous,ambiguity at personal short-circuits).Test coverage (15 tests,
test/skillResolver.test.ts, all pure — no pg gate needed)ownerScope: null) never resolves; a personal skill owned by a different user is not auto-matched; team membership in a different team than the owner doesn't match; org candidate doesn't match when the requester has no org.requesterScope.kind !== 'personal'(e.g. a system/routine scope) correctly leaves the personal bucket empty without erroring.Mutation evidence
orgchecked beforepersonal)personal beats org…,shared beats team beats org,ambiguity at personal short-circuits…lifecycleStatus === 'published'filterBoth mutations reverted after confirming failure;
git diff --staton the mutated file shows no residue afterward.Blast radius
skillLoader.ts(the on-diskSKILL.mdparser) is untouched; this resolver is a new, independent module operating over DB-backed candidate rows a caller supplies (P3/P4 wire the actualAgentGraphStorequery — out of scope here, kept pure and testable in isolation).Not in this PR (later #577 phases)
GrantStore-backed resolution ofsharedSkillIds(P3).RoleSourceRegistry-adjacent membership source already exists).Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.