ADR-0003: Separate identity from labels¶
- Status: Accepted
- Date: 2026-06-20 (recorded 2026-09-14, retrospectively, from the
identity-and-labelsmanifesto) - Deciders: Chas Egan (@chasegan)
- Tags: ide, engine
Context and problem statement¶
Many things in Kalix have both an identity (which thing it is) and a
label (the text shown for it): runs, nodes, datasets, loaded files,
series. The IDE's run-manager and flowviz subsystems originally conflated
the two — a series was keyed by its display string, and a rename meant
propagating the new string, in lockstep, into every map, set, undo frame and
cache that had keyed by the old one. Miss one and you get stale or orphaned
state. Code also grew habits like endsWith(" [Last]") to recover structure
from a label, which is identity being reconstructed after it was thrown away.
The question is how to represent identity so that a whole class of rename / stale-key / duplicate-key defects becomes structurally impossible rather than merely avoided by care.
Decision drivers¶
- Manifesto §2.6 — clarity off the hot path; use structure and the type system to make features robust in the face of future change.
- The IDE's commitment to idiomatic architecture over cleverness (Manifesto §2.6): a rename should be a no-op for storage, not a coordinated update.
Considered options¶
- Label as identity — key collections by the display string; on rename, update every keyed structure. The status quo at the time.
- Typed identity token, label derived — key everything by an opaque, stable, typed token; produce labels on demand from a single resolver.
Decision¶
Chosen option: typed identity token, label derived.
1. Principle¶
Identity is separate from label. Identity is an opaque, stable, typed token. The human-readable string is a projection of that token, computed on demand by a resolver — never stored, never used as a key, never parsed back into identity. A label can change without the identity changing; an identity must survive every cosmetic change to its label. So the label cannot be the identity, and identity cannot be recovered by reading a label.
2. Rules¶
- Never key a collection by a display string. A
Map<String, …>orSet<String>that holds the identity of a domain object is a bug. Key by the typed identity token instead. - Never parse identity out of a label. No
endsWith(" [Last]"), nolastIndexOf(" ["), nosubstringto recover a suffix. If you are reading structure out of a display string, the identity was thrown away too early. - Never hand-build a label. No
name + " [" + run + "]"at a call site. Display strings come only from the resolver that owns that projection. - Identity tokens are opaque and typed. Prefer a sealed/closed set of identity types over a bare string or int, so the compiler — not a convention — rejects the illegal cases.
3. Scope¶
The principle binds wherever a domain object has both an identity and a display name — not only series. New identity types follow the same shape: a typed, stable token; storage keyed by it; the label derived, never stored or parsed.
Consequences¶
- ✅ Rename is a no-op for storage: nothing keyed by identity changes when the label changes.
- ✅ Stale-key, orphaned-state and duplicate-key defects from renames become impossible by construction, not by vigilance.
- ✅ "Last run" can be a pure alias resolved at the boundary and can never go stale.
- ❌ One more type per identity kind, and a resolver to maintain. Accepted: the type is the enforcement.
Pros and cons of the options¶
Option 1: Label as identity¶
- ✅ Nothing to design; strings are already everywhere.
- ❌ Every rename is a distributed update across every keyed structure.
- ❌ Structure gets parsed back out of strings, which is fragile and invisible to the compiler.
- ❌ Two things with the same label are indistinguishable.
Option 2: Typed token, label derived¶
- ✅ The compiler rejects most violations.
- ✅ One resolver owns every display string, so labelling is consistent.
- ❌ Requires a deliberate identity type per domain, and a migration of existing string-keyed code.
Worked example¶
The run-manager / flowviz subsystem is the reference implementation (full
dataflow in kalixide/docs/SERIES_IDENTITY_DATAFLOW.md):
- Identity is a sealed
SeriesRef—RunSeries(runId, baseName),LastSeries(baseName),DatasetSeries(datasetId, baseName). Every store keys bySeriesRef, never by a string. TimeSeriesDatacarries no name — identity is supplied externally when data is inserted, so the data object cannot smuggle in a second source of truth.- Labels are produced solely by
LabelResolver.labelFor(ref)at render time. A run'srunIdis stable acrosswithName, so renaming re-renders without touching storage. LastSeriesis a pure alias resolved to the underlyingRunSeriesat the pool boundary — never a stored key — so "last run" data can never go stale.
Enforcement¶
Structural where the type system allows: the sealed identity types and
the ref-only DataSet API mean a violation usually won't compile. The audit
checklist in SERIES_IDENTITY_DATAFLOW.md §"Invariants" catches the rest.
Rule 3 (never hand-build a label) and the extension of the principle to new
identity kinds (§3) are Advisory, held by review.
Links and references¶
- Related:
kalixide/docs/SERIES_IDENTITY_DATAFLOW.md. - Related: ADR-0004 §3.2 — the same discipline on the engine side: resolve identity to indices at setup, never carry strings into the loop.
Amendments¶
None.