Skip to content

feat: member-derived skillset visibility (#1136)#1137

Merged
chronoai-shining merged 14 commits into
developfrom
feature/1136-skillset-derived-visibility
Jun 16, 2026
Merged

feat: member-derived skillset visibility (#1136)#1137
chronoai-shining merged 14 commits into
developfrom
feature/1136-skillset-derived-visibility

Conversation

@chronoai-shining

Copy link
Copy Markdown
Collaborator

Summary

Redesigns skillset visibility so a skillset has no owner-set visibility — its reach is bounded by its least-privileged member skill:

A caller can read / use / discover a skillset iff they can read every member skill of it.

Owners can no longer manually set a skillset's visibility. To widen a skillset's reach, expose the underlying member skills to the intended audience. The page warns the owner (and an in-product notification fires) when a member skill becomes unreadable and shrinks the skillset's reach.

Closes #1136. Follow-up to #1125. Related to #1134.

Backend (ornn-api)

  • Derived cachemembersAllPublic + memberVisibilityState (all-public / restricted / unresolvable) on each skillset, computed from the latest version's direct members under SYSTEM. Recomputed on create/publish, reactively on member-skill changes, and by an idempotent boot backfill.
  • Reverse indexskillset_versions.members multikey index + findSkillsetGuidsByMember so a skill change can find the skillsets containing it.
  • Member-derived read gategetSkillsetForRead resolves each member under the caller; owner/admin always see the detail with unreadableMembers (for repair), everyone else gets a flat 404 the moment a member is unreadable (no leak of which one). The closure resolver drops its standalone entry gate — the per-member loader already enforces visibility.
  • Option-B live discoverypublic/mine paginate exactly on the denormalized fields (all-public fast-path); private/mixed/shared-with-me fetch bounded candidates and live-check each restricted-by-others candidate per caller. A restricted skillset is discoverable only by callers who can read every member.
  • Reactive recompute + notificationfireSkillsetRecompute wired into the 5 visibility-affecting skill routes (privacy flip, permissions, transfer, nyxid-service, delete); fires a one-per-skillset skillset.member_unreadable owner notification when the change cost the owner access. Best-effort owner-readability (a background task can't resolve the owner's org memberships — the page recomputes the authoritative set under the owner's token).
  • RemovedPUT /skillsets/:id/permissions, setPermissions, the permissions schema. Legacy isPrivate/grants columns stay inert this release (rollback safety).
  • Single-sourced the notification-category vocabulary (also fixes a latent drift that was deleting launchPromo.codeDelivered rows on reboot).

Frontend (ornn-web)

  • Read-only SkillsetVisibilityBadge (Public / Restricted / Broken) in the hero strip + browse cards.
  • Read-only SkillsetDerivedVisibilityCard (no "Manage permissions" — explains reach is widened by sharing the members) + an owner/admin SkillsetMemberWarningBanner listing unreadable members with the fix.
  • Removed the SkillsetPermissionsModal + permissions API/hook/type.
  • Renders the skillset.member_unreadable notification (deep-links to the skillset).

Verification

  • bun run typecheck (api + web + sdk) — clean
  • bun run lint — 0 errors (2 pre-existing warnings in an untouched file)
  • bun run test:api — 1949 pass / 0 fail
  • bun run test:web — 573 pass / 0 fail
  • assistant KB digest — unchanged
  • Changeset added (minor, both packages)

🤖 Generated with Claude Code

First step of member-derived skillset visibility: add `membersAllPublic`
+ `memberVisibilityState` ("all-public" | "restricted" | "unresolvable")
to `SkillsetDocument`, plumbed through create (seeded all-public), update
(recompute-only setters), and mapDoc (defaults to all-public when absent
so existing docs read cleanly until the boot backfill runs).

No behaviour change yet — these fields are written but not yet computed
from members or consulted by the read/discovery gates. The legacy
owner-set `isPrivate`/`sharedWith*`/`grants` on skillsets are marked
inert (kept for rollback); subsequent commits make visibility derived.

Part of #1136
A skill visibility change must recompute every skillset that references
it, so we need to answer "which skillsets contain skill X?" cheaply.

Add a multikey index on `skillset_versions.members` and a
`findSkillsetGuidsByMember(name, guid)` lookup. Member refs come in
several grammars (`name@major.minor`, `guid@major.minor`,
`name@dist-tag`), so the query anchors on `^(name|guid)@` — the `@`
boundary prevents prefix bleed (`rev` must not match `review@1.0`) and
the name is regex-escaped so a metachar in a skill name is matched
literally. Scans all (immutable) versions; callers dedupe to the guid
and recompute only each skillset's latest version.

Part of #1136.
A skillset has no owner-set visibility — its reach is bounded by its
least-privileged member. Add the recompute that derives the
denormalized cache (`membersAllPublic` + `memberVisibilityState`) from
the latest version's direct member refs:

  - all members public        → all-public
  - ≥1 private (resolvable)    → restricted
  - ≥1 ref no longer resolves  → unresolvable

Classification resolves each ref under SYSTEM via the shared skill
version loader, so the ref grammar (name/guid, version/dist-tag/latest)
stays single-sourced with the closure walk. To classify without a
second lookup, `ResolvedVersion` now carries the skill's `isPrivate`
(the closure algorithm ignores it; under SYSTEM it faithfully reports
privacy since the read gate passes everything).

`recomputeForSkill(name, guid)` fans the recompute over every skillset
referencing a changed skill (via the reverse member index) and returns
the affected guids for downstream owner-notification work. Writes go
through a dedicated `setDerivedVisibility` repo method that does NOT
bump `updatedBy`/`updatedOn` — the cache reflects member privacy, not a
user edit. Not yet wired into routes.

Part of #1136.
The repo seeds a new skillset as all-public, which is wrong the moment
a member is private. Wire `recomputeSkillsetVisibility` into
`createSkillset` (after the first version is written) and
`publishVersion` (after `latestVersion` advances) so the derived cache
reflects the actual member set immediately — a skillset created with a
private member is "restricted", and a publish that swaps members
rederives against the new latest version.

The skill-side write path drives the same recompute reactively in a
later commit; this covers the skillset-side writes.

Part of #1136.
Existing skillsets predate the derived-visibility cache, so their
`membersAllPublic` / `memberVisibilityState` default to the seeded
all-public until something recomputes them. Add an idempotent one-shot
backfill (`backfillDerivedVisibility`) that recomputes the cache for
every skillset on startup, wired into `wireSkillsets` and awaited right
after `ensureIndexes`.

A single skillset's recompute failure is logged and skipped — one bad
or unresolvable member set must not abort the backfill or block boot.
Adds `SkillsetRepository.listAllGuids` (projection-only) to drive the
iteration cheaply.

Part of #1136.
A skillset has no owner-set visibility — a caller may read it iff they
can read every member skill. Replace the standalone `canReadSkillset`
delegate with a service-level, per-caller gate:

  - `getSkillsetForRead` resolves each member ref under the caller via
    the shared skill loader; the owner/admin always see the detail WITH
    the members they can't read (`unreadableMembers`, request-scoped) so
    they can repair access, while everyone else gets a flat 404 the
    moment any member is unreadable — never leaking which one.
  - the closure resolver drops its standalone entry gate; the per-member
    loader already enforces visibility node-by-node
    (`skill_dependency_not_found`, no leak).

The detail/search responses now carry the derived `memberVisibilityState`
(authoritative) + `unreadableMembers`; the legacy `isPrivate`/`grants`
are inert. The skillsets `authorize.ts` (a thin, now-unused delegate to
the skill gates) is deleted.

Part of #1136.
Discovery now follows the member-derived model: a skillset is
discoverable by a caller iff they can read all its members. Rewrite the
skillset repository's scoped read off the derived `memberVisibilityState`
(NOT the shared skill `applyScope`, which assumes owner-set `isPrivate`):

  - `public` / `mine` → exact Mongo pagination on denormalized fields
    (`findCheapScope`); the all-public fast-path keeps the common browse
    case O(1).
  - `private` / `mixed` / `shared-with-me` → `findLiveScopeCandidates`
    returns a bounded candidate superset, then the search service
    live-checks each restricted-by-others candidate via the new
    `SkillsetService.canDiscoverSkillset` (per-caller member readability)
    and paginates in-memory. A restricted skillset surfaces only to
    callers who can read every member; never leaked otherwise. The
    candidate cap is logged when hit (no silent truncation).

The search service now takes the resolved `ActorContext` (the live gate
resolves member refs under the caller, so it needs org memberships, not
just the user id) and is wired with the skillset service. Drops the now
unused `applyScope`/`applyExtraFilters` import and the inert legacy
`sharedWith*Any`/`createdByAny` skillset filters.

Part of #1136.
Add the owner-side notification fired when a member skill's visibility
change costs the skillset OWNER read access, so they know the skillset's
reach shrank and can repair it. `notifySkillsetMemberUnreadable`
coalesces to one notification per skillset per recompute (the caller
passes the full unreadable member set) and deep-links to the skillset.

Single-source the category vocabulary: `NOTIFICATION_CATEGORIES` is now
the one canonical list, with both the `NotificationCategory` type and the
boot migration's allow-list derived from it. This also fixes a latent
drift — the hand-maintained allow-list had already lost
`launchPromo.codeDelivered`, so those notifications were being deleted on
every reboot.

Not yet wired into the recompute path.

Part of #1136.
Wire the derived-visibility recompute into every skill mutation that
changes a skill's readability: `PUT /skills/:id` (privacy flip only),
`PUT /skills/:id/permissions`, `POST /skills/:id/transfer-ownership`,
`PUT /skills/:id/nyxid-service`, and `DELETE /skills/:id`. Each fires a
fire-and-forget `fireSkillsetRecompute({guid, name})`, mirroring the
existing `fireMirrorSync` pattern — never blocking the response.

`SkillsetService.recomputeForChangedSkill` recomputes the cache for
every skillset referencing the skill, then — for each affected skillset
whose OWNER the change cost member-read access — fires one
`skillset.member_unreadable` notification. To avoid re-notifying on
unrelated changes while a skillset is already broken, a notification
fires only when the just-changed skill is among the owner's now-unreadable
members. Owner-readability is best-effort: a background task has no way
to resolve the owner's org memberships, so org-granted access can't be
confirmed here — the skillset page recomputes the authoritative set
under the owner's own token.

Bootstrap binds the hook via a forward reference because the skill
routes are built before the skillset wiring (which injects the skill
service); the hook is only invoked at request time, after boot.

Part of #1136.
A skillset's visibility is now wholly derived from its members, so the
owner-set permission path is dead and misleading. Remove
`PUT /skillsets/:id/permissions`, `SkillsetService.setPermissions`, the
`skillsetPermissionsSchema` / `SkillsetPermissionsInput`, and the
now-unused grant-resolution imports. Drop the obsolete visibility-
transition tests; the closure test no longer needs to "make it public"
to admit an anon caller (there is no entry gate — the private member dep
is what blocks them).

The legacy `isPrivate`/`grants` columns stay on the schema, inert, for
this release (rollback safety); a later cleanup drops them.

Part of #1136.
…ref (#1136)

prefer-const flagged the let forward reference; a const holder object
filled in after the skillset wiring exists is equivalent and lint-clean.
A skillset has no owner-set visibility — its reach is bounded by its
member skills. Surface that in the UI and remove the owner-set
permissions path:

- types carry the derived `memberVisibilityState` + per-caller
  `unreadableMembers`; the legacy `isPrivate`/`grants` are inert.
- a read-only `SkillsetVisibilityBadge` (Public / Restricted / Broken)
  replaces the isPrivate pill in the hero strip and browse cards.
- the detail page's right-rail visibility card becomes the read-only
  `SkillsetDerivedVisibilityCard` (no "Manage permissions" action;
  explains that reach is widened by sharing the member skills), and an
  owner/admin `SkillsetMemberWarningBanner` lists members the caller can
  no longer read with the fix.
- the `SkillsetPermissionsModal`, `updateSkillsetPermissions` API,
  `useUpdateSkillsetPermissions` hook, and `SkillsetPermissionsInput`
  type are removed (the backend endpoint is gone).

Part of #1136.
Add the `skillset.member_unreadable` category to the frontend
notification union and the category-label maps (bell detail modal +
notifications page) so the owner-side member-access-loss notification
renders with a "Skillset" label and deep-links to the skillset. Also
adds the `launchPromo.codeDelivered` category the union had been
missing.

Part of #1136.
@chronoai-shining chronoai-shining merged commit d500d69 into develop Jun 16, 2026
25 of 26 checks passed
@chronoai-shining chronoai-shining deleted the feature/1136-skillset-derived-visibility branch June 16, 2026 10:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Member-derived skillset visibility (no manual control) + lost-access warnings & owner notifications

1 participant