ADR-0422: Inventory identity for export ownership¶
Prune ownership must know which kind of inventory wrote a file, so cluster inventories can prune again without ever deleting a namespaced inventory's files.
Theme: 04 · Export & sinks · Status: Current (accepted 2026-10-04)
Context¶
Git and GitLab tree exports (perResource, split) delete stale files through ownership records,
.kollect-prune/<sha256(owner)>.json (ADR-0419, "Exact
file ownership for pruning"). An export deletes the paths its previous record lists and its current
export no longer projects; a file no record lists is never deleted.
Before this decision the owner was the JSON triple [cluster, inventoryNamespace, inventoryName],
with namespace and name parsed out of the object path; the request carried no kind. A
KollectClusterInventory X uses object path inventory/cluster/X.json, the same as a
KollectInventory X in a namespace called cluster. Both got the same owner, so either could
prune the other's files.
PR #394 (7cb3348) made that safe by suppressing prune whenever the namespace component is cluster.
Every cluster inventory has that component, so since #394 no cluster inventory's tree export pruned:
deleted resources stayed in the repository, and an empty snapshot was a no-op. v0.21.0 contains
neither #394 nor ownership records, so the next release would have been the first to ship this
regression.
Constraints from the code:
- The record schema is strict:
version: 1, fieldsversion,owner,paths, unknown fields rejected (internal/sink/git/prune_owned.go). The owner is otherwise opaque to the engine: hashed for the record path and compared as a string. Two owners claiming one path is a terminal error. - Default paths: resources at
{cluster}/{sourceNamespace}/{kind}/{sourceName}{extension}(no inventory identity), the split index atinventory/{namespace}/{name}{extension}, the multipart set manifest atinventory/{namespace}/{name}.manifest.json(api/v1alpha1/layout_spec_types.go,internal/sink/layout/manifest.go). - Kollect is not in production. Records written by earlier
mainbuilds need not be understood, migrated or rolled back to (operator decision, 2026-10-04).
Options¶
A. Kind-qualified owner for every inventory, paths unchanged¶
The reconcilers pass kind, namespace and name in the export request. Every inventory's owner becomes
["v2", kind, cluster, namespace, name] (namespace empty for the cluster kind). The suppression is
removed. Record schema and paths unchanged. Shared projected paths (split index, set manifest, the
same resource under the default template) remain shared: whichever of two inventories exports second
is rejected as "belongs to another inventory", which is the rule for every pair of inventories.
B. Kind-prefixed path layout¶
A cluster inventory renders its namespace component as _cluster (not a valid DNS-1123 label):
inventory/_cluster/X.yaml. The two inventories' index and manifest paths stop colliding, but
user-visible paths move for every sink type that renders the object path (Git, GitLab, S3, GCS, the
Parquet ns= partition), the deletion cleanup path, status lastExportPaths evidence, and any
{namespace} in a custom template.
C. Per-kind record directory¶
Keep the triple and store records under .kollect-prune/<kind>/<sha>.json. The loader must read
every directory to keep cross-owner conflict checks, which is option A with a different encoding and
a second place to look.
Trade-off matrix¶
Scores 1 (worst) to 5 (best); weight × score.
| Criterion | Weight | A | B | C |
|---|---|---|---|---|
| Safety against wrong deletion | 5 | 4 (20) | 5 (25) | 4 (20) |
| User-visible path stability | 3 | 5 (15) | 1 (3) | 5 (15) |
| Implementation size | 2 | 4 (8) | 2 (4) | 3 (6) |
| One owner rule for every inventory | 3 | 5 (15) | 3 (9) | 4 (12) |
| Total (max 65) | 58 | 41 | 53 |
- Safety: all three stop cross-kind deletion. B scores higher because the two inventories also stop sharing index and manifest paths. A and C reject the second writer of a shared path instead, which deletes nothing but stops that export.
Decision¶
Option A, for every inventory (operator decision, 2026-10-04).
- Identity in the request.
sink.ExportEnvelopeRequest.Inventorycarries kind (KollectInventoryorKollectClusterInventory), namespace (empty for the cluster kind) and name. Both reconcilers set it next to the object path. The object path still drives path rendering; the identity decides the owner. - Owner.
["v2", kind, cluster, namespace, name], built bygit.InventoryPruneOwner.clusteris the sink'sspec.clusterordefault;namecarries no multipart suffix. The owner carries no UID. A kind or name change never continues a record. - Release on deletion (2026-10-05, slice 2). Deleting an inventory releases its record on every
reachable Git or GitLab sink under both deletion policies
(ADR-0421): the cleanup derives the owner from the
inventory's identity with
git.InventoryPruneOwner, exactly as the export does, and removes.kollect-prune/<sha256(owner)>.jsonin a commit.Retain(and a retraction skipped for a shared export identity) removes only the record;Deletealso removes the cleanup candidates and every path the record lists, never a path another owner's record lists. An absent record is a completed release. A recreated inventory with the same identity therefore starts a new record. The record survives only a deletion that cannot reach the sink (the sink CR is gone, or the finalizer is forced), and a recreated inventory then continues it. - Ownerless deletion respects records.
DeleteExportwithout an owner and the legacy directory-scoped prune (an ownerless tree export) never remove a path any record lists. - Identity is mandatory. A git layout export (everything that goes through the tree writer and therefore carries an owner) without an identity is a terminal error. There is no fallback to a path-derived owner and no suppression.
- Consistency asserts, all terminal. The identity's namespace and name must equal those parsed
from the object path (multipart suffix removed), and a
KollectClusterInventorymust come with path namespacecluster. The engine refuses, before writing, a commit whose ownership records would claim one path twice. - The rejection names the owner. "prune path ... belongs to another inventory: \<kind> \<namespace/name> (cluster ...), ownership record .kollect-prune/\<sha>.json".
- Multipart manifest pre-claim. Every non-final part of a prune-bearing set claim-checks the set-manifest path the final part will write, so a set whose manifest belongs to another inventory is rejected on part 1, before any part is committed.
- No compatibility layer. Records with any other owner encoding are treated as another inventory's records; there is no migration, dual encoding or rollback handling.
Consequences¶
Positive¶
- Cluster inventories delete their own stale files again, including on an empty snapshot.
- The kind is explicit in the request; the sink no longer infers it from a path segment.
- One owner rule for every inventory, enforced by asserts rather than by a special case.
Negative¶
- A same-name cluster inventory and namespaced inventory in
clustercannot share a projected path (split index, set manifest, a resource under the default template); the second exporter is rejected. - A repository that holds records from earlier
mainbuilds rejects exports whose paths those records list, until the old records are removed by hand. Acceptable while Kollect is not in production. - Releasing a record on deletion needs the backend: a Git or GitLab sink with a broken credential
now holds a
Retaindeletion inTerminatinguntil the credential is fixed or the finalizer is forced (which leaves the record).
Follow-ups¶
- GitLab
branchMRmode: two merge requests open at the same time can still both claim a path. Exports are checked against the merge target's records, each inventory kind has its own feature branch, and the duplicate-ownership error names both records (openspec/changes/archive/2026-10-05-gitlab-branchmr-claims/).
Related¶
- ADR-0419 — layouts and owned pruning
- ADR-0421 — deletion policy and shared export identity
- ADR-0407 — repository and path layout
- ADR-0501 —
spec.clusterpartitioning openspec/changes/archive/2026-10-05-inventory-export-identity/— the change that implements itopenspec/changes/release-ownership-on-delete/— release on deletion (slice 2)