kind: Export — deliver an entity as CSV
A
kind: Exportmanifest is a stored delivery contract: one canonical entity, the columns you pick, one CSV, one destination, on demand or on a cron. Each execution is a managed run, recorded from claim to receipt. The file is published atomically and never overwrites anything.
| Kind | Export (apiVersion: apps.lumnik.io/v1, strictly checked) |
| Source | one entity |
| Format | CSV, UTF-8 |
| Destinations | sftp, s3 |
| Schedulable | cron, UTC |
| Role | lm_integrator for everything, reads included |
Quickstart — apply, run, read the receipt
# 10-sftp-daily.yaml — the desk's customers, every night at 04:00 UTC, to a partner's SFTP.
#
# The entity is the demo's AcmeCustomer (docs/apps/demo/entities/10-customer.yaml); `desk` is
# one of its declared scopes. No credential is written here: key_env names a secret.
apiVersion: apps.lumnik.io/v1
kind: Export
metadata:
name: customers-daily
spec:
scope: desk
source:
entity: AcmeCustomer
columns: [code, name, city, segment, email]
order: [code]
format:
kind: csv
delimiter: ";"
header: true
destination:
kind: sftp
host: sftp.partner.example
port: 22
user: lumnik
path: /inbound/customers-${date}.csv
key_env: PARTNER_SFTP_KEY
host_key_fingerprint: "SHA256:M0Is0aG8ubOr0cLpQs6QcDwVSxMkvmlfuW4K9Q6CJuc"
mode: snapshot
schedule: "0 4 * * *"
Store the private key once, then apply and run. The key is a multi-line PEM, so it goes in through stdin:
$ lm secret set PARTNER_SFTP_KEY - < partner_ed25519
Created secret "PARTNER_SFTP_KEY"
$ lm export apply -f docs/apps/exports/10-sftp-daily.yaml
Applied export "customers-daily" → entity AcmeCustomer, scope desk
$ lm export run customers-daily --wait
• export run 3f1c0a2b-9d4e-4b7a-a1c2-6e5f0d9b8c71 started — waiting for the hub to record its outcome
Run 3f1c0a2b-9d4e-4b7a-a1c2-6e5f0d9b8c71
Export customers-daily
Entity AcmeCustomer
Scope desk
Destination sftp
Status Completed
Trigger manual
Attempt 1
Started 2026-09-24 10:02:11
Ended 2026-09-24 10:02:12
Rows 14
Bytes 1187
SHA-256 9c1e5a0f3b7d2e48a6c4f19d0b3e7a25c8f61d4e0a9b72c35e8d1f6a4b0c9e27
Location /inbound/customers-2026-09-24.csv
--waitexits 0 only onCompleted. Without it,lm export runreturns as soon as the hub has claimed the run (started (Running)), which does not mean delivered.- The
SHA-256is computed over the exact bytes sent. Compare it with what the partner received. - Times are shown in your terminal's time zone. The hub stores them in UTC.
The manifest, field by field
| Field | Required | Rule |
|---|---|---|
metadata.name |
yes | the export's name, a slug, stored lower-cased (Customers-Daily becomes customers-daily), one per tenant. Re-applying the same name updates it |
spec.scope |
yes | one of the entity's declared scopes, and one you hold (below) |
spec.source.entity |
yes | an existing entity, case-insensitive |
spec.columns |
yes | canonical columns of the entity, no duplicates, in file order. _entity_id, _conflicts and _resolution are refused |
spec.order |
yes | the sort, columns taken from spec.columns (ordering) |
spec.format.kind |
yes | csv, the only format |
spec.format.delimiter |
no | one character, default ,. Not ", a line break or NUL |
spec.format.encoding |
no | UTF-8, the only value accepted |
spec.format.header |
no | true (default) or false |
spec.destination |
yes | kind: sftp or kind: s3, below |
spec.mode |
yes | snapshot, the only mode |
spec.schedule |
no | a 5-field UNIX cron, evaluated in UTC. Omit it for manual runs only |
Everything is checked at apply time: unknown keys, wrong types, a column the entity does not have,
a scope it does not declare, a bad cron or an unknown ${…} token. The error names the field:
$ lm export apply -f customers-daily.yaml
Error: API error 400: spec.columns: 'phone' is not a canonical column of entity 'AcmeCustomer'
Destinations
Credentials are never inline. password, private_key, access_key and secret_key are
refused and the error names the *_env field to use instead. A *_env field holds the name
of a secret stored with lm secret set, never the value:
$ lm export apply -f leaky.yaml
Error: API error 400: 'password' is not accepted — store the credential via `lm secret set` and reference it as `password_env`
SFTP
destination:
kind: sftp
host: sftp.partner.example
port: 22 # optional, default 22
user: lumnik
path: /inbound/customers-${date}.csv
key_env: PARTNER_SFTP_KEY # OR password_env, exactly one of the two
host_key_fingerprint: "SHA256:M0Is0aG8ubOr0cLpQs6QcDwVSxMkvmlfuW4K9Q6CJuc" # required
- The host key is always verified. There is no
strict_host_key: falseon an export. Get the fingerprint from a machine you trust:ssh-keyscan -p 22 sftp.partner.example | ssh-keygen -lf -. - The directory in
pathmust exist. The hub does not create it. - The file is written to
customers-2026-09-24.csv.<uuid>.partin the same directory, then renamed to the final name. The rename refuses to replace an existing file.
S3
# 20-s3-hourly.yaml — the same entity, every hour, to an S3 bucket (AWS, MinIO, R2…).
#
# ${timestamp} gives each run its own key, so two runs in a day never collide. Credentials
# are secret names; omitted, they default to AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY.
apiVersion: apps.lumnik.io/v1
kind: Export
metadata:
name: customers-hourly
spec:
scope: desk
source:
entity: AcmeCustomer
columns: [code, name, credit_limit]
order: [code]
format:
kind: csv
destination:
kind: s3
bucket: acme-outbound
prefix: customers/
filename: customers-${timestamp}.csv
region: eu-west-3
access_key_env: OUTBOUND_S3_ACCESS_KEY
secret_key_env: OUTBOUND_S3_SECRET_KEY
mode: snapshot
schedule: "0 * * * *"
- The object key is
prefix+filename.customersandcustomers/both givecustomers/x.csv. A leading/is refused. regiondefaults toeu-west-3. Setendpointonly for S3-compatible stores (MinIO, R2…).access_key_env/secret_key_envdefault toAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY.- The object is committed with
If-None-Match: *, so an existing key is never replaced. The store must support conditional writes. AWS S3 does. - The upload is multipart above 8 MiB. Until it completes, nothing is visible under the key.
- A write-only bucket works: a
403on the pre-check is treated as "absent".
File-name tokens
path (SFTP), prefix and filename (S3) accept two tokens, resolved in UTC against the run's
artifact time: the moment you asked for a manual run, or the cron slot for a scheduled one.
| Token | Resolves to | Two runs the same day |
|---|---|---|
${date} |
2026-09-24 |
the second one collides |
${timestamp} |
20260924T040000Z |
distinct names (one per second) |
A literal name (/inbound/customers.csv) succeeds once. Every later run ends in
collision. Pick the token that matches your cadence: ${date} for daily, ${timestamp} for
anything more frequent.
Scope and tenant
- An export belongs to its tenant. Another tenant never sees it, even under the same name. Rows are read through RLS for that tenant only.
spec.scopebinds it. To apply, the scope must be one the entity declares. If you are bound to scopes, it must also be one of yours. To list, read or run an export, you need that scope too; otherwise you get the uniform403 {"error":"scope not granted"}. See Scopes.- A scheduled run has no user. Its tenant and scope come from the stored manifest.
- Every run checks the definition again before any byte moves. If the entity was dropped,
the scope was removed from it or a column is gone, the run fails with
definition. That is the same sentence the apply would give today.
The CSV bytes
| Encoding | UTF-8, no BOM |
| Line ending | CRLF (\r\n), after every record including the last |
| Delimiter | spec.format.delimiter, default , |
| Quoting | every non-null value is quoted, header included. " inside a value is doubled |
| NULL | an empty, unquoted field |
| Empty string | "" (quoted), so it stays distinct from NULL |
| Header | the spec.columns names in order, when header: true |
| No rows | a file with only the header, or a 0-byte file with header: false. It is still published |
With delimiter: ";", two rows where the second has no city (each line ends in \r\n):
"code";"name";"city"
"P-1000";"HYDRAULIQUE RHONE";"Lyon"
"P-1005";"ATELIERS DU ""NORD""";
Values are the canonical text in the entity view, as-is. There is no type formatting and no date conversion.
Ordering and the receipt
- The order is deterministic. Rows are sorted by
spec.order, then by the remainingspec.columnsin their declared order. Each column is compared byte-wise (COLLATE "C"), with NULLs last. - The same data gives the same bytes and the same SHA-256. A retry over unchanged data can be checked against the failed attempt's target, byte for byte.
- A completed run's receipt contains
rowCount,byteCount,sha256andlocation. S3 addsetagandversionId(versioned bucket only). - The ETag is not a checksum. It identifies the object and nothing more. Always compare the
sha256. - There is no sidecar file. The receipt lives in the run ledger (
lm export status). A single-part S3 object (≤ 8 MiB) also carries the checksum asx-amz-meta-lumnik-sha256.
Managed export vs one-shot download
lm export run (managed) |
lm entity export (one-shot) |
|
|---|---|---|
| Manifest | a kind: Export |
none |
| Columns, order, format | from the manifest | all canonical columns, in declared order, default format (,, header) |
| Goes to | the SFTP / S3 destination | your machine |
| Ledger, retry, schedule | yes | no. Audited as data egress (rows, bytes, SHA-256) |
Use the one-shot for a quick look or a one-off hand-over:
$ lm entity export AcmeCustomer -o customers.csv
AcmeCustomer: 1402 bytes written to customers.csv (sha256 4b0e…)
$ lm entity export AcmeCustomer -o customers.csv
Error: customers.csv already exists — refusing to overwrite it (use --force to replace it)
$ lm entity export AcmeCustomer -o - | head -3
- The download goes to a hidden
.customers.csv.*.partand gets its final name only once the whole body has arrived. An interrupted download leaves nothing behind. - An existing file is never replaced without
--force. The file is created0600. - With
-o -, bytes already written to stdout cannot be taken back. On failure the command still exits non-zero.
Runs from lm
$ lm export list
NAME ENTITY SCOPE DESTINATION SCHEDULE
customers-daily AcmeCustomer desk sftp 0 4 * * *
customers-hourly AcmeCustomer desk s3 0 * * * *
$ lm export runs customers-daily
ID STATUS TRIGGER ATTEMPT ROWS BYTES STARTED ENDED FAILURE
c7d0e913 Failed manual 1 2026-09-24 11:30 2026-09-24 11:30 collision
3f1c0a2b Completed manual 1 14 1187 2026-09-24 10:02 2026-09-24 10:02
$ lm export status customers-daily # the latest run in full; add a RUN_ID for another
$ lm export retry customers-daily 5e8a41f0 --wait
$ lm export get customers-daily # the stored manifest
--timeout(default 30m) and Ctrl-C only stop the waiting. The run carries on on the hub.-o json|yamlgives every command a stable machine-readable shape for scripts.- Short ids from
lm export runsare accepted everywhere.
The same actions are available over HTTP (lm_integrator):
| Method and path | Answer |
|---|---|
POST /api/exports/apply |
apply a manifest (YAML body) |
POST /api/exports/{name}/runs |
202 {"runId": "…"} once claimed. 409 if a run is in flight |
GET /api/exports/{name}/runs?limit=50 |
runs, newest first (limit 1..200) |
GET /api/exports/{name}/runs/{id} |
one run |
POST /api/exports/{name}/runs/{id}/retry |
202 for a Failed run. 409 otherwise |
GET /api/exports/entities/{entity}/download |
the one-shot CSV |
Failures, and whether to retry
A run is Running, then exactly one of Completed or Failed. A failure carries a
failureCode and a failureReason written by the hub. The reason never contains a credential or a raw
third-party message.
failureCode |
What happened | What is on the remote side | Safe to retry? |
|---|---|---|---|
definition |
entity dropped, scope removed from the entity, column gone | nothing | after fixing the entity or the manifest |
generation |
reading or encoding the snapshot failed | nothing (temporary file removed, multipart aborted) | yes |
publication |
upload or commit refused (credentials, missing directory, network, host key) | nothing | yes, once the cause is fixed |
collision |
the final name already exists | the existing file, untouched | no, look first |
interrupted |
the hub stopped mid-run (crash, restart) | possibly a leftover, below | yes, and a collision then means it was delivered |
$ lm export status customers-daily c7d0e913
…
Status Failed
Failure collision
Reason SFTP destination: /inbound/customers-2026-09-24.csv already exists — refusing to overwrite a previously delivered artifact
✖ export run c7d0e913-… failed (collision): … — retry with: lm export retry customers-daily c7d0e913
What a retry does. It creates a new run with retryOf set and attempt + 1. It regenerates the file
from byte 0, since there is no resume. It keeps the original artifact time, so it aims at the
same file name. So:
- A retry is always safe: it can never overwrite. At worst it ends in
collision. collisionon a retry means the earlier attempt did publish, even though the ledger says it failed. Compare the remote file's SHA-256 with a fresh one-shot (lm entity export … -o - | sha256sum, same columns and order). Then either leave it, or move it aside on the remote and retry.- Only
Failedruns can be retried. A scheduled slot that was delivered since answers409 {"error":"already delivered"}.
Scheduling and concurrency
- The scheduler checks every minute and runs the export's latest due slot, once.
- There is no back-fill. After an outage, only the latest slot runs. An export applied after today's slot waits for the next one.
- There is no automatic retry. A failed slot stays
Faileduntil someone runslm export retry. The next slot runs normally. - Only one run is in flight per export. A second trigger gets
409 … already has a run in flight. A slot that is due while a manual run is going is picked up on a later tick, if it is still the latest. - With several hub replicas, a slot is still delivered only once.
- A run has 30 minutes to generate its snapshot (
lumnik.export.snapshot.timeout-seconds).
Recovering from a crash
A run whose hub died is closed as Failed / interrupted within a minute (at startup, and on every
scheduler tick). Run lm export retry: it either delivers or ends in collision, and a
collision means the lost run had already published. The hub cannot clean up what the lost run
left on the remote side:
- SFTP. A
<name>.<uuid>.partfile stays next to the target. It never takes the final name. Remove it by hand, or with a housekeeping job that deletes*.partfiles older than a day. -
S3. An incomplete multipart upload stays invisible but its parts are billed. Add a lifecycle rule to the bucket once:
{ "Rules": [{ "ID": "abort-incomplete-exports", "Status": "Enabled", "Filter": {"Prefix": ""}, "AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 1} }] }
Not in this version
- Never overwrites and never appends. A delivered name is final.
- No resume. A retry rewrites the whole file.
- No delta or incremental export.
mode: snapshotmeans the full entity every time. - CSV only, UTF-8 only. No JSON, Parquet or Excel, no compression, no encryption.
- One entity per export. No joins and no row filter;
spec.columnsis the only projection. - No other destinations: no local disk, email or HTTP push. No sidecar or
.donemarker file. - No back-fill of missed slots and no automatic retry.
See also
- Entities: what an export reads.
- Scopes: what
spec.scopebinds. - Secrets:
lm secret setfor*_envnames. - CSV & files (SFTP/S3): the same transports, on the way in.
- CLI reference: every
lm exportflag.