Skip to Content

← All archived runs

Run: skill-service-retier

run.md

Run: skill-service-retier

  • branch: claude/keen-dirac-m5eyrw
  • pr: #593

00_intake/stub.md

Stub: Retire skill categories, link skills to services

  • feature-slug: skill-service-retier
  • epic: catalogue-hierarchy
  • personas: Admin
  • initiative: Build the bridge / objective: okrs/2026-Q2 Objective 2 — Build Repeatable Lead Generation Pipeline (lead-to-vendor match accuracy)
  • depends-on: none
  • sequence: 2 of 5

Problem

Skills are grouped under skillCategory, a taxonomy that is disconnected from products and services. The tiered catalogue wants skills to derive from services, not from an independent category tree, so skill categories must go and skills must instead link to services.

Proposed change

Remove the skillCategory collection entirely and link skillservice many-to-many (a generic skill can support several services). Retire everything that depended on categories: the skillCategory model, the required skillCategory FK on skill, the category admin pages (/skills/categories/**) and their nav, the category CRUD on SkillService, and the category grouping utilities (groupSkillsByCategory, and formatSkillsForSelect if category-shaped). Update the skill create/edit admin to pick services instead of a category. Because skillCategory is currently a required FK and is read across admin + utils, this feature necessarily updates those call sites so the build stays green.

Acceptance criteria (rough)

  • skillCategory model, FK, admin pages, nav entries and CRUD are gone; nothing references it and the build is green.
  • A skill links to many services and a service to many skills (M2M), tenant-scoped; the reverse lookup ("skills for service X") is available in the service layer.
  • Skill create/edit admin selects one or more services in place of the old category selector.
  • Existing skills[] on leads and experts continue to resolve (skills still exist as documents); only the categorisation changes.

Out of scope (this feature)

  • The product↔service link (stub 1) and product admin (stub 3).
  • Lead capture cascade (stub 4) and demo reseed (stub 5).
  • Reworking the matching engine or expert profiles (epic-level out of scope).

Notes for Define

  • This is the one destructive stub — plan the removal so no dangling skillCategory import/route/nav remains. A grep sweep for skillCategory / skill-categor / groupSkillsByCategory is the completeness check.
  • M2M storage shape is a Build decision (array vs join collection); keep it tenant-scoped and index the reverse lookup. Follow mongodb-schema-design.
  • touches: packages/services/src/db/models/{skill,skill-category}.ts, packages/services/src/db/services/skill/, packages/services/src/utils/{groupSkillsByCategory,formatSkillsForSelect}.ts, apps/web/app/(app)/skills/** (esp. categories/** and skill create/edit).

01_define/output/spec.md

Spec: Retire skill categories, link skills to services

  • slug: skill-service-retier
  • personas: Admin
  • touches: packages/services/src/db/models/{skill,skill-category}.ts, packages/services/src/db/migrations/, packages/services/src/db/services/skill/, packages/services/src/db/services/leads/index.ts, packages/services/src/utils/{groupSkillsByCategory,formatSkillsForSelect}.ts, apps/web/app/(app)/skills/, apps/web/components/skills/, apps/web/components/expert/my-knowledge/my-knowledge.tsx
  • complexity: standard

Problem

Skills are grouped under skillCategory, a taxonomy that is disconnected from products and services. The tiered catalogue (platform → product → service → skill, see the catalogue-hierarchy epic) wants skills to derive from services instead of an independent category tree. This advances the Build the Bridge / 2026-Q2 Objective 2 goal of a repeatable lead-generation pipeline with accurate lead-to-vendor matching: a catalogue where skills trace back to the services and products a vendor actually offers is a prerequisite for the lead-capture cascade (product → service → skill) that later stubs in this epic build on top of.

Proposed change

Remove the skillCategory collection entirely and link skillservice many-to-many (a generic skill can support several services). Retire everything that depends on categories:

  • the skillCategory Mongoose model and its required FK on skill
  • the category admin pages (/skills/categories/**) and their nav entry
  • the category CRUD on the skill service layer
  • the category grouping utilities (groupSkillsByCategory, and formatSkillsForSelect where it is category-shaped)
  • the skillCategory populate in the leads service (packages/services/src/db/services/leads/index.ts) — leads keep populating skills, just without the nested category

Add a tenant-scoped skillservice many-to-many link, with a reverse lookup ("skills for service X") available in the service layer for later stubs (lead-capture cascade, demo reseed) to consume. Update the skill create/edit admin to pick one or more services in place of the old category selector — this field is required, at least one service, mirroring the integrity guarantee the old required skillCategory FK gave.

Because skillCategory is currently a required FK and is read across admin, the leads service, and utils, this feature necessarily updates every one of those call sites so the build stays green.

Migration: this run includes a real db-migration (symmetric up/down): drop the skillCategory collection, remove the skillCategory field from skill, and add the new services M2M field (backfilled empty on existing skill documents — no attempt to infer service links from the retired category data; that reconciliation is explicitly out of scope, see below).

Docs flag for Release: apps/docs/app/business/roles/page.mdx currently lists a CSM capability as "Skills — Skill categories and expertise area management". This wording is stale once categories are gone and must be updated by docs-sync at Release (e.g. to reflect skills-linked-to-services).

Acceptance criteria

  • skillCategory model, FK, admin pages (/skills/categories/**), nav entries, and CRUD are gone; nothing references it and the build is green.
  • A skill links to many services and a service to many skills (M2M), tenant-scoped; the reverse lookup ("skills for service X") is available in the service layer.
  • Skill create/edit admin selects one or more services in place of the old category selector; the field is required (at least one service).
  • Existing skills[] on leads and experts continue to resolve (skills still exist as documents; the leads service's skill populate no longer nests a category); only the categorisation mechanism changes.
  • A db-migration ships with this run: drops the skillCategory collection, removes skillCategory from skill, adds the new services M2M field (empty on backfill), with a working down migration.
  • apps/docs/app/business/roles/page.mdx's CSM "Skills" capability line is flagged for docs-sync to update at Release (stale category wording).

Out of scope

  • The product↔service link (product-service-links) and product admin (product-services-admin) — separate stubs in this epic.
  • Lead capture cascade (lead-capture-cascade) and demo reseed (catalogue-demo-reseed) — separate stubs.
  • Reworking the matching engine or expert profiles (epic-level out of scope) — experts keep their flat skills[].
  • Inferring or backfilling skillservice links from the retired skillCategory data — the migration backfills services empty; populating real links is a data-entry/reseed concern (catalogue-demo-reseed for demo tenants), not this migration.
  • Platform tier changes and SDM demand/coverage grids (epic-level out of scope).

Open questions

  • none

02_build/output/notes.md

Build notes: skill-service-retier

  • commits: feat: skill-service-retier — retire skillCategory, link skill↔service M2M

What changed

  • packages/services/src/db/models/skill.ts: dropped the skillCategory FK; added a required, non-empty services: ObjectId[] M2M field with a tenant+services index for the reverse lookup.
  • packages/services/src/db/models/skill-category.ts: deleted (model retired).
  • packages/services/src/db/models/index.ts, taxonomy.ts: dropped the skillCategory export / stale doc-comment references.
  • packages/services/src/db/services/skill/index.ts: rewritten — SkillCreateInput/SkillUpdateInput now take serviceIds: string[] (validated non-empty + must exist for the tenant); findAll/findById populate services ({_id, name}[]) instead of skillCategory; all skillCategory CRUD methods removed; added findActiveByServiceIds as the explicit reverse lookup for later stubs (lead-capture-cascade, catalogue-demo-reseed).
  • packages/services/src/db/services/leads/index.ts: the lead bid-detail populate now nests skills.services instead of skills.skillCategory; dropped the getSkillCategoryModel dependency.
  • packages/services/src/utils/{groupSkillsByCategory,formatSkillsForSelect}.ts: deleted (dead code, category-shaped, unused anywhere in the app).
  • packages/services/src/db/migrations/1782800000000-skill-service-retier.ts: new migration — backfills skill.services = [] / unsets skillCategory, swaps the old category index for a tenantId+services index, and drops the skillcategories collection. down reverses the schema shape (data in the dropped collection is not recoverable, called out in the migration's docstring).
  • apps/web/app/(app)/skills/**, apps/web/components/skills/**: the skill admin now manages a single /skills list (no more skills/categories tabs) with a service filter dropdown; skill create/edit uses a required multi-select checkbox list of services instead of a category <select>. Removed the whole /skills/categories/** route tree and its components (category-form, category-delete-button, category-row-actions, categories-list, skill-categories-list-toolbar, skill-categories-list-query).
  • apps/web/components/expert/my-knowledge/{my-knowledge,my-knowledge-form}.tsx: the skill grouping key changed from the category name to the joined linked-service names (experts' own skill selection UX is otherwise untouched, per the epic's "expert profiles out of scope").

Acceptance criteria status

  • skillCategory model, FK, admin pages (/skills/categories/**), nav entries, and CRUD are gone; nothing references it and the build is green.
  • A skill links to many services and a service to many skills (M2M), tenant-scoped; the reverse lookup ("skills for service X") is available in the service layer (findActiveByServiceIds, plus the serviceId filter on findAll).
  • Skill create/edit admin selects one or more services in place of the old category selector; the field is required (at least one service) — both client-side and in the Zod action schema (.min(1, ...)).
  • Existing skills[] on leads and experts continue to resolve; the leads service's skill populate no longer nests a category (now nests services).
  • A db-migration ships with this run: drops the skillCategory collection, removes skillCategory from skill, adds the new services M2M field (empty on backfill), with a working down migration.
  • apps/docs/app/business/roles/page.mdx's CSM "Skills" capability line is flagged in spec.md's Proposed change for docs-sync to update at Release.

Verify result

  • pnpm --filter @sustentus/services typecheck — clean.
  • pnpm --filter web typecheck — clean (after rebuilding @sustentus/services so dist/*.d.ts picked up the model/service changes — the web app resolves @sustentus/services/* through the package's built output, not source).
  • pnpm --filter web exec eslint . / pnpm --filter @sustentus/services exec eslint . — 0 errors (pre-existing warnings only, none in touched files).
  • pnpm --filter web build — succeeds; /skills, /skills/[id], /skills/[id]/edit, /skills/create compile, /skills/categories/** no longer exists in the route list.
  • Migration itself was not run against a live database in this environment (no DB available here); CI's db-migrate workflow applies it on merge to main, per packages/services/AGENTS.md.

Notes for review

  • SkillService.softDelete still guards on lead/expert skill references only — unchanged from before, since neither of those checks depended on categories.
  • The skillcategories collection drop in the migration is irreversible data loss by design (this is the one destructive stub in the catalogue-hierarchy epic, per the intake breakdown); down restores the schema shape, not the original per-skill category assignments.

03_release/output/changelog.md


title: "Skills now link to services, not categories" date: 2026-07-06T12:00:00+01:00 personas: [admin, csm, sdm] slug: skill-service-retier pr: https://github.com/sustentus/sustentus/pull/593

Skills now link to services, not categories

You can now link each skill directly to the services it supports, instead of filing it under a separate category.

  • The skills page no longer has a categories tab — each skill's card shows the services it supports instead.
  • When you create or edit a skill, you pick one or more services it supports instead of a category.
  • You can filter the skills list by service.

Every skill needs at least one linked service, so the catalogue always shows what each skill is actually for.

03_release/output/investor-update.md

Skill catalogue now traces to the services vendors actually sell

Who it's for: Admin, CSM, SDM What shipped: Skills now link directly to the services they support, replacing a disconnected category taxonomy. Why it matters: Build the Bridge / 2026-Q2 Objective 2 (Build Repeatable Lead Generation Pipeline) — accurate lead-to-vendor matching needs skills traceable to services.

Every skill must now name at least one supported service — no more orphaned entries.

Dig deeper: <merged-PR URL> · <changelog entry URL>

03_release/output/release.md

Release: skill-service-retier

  • pr: #593 · merged: pending (this stage merges after CI + the Ready-to-merge gate)
  • CI: pending check at merge time (see step 6)
  • technical docs: apps/docs/app/technical/demo-environment/page.mdx, apps/docs/public/taxonomy-governance-matrix.md updated in this PR
  • business docs: apps/docs/app/business/roles/page.mdx (CSM "Skills" capability line) updated in this PR
  • release notes: both — changelog entry (apps/help/app/changelog/2026-07-06-skill-service-retier/page.mdx) + investor draft (pipeline/runs/skill-service-retier/03_release/output/investor-update.md) in this PR
  • deploy: pending (step 7, after merge)
  • sent: pending (step 8, gated on green deploy)

Review summary

Ran /code-review medium (complexity: standard) via 8 parallel finder angles against origin/main...HEAD. Fixed on the branch:

  • CorrectnessServiceService.guardReferences (packages/services/src/db/services/service/index.ts) didn't check whether any skill still linked to a service before archiving it, unlike ProductService's equivalent guard — the old skillCategory archive guard had no successor on the new skill→service edge. Added a skill-count check alongside the existing product-link check.
  • Correctness (cosmetic) — My Knowledge's skill grouping key (apps/web/components/expert/my-knowledge/my-knowledge.tsx) joined a skill's service names in DB order, so two skills sharing the same services in a different order rendered as separate groups. Sorted before joining, and reused the sorted list for both the sort key and the grouping key instead of computing it twice.
  • Convention (CONVENTIONS.md "Always use type, never interface")ISkillPopulated in packages/services/src/db/services/skill/index.ts was declared with interface on a line this PR itself edited. Converted to type.
  • EfficiencySkillService.update discarded findOneAndUpdate's returned document and re-fetched via findById; now populates services directly from the update result, saving a round trip. The skill admin's create/edit/list pages each independently fetched-and-mapped the service option list; extracted into apps/web/lib/service-options.ts and parallelized the edit page's two independent reads with Promise.all.
  • Cleanup — a stale "skills with category" doc comment in packages/services/src/db/services/leads/index.ts and the taxonomy-governance-matrix's Service guard row (which predated this PR but was in scope since this PR touches that exact row).

Findings surfaced but not actioned (documented trade-offs, not regressions):

  • The migration backfills skill.services empty on existing skills, which — combined with the schema's new required-non-empty validator — leaves pre-existing skills needing manual re-linking. This is the explicit, spec-documented trade-off of the "one destructive stub" in the catalogue-hierarchy epic (out of scope: inferring/backfilling links from retired category data); catalogue-demo-reseed repopulates demo tenants.
  • SkillService doesn't extend the generic TaxonomyService<T> base class the other taxonomies use (pre-existing pattern, not introduced by this PR — skill's CRUD was always bespoke to support the service-validation/populate logic).
  • A few pre-existing, unrelated patterns (tenant-unscoped service-name lookup in populateServices, validateServiceIds not filtering isActive) mirror gaps that already existed in the pre-PR skillCategory code and are not new regressions.

Acceptance check (vs spec)

  • skillCategory model, FK, admin pages (/skills/categories/**), nav entries, and CRUD are gone — verified via repo-wide grep sweep (zero non-migration references) and a clean pnpm --filter web build.
  • Skill↔service M2M, tenant-scoped, with a reverse lookup in the service layer — SkillService.findActiveByServiceIds plus the serviceId filter on findAll.
  • Skill create/edit requires ≥1 service — enforced client-side (checkbox list + submit guard) and server-side (Zod .min(1, ...) in actions.ts + the Mongoose schema validator).
  • Leads/experts' skills[] keep resolving — the leads bid-detail populate now nests services instead of skillCategory; verified via pnpm --filter @sustentus/services typecheck.
  • A working, symmetric db-migration ships with this run (1782900000000-skill-service-retier.ts) — renamed during Release to dedupe a timestamp collision with the sibling product-service-links migration that merged to main first (1782800000000-product-service-link-indexes.ts); rebased cleanly.
  • The Roles doc's stale "Skill categories" wording is fixed in this PR (done directly, not deferred) via the docs-sync pass.