Datasets — file datasets and versions¶
Design note. Not implemented. A companion decision to Ontology — objects, links, datasets: a dataset is the catalog entry for data, not necessarily for a table.
The trigger is MLOps: a training set is a file (parquet), not a table. It has to be browsable in the
same Catalog as factor_values, downloadable by an agent, and pinnable — "model v7 was trained
on dataset training-set-5d, version 3". MLflow can record a URI; it cannot say who registered that
data, what columns it had, what it supersedes, or whether it is still around.
Scope¶
| Capability | Table dataset | File dataset |
|---|---|---|
| Catalog entry: name, description, owner, domain, tags, sensitivity, ai_context | ✅ | ✅ |
Permissions (data:read / data:write) and usage analysis |
✅ | ✅ |
| Browse rows | ✅ SQL | ✅ preview / filtered read |
| Download the raw file | — | ✅ (presigned URL) |
Versions and pinning (dataset_id + version) |
— | ✅ |
dataset_joins (relationships) |
✅ | ❌ rejected |
| Bind as an object type backend | ✅ | ❌ rejected |
| Junction for a link type (graph index) | ✅ | ❌ rejected |
SQL / aggregate / text-to-cypher grounding |
✅ | ❌ excluded |
| Row editing | ✅ (opt-in) | ❌ immutable |
A file dataset is a versioned data asset. The Catalog's job for it is identity, provenance, pinning and governance — not query federation. Reading a file supports selection (column and predicate pushdown: "only 2026-06..09, only these columns"), which is not a join. If cross-file or file-to-table joins ever become a requirement, that is a query-engine decision (duckdb and friends) and its own design note.
Three layers¶
dataset the logical asset: name, description, owner, recorded schema
└ versions a numbered milestone — draft while being assembled, immutable once published
└ files immutable data units, one per slice (e.g. one parquet per month)
Adding data is adding a file, not cutting a version. A new month lands as a new file; a version is cut when someone decides to freeze one (a training snapshot, a re-export):
The version is its integer — v1, v2, v3. There is no business label to keep in sync and no
"current version" pointer: a consumer that needs a specific one asks for it (--version 2), and
"latest" is just the highest number. notes is free text for the human who cut it.
A version is a draft until it is published¶
Assembling a version is iterative: create v3 as a draft, add the files as they are produced,
look at the totals, then publish. Only publishing freezes it.
| State | File set | Manifest | Pinnable (readable by a consumer) |
|---|---|---|---|
draft |
mutable — files added, removed | none yet | no |
published |
frozen | written once | yes |
deprecated |
frozen (unchanged) | unchanged | yes — a consumer may still ask for it by number |
A published version's file set never changes; adding data afterwards means v4. Deprecating is a
label, not a change: deprecated versions are kept (see retention).
Files are shared, not copied¶
A newer version reuses the files it did not change by referencing their existing keys, so storage grows with the data, not with the square of it:
datasets/<dataset_id>/versions/1/files/2026-01.parquet
datasets/<dataset_id>/versions/1/files/2026-02.parquet
datasets/<dataset_id>/versions/2/files/2026-03.parquet ← only the new slice lives here
Version 2's manifest points 2026-01 and 2026-02 at version 1's keys. Consequences:
- deleting a version deletes only the files it introduced — and only when no other version's manifest references them (reference check, not cascade);
- re-importing a month is never an overwrite: it is a new file plus a new version that references it.
manifest.json¶
The manifest is the contract. It is written at publish and never edited; a draft has no manifest, which is exactly why a draft cannot be pinned.
{
"dataset_id": "…",
"version": 2,
"notes": "2026Q3 冻结,用于 5 日 Top-50 模型第 7 版",
"created_by": "yingrui",
"created_at": "2026-10-26T09:12:00Z",
"schema": [
{"name": "ts_code", "type": "string"},
{"name": "trade_date", "type": "date"},
{"name": "mom_20d", "type": "double"}
],
"files": [
{
"name": "2026-01.parquet",
"key": "datasets/<id>/versions/1/files/2026-01.parquet",
"reused_from_version": 1,
"rows": 112000,
"bytes": 1834521,
"sha256": "…",
"date_range": ["2026-01-05", "2026-01-30"]
}
]
}
Two properties earn their keep:
date_rangeper file lets a read skip whole slices — "only June to September" never opens January's object;schemaon the version is recorded, not enforced. It exists so a reader knows what to expect; whether a later file may add a column is the dataset owner's call, not a rule the platform applies. A model that finds a missing column fails loudly on its own side.
sha256 here is the byte hash: it proves a download is intact. It is not a content identity —
two parquet writes of the same rows differ by writer version and compression. "Same data" is
answered by the version + file set, which is why pinning is (dataset_id, version), not a hash.
Data model¶
-- One built-in source, no configuration: the platform's own MinIO bucket.
-- It is seeded, not created by a user, and it has no credentials form or "test connection" —
-- the deployment already holds those (OPENKMS_S3_*). Every file dataset points at it.
data_sources (kind='object_storage', name='Object Storage')
datasets
+ kind -- 'table' | 'file'
-- schema_name/table_name become nullable (only kind='table')
-- partial unique index: (data_source_id, schema_name, table_name) WHERE kind='table'
-- is_editable forced false for kind='file'
dataset_versions
id, dataset_id → datasets
version int -- auto-increment per dataset; it *is* the version ("v3")
notes text -- why it was cut, written by whoever cut it
status text -- draft | published | deprecated
manifest_key text -- null until published, then the object holding the manifest
schema jsonb -- column contract, copied out of the manifest for querying
row_count bigint, byte_size bigint, file_count int
date_from, date_to date -- overall coverage
published_at timestamptz -- null while draft
created_by, created_by_name, created_at
UNIQUE (dataset_id, version)
dataset_version_files
version_id → dataset_versions
name, storage_key, sha256
rows, bytes, date_from, date_to
reused_from_version int | null
created_by, created_at -- a draft can be filled by more than one person
PRIMARY KEY (version_id, name)
dataset_version_files duplicates the manifest on purpose: it answers "which versions contain this
file" and "is it safe to delete" without parsing every manifest.
Guards (three, and they are semantic)¶
validate_join— either endpointkind='file'→ 400: file datasets cannot take part in a relationship. Without this, a relationship would be created and then read rows through a path that cannot do it.- object type binding —
ObjectType.dataset_idpointing at a file dataset → 400: the object layer needs a key, MERGE and write-back; a file snapshot has none of them. - row access paths — link-type junction indexing,
aggregate, text-to-cypher grounding and agent SQL all read tables. They must exclude file datasets explicitly rather than fall back to a preview, because a preview silently stands in for the whole table and returns wrong answers.
API and CLI¶
# register / browse
POST /api/datasets {kind: "file", ...} # catalog entry only
GET /api/datasets?kind=file
GET /api/datasets/{id}/versions
GET /api/datasets/{id}/versions/{version} # manifest + files
POST /api/datasets/{id}/versions # open a draft: {notes} → {version: 3, status: draft}
POST /api/datasets/{id}/versions/{v}/files # add a file to a draft
DELETE /api/datasets/{id}/versions/{v}/files/{name} # only while draft
POST /api/datasets/{id}/versions/{v}/publish # freeze: writes the manifest
POST /api/datasets/{id}/versions/{v}/deprecate
GET /api/datasets/{id}/versions/{v}/files/{name}/url # presigned download
# data
GET /api/datasets/{id}/versions/{v}/preview?limit=100
GET /api/datasets/{id}/versions/{v}/rows?columns=…&where=trade_date>='2026-06-01'
POST /api/datasets/{id}/uploads # small files, straight from the UI
Large files never pass through the API: the CLI/agent asks for a presigned URL, uploads directly to MinIO, then registers the object (existence checked, size and sha256 computed server-side). That split is what makes "agent uploads a 500 MB parquet" workable.
CLI (skill), because an agent has to be able to do all of this without a browser:
datasets version create --id <dataset> --notes "2026Q4 数据入湖" # → draft v3
datasets file put --id <dataset> --version 3 --path ./2026-10.parquet # presigned, direct
datasets version publish --id <dataset> --version 3
datasets version deprecate --id <dataset> --version 2
datasets file get --id <dataset> --version 3 --out ./cache/ # honours file date_range
datasets file list --id <dataset> --version 3
UI¶
Datasets list gains a kind badge. A file dataset's detail page has Overview · Versions · Data
(preview + download) · Usage · Sharing, and hides Columns (live) and Relationships, which have no
meaning for a file. The version list reads like a ledger — v3 draft · 8 files · 900k rows, v2
published 2026-10-01 · used by 2 versions — with add file and publish on drafts and deprecate
on published ones.
Deletion and retention¶
- Deprecated versions are kept. They stay readable and pinnable, so a model can always be traced back to the data it saw. Nothing expires by policy.
- A draft may be deleted outright — it was never publishable, so nothing can reference it.
- A file becomes deletable only when no published manifest references it; the reference check runs on delete and is surfaced as "used by versions 2, 3".
Non-goals¶
- No query federation, no joins, no SQL over files (see scope table);
- no object types backed by files;
- no write path into a version — correction means a new file and a new version;
- openKMS does not record which MLflow run used a version. The consumer records that (the training script knows its run id), which keeps openKMS from mirroring MLflow's lineage.
Decided¶
- Version is an integer. No label, no "current version" pointer; a consumer asks for the version it needs, and "latest" is the highest number.
- Draft → published. A version is assembled as a draft (files added, removed), then published, and publishing is what freezes it and writes the manifest.
- One object-storage source, unconfigured — the deployment's MinIO bucket, seeded rather than
entered by a user. A dataset's data lives under
datasets/<dataset_id>/in that bucket. - Schema is recorded, not enforced. Adding a column later is the dataset owner's decision; the platform does not police compatibility.
- Deprecated versions are retained indefinitely.
Still open¶
- Who may publish? Opening a draft is
data:write, but publishing is the moment a training set becomes pinnable — it may deserve its own permission once more than one person touches a dataset. - Two people adding files to the same draft: last-writer-wins is fine for now, but the UI should
show who added which file (
dataset_version_files.created_by) so a surprise is explainable.