Scopes — business-data boundaries for Ask
A scope is declared on the connector —
metadata.scopes— no object to create, no admin screen. By default it keeps every answer inside one declared scope; opt-inscope:<name>Keycloak roles turn it into a need-to-know boundary too.
Every lm ask example passes --scope. This page defines what a scope actually is, how one
comes to exist, and what it guarantees.
A scope is declared on the connector
There is no separate scope object to create or administer. A scope is a name a connector manifest declares about itself:
metadata:
name: crm-customers
connector_type: csv-file
scopes: [clients] # ← this IS the scope
- Every table and chunk the connector ingests carries its scopes. A tenant's scopes are those of its connectors and its fused entities.
metadata.scopesis the only place:metadata.tagsortarget.tagsis refused at validate, with the line to paste instead.- A connector that declares none is valid but not askable in any scope; validation warns.
- A scope name is lowercase, starts with a letter, then letters, digits,
_or-, at most 41 characters — the same rule on every door that writes one. It becomes the case-sensitive Keycloak rolescope:<name>.
Using scopes
lm scopes # list scopes in this tenant (chunks, connectors, last indexed)
lm ask --scope clients "how many customers in Lyon?"
lm scope # show your default scope (fallback: 'system')
lm scope set clients # set your default; empty string clears it
lm ask "how many customers in Lyon?" # no --scope: asks your default
$ lm scopes
SCOPE CONNECTORS CHUNKS LAST INDEXED
demo 0 1 2026-10-24 21:59
desk 0 2 2026-10-24 23:35
retail 0 1 2026-10-24 21:55
$ lm scope set desk
default scope set: desk
$ lm ask "combien de clients à Lyon ?"
Il y a 2 clients à Lyon.
data as of: 2026-10-24T21:35:11.811114Z (last completed run)
…
SQL: SELECT COUNT(*) FROM "connector"."v_acmecustomer" WHERE "city" = 'Lyon'
LIMIT 500
lm scopeslists the scopes the chat has indexed content in.CHUNKSis what the chat can cite.CONNECTORScounts only connectors whose rows the chat indexed: it often reads0.lm connector scope ls <connector>shows a connector's declared scopes, run or not;lm source schema --scope <tag>shows what is askable.- Moving a connector to another scope moves its tables between the two scopes' schema cards at once
(capture). Its indexed chat chunks keep
their old scopes until
lm connector scope sync. - The default is a preference, not a grant: a scope-bound user whose default names a scope they do
not hold gets
Error: access refused (403): scope not granted.
In the TUI the header shows the session scope — seeded from your default (TUI).
What a scope guarantees — and what it doesn't
- Tenant isolation is beneath scopes, enforced by PostgreSQL Row-Level Security. A scope never crosses a tenant.
- Within a tenant, the scope is the business-scope boundary on analytic SQL: the leak guard refuses any generated query that reads a table not declared in the scope (see the guard page for the refusal message).
- By default a scope is a working boundary, not a security boundary between users. A user with no scope grants may ask any scope; the scope keeps the answer inside the selected data set. To make it a need-to-know boundary too, bind the user (next section).
Binding users to scopes (opt-in)
A grant is a Keycloak realm role named scope:<name> — nothing else to create or administer.
- A user with no
scope:*roles is unscoped: tenant-wide access, exactly the default behavior above. An admin who carries no grant sees the whole tenant. - A user with one or more
scope:*roles is scope-bound: - asking or searching a scope they do not hold:
Error: access refused (403): scope not granted; lm askneeds a scope they hold: named with--scope, or set as their default.allis refused;- search and chat without a scope read only their scopes;
lm scopeslists only their granted scopes.
The boundary covers every read, not only the chat:
| Surface | A bound user sees |
|---|---|
| views, entities, search | only their scopes; another one answers scope not granted |
the inbox (lm inbox, Inbox) |
only decisions in their scopes; an id from another scope is a 404 |
| workflows and their tape | only workflows in their scopes |
| connectors, runs, dead letters | only connectors whose every scope they hold — the details |
Writing is stricter than reading. A bound user may see a resource holding any one of its
scopes, but may publish one (lm apply, lm entity apply) only while holding every scope it
declares. A scopeless manifest is refused too. Here the caller holds scope:sales and the manifest
declares scopes: [desk]:
$ lm apply -f docs/apps/demo/sources/10-erp.yaml
Error: access refused (403): scope not granted
Administering a connector follows the writing rule. A bound integrator sees, runs and changes only the connectors whose every scope they hold, and their runs and dead letters. The details, with captures: A scope-bound integrator.
Checking grants. lumnik does not store them: a grant is a Keycloak realm role, read from each request's token.
- Your own: no
lmcommand shows them;GET /api/bff/v1/mereturns your tenant, roles (with yourscope:<name>grants) and modules. - Someone else's: Keycloak's admin console — realm → Users → the user → Role mapping.
The same table can belong to several scopes: declare scopes: [achat, compta] on the connector
and a scope:achat user sees it while a scope:stock user does not:
flowchart LR
u0["no scope:* roles<br/>— unscoped"] -->|"tenant-wide"| all
ua["scope:achat"] --> A
ua --> AB
ua -.->|"403 scope not granted"| S
subgraph all["one tenant's data"]
A[("scopes: [achat]")]
AB[("scopes: [achat, compta]")]
S[("scopes: [stock]")]
end
Scoping and the ladder
The same scope name runs the whole ladder:
- a
kind: Viewrenders a scope's table; - a
kind: Processwatches a scope's raw ingest table; - a
kind: Workflowdeclares a lifecycle in a scope, and the chat tells it only to users who may read that scope.
One scope name, end to end.
See also
- When Ask refuses
- CSV & files / JDBC /
REST manifests — every kind declares
metadata.scopesthe same way. - Role management — the three confusable 403s —
scope not grantedis one of three different-looking 403s; the table there tells them apart.