kind: View — see your data¶
The first rung of the per-métier app ladder: a declarative, read-only list over a scope's
table, rendered by the hub and displayed by lm. No code, no frontend — one YAML.
The real manifest (from the WOW demo)¶
# The first per-métier app surface: a list over the fused Customer entity (scope: clients).
# Read-only, card-backed — every column must exist in the clients SchemaCard or the render is refused.
apiVersion: apps.lumnik.io/v1
kind: View
metadata:
name: clients-desk
spec:
scope: clients
table: connector.v_customer
title: "Clients"
columns: [customer_code, name, city, segment]
Fields¶
| Field | Required | Meaning |
|---|---|---|
metadata.name |
yes | the view's name |
spec.scope |
yes | the scope (connector tag) whose card backs the view |
spec.table |
no | defaults to the scope's single table; required if the scope has several |
spec.title |
no | display title (falls back to the table's label, then the table name) |
spec.columns[] |
yes (≥1) | columns to show — every column must exist in the scope's card |
That table is the whole surface: apply refuses a key it does not honor and names it, so a
misspelled titel: costs you a 400 rather than a silently missing title. The apiVersion: envelope
is accepted and ignored. Reads are untouched — a view already saved with an extra key still renders.
Rendering¶
lm view -f clients-view.yaml # list
lm view -f clients-view.yaml --filter city=Lyon # filtered list (repeatable, AND)
lm view CUST-042 -f clients-view.yaml # one record's detail (by unique key)
For the CLI the manifest stays a file you hold — lm view posts it to
POST /api/views/render (list) or POST /api/views/detail (one record) and prints the result.
(A third endpoint, POST /api/views/generate, writes a manifest for you — see
Generate a view with the AI.)
Since the saved-view store, a manifest can also live in the hub: an integrator applies it
with POST /api/views/apply (curated — the same parser + schema-card gate as render: a view
that cannot render is not saved), and renderers without a filesystem — the métier PWA at
/app/ — discover it through GET /api/views (scope-bound) and fetch its YAML by name.
The CLI speaks to the store too: lm view apply -f FILE (validate-then-upsert), lm view list
(NAME | SCOPE | TITLE), and lm view get NAME (the manifest YAML verbatim; -o file saves it) —
the full round-trip: generate on one machine, apply, get it back on another.
In the TUI, :view opens the same file interactively — filters with f, detail on Enter,
g generates a manifest from a sentence, a applies the rendered view to the store.
The detail includes the _conflicts ledger of the fused entity — you see which source
disagreed on a field, not a silently-picked winner.
Every view endpoint requires the lm_user role and lives inside the
métier-scope boundary: a manifest whose spec.scope you are not granted
is the uniform 403 {"error":"scope not granted"} — render, detail, generate, and the store
reads alike. Only POST /api/views/apply demands more: the lm_integrator role.
Rendering is bounded: at most 1000 rows and a 5-second statement timeout per read —
the same read-only, tenant-isolated boundary as ask. Rows come back ordered by the first
declared column (which is also the detail key). Both bounds speak when hit: a render cut
at the cap returns "truncated": true alongside the rows (narrow it with filters), and a
query that outlives the timeout is refused with a spoken 400 — never a bare 500.
The saved-view store¶
POST /api/views/apply validates against the applying tenant's schema card, then upserts
into the store — re-applying the same name overwrites; there is no disable flag and no delete
endpoint yet (a saved view is live from the moment it is applied). Two refusals exist only here:
- name rules — the stored name is
metadata.namelowercased, and must be a URL-safe slug ([a-z0-9_-], starting with a letter or digit): it becomes the store key and a URL path segment inGET /api/views/{name}; - reserved names —
apply,render,detail,generateare refused: a view so named would shadow a sibling endpoint — it would list but never fetch.
Reads are lm_user, scope-bound with opt-in semantics:
GET /api/views— summaries (name,scope,title); an unbound user sees all, a métier-bound user only the granted scopes' views.?scope=narrows — and asking for an ungranted scope is the uniform 403, never a lying empty list;GET /api/views/{name}— the manifest YAML, byte-for-byte as applied (application/yaml); 404 for an unknown name, the uniform 403 when the view's scope is not granted.
Stored manifests are deployment-global (the entity-manifest posture — one store, all tenants), but validation is tenant-local at apply time and every render of data through a view stays tenant-isolated.
Filter grammar¶
| Form | Meaning |
|---|---|
col=value |
exact match; on a column with declared values, an out-of-domain value is refused |
col~text |
substring, case-insensitive |
col>v col<v col>=v col<=v |
ordering — numeric or ISO-date columns only |
Honesty rules¶
A view never lies with an empty table:
- an unknown column in
columns:→ refused: the column "is not in the scope's schema for the table — fix the column name or add it to the scope's entity"; - a filter on a column the view does not show → refused: the view "does not show it;
declare it in
spec.columnsto filter on it" — filters run over the view's own columns, not the whole card; - a malformed filter → refused, not silently ignored;
- an
=filter with a value outside the column's declared domain → refused with the domain hint (the same contract as the domain guard).
A zero-row result you do get is then meaningful: the filter is valid and genuinely matches nothing.
Generate a view with the AI¶
Describe the need; the hub's model selects the table, columns, and filters — then the SAME gate that guards every render (unknown columns and invalid filters are refused) validates the selection, and the server assembles the YAML deterministically. The manifest is yours to review, edit, and keep: the file IS the app.
lm view generate clients "les clients gold de Lyon" -o gold.yaml
cat gold.yaml # review — the suggested render command rides as the first line
lm view -f gold.yaml --filter 'segment=GOLD' --filter 'city~Lyon'
The CLI posts {scope, description} to POST /api/views/generate and receives the YAML.
The first line is a ready-to-run comment: its filters passed the same validation as a
real render before being suggested, and with -o the CLI points the command at your
filename. Note: within generate, -o means the output file (it shadows the global
-o output-format flag, which has no role here — the output is always the manifest).
What the model selects is bounded: it never writes YAML (the server assembles it), the name is derived server-side, and every column and filter must survive the card-backed gate. A generated manifest is indistinguishable from a hand-written one.
Generation is also the one rate-limited view surface — the view-generate bucket of the
hub's fixed-window limiter (lumnik.ratelimit.view-generate.rpm, default 10 requests/minute
per tenant). Past the limit the answer is 429 {"error":"rate limit exceeded"} with a
Retry-After header; wait it out and retry.
Generation honesty¶
| Case | Answer |
|---|---|
| The scope cannot serve the description ("les commandes" over a clients scope) | the model abstains; you get the reason, never invented columns |
| The selection fails the gate (unknown column, invalid filter) | ONE corrective retry with the exact error; a second failure is an honest error carrying the model's raw output |
| No card for the scope | no schema for scope '…' — run discovery first |
Refusals surface verbatim: an abstention is
400 {"error":"generation abstained: <the model's reason>"} — the reason is the model's own
sentence, not a paraphrase — and a post-retry failure carries the model's raw output in a
raw field, so you always see what actually happened.