ADR-0405: Export data contract and schema versioning¶
The serialized inventory shape every sink and consumer depends on: the
Itemrow, its ordering, and how the contract is versioned.
Theme: 04 · Export & sinks · Status: Current (schema versioning: Exploring)
Context¶
Kollect's external value is the exported inventory payload. Portals, SQL queries, Git diffs,
Kafka consumers, and the HTTP API all read this contract — it is the most stability-sensitive surface
in the project, yet it had no ADR. The shape is implemented in internal/collect/store.go but its
guarantees (field set, ordering, null handling, versioning) were never written down.
A data contract must be: explicit, stable-ordered (for diffable Git and golden tests — ADR-0103), bounded (no full payload in etcd status), and versioned so consumers can detect breaking changes.
Decision¶
Row shape (Item)¶
One collected resource = one Item (internal/collect/store.go):
{
"targetNamespace": "team-a",
"targetName": "deployments",
"namespace": "team-a",
"name": "api",
"group": "apps",
"version": "v1",
"kind": "Deployment",
"uid": "…",
"attributes": { "image": "…", "images": ["…"] }
}
- Identity fields (
group/version/kind,namespace,name,uid) locate the source object;targetNamespace/targetNamerecord whichKollectTargetproduced the row. attributesis the profile-defined extraction result (map[string]any); JSONPath[*]yields a JSON array (ADR-0302).groupisomitempty(core kinds); all other identity fields are always present.
Aggregated payload¶
- Default export = a JSON array of
Itemfor the inventory's scope (MarshalNamespaceJSON). - HTTP =
NamespaceSummary { namespace, itemCount, items }. - Sink projections derive from this canonical snapshot (ADR-0401):
Postgres rows keyed by
(inventory_namespace, inventory_name, target_name, source_uid)+cluster; Kafka keyed by{cluster}:{ns}/{name}; Git/object-store as the whole JSON document.
Export metadata¶
Carried alongside the payload (status + sink columns/headers), not inside each row:
schemaVersion (envelope contract version), checksum (SHA-256 of payload — aggregate.ContentHash),
source generation, itemCount, exportedAt, and cluster. These drive debounce/coalesce
(ADR-0305) and staleness detection without bloating rows.
Multipart completeness marker (REL-02)¶
When a snapshot exceeds maxExportBytes it is sharded across several bounded envelopes
(export.PartitionEnvelopes). Sharded writes are not atomic: parts are persisted one by one, so a
mid-write failure — or a stale generation-N-1 shard left beside fresh generation-N shards — can
leave a torn set that would otherwise masquerade as complete. To make torn sets detectable, each
part of a multipart JSON ExportEnvelope carries a completeness marker:
partIndex— 1-based position of this part.partTotal— total number of parts in the set.generation— already present; identical across all parts of one set.
Scope of the guarantee (important). These markers live in the JSON ExportEnvelope header. They
are present, and payload-level torn-set detection applies, only where the envelope itself is the
persisted payload: the state-sink JSON contract — non-git object stores (S3/GCS/Azure/HTTP) and the
Git/GitLab document + serialization.format: json case, which writes the canonical envelope
unchanged.
The default Git/GitLab sink serializes to YAML via the human-readable layout projection
(ADR-0419), which intentionally emits bare Item rows and
carries NO ExportEnvelope metadata — no schemaVersion, checksum, itemCount, generation, or
partIndex/partTotal inside the data files. Payload-level (per-row) completeness detection therefore
does not apply to YAML sinks. Instead, a multipart YAML set carries its completeness marker in a
per-set manifest sidecar (REL-02-FUP), giving YAML consumers the same torn-set / stale-set guarantee
JSON consumers already have.
Per-set manifest sidecar (YAML layout, REL-02-FUP). A prune-bearing layout export
(layout.mode: perResource/split) that shards into more than one part writes exactly one manifest
sidecar for the whole set:
- Path — deterministic and per-set:
inventory/{namespace}/{name}.manifest.json. It carries no{generation}or part placeholders, so a re-export at a new generation replaces the manifest in place (replaced-not-orphaned) rather than accumulating stale siblings. The.manifest.jsonextension is distinct from the.yaml/.ndjsondata files, so an existing consumer globbinginventory/{ns}/*.yamlskips it (graceful-ignore). - Shape — a
layout.SetManifest, always serialised as JSON (a machine-readable control file, independent ofserialization.format). It self-identifies viakind: KollectExportSetManifestandschemaVersion(==ExportSchemaVersion, additive-only per rule 2), and declaresgeneration,partTotal, the per-part identifiersparts: [1..partTotal], andpaths— the union of every part's projected data-file paths. - Distinct from the split
Index—layout.Indexis the per-projection split-mode sidecar (one projection's rows, for CI gating);SetManifestspans every part of one multipart set. When a split-mode export is also multipart the two coexist at different paths and self-identify bykind. - Scope — the sidecar targets prune-bearing tree layouts, where parts occupy distinct paths and a
torn set is possible. It is not emitted for single-part exports (byte-compatible with today, no
sidecar) nor for
documentmode (all parts overwrite one path — a degenerate set;documentisprune: off). Its prune interaction is specified in ADR-0419.
Consumer validation (YAML sidecar contract). Writer/verifier split: the controller writes the
manifest (it never reads it back), and completeness detection is the consumer's responsibility — the
marker is a detection contract, not a controller-side guarantee. A consumer confirms a YAML set is
complete by reading the manifest and checking that every path in paths exists on disk (a missing
path = a torn set, a part failed to persist) and that generation matches the generation it expects
(from the CR status or commit metadata — the same expected-generation signal the JSON contract uses; a
mismatch = a stale set left by a torn export that never rewrote the manifest). layout.VerifySet is a
consumer-side helper (it has no production callers inside the operator) that a Go consumer can vendor
to apply exactly this rule — and, for generation-scoped path templates, it additionally surfaces
prior-generation orphan data files; its present argument may be a raw directory listing — the
.manifest.json sidecar is an expected member of the set, never counted as a stale extra. A single-part
export writes no sidecar and is complete on its own, byte-identical to the pre-marker shape (additive
evolution, rule 2).
Consumer validation (JSON envelope contract). A consumer reassembling a set from JSON envelopes
MUST verify it holds every index 1..partTotal, that the count equals partTotal, and that
generation is uniform across the parts; a missing index or a mixed generation means the set is
torn or stale and MUST NOT be treated as complete. The absence of partTotal (the legacy/
omitempty form) denotes a standalone single-part document that is complete on its own — single-part
exports stay byte-identical to the pre-marker shape, so existing consumers are unaffected (additive
evolution, rule 2). The marker is a detection contract only; manifest-last write ordering /
staged-commit / GC of orphaned parts is deliberately out of scope here and tracked separately.
Stability rules (binding)¶
- Deterministic ordering — stable key order on serialize so Git diffs and golden tests are reproducible.
- Additive evolution preferred — new attributes/fields are additive; removals/renames are breaking and gated by the API versioning policy (ADR-0206).
- No secrets, ever — redaction happens before export (ADR-0303, ADR-0104).
- Bounded size — spill over
maxExportBytesto object store; never to etcd (ADR-0103).
Consequences¶
- Consumers have one documented schema across all sinks.
- Golden/contract tests can assert the shape; breaking it fails CI.
- Consumers can branch on
schemaVersionwithout coupling to CRD API versions.
Implementation status (schemaVersion milestone)¶
| Export path | schemaVersion envelope |
Status |
|---|---|---|
Kafka EventEnvelope |
Yes — internal/sink/kafka/backend.go |
Shipped |
| Inventory / cluster inventory sink export | No — bare []Item JSON array (MarshalNamespaceJSON) |
Pre-beta gap |
| Git / Postgres / S3 / GCS object payloads | No — canonical array only | Pre-beta gap |
| Read API HTTP responses | No — NamespaceSummary without envelope |
Pre-beta gap — align with OpenAPI openapi/v1alpha1/inventory.yaml |
Contract value: kollect.dev/v1alpha1 (ADR-0206).
Pre-beta milestone: wrap all sink exports and Read API responses in a versioned envelope
(schemaVersion, items, metadata) so consumers decouple from CRD API versions
(ADR-0206). Until then, schema versioning remains Exploring.
Open questions¶
- PARTIAL: Explicit
schemaVersionon Kafka event envelopes — inventory and state-sink JSON exports still emit bare arrays; milestone tracked above. - DECIDED : Attributes stay
map[string]anyin the contract; stronger typing is a sink-side concern — the Parquet sink promotes a hot-attribute allowlist to typed columns while keeping a JSONattributescolumn (ADR-0401). - PARTIAL : OpenAPI extensions (pagination, filters, envelope,
exportStatus) tracked in the Read API OpenAPI contract; publish JSON Schema forItemalongside OpenAPI when envelope milestone closes.