---
title: "Lenses and sharing"
---

# Lenses and sharing

Create and share lenses, saved retrieval views over chosen sources, with share levels, expiry, approval above a classification line, built-in system lenses and the share target lookup.

A lens is a saved selection of sources and pinned chunks that retrieval runs through. People create lenses for a task, share them with colleagues, freeze them as immutable snapshots, and fork them.

Sharing a lens grants no access to content. Retrieval through a lens still authorises every space for the person reading and applies DLP and classification unchanged: a lens can never return a chunk that the reader's own retrieval would not.

## Routes

| Method | Path | What |
|---|---|---|
| `GET` | `/api/v1/context/lenses` | Lenses the caller sees: owned, shared with them, and the system lenses that apply. `?sharedWithMe=true`, `?system=true\|false` |
| `POST` | `/api/v1/context/lenses` | Create a lens (`context:lens:create`) |
| `GET` | `/api/v1/context/lenses/{id}` | One lens; `accessLevel` is `owner`, `view`, `use` or `edit` |
| `PATCH` | `/api/v1/context/lenses/{id}` | Update or archive; versioned (`409` on a stale version); never when frozen |
| `POST` | `/api/v1/context/lenses/{id}/share` | Share with several targets |
| `GET` | `/api/v1/context/lenses/{id}/shares` | The targets, with `effective` and `violations` (owner only) |
| `PUT`, `DELETE` | `/api/v1/context/lenses/{id}/shares/{kind}/{targetId}` | Add or change one share; remove one share |
| `POST` | `/api/v1/context/lenses/{id}/freeze` | Freeze with a snapshot (never a system lens) |
| `POST` | `/api/v1/context/lenses/{id}/apply` | Apply: the lens's sources as the caller may use them |
| `POST` | `/api/v1/context/lenses/{id}/fork` | Fork into a lens the caller owns |

A person may own at most 500 live lenses and an organisation 20,000 (`KEYSTONE_CONTEXT_LENSES_PER_OWNER`, `KEYSTONE_CONTEXT_LENSES_PER_ORG`; system lenses do not count).

## Who sees a lens

1. The creator.
2. A share target: the user, a member of the group (groups nested up to 5 levels included), or a member of the team (Directory team memberships, direct or through groups).
3. System lenses, by rule (below).

A lens a person cannot see is a `404`, never a `403`. Nothing is cached: a removed share or a person leaving a team applies to the next request on every path, including snapshots made from the lens.

## Share a lens

```http
POST /api/v1/context/lenses/{id}/share
Content-Type: application/json

{"targets": [
  {"kind": "team", "id": "<team id>", "level": "use", "expiresAt": "2026-12-31T00:00:00Z"},
  {"kind": "user", "id": "<user id>", "level": "view"}
]}
```

| Level | The person may |
|---|---|
| `view` | See the lens and retrieve through it |
| `use` | Also apply and fork it |
| `edit` | Also change its sources and pinned documents (not its name, description or archive state) |

- A share without a level is `use` (or the first level the policy allows); a share without an expiry gets the policy's default expiry.
- Targets must belong to the organisation (a user must be a member); otherwise `422`.
- Sharing is checked against the sharer's [collaboration policy](https://neuralinverse.com/docs/context/collaboration-policy/): lens sharing switched on, every space of the lens at or below the classification ceiling, the audience within the share scope, the number of targets, the allowed levels and the maximum expiry. A refusal is `403 context.collab_policy.<reason>` and nothing is stored.
- `PUT .../shares/{kind}/{targetId}` is idempotent; a changed level or later expiry changes the share in place. `DELETE` works on frozen and archived lenses and on targets that no longer exist, and needs only ownership.
- Sharing, approving and other decisions need a signed-in person: an API key gets `403 context.person_required`.

### Shares that stop holding

A share confers access only while it has not expired and still complies with its sharer's policy as it is now, judged at read time. A share that breaks a tightened policy is ineffective at once, listed with `effective: false` and `violations` (`capability_disabled`, `classification_ceiling`, `approval_required`, `scope_exceeded`, `too_many_targets`, `level_not_allowed`, `expiry_exceeds_max`), and never deleted: when the policy allows it again, it holds again.

### Approval above a classification line

When the policy sets `approval_above`, a share of content classified above that line is not refused. It is stored as a pending share and an approval request goes to the holders of the policy's `share_approvers` roles whose scope covers the content:

1. The share request must carry a `reason` (at most 2000 characters; `422 context.share.reason_required` without it).
2. The answer is `202` with `pendingShares`; the lens is unchanged and the share confers nothing yet.
3. Approvers decide in the approvals inbox (`GET /api/v1/approvals/inbox`, requests of agent `keystone-context`, action `context.collab.share`). The requester can never approve their own request.
4. An approval is checked again against the policy in force at that moment. If a rule now fails, or the content became more sensitive than the approvers were told, the share ends as `refused` with a stable `refusalReason`.
5. A pending share expires after `KEYSTONE_CONTEXT_PENDING_SHARE_TTL` (default 14 days). The requester can cancel it with `POST /api/v1/context/collaboration/pending/{id}/cancel`.

`GET /api/v1/context/collaboration/pending` lists the caller's requests and the ones they may decide. When nobody but the requester holds an approver role for the content, the share is refused with `approval_required`.

Approver roles come from role bindings in Keystone Access: a person holds `org_admin`, `department_admin` or `team_lead` when a binding on the organisation, a business unit or a team gives them a role that grants `context:space:admin` (for example Owner and Admin). In a new organisation the Owner and Admins approve everything; to let a team lead approve, bind a role with `context:space:admin` on the team.

## System lenses

Every organisation has three built-in lenses, kept up to date by the space reconciliation job:

| Lens | Sources | Who sees it |
|---|---|---|
| `onboarding` | The organisation space's docs and codebase sources | Every member |
| `incident` | The organisation space's issues and docs sources | Every member |
| `team-{teamId}` | The team space's codebase and docs sources | Members of the team and administrators |

A system lens carries `system`, `systemKind` and `autoApplyWhen` (`first_session`, `team_member`, `incident_declared`) as data; the server never pushes it, the client decides when to apply it. Only administrators may change its description and sources or archive it; nobody may rename, share or freeze one. A fork is an ordinary lens owned by the forker.

## Finding share targets

People do not know ids. `GET /api/v1/context/collaboration/targets` and `.../targets/resolve` are the one lookup behind the sharing picker of the first-party apps and the text handles of the CLI (`user:<email>`, `team:<slug>`, `group:<slug>`).

```http
GET /api/v1/context/collaboration/targets?q=plat&kinds=user,group,team&limit=10
GET /api/v1/context/collaboration/targets/resolve?handle=team:payments
```

- The search needs 2 to 64 characters and matches them as literal text (no wildcards) against display names, slugs and, for callers who may read the Directory (`directory.members:read`), emails. With no query it lists the caller's own teams, their groups and teammates.
- Only targets the caller's policy would let a share reach are returned. A hidden, suspended, removed or other-organisation person and an unknown name all give the same empty answer.
- `resolve` answers one target, `404 context.targets.not_found`, or `409 context.targets.ambiguous` with up to 10 visible candidates; it never guesses. User handles need `directory.members:read`.
- Pages of 10 (at most 25), with the next cursor in `X-Next-Cursor`. A person may make `KEYSTONE_CONTEXT_TARGETS_PER_MINUTE` lookups a minute (default 30); over it, `429` with `Retry-After`.
- Each lookup is audited with the query's length and the number of results, never the query.

From the CLI:

```bash
ni keystone context lens create --name="refunds" --source=<source id> --share=team:<team id>
```
