Skip to content

Release v0.13.0 — skill & skillset permissions#1130

Merged
chronoai-shining merged 35 commits into
mainfrom
develop
Jun 16, 2026
Merged

Release v0.13.0 — skill & skillset permissions#1130
chronoai-shining merged 35 commits into
mainfrom
develop

Conversation

@chronoai-shining

Copy link
Copy Markdown
Collaborator

Release v0.13.0 — Skill & skillset permissions

Promotes developmain to cut v0.13.0. Ships the skill/skillset permissions work landed since v0.12.0:

Carries 3 feature changesets (all minor → v0.13.0) + the curated release-notes-20260616.md.

Migration note: the new ornn-api image runs idempotent, non-disruptive boot migrations that backfill typed grants (read-level) from the legacy share lists and rename any read_writewrite. Existing public / org / user shares keep exactly their current access (read-only); nobody is escalated to write. Rollback-safe (additive grants field).

On merge, the changeset-release workflow opens the release/v0.13.0 → main version-bump PR.

chronoai-shining and others added 30 commits June 15, 2026 13:19
Introduce the canonical typed access-grant shape that will replace the
legacy read-only `sharedWithUsers` / `sharedWithOrgs` allow-lists: a
`SkillGrant { type, id, level }` pairing a user/org principal with a
`read` or `read_write` level, plus a pure, reusable `grants.ts` helper
module (effective-grant resolution, legacy derive/project, normalize).

The field is added to `SkillDocument` as OPTIONAL on purpose. Skills
predating #1123 carry only the legacy lists, and `effectiveGrants`
derives identical READ-level grants from them when `grants` is absent —
so this commit changes no behaviour and cannot disrupt existing skills.
`legacyListsFromGrants` is the inverse projection used later for the
transitional dual-write that keeps a rolling deploy non-disruptive.

Pure machinery, no wiring yet (authz/scopeFilter/routes follow). Unit
tests pin the grant algebra, especially the legacy round-trip and the
read_write-wins normalization.

Part of #1123
Wire the `grants` field through the skills repository and the detail
response:

- `create` seeds an empty `grants` array (new skills are born migrated)
  and keeps the legacy lists in lock-step.
- `update` treats `grants` as the canonical ACL write: when provided it
  dual-writes the legacy `sharedWithUsers` / `sharedWithOrgs` lists via
  `legacyListsFromGrants` (every grant id → read visibility) so an older
  pod mid-rolling-deploy still resolves correct read access and nobody is
  escalated to write. Legacy `sharedWith*` writes only apply when `grants`
  is absent.
- `mapDoc` surfaces stored grants with defensive coercion (drops malformed
  entries) and leaves the field undefined for un-migrated docs.
- the skill detail response now carries `grants`, emitted via
  `effectiveGrants` so the shape is identical whether or not the skill has
  been migrated.

No authorization behaviour changes yet — `grants` is only read back, the
gates still use the legacy lists. Existing CRUD suite (342 tests) stays
green.

Part of #1123
Mirror the skills grant-persistence onto the skillsets domain, which
shares the ownership model verbatim:

- `SkillsetDocument` + `SkillsetDetailResponse` gain the optional `grants`
  field; the detail builder emits `effectiveGrants(skillset)`.
- the skillset repository create/update/mapDoc handle `grants` with the
  same canonical-write + transitional dual-write of the legacy lists.

Promote the defensive stored-grant coercion into the shared `grants.ts`
as `coerceStoredGrants` and use it from both repositories, removing the
duplicate that briefly lived in the skills repo. Unit tests cover the
malformed-entry drops and the un-migrated/empty cases.

Still no authz behaviour change — grants are only read back.

Part of #1123
Backfill the typed `grants` array on every skill / skillset that predates
it, deriving one `read`-level grant per legacy `sharedWithUsers` /
`sharedWithOrgs` entry. Runs at boot right after the model-catalog
migration, before any skill read is served.

Non-disruptive by construction — the hard requirement for this feature:
the legacy lists are left untouched, public/private flags are untouched,
and nobody is escalated to write (every backfilled grant is `read`).
Idempotent: only docs missing `grants` are matched, so reruns are no-ops;
it runs server-side as one updateMany + aggregation pipeline per
collection. Failure is logged non-fatally because `effectiveGrants` still
falls back to the legacy lists for any un-migrated doc.

Integration tests run the migration against a real Mongo and pin the
non-disruption invariant, the empty/ancient-doc cases, idempotency, and
that an existing read_write grant is never clobbered.

Part of #1123
Make the single source-of-truth `authorize.ts` (shared by skills +
skillsets) grant-aware:

- `canReadSkill` now resolves access from the typed `grants` ACL — any
  level confers read, directly or via membership of a granted org. It
  falls back to `effectiveGrants` so an un-migrated doc reads exactly as
  before off its legacy lists.
- new `canWriteSkill` = author OR platform admin OR a `read_write` grant
  (direct or via a granted org) — the READ_WRITE tier (content + metadata
  edits only).
- `canManageSkill` is unchanged (author OR platform admin) = the ADMIN
  tier; a read_write grantee is deliberately never an admin.

A shared `actorMatchesGrant` core walks the effective grants once so the
read and write gates can never diverge on the matching rules; both fail
soft on an unresolved org-membership lookup, matching the existing read
behaviour. `SkillOwnership` gains the optional `grants` field.

No route yet calls `canWriteSkill` — that re-pointing lands next. Full
skills + skillsets suites (758 tests) stay green.

Part of #1123
Re-point the content/metadata update paths at the new write gate so a
read_write grantee can update a skill without being its owner:

- `PUT /skills/:id`: base gate becomes `canWriteSkill`; a request that
  actually changes `isPrivate` additionally requires `canManageSkill`, so
  flipping visibility stays an ADMIN-tier action (a read_write grantee can
  replace content but not toggle public/private).
- skillset `publishVersion` gates on `canWriteSkill` (publishing a new
  version is a content edit); permissions/transfer/delete stay ADMIN-only.
- add `canWriteSkillset` to the skillset authorize delegation + `grants`
  to `SkillsetOwnership`.

Route tests cover the new matrix: read_write grantee republishes content
(200), read_write grantee flipping visibility (403), read-only grantee
republishing content (403).

Part of #1123
Extend `PUT /skills/:id/permissions` and `PUT /skillsets/:id/permissions`
to carry the canonical typed `grants` ACL, while still accepting the
legacy `sharedWithUsers` / `sharedWithOrgs` arrays for backward-compatible
callers (they map to READ-level grants).

- add the shared `skillGrantSchema` + a `resolvePermissionGrants` helper to
  `grants.ts` (prefers typed grants, falls back to the legacy lists).
- both permission setters now resolve to normalized grants, drop a grant
  that names the author (implicit ADMIN), and reuse the existing #815/#842
  org-membership gate over the resolved org grants — so sharing a
  read_write grant into a non-member org is rejected exactly like a read
  share, and an unresolved lookup still 503s.
- `skill.permissions_changed` analytics gains a `readWriteGrants` count.

The service methods are the back-compat boundary, so the existing
org-gate tests pass unchanged; assertions that checked the dual-written
legacy lists now assert on the canonical `grants`. New tests cover the
typed-grants path (read_write user + org), the non-member gate over an
org grant, and author-self-grant pruning.

Part of #1123
Add owner-to-owner skill transfer — the first path that mutates the
otherwise-immutable `createdBy`.

- repository `transferOwnership`: the single explicit `createdBy` write,
  refreshing the cached owner labels and replacing the ACL (dual-writing
  legacy lists), kept separate from `update` so the immutability invariant
  stays obvious.
- service `transferSkillOwnership`: rejects a no-op transfer to the current
  owner (`ownership_conflict`, 409), and recomputes grants so the prior
  owner is appended as a READ grantee (keeps visibility, loses edit/admin)
  while the new owner is dropped from any prior grant (they hold implicit
  ADMIN now).
- route `POST /skills/:id/transfer-ownership`: ADMIN-gated (`canManageSkill`
  — never a read_write grant); validates the target against the injected
  user directory (`invalid_transfer_target`, 400, when unknown); emits the
  new `skill.ownership_transferred` analytics event; resyncs the mirror to
  refresh stale author labels. Immediate/unilateral per the agreed design.
- bootstrap wires `resolveUser` from the user directory repo.

Service tests cover label refresh + prior-owner read grant + new-owner
drop + ownership_conflict; route tests cover the 403/404/409/400 matrix,
the owner happy path, and platform-admin force-transfer.

Part of #1123
Mirror skill transfer onto skillsets. Because the skillset routes
delegate authorization to the service (the route layer has no repo), the
service owns the whole flow — ADMIN gate (`canManageSkill`), no-op
rejection (`ownership_conflict`), AND target resolution against the
injected user directory. Resolving inside the service (rather than the
route) means a non-owner can't enumerate users via the 400-vs-403
difference. The repository gets the same dedicated `transferOwnership`
write (the one explicit `createdBy` mutation), refreshing owner labels +
replacing the ACL with the prior owner kept as a READ grantee.

`resolveUser` is injected into `SkillsetService` via `wireSkillsets`,
backed by the same user-directory repo as the skills path.

Service tests cover owner reassignment + prior-owner read grant, the
non-owner 403, the no-op 409, and the unresolvable-target 400; route
tests cover the permission gate, delegation, and body validation.

Part of #1123
Minor bump for ornn-api + ornn-web (fixed-version pair). The SDKs release
on a separate cadence and are not part of the changeset set.

Part of #1123
Frontend plumbing for the new API surface, no UI yet:

- `SkillPermissionLevel` + `SkillGrant` domain types; `grants?` added to
  `SkillDetail`.
- `permissionsApi`: `SkillPermissionsInput.grants` (legacy `sharedWith*`
  arrays kept optional for back-compat) + a `transferSkillOwnership` POST.
- `useTransferSkillOwnership(skillGuid, idOrName)` mutation following the
  established #750 two-id split + invalidation (detail, lists, my-skills,
  shared-with-me) so the new owner redraws and the owner-only UI drops
  after a transfer.

Part of #1123
Add a "Transfer ownership" action above Delete in the owner-only Danger
Zone. `TransferOwnershipModal` reuses the directory email-typeahead (the
same `searchUsersByEmail` source the PermissionsModal uses, NOT the
admin-only picker) for a single-target select, then requires the owner to
type the skill name before the danger-styled Transfer button enables.

On success the caller is no longer the owner, so the existing list/detail
invalidation in `useTransferSkillOwnership` refetches the detail — the new
owner renders and the owner-only Danger Zone drops away. Modal state lives
in `useSkillDetail` alongside the other modal flags.

Tests cover the two-step gate (target + exact name) and that confirming
fires the mutation with the selected user id.

Part of #1123
#1123)

The PermissionsModal now drives the typed `grants` ACL instead of bare id
lists:

- each granted user (chip) and checked org (row) carries a compact
  read / read-write toggle (`LevelToggle`); new grants default to read.
- form state initializes per-grant levels from `skill.grants` (falling
  back to read-level from the legacy lists), the resolve-labels effect
  preserves the level, and Save builds + sends typed `grants` with
  level-aware change detection (a level flip alone now counts as a change).
- unresolved (ghost) user grants hide the toggle — you can only revoke them.
- `SkillVisibilityCard` gains a "N can edit" line counting read-write grants.

A focused test covers init-from-grants, the read-write→read flip, and that
Save sends the canonical typed-grants payload. Full web suite stays green.

Part of #1123
Expose the new permission surface in @chronoai/ornn-sdk:

- `SkillPermissionLevel` + `SkillGrant` types; `grants` on SkillDetail /
  SkillsetDetail; a shared `PermissionsInput` (carries `grants`, legacy
  `sharedWith*` still accepted) re-exported as the skill/skillset aliases.
- `setSkillPermissions(id, …)` — the SDK had no skill permissions setter
  before (only skillsets); this fills the gap.
- `transferSkillOwnership(id, newOwnerUserId)` and
  `transferSkillsetOwnership(id, newOwnerUserId)`.
- `setSkillsetPermissions` now accepts `grants`.

Transport plumbing (envelope/error/zip helpers) extracted into a new
`http.ts` so `client.ts` stays under the 500-line cap after the additions.
8 new tests (transfer success + 403, grants setters + 400 validation);
38 pass; tsc + lint clean.

Part of #1123
Mirror the TypeScript SDK additions in the Python client:

- `SkillPermissionLevel` literal + `SkillGrant` dataclass (with
  from_dict/to_json); optional `grants` parsed onto SkillDetail /
  SkillsetDetail (defaults to [] on pre-#1123 responses).
- `set_skill_permissions(...)` — NEW (no skill permissions setter
  existed before); `set_skillset_permissions(...)` gains a `grants` kwarg;
  a shared `_permissions_payload` builds both bodies and omits `grants`
  from the wire when not supplied (so the server's legacy fallback holds).
- `transfer_skill_ownership(id, new_owner_user_id)` and
  `transfer_skillset_ownership(id, new_owner_user_id)`.

45 pytest pass (py3.9 + py3.12); ruff + strict mypy clean.

Part of #1123
- CONVENTIONS §5.2: reconcile the documented↔code drift — replace the
  fictional `ornn:skill:read/write/admin` catalog with the real route
  scopes (`ornn:skill:{create,read,update,delete}`, `ornn:skill:build`,
  `ornn:playground:use`, `ornn:admin:skill`), and frame them as route
  scopes distinct from object tiers. New §5.4 documents the
  READ / READ_WRITE / ADMIN object-permission matrix, the typed `grants`
  ACL shape, and both transfer-ownership endpoints (with the
  invalid_transfer_target / ownership_conflict behaviors; rides on
  ornn:skill:update, no new scope). §2.6 + §12 updated.
- ERRORS: add `invalid_transfer_target` (400), `ownership_conflict` (409),
  `invalid_permission_level` (400) with client-action guidance + migration
  map rows.
- ARCHITECTURE: add the `skill.ownership_transferred` analytics event and
  the `readWriteGrants` count on `skill.permissions_changed`.

Part of #1123
…1123)

The KB digest is built from README/CONVENTIONS/ARCHITECTURE etc.; the §5.2
scope reconciliation + §5.4 permission-tier additions changed the
CONVENTIONS source, so the committed digest went stale and the
assistant-kb-freshness CI gate failed. Regenerated via
`bun run build:assistant-kb`.

Part of #1123
…ission-levels

feat: skill permission levels (read / read-write) + ownership transfer (#1123)
`SkillsetDetail` gains the optional `grants` field and
`SkillsetPermissionsInput` accepts `grants` (legacy `sharedWith*` arrays
made optional) — parity with the skill types from #1123, which the web
side hadn't mirrored for skillsets yet. Additive; no behaviour change.

Part of #1125
Add the reusable building blocks that separate read and write access:

- `PrincipalSelector` — org checkboxes (incl. unresolved-grant handling) +
  user email typeahead chips for one audience, emitting a flat Principal[].
  Consolidates the org/user picking logic previously duplicated in the
  skill and skillset modals.
- `PermissionsEditor` — a Read tab (Public toggle, or restricted orgs/users)
  and a Write tab (orgs/users who can edit; no public option). Save composes
  the canonical `grants` (read-tab → read, write-tab → read_write, write
  wins on overlap; read grants dropped when public since everyone reads) and
  carries `isPrivate` from the Read tab — so "public read + org/user write"
  is finally expressible.
- `initialGrants` helper — seed the editor from a skill/skillset detail
  (canonical grants, or read-level derived from legacy lists) + a reset-key
  signature.

Standalone (wired into the modals next).

Part of #1125
Replace the single-visibility-ladder PermissionsModal (and the #1123 inline
per-grant level toggles) with a thin wrapper around the shared
`PermissionsEditor`. An owner can now set independent read/write audiences —
notably public read + org/user write, which the old modal couldn't express
because turning on Public disabled the grant pickers.

Tests rewritten for the new UI: public-read + org-write round-trip, Write
tab seeded from an existing read_write grant, and the no-change
short-circuit.

Part of #1125
Rewire SkillsetPermissionsModal to the shared `PermissionsEditor`, keeping
it in lock-step with the skill modal and removing the near-duplicate
single-ladder implementation. This also brings skillsets the read /
read-write level support they never had. Test updated to the new
`{ isPrivate, grants }` payload; the skills-hook-isolation guard stays.

Part of #1125
…s-read-write-tabs

feat(web): two-tab Read/Write permissions editor (#1125)
Separate read and write access: the combined `read_write` level is gone.
`SkillPermissionLevel` is now `"read" | "write"` — a `write` grant implies
read (you can't edit what you can't see) but there's no combined label and
a principal holds at most one level.

Behaviour is unchanged from #1123: `canReadSkill` (any grant ⇒ read),
`canWriteSkill` (a `write` grant), `canManageSkill` (owner/admin) are the
same gates with the value renamed. Updated the Zod enum, `normalizeGrants`
(write wins), `levelAllowsWrite`, the `permissions_changed` analytics count
(`readWriteGrants` → `writeGrants`), and the skillset delegation comments.

`coerceStoredGrants` defensively maps a stored legacy `read_write` → `write`
at read time, so a doc the boot migration hasn't rewritten yet (or one
written by an older pod mid-rolling-deploy) still resolves to write —
never dropped, never silently downgraded.

Part of #1127
Rewrite any stored grant carrying the legacy `read_write` level to `write`
on skills + skillsets, at boot, right after the typed-grants backfill.
Idempotent (only docs with a `read_write` grant match) and non-destructive
(read grants + all other fields untouched; `write` confers exactly what
`read_write` did). Failure is non-fatal — `coerceStoredGrants` maps the
legacy value at read time, so no access is lost.

Integration tests pin the rename (read_write→write, read grants intact,
legacy lists + privacy preserved), idempotency, and the skillset twin.

Part of #1127
`SkillPermissionLevel` becomes `read | write` in both SDKs, matching the
API. Value-only rename — method names and shapes are unchanged. Tests
updated; TS vitest + Python pytest/ruff/mypy green.

Part of #1127
`SkillPermissionLevel` → `read | write` in the web model; the permissions
editor's Write tab emits `write` grants; the visibility-card count prop
`readWriteCount` → `writeCount`. Value-only rename matching the API/SDK.

Part of #1127
Fix the bug where a user with write access saw no edit affordances: the UI
gated everything on `isOwner` and never learned about the write tier.

Add a shared `useSkillAccess(skill)` hook → { isOwner, isAdmin, canWrite,
canManage }, mirroring the backend gates (`canWrite` = owner OR platform
admin OR a `write` grant matching the user directly or via a granted org,
resolved against `useMyOrgs`). Re-point the content-edit affordances — the
Edit button, the inline file editor, and Save — at `canWrite`, while
admin actions (permissions, transfer, delete, visibility, refresh, version
mgmt) stay on owner/admin. `SkillHeroStrip`'s edit button now keys off the
handler's presence (dropped its `isOwner` prop). EditSkillPage gates its
package update on `canWrite` and its visibility toggle on `canManage`, with
a read-only notice for non-writers.

Tests: write-grantee sees Update Package but not the visibility toggle; a
read-only viewer sees the notice and no controls.

Part of #1127
chronoai-shining and others added 5 commits June 16, 2026 14:42
CONVENTIONS §5.4 tier renamed READ_WRITE → WRITE, grant level value and
prose updated to `write` (write implies read); ERRORS accepted levels now
read/write; ARCHITECTURE analytics field readWriteGrants → writeGrants.

Part of #1127
…-split

feat: separate read/write access (drop read_write) + edit UI for write-grantees (#1127)
Curated user-facing notes for the skill/skillset permissions release
(#1123 levels + transfer, #1125 two-tab editor, #1127 read/write split +
write-grantee edit UI). Empty changeset satisfies the changeset gate
without adding a version bump.
@chronoai-shining chronoai-shining merged commit b9daeeb into main Jun 16, 2026
21 checks passed
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.

1 participant