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
- The creator.
- 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).
- 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
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: 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.DELETEworks 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:
- The share request must carry a
reason(at most 2000 characters;422 context.share.reason_requiredwithout it). - The answer is
202withpendingShares; the lens is unchanged and the share confers nothing yet. - Approvers decide in the approvals inbox (
GET /api/v1/approvals/inbox, requests of agentkeystone-context, actioncontext.collab.share). The requester can never approve their own request. - 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
refusedwith a stablerefusalReason. - A pending share expires after
KEYSTONE_CONTEXT_PENDING_SHARE_TTL(default 14 days). The requester can cancel it withPOST /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>).
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.
resolveanswers one target,404 context.targets.not_found, or409 context.targets.ambiguouswith up to 10 visible candidates; it never guesses. User handles needdirectory.members:read.- Pages of 10 (at most 25), with the next cursor in
X-Next-Cursor. A person may makeKEYSTONE_CONTEXT_TARGETS_PER_MINUTElookups a minute (default 30); over it,429withRetry-After. - Each lookup is audited with the query's length and the number of results, never the query.
From the CLI:
ni keystone context lens create --name="refunds" --source=<source id> --share=team:<team id>