---
name: lumo
description: Operates the Lumo CLI for Horus BI tenant management. Use when the user needs to (1) manage declarative workspaces for Horus BI tenants, (2) create or edit ETL flows, DW tables, BI apps, dashboards, widgets, credentials, schedules, agents, data desks (dw-desk), application desks (bi-desk), or tenant variables, (3) run lumo commands such as init, status, push, pull, fetch, diff, lint, new, rm, info, scaffold, run, clone, publish, query, export, or columns, (4) edit YAML resource files in flows/, tables/, apps/, credentials/, schedules/, agents/, dw-desks/, bi-desks/, or variables/, (5) understand Horus BI platform concepts such as load types, star schema, relationships, expressions, data vs application desks, agents, or indicators (facts[]). Do not use for frontend source-code changes, raw SQL database administration, direct backend API calls, or tenant/user provisioning.
---

# Lumo CLI Skill

## Core Rule

Lumo is a workspace-first CLI. A tenant workspace is a local, declarative mirror of one Horus BI tenant.

Default workflow:

```bash
lumo status
# read/edit YAML in flows/, tables/, apps/, credentials/, schedules/, agents/, dw-desks/, bi-desks/, variables/
lumo push
```

Use the local workspace first. Do not spend turns rediscovering resources from the server when the workspace was initialized with `lumo init`.

Every `lumo push` is drift-checked and idempotent. After a successful push, the CLI rewrites WORKING YAML with the server's canonical response so files never diverge.

## Mental Model

```text
WORKING YAML  <->  STATE cache  <->  SERVER
editable           internal          source of truth
```

- **WORKING**: YAML files in resource folders. This is what you read and edit.
- **STATE**: internal cache in `.lumo/state/`, used by the CLI for drift detection and sync. Never open, edit, copy, or reason from it during normal work.
- **SERVER**: live Horus BI tenant.

> **Git analogy:** If you know Git, WORKING is like your working tree, STATE is like `.git/` (read-only), and `push`/`pull`/`fetch` feel similar — with the key difference that `push` auto-rewrites your local YAML from the server's canonical response.

The workspace root determines the tenant. `lumo init <tenant-id>` binds the current directory to that tenant. Switching tenants by initializing a new workspace in a separate directory or logging into a different one.

## State Vocabulary

HorusBI uses two states for every flow, table, and app:

| Field value | State | Means |
|---|---|---|
| `deskId: null` | **draft** | "minha mesa" — work in progress, editable, only the owner sees it |
| `deskId: <id>` | **published** | live on desk `<id>`, structurally readonly, shared with other users |

Mental model = git: a draft is a branch, publish is merge-to-main, the resource id stays the same across the cycle. "Minha mesa" is NOT a desk you can list — it is the implicit scratch space of any owner. When a user asks "what is on my desk", they mean "what do I own with `deskId: null`".

**Publishing is a human decision — never publish on your own initiative.** Because publish = merge-to-main, it is destructive and outward-facing: the resource becomes **readonly**, is **shared with other users**, and re-publishing **overwrites** the previous version (only undoable by clone → edit → republish). Build and iterate everything as **drafts**; when the work looks done, **stop and let the user decide** whether, when, and where to publish. Instructions like "create the dashboard", "load the data", or "make it work" do **not** authorize a publish — only an explicit "publish / publica" does.

#### What "readonly" actually allows

"Published = readonly" is about **structure**, not about every byte of the file. What a
published resource still accepts differs by kind — knowing this saves you a clone→publish
cycle, which is the most destructive operation available (it regenerates every expression
id and is the only thing that can drop metadata).

| Kind | Editable in place while published | Needs clone → edit → publish |
|---|---|---|
| `table` | `label`, `mask`, `human_description` and other presentation fields | column set, key/partition flags, table type |
| `app` | `nome`, `descricao`, `icon`, `color`, `facts`, `filtros` | `tables`, `expressions`, `relationships`, `dashboards`, `config`, `tags` |
| `flow` | nothing | everything |

For an app, `lumo push app:<id>` on a published app succeeds when the **only** differences
are in the in-place column above, and fails with exit 9 **naming the field that blocked it**
plus the fields that would have gone through:

```
app:23967 is published (deskId=2849): `dashboards` and `expressions` requires clone→publish.
`icon` would have been accepted in place.
  in-place on a published app: nome, descricao, icon, color, facts, filtros
  structural change:          lumo app clone app:23967   # edit the clone, then `lumo app publish app:<clone-id> --desk 2849`
```

So fixing an indicator name (`facts`), swapping the default period (`filtros`) or changing a
colour never requires a clone. Widget labels are **not** in the in-place set — they live
inside the dashboard aggregate, which is the structural machinery the clone exists to
isolate. When the refusal names a field you did **not** touch, that is server-side drift,
not an illegal edit: the CLI says `drift:` instead and offers `lumo pull` / `--force-ours`.

> This gate is enforced on **both** sides. The CLI refuses before the request leaves your
> machine (nothing is written); the server applies the same allow-list, all-or-nothing —
> a request that mixes a legal `icon` with an illegal `dashboards` writes neither.

A clone is born **draft** (`deskId: null`) and stays on Minha Mesa until explicitly published. "Clona o X pra minha mesa" / "traz pra minha mesa" means *clone it and leave it a draft* — do not ask which desk to publish to and do not offer to publish; the cloning is the whole task.

### Desk types: dw-desk vs bi-desk

Desks come in **two unrelated types** stored in different backend tables. They share the name "mesa" but are not interchangeable:

| Kind | Holds | Publish targets it | Example names |
|---|---|---|---|
| `dw-desk` (data desk, `dw_desk`) | flows + DW tables | `lumo flow publish`, `lumo table publish` | "RAW", "Refined", "Gold", "Operação" |
| `bi-desk` (application desk, `bi_desk`) | BI apps + dashboards | `lumo app publish` | "Comercial", "Financeiro", "Projetos" |

Publishing a flow to a `bi-desk` (or an app to a `dw-desk`) is a server-side error — they live in separate ID spaces. Before any publish, confirm the desk type:

```bash
lumo list dw-desk                # data desks (publish targets for flow/table)
lumo list bi-desk                # application desks (publish targets for app)
lumo info dw-desk:<id>           # detail of one data desk
lumo info bi-desk:<id>           # detail of one application desk
```

When a user names a desk ("publica o flow na mesa Operação") and you cannot find it in one list, check the OTHER list — the same human name does not collide across types because the server stores them separately, but the user is unlikely to spell out "data" vs "application". If they ask to publish a **flow** or **table**, the target is always a `dw-desk`; if they ask to publish an **app**, it is always a `bi-desk`.

Lifecycle (a flow / table example; substitute `bi-desk` for app):
1. Create draft (`deskId: null`).
2. `lumo flow publish flow:<id> --desk <dw-desk-id>` → first publish, **CREATE NEW path** (server attaches `deskId` to the flow; its table inherits the same `deskId`).
3. To edit later: `lumo flow clone flow:<id>` — creates a new draft and sets its `cloned_from` field to the published flow's id (server-managed field, visible in `lumo info flow:<id>`; do not edit by hand).
4. Edit the clone, then `lumo flow publish flow:<clone-id> --desk <same-dw-desk-id>` → **REPLACE path** (clone disappears, the original gets the new body — like `git merge --squash`). Publishing the clone to a DIFFERENT desk than the original instead takes the CREATE NEW path again, creating a parallel published flow.

A table's `deskId` is propagated from its flow on the first publish. Subsequent edits via clone→publish carry through. **A published flow always has its table in the same dw-desk.**

## Setup

```bash
lumo auth login                 # one-time user session
lumo tenants list               # only to choose a tenant before init
lumo init <tenant-id> [<dir>]   # fetches the tenant into <dir> (or current directory)
```

If `<dir>` is provided, the CLI creates it and initializes the workspace inside it. If omitted, the current directory is used. The target directory may already contain other files (e.g., an existing git repo), but it must not already contain a `.lumo/` workspace.

After init, run commands from anywhere under that workspace. Do not re-run tenant discovery unless the user is choosing a tenant for a new workspace.

## Atualização do CLI (update available)

O `lumo` checa periodicamente se há versão nova e, quando há, imprime no **stderr**:

    ⬆  lumo vX.Y.Z disponível (atual: vA.B.C) — rode: lumo update

Quando você (LLM) vir essa linha:

1. **Avise o usuário proativamente** — não espere ele perguntar.
2. **Mostre o que ele ganha:** busque o changelog e resuma, em termos de benefício,
   as entradas acima da versão atual:
   `curl -fsSL https://storage.horusbi.com.br/download/lumo/CHANGELOG.md`
   (ou rode `lumo update --check` para confirmar a disponibilidade).
3. **Seja insistente:** recomende atualizar agora e reforce em pontos naturais da
   conversa se ele enrolar — vale a pena estar na última versão.
4. **Respeite um "não" explícito** na sessão (não trave o trabalho dele), mas pode
   lembrar de novo em sessões futuras.
5. **Após o ok, rode `lumo update`** — atualiza binário + skills + demais recursos.

6. **Se `lumo update` sair pedindo privilégio (exit 20)**, ele imprime a ação exata
   (ex.: `sudo lumo update`) — avise o usuário e rode essa ação. Falhas de rede/checksum/IO
   saem com exit 21.

Para silenciar o aviso: defina `LUMO_NO_UPDATE_NOTIFIER=1`.

## Version Control

Git is the recommended safety net for Lumo workspaces. The CLI already writes a `.lumo/.gitignore` that excludes STATE cache and sync logs, so only `workspace.json` and your WORKING YAML are versioned by default.

**Default pattern (recommended):** one git repo per workspace = one tenant.

```bash
lumo init <tenant-id> [<dir>]
cd <dir>
git init && git add . && git commit -m "checkpoint: init tenant <tenant-id>"
```

**Monorepo pattern (advanced):** one repo with multiple tenant subdirectories. Use `lumo init <tenant-id> <subdir>` for each tenant inside a shared root.

Before destructive operations (`rm`, `truncate`, `--force-ours` push, or bulk `pull --discard-local`), commit WORKING YAML so you have rollback.

## Inspection Policy

Use local inspection by default:

```bash
lumo status                     # local WORKING vs STATE
lumo list <kind>                # local list from workspace
lumo columns table:<id>         # local table column view
lumo lint [files...]            # local schema validation
lumo log                        # local sync audit
```

Use server/network inspection only when it answers a real runtime or drift question:

```bash
lumo diff <kind>:<id> --include-remote   # compare WORKING/STATE/SERVER
lumo info <kind>:<id>                    # read-only metadata and runtime status
lumo info app:<id> --full                # exhaustive semantic report for BI building
lumo fetch [<kind>:<id>...]              # refresh STATE only; does not touch WORKING
lumo pull [<kind>:<id>...]               # refresh and overwrite WORKING
lumo pull --prune                        # also drop YAML of resources deleted server-side
lumo list <kind> --remote                # explicit live server list
```

Prefer `lumo diff --include-remote` over `lumo fetch` when you only want to inspect remote drift without changing local STATE.

When `lumo status` lists files under **Orphaned**, the server no longer has those resources — `fetch` kept the YAML on disk so a mistaken delete stays recoverable. Do not push them and do not re-run `new` on them unless you mean to re-create them. `lumo pull --prune` clears them all at once. Expect this after any bulk server-side delete, including `lumo app publish` over many clones. Counting files in `tables/` or `flows/` to learn how many resources exist is unreliable while orphans are present; read `lumo status` instead.

## Resource Lifecycle

### Editing

```bash
lumo status
$EDITOR <workspace-yaml>
lumo lint <workspace-yaml>
lumo push <kind>:<id>
```

`push` checks drift before writing and then rewrites WORKING with the server's canonical response. Read the field diff it prints:

- **Accepted**: server applied your intent.
- **Ignored/Reverted**: the field is not editable at that layer (e.g. structural columns in a table).
- **Augmented/Normalized**: server added or canonicalized data.

If a field was ignored, do not retry the same YAML. Fix the root cause.

When `push` exits **15 (partial apply)** because the server ignored at least one field, the workspace is **already self-healed** — the post-save GET rewrote your YAML and STATE to match what the server actually kept. Do not run `lumo fetch` or `lumo pull` to "recover": read the field diff in stdout, fix the root cause (often the producing flow node, not the table), then re-push.

### Creating

```bash
lumo new <kind> [--name ... --variant ...]
# edit the Created path from the command output
lumo push <kind>:<id>
```

Use `lumo scaffold <kind> [variant]` as primary documentation for field shapes, processor types, and widget configurations. Scaffolds contain realistic example values and inline comments explaining complex patterns.

Resource files should come from `lumo init`, `lumo new`, `lumo pull`, or server-mutating commands that write back canonical YAML.

## Domain Workflows

Load detailed guides only when needed:

- **ETL & Data Warehouse**: `references/workflows.md` — end-to-end pipelines, database-to-DW, REST API ingestion, Python enrichment, multi-source joins, scheduling.
- **BI Apps & Dashboards (estética)**: **carregue a skill `lumo-design`** ao criar/redesenhar dashboards — paleta, seleção de gráfico, recipes de widget, layout. A mecânica de widgets (dataSource, relationships) está em `references/data-modeling.md`.
- **Data Modeling**: `references/data-modeling.md` — star schema, relationships, calendar tables, expressions, common patterns, anti-patterns.
- **Platform Concepts**: `references/concepts.md` — suites, desks, agents, load types, indicators (facts[]), RBAC, authentication.

### Dashboard and App Work

> **Ao criar ou redesenhar dashboards, carregue antes a skill `lumo-design`** — ela cobre a
> estética (paleta, seleção de gráfico, layout, estilização de widgets). Esta seção cobre a
> mecânica (IDs, `dataSource`, relationships, validação); use as duas juntas.

Before creating or editing widgets, always run `lumo info app:<id> --full`. This report contains column IDs, qualified names, data sources, expressions, relationships, indicators, and existing dashboards.

Widget `dataSource` forms:
- Table column: `<column_id>:<AGG>` (`SUM`, `AVG`, `COUNT`, `MIN`, `MAX`, `AGP`, `NONE`).
- App expression: `X<expression_id>:EXPR`.

Validate dashboards with:
```bash
lumo app query app:<id> --fields <colId:AGG>
lumo app dashboard-preview app:<id>
lumo app export app:<id> --image-format png
```

A dashboard is not "done" until **visually inspected** (`export ... png`): data preview confirms the numbers but misses layout, legends, and datalabels. Likewise, `flow run` returning "enfileirado" means *queued*, not finished — confirm completion (row count / `table preview`) before reporting success. Do not declare work complete on a partial signal.

**App-scoped column visibility & labels (`tables[].columns` — the "Configurar Campos" panel).** Each app table in the app YAML takes an optional `columns` overlay, keyed by `column_name`, to declutter the end-user experience per app:

```yaml
tables:
  - tableId: 100
    tipo: dimension
    columns:
      ID_INTERNO:  { visible: false }        # hide from the report generator
      VALOR_BRUTO: { label: "Valor" }        # per-app display name
```

- `visible: false` removes the column from the **end-user report generator / field picker** only. The column stays **fully usable in expressions and widgets** — it's decluttering, **not** access control (use it to hide system/surrogate columns from business users).
- `label` overrides the column's display name **for this app only** (the DW table's own label and other apps are untouched).
- List only columns that differ from the default. **Omit** a table's `columns` to leave its overlay untouched; `columns: {}` clears every override. Column names must match `lumo columns table:<id>`.
- **Draft apps only** (published is readonly — clone first). This is app-scoped (`bi_app_table_custom_column`), so two apps can show the same DW column differently. Distinct from a **widget Table's** own `hidden: true`, which hides a column inside one specific widget.

### Flow and Table Work

DW table schema is produced by flow `InsertDatawarehouse` nodes. Do not edit structural table columns directly in table YAML.

**A table's columns are frozen to the schema captured when it is first created from the node.** Editing the flow's SQL afterward does **not** retro-change an existing table — you must **re-derive** it with `lumo flow sync-table` (below). Pick neutral source-preserving column names (e.g. `EST_ID`, not a surrogate `ID`) *before* creating the table.

**`lumo flow sync-table flow:<id>` — create OR sync the DW table (draft flows only).**
This mirrors the web "Criar/Recriar Tabela" button: it creates the table from the InsertDatawarehouse node's source schema if none exists, or **re-derives** its columns if the schema diverged, then auto-pushes the flow and reports the schema's capture time. It resolves the InsertDW node and its source automatically (pass `--node <internalId>` only to disambiguate when a flow has more than one InsertDW).

> **`sync-table` only touches the table STRUCTURE, it never loads data.** Loading rows is a separate, explicit step: `lumo flow run-node flow:<id> --node <InsertDW>` (or `lumo flow run` on a published flow). A re-derive **recreates the table and drops loaded rows**, so re-run the load afterward.

**Re-deriving an EXISTING table asks for confirmation** (`Type 'yes' to confirm`), because it drops loaded rows; creating a table for the first time never prompts. Pass `--force` (or `--yes`/`-y`) to skip the prompt. A caller that can't answer a prompt (no `--force`, and either `--no-input`, `--format json`, or no attended terminal on stdin) gets a clear error instead of a silent no-op: scripts and CI jobs must pass `--force` explicitly for a re-derive.

Schema-changing flow loop (**draft flows only** — clone a published flow first; `run-node` is forbidden on published flows):
1. Edit the source node (e.g. the Extract SQL) in `flows/<flow>.yaml`.
2. `lumo flow run-node flow:<id> --node <source> --wait` — re-run the source so its `metadata.Fields` (the schema) are fresh.
3. `lumo flow sync-table flow:<id>`: create/re-derive the table to match (add `--force` if scripted). If it reports *"schema de <old date>"*, you changed the SQL but didn't re-run the source in step 2, do that first.
4. Load the data: `lumo flow run-node flow:<id> --node <InsertDW> --wait`, then confirm with `lumo table preview table:<id>`.

An `InsertDatawarehouse` (sink) `run-node` returns **no schema and no row count** (`0 field(s), ? row(s)`): neither a failure nor a success signal. If the upstream ran with data and there was no error, the rows loaded; **confirm with `lumo table preview table:<table-id>`** instead of re-running or assuming failure. This applies to `InsertDatawarehouse` specifically; a different node kind reporting the same empty shape gets its own hint naming that kind, not the sink message; check the run log or `lumo flow preview-node` for those.

Never hand-edit `TableID` in the InsertDatawarehouse node — it is server-managed (set by `lumo flow sync-table` / `lumo new table`); editing it causes drift.

Editable table fields are presentation/BI fields such as labels, masks, descriptions, and behaviors.

- Flows: `lint` rejects an input edge that matches no node's `complete_id`/`internalId`
  (a dangling edge that disconnects the graph). Matching is exact — mirror of the executor.

### `lumo flow logs` — Execution History

```bash
lumo flow logs flow:<id>              # most recent page of executions
lumo flow logs flow:<id> --all        # full history, paginated transparently
lumo flow logs flow:<id> --since 7d --until 2026-06-01
lumo flow logs flow:<id> --failed
lumo flow logs flow:<id> --all --format ndjson   # pipe-friendly
lumo flow logs flow:<id> --all --format tsv
```

Each row: status, duration, rows loaded, memory, message, finish timestamp.

**Flags:**
- `--all` — fetches all pages (default stops at the most recent page; use `--tail N` to limit to N records).
- `--since`/`--until` — ISO-8601 date (`2026-01-01`) or duration (`24h`, `7d`, `2w`).
- `--success` / `--failed` — filter by outcome.
- `--format ndjson|tsv` — machine-readable output for downstream aggregation.

**Philosophy:** lumo emits raw rows; aggregate downstream with `jq`, `duckdb`, or `awk`.

```bash
# Monthly execution rollup (post-mortem pattern):
lumo flow logs flow:18231 --all --success --format ndjson \
  | jq -s 'group_by(.finishedWhen[:7])[] | {mes:.[0].finishedWhen[:7], execs:length}'
```

> Default (no flags) returns only the most recent page. Use `--tail N` to limit output and `--all` for the complete series.

### Published Resources

Published resources are read-only. Clone to your personal desk first, edit, then publish back.

```bash
lumo flow clone flow:<id>
# edit, push, and validate the clone
lumo flow publish flow:<clone-id> --desk <dw-desk-id>
# apps follow the same shape but target a bi-desk:
# lumo app publish app:<clone-id> --desk <bi-desk-id>
```

`flow publish` overwrites the original published resource with the clone body and removes the clone. Apps follow the same clone/edit/publish pattern. `app clone` does not clone tables; use `lumo app change-table` if you need to rewire an app to a cloned table.

**Cloning regenerates every node's `internalId`** — the clone does NOT keep the original's `ext01`/`load01`/etc. After `flow clone`, run `lumo flow nodes flow:<clone-id>` to read the new internalIds before any `run-node`; reusing the original ids fails with "node não encontrado".

**Build the whole stack unpublished before publishing anything.** Flows, tables, and apps can all live unpublished side-by-side while you iterate. Publish order: **flows first, then app** (the server enforces that apps cannot reference unpublished tables).

**Editable on published tables** (cosmetic): `label`, `mask`, `default_behavior`, `human_description`, header `nome`/`icon`. Push these directly with `lumo push table:<id>`.

**Not editable on published tables** (structural, flow-owned): `column_name`, `data_type`, `table_type`, `deskId`, column add/remove. For these: clone the flow, edit, republish (the table follows automatically). (The Doris key-model — `key_type`/`key_columns`/`partition_column` — IS editable; see below.)

**Editable on published apps** (finish): `nome`, `descricao`, `icon`, `color`, `facts` (the AI
indicators) and `filtros` (the app-level period/global filter). Push these directly with
`lumo push app:<id>` — no clone. Everything else (`tables`, `expressions`, `relationships`,
`dashboards`, `config`, `tags`) is refused with exit 9 naming the field. See *What "readonly"
actually allows*.

**Editable on published flows**: nothing. Clone first.

### Doris key-model — `key_type`, `key_columns`, `partition_column`

Doris-backed DW tables (`table_type: table` or `file`) carry a **key-model** in the table YAML body:

- `key_type: duplicate | unique` — `duplicate` accumulates every loaded row (append-only); `unique` deduplicates rows that share the same `key_columns` (upsert / last-write-wins). Default `duplicate`.
- `key_columns: [COL, ...]` — the columns that form the key (the dedup key when `unique`, the sort/prefix key when `duplicate`). Names must match physical columns.
- `partition_column: COL | null` — the Doris partition column, or null.

`cloud` (datalake/parquet) tables have **no** key-model — these fields are rejected for them.

**`key_type` is NOT the InsertDatawarehouse node's `PrimaryKeys`.** The node's `PrimaryKeys` only dedups *in memory during a single load* — it does not change the physical table's key-model. To dedup at rest you must set `key_type: unique` + `key_columns` on the table.

**Changing `key_type`, `key_columns`, or `partition_column` is STRUCTURAL** — it DROPs+CREATEs the physical Doris table, so **rows are lost and the flow must reload them**. `lumo push` refuses such a change on a table that already has data unless you confirm:

```bash
# edit key_type / key_columns / partition_column in tables/<t>.yaml, then:
lumo push table:<id> --force-recreate              # confirm the destructive recreate
lumo flow run-node flow:<id> --node <InsertDW>     # reload (draft); or `lumo flow run` if published
```

A table that was never loaded recreates with no confirmation (nothing to lose). The key-model is editable on **both draft and published** tables (unlike column schema).

### `app change-table` — Cross-Cycle Synchronized Edits

**Concept:** *"I swapped the underlying table for a compatible one (same column NAMES, different ids — typically a clone). Re-point every column reference (widgets, filters, facts) from the old ids to the new ids by matching names, so the dashboards keep working."* change-table is NAME-based id translation — it preserves the dashboards. It is **different** from *replacing* a table with a different one: for that, edit the table's `tableId` in the app YAML and push (the app points at the new table and you rebuild the widgets).

**Safety:** the target must share the column names the app actually uses. Columns you use that are missing in the target would be left dangling (widgets pointing at dead ids). `change-table` now **refuses by default** and lists them; use `--dry-run` to preview (what rewires vs what dangles) and `--force` to apply anyway (those widgets lose their data).

Flow and app each have their own clone-publish cycle, independent of each other. When a change requires BOTH a new column on a published table AND a widget that uses it, `change-table` is the sync point. It is NOT a tool for repairing broken state.

Walkthrough — adding a column + a widget that consumes it:

1. `lumo app clone app:<A>` → draft "Clone of A", still pointing at published table Z.
2. `lumo flow clone flow:<flow-of-Z>` → draft "Clone of Z" with its own draft table T'.
3. Edit T' (add column), test with `lumo flow run-node` or `lumo flow run` on the draft.
4. `lumo app change-table app:<Clone-of-A> --from-table Z --to-table T'` — temporarily wires the draft app to the draft table so you can build widgets against the new column.
5. Add widgets in Clone of A, test.
6. `lumo flow publish flow:<Clone-of-Z>` → REPLACE: Z is updated with the new column, T' is removed. **The backend automatically remaps every app that was pointing at T' back to Z** (inverse of step 4, done server-side) — you do not manually undo the change-table.
7. `lumo app publish app:<Clone-of-A> --desk <bi-desk-id>` → A gets the new widgets pointing at the updated Z.

The auto-rewire on step 6 only fires for REPLACE (publishing a clone back to its original's dw-desk). If you publish the flow clone as `--new` to a different dw-desk, the rewire does not happen and your app stays pointing at T'.

### Variables, Schedules, Agents

- Variables live at `variables/tenant.yaml`.
- Schedules live under `schedules/`.
- Agents live under `agents/`.

**A schedule's `flows[].context` must match the bound flow's `load_type`.** The context is
not a per-run override — it selects the extraction window, while the flow's `load_type`
selects the DELETE predicate. Binding `context: Temporal:Days:N` to a `load_type: Total`
flow windows the SELECT and leaves the DELETE at `1=1`, so the scheduled run **empties the
published table** and reloads only the window. `lumo lint` cross-checks every binding
against the flow file and the API rejects the mismatch on push, but do not author it in the
first place:

| Flow `load_type` | Valid `context` |
|---|---|
| `Total` | `Total` |
| `Temporal` | `Temporal:Days:N`, `Temporal:Future:N`, `Temporal:Month:M-YYYY`, `Temporal:Year:YYYY`, `Temporal:Year:Current`, or `Total` |
| `Incremental` | `Incremental`, `Total`, or `KeySweep` |

See the Temporal contract in `references/concepts.md` — a Temporal flow also needs
`load_type_column` **and** `{StartDate}`/`{EndDate}` in its extract SQL.

**`context: KeySweep` is deletion sync, not a load.** An Incremental load catches inserts
and updates but never notices a row that vanished from the source; until now the only cure
was a periodic `Total`, which re-reads and re-transfers the whole table. With `KeySweep` the
agent runs no processor at all: it executes the flow's `key_sweep` query, which returns
**only the keys still present in the source**, and marks as deleted every row in the
destination whose key did not come back. Schedule it beside the Incremental load — the load
every few minutes, the sweep overnight.

```yaml
# flows/vendas--100.yaml
load_type: Incremental
key_sweep:
  sql: SELECT ID_VENDA FROM vendas          # key columns only, in the destination's order
  credentialId: 7                           # use a read-only credential

# schedules/varredura--200.yaml
flows:
  - flow_etl_id: 100
    context: KeySweep
```

It requires `load_type: Incremental` and a destination that is a Doris table (`table_type:
table`) with `key_type: unique` and `key_columns` set: the delete mark is a unique-model
concept, and without a key it would add a dead row beside the live one instead of replacing
it. `lumo lint` checks all of that against the flow and table files before you push.

To migrate an agent assignment, edit the `tokenId` field in the flow or agent YAML and run `lumo push`.

Imperative operations:
```bash
lumo schedule run|pause|resume <id>
lumo agent restart agent:<id>
lumo credential test|query|schema <id>
```

### Agent investigation (state tools)

Read-only tools to investigate an agent's state — built for flaky, uncontrolled
agent boxes (OOM, container restarts, loads that silently don't run). Both work
**even while the agent is offline** (data is server-side).

```bash
lumo agent events agent:<id>            # loads that missed / ran late / errored / crashed
lumo agent events agent:<id> --all --window 7d
lumo agent events agent:<id> --recovered --format ndjson | jq
lumo agent health agent:<id>            # 24h uptime %, memory, jobs, queue + OK/Degradado/Offline timeline
lumo agent health agent:<id> | jq '.buckets'
lumo agent health agent:<id> --full --range 7d
```

- `events` defaults to the **open** ("precisa atenção": not acknowledged, not
  recovered) view over 48h. `--recovered`/`--acknowledged`/`--all` widen it;
  `--window`, `--status MISSED,LATE,ERROR,CRASHED`, `--since/--until` narrow.
  A recovered MISSED can still mean stale data — it is not silently dropped.
- `health` shows a 48-cell 30-min OK/Degradado/Offline availability timeline
  + memory sparkline; on a pipe it emits the full pulse JSON (buckets in
  **epoch seconds**) so it correlates with `events` (and, later, `agent logs`).
- Output is **mode-aware**: pretty on a TTY, structured when piped; `--format`
  overrides — `table|ndjson|tsv|json` for `events`, `table|json` for `health`.
- `lumo agent logs` (agent-side `.jsonl` segments) is a later increment.

## Imperative Commands

Use imperative commands for eager server operations:
- Lifecycle: `new`, `rm`, `flow clone`, `flow publish`, `app clone`, `app publish`
- Execution: `flow run`, `flow run-node`, `credential test/query`, `app query/export/open`, `schedule run`, `table truncate`
- Discovery: `info`, `columns`, `scaffold`, `list --remote`

## Inconsistent States — Stop and Ask

Some states are legitimate dead-ends; throwing more commands at them makes it worse. Stop and ask a human/admin if you encounter:

- **Published flow bound to a draft table** (`lumo info table:<id>` shows `deskId: (null)` but the flow that owns it has `deskId: <X>`). This is a server-side glitch. Do NOT clone+change-table to "fix" it — that creates an empty table and rewires the app to it.
- **App publish error "tabelas não publicadas"** when the flow is clearly published. Same root cause as above.
- **Server diff says "kept null (your value: X)" for `deskId`**. `deskId` is owned by the publish path, not by `push`. Editing it in YAML is silently ignored.

Anti-pattern to avoid (real incident): cloning a flow + publishing to a different desk + using `lumo app change-table` to swap to the new clone-table. This "succeeds" but the new table is empty and the app loses its data. Don't.

## Anti-Patterns

| Do not | Why |
|---|---|
| Open or edit `.lumo/state` | It is internal cache data; changing it corrupts sync |
| Use server list commands for normal discovery | `lumo list` is local by default; workspace already has the tenant inventory |
| Use `fetch` as a reflex before every edit | It changes STATE; use local files or `diff --include-remote` |
| Edit structural columns in table YAML | Schema belongs to the producing flow node |
| Invent commands | Use `lumo <topic> --help` or `lumo scaffold ls` |
| Recreate YAML from scaffold by hand | Use files created by init/new/pull so headers match |
| Loop on drift errors with retries | Drift = real divergence; retry will not "eventually" succeed. Resolve, then push. |
| Run `lumo flow run-node` on published flows | run-node mutates the flow JSON (metadata persistence, schema sync). Use `lumo flow run flow:<id>` (whole flow) on published. Clone first if you need node-level testing. |
| Run `lumo flow run flow:<draft-id>` on a draft | Server rejects unpublished flows. Use `run-node` per-node while developing, then publish, then `run`. |
| Use `app change-table` to "fix" unpublished-table or null-deskId errors | change-table is for cross-cycle synchronized edits (see section **`app change-table` — Cross-Cycle Synchronized Edits**), not state repair. Using it to swap to an empty/unpublished target leaves the app with no data and the original table abandoned. |
| Manually edit `deskId` in any YAML | `deskId` is server-managed. Edits are silently ignored on push. |
| Pass a bare id where a ref is expected (`539`) | Commands take `kind:id` — `credential:539`, `flow:42`, `table:23913`. A bare number is rejected or misread. |
| Read a 0-row query result as "no data" | A DB query without the schema prefix (`SCHEMA.TABLE`) returns 0 rows silently. Confirm the schema qualifier before concluding the source is empty. |
| Hand-edit `TableID` in an `InsertDatawarehouse` node | `lumo new table --from-flow` sets it server-side. Setting it by hand causes drift on push — after creating the table, `lumo fetch flow:<id>` to pull the binding. |
| Publish a flow / table / app without an explicit user request | publish = merge-to-main: readonly, shared with others, overwrites the prior version, only undoable by clone→edit→republish. Always a human decision — build as drafts and ask before publishing. |
| Push to a published flow YAML | Returns 409 `FLOW_READONLY_PUBLISHED`. Clone first, edit the clone. |
| Edit widgets without `lumo info app:<id> --full` first | You may miss existing indicators/columns and reinvent them. Audit before editing. |

## References

Load references only when needed:

- [`references/commands.md`](references/commands.md) — command details, flags, and exit codes
- [`references/yaml-format.md`](references/yaml-format.md) — YAML header spec, split mode, gotchas
- [`references/workflows.md`](references/workflows.md) — end-to-end ETL/BI scenarios
- [`references/data-modeling.md`](references/data-modeling.md) — modeling, relationships, expressions, widget config
- [`references/concepts.md`](references/concepts.md) — platform concepts: suites, desks, agents, load types, indicators
