Skip to content

SRD-031.A — Definition versioning and the registration handle

Field Value
Status Accepted
Version v.1
Date 2026-06-28
Owner Ruslan Gabitov
Implements ADR-019 v.1 Definition versioning

This SRD lands the versioning half of ADR-019 v.1: RegisterProcess returns a registration handle naming a (key, version); the engine snapshot is isolated from the model at registration time (closing the architecture audit §3.3 mutation leak/race by construction); instances start by handle, by (key, version), or by latest-of-key; and registering a newer version supersedes the previous version's auto-start. The Thresher registry's concurrency-discipline rework (audit §2.6) is the sibling SRD-031.B, landed on the same branch after this one. The first-class model-editing API is a separate ADR, not in scope here.


1. Background & current state (verified against the code)

1.1 Registration identity is ambiguous, and re-registration is a silent no-op

Thresher.RegisterProcess returns only an error and stores the snapshot keyed on the process id; a second registration of the same id is silently discarded (pkg/thresher/thresher.go:485-491):

t.m.Lock()
if _, ok := t.snapshots[s.ProcessID]; ok {
    // Already registered — idempotent, keep the first registration.
    t.m.Unlock()
    return nil
}

StartProcess then takes that id string and starts whatever is stored under it (pkg/thresher/thresher.go:702,716):

func (t *Thresher) StartProcess(processID string) (*InstanceHandle, error) {
    ...
    s, ok := t.snapshots[processID]

A caller who edits a process and re-registers it expecting the new shape gets the old snapshot, with no error — and addresses the registration by reaching back into the model object for its id, with no way to say which registered shape is meant.

1.2 The snapshot shares the model's node graph — the audit §3.3 leak

snapshot.New does not copy the definition's nodes/flows; it stores the model objects by reference (internal/instance/snapshot/snapshot.go:58,68,113):

Properties:  p.Properties(),
...
for _, n := range p.Nodes() {
    s.Nodes[n.ID()] = n        // shares the model node by pointer
}
...
    s.Flows[f.ID()] = f        // shares the model flow by pointer

The deep copy happens only later, once per running instance, in Clone (snapshot.go:149-237): clone every node, relink flows, remap default flows, rebind boundary events. The process model stays mutable after registration via its public Add/Remove (pkg/model/process/process.go:168,272), so:

  • a node/flow added after registration never enters the already-taken snapshot (invisible), and
  • an in-place edit to an existing shared node reaches the snapshot through the shared pointer and is picked up by the next Clone, racing any instance already cloning it (no lock on that path).

This is architecture-audit §3.3 ("validation and mutability of the process model"). ADR-019 §2.3 resolves it by isolation (copy at registration) rather than the audit's freeze.

1.3 The registry today

The Thresher holds four maps under one mutex (pkg/thresher/thresher.go:109-121):

snapshots    map[string]*snapshot.Snapshot      // key = ProcessID
instances    map[string]instanceReg             // key = instance id
starters     map[string][]*instanceStarter      // key = ProcessID
seenKeys     map[string]struct{}                // correlation dedup
m            sync.Mutex
state        State

Auto-start starters are built per process by scanInstantiatingStarts (instance_starter.go:109) and registered/torn down on the EventHub via registerStarters (thresher.go:561, RegisterPersistentEvent) and eventHub.UnregisterEvent(st, st.eDef.ID()) (thresher.go:544). Running instances are tracked independently in instances and are unaffected by unregistering a process (UnregisterProcess comment, thresher.go:512-514) — they keep running their own per-instance clone.

2. Requirements

Functional

  • FR-1 — Snapshot isolation. snapshot.New deep-copies the definition's node graph (nodes, flows, default-flow and boundary wiring) so the snapshot owns an independent graph. After RegisterProcess returns, no edit to the source *process.Process — structural or in-place — changes the taken snapshot or any instance later born from it. Immutable per-definition config (properties, correlation keys) may remain shared by reference.
  • FR-2 — Registration handle. RegisterProcess returns a *ProcessRegistration exposing Key() string, Version() int, ID() string.
  • FR-3 — Versioning. Registering a process whose key already has registrations appends a new version. Version numbers come from a per-key monotonic counter, not the slice length: a number is never reused while the key has at least one live version, so removing a non-latest version (FR-8) cannot make the next registration collide with a still-registered version. The counter resets only when the key is fully unregistered (a later registration of that key is v1 again). The silent idempotent no-op (§1.1) is removed. A process with no explicit id (auto-generated UUID) is its own singleton key at version 1.
  • FR-4 — Start by handle. StartProcess(reg *ProcessRegistration) starts the exact version the handle names and returns its *InstanceHandle.
  • FR-5 — Start by (key, version). StartVersion(key string, version int) starts that specific version, or errors (ObjectNotFound) if the key or version is unknown. It addresses by the version number, not slice position, so it stays correct across gaps left by non-latest removals (FR-8) — e.g. after v2 is removed, StartVersion(key, 3) still starts v3 and StartVersion(key, 2) errors.
  • FR-6 — Start latest. StartLatest(key string) starts the latest registered version of the key, or errors (ObjectNotFound) if the key is unknown.
  • FR-7 — Latest-supersedes auto-start. Registering a newer version of a key in auto mode tears down the previous version's instance-starters from the EventHub and registers the new version's, so only the latest version spawns new instances from a trigger. Instances already running under older versions continue to completion. Manual-start versions register no starters.
  • FR-8 — Unregister one version by handle, promote-on-removal. UnregisterVersion(reg *ProcessRegistration) removes the single version that handle names (its snapshot and, if it was the live latest, its starters). Running instances survive. Removing the latest auto version promotes the now-newest remaining version: its starters are registered so it becomes the live auto-start version — the invariant latest registration == live starter set holds at all times. (This is how a user re-activates a previous version's auto-start: remove the later versions until it is latest again; a previous version is otherwise manual-only via StartVersion.) Removing the last version of a key drops the key entirely. Removing a non-latest version touches no hub subscriptions and leaves a gap in the key's version sequence (v1, v3, …) — the surviving versions keep their numbers, addressable by StartVersion (FR-5) and enumerable by Registrations (FR-10); StartLatest still resolves to the highest surviving version.
  • FR-9 — Migration. Every in-repo caller of the changed methods adopts the new API: all examples/*, all pkg/thresher/*_test.go, README.md, README.ru.md, examples/README.md. Examples build and run green.
  • FR-10 — List registered versions. Registrations(key string) []*ProcessRegistration returns the key's registered versions ascending by version (empty for an unknown key). Each element is a live handle — read its Version() / ID(), or pass it straight to StartProcess / UnregisterVersion — so a caller can discover which versions exist (including gaps from FR-8) before addressing one by StartVersion.
  • FR-11 — Unregister the whole process by key. UnregisterProcess(key string) removes every registered version of the key in one call, drops the key, and resets its version counter (a later registration of the key is v1 again). It is the bulk counterpart of UnregisterVersion; the name matches its scope — a process is the whole keyed definition, a version is one snapshot of it. Only the latest version's starters are live, so only those are torn down; running instances of any version survive. Errors EmptyNotAllowed on an empty key, ObjectNotFound on an unknown key.

Non-functional

  • NFR-1 — Parameter validation. Every new/changed public method rejects bad input with a self-identifying error (nil handle, empty key, version < 1) — per the project's public-API validation rule — never a silent default or panic.
  • NFR-2 — No new races. The change introduces no data race under concurrent register/start/unregister (go test -race). This SRD preserves the existing lock discipline; the explicit atomic-state/lock-boundary hardening (audit §2.6) is SRD-031.B.
  • NFR-3 — Bounded cost. Isolation adds one graph clone per registration (paid once, bounded by definition size); no per-instance runtime regression (the per-instance Clone is unchanged, now cloning from the isolated snapshot).
  • NFR-4 — Coverage. Diff-coverage ≥ project standard (95%, aim 100%) on every touched file; make ci green.
  • NFR-5 — Documented breaking change. The API break is recorded in CHANGELOG.md with a migration note (old StartProcess(id)StartProcess(reg) / StartLatest(key)).

3. Models

3.1 ProcessRegistration — the registration handle (pkg/thresher/registration.go, new)

A read-only receipt wrapping the engine's internal per-version state, mirroring InstanceHandle's wrap-by-reference pattern (handle.go:24):

// ProcessRegistration is the receipt for one registered version of a process
// definition. It names a (key, version) and is the token passed to StartProcess
// and UnregisterVersion. Read-only: it exposes identity, never the snapshot or
// the engine internals it wraps.
type ProcessRegistration struct {
    key      string                 // the versioning key = process id
    version  int                    // 1-based, increments per key
    id       string                 // opaque registration id (foundation id)
    snapshot *snapshot.Snapshot     // the frozen version (engine-internal)
    starters []*instanceStarter     // auto-start starters of this version (nil in manual mode)
    manual   bool                   // registered WithManualStart
}

func (r *ProcessRegistration) Key() string  { return r.key }
func (r *ProcessRegistration) Version() int  { return r.version }
func (r *ProcessRegistration) ID() string    { return r.id }

3.2 The versioned registry (pkg/thresher/thresher.go)

The snapshots and starters maps collapse into one ordered-by-version map; each version's snapshot and starters live on its ProcessRegistration. instances, seenKeys, m, and state are unchanged by this SRD.

// registrations maps a process key (process id) to its versions in registration
// order: index i is version i+1; the last element is the latest.
registrations map[string][]*ProcessRegistration
instances     map[string]instanceReg   // unchanged
seenKeys      map[string]struct{}       // unchanged (see §4.5)

3.3 snapshot.New clones the definition directly; a shared wiring helper keeps it DRY (internal/instance/snapshot/snapshot.go)

New already makes one pass over the definition's nodes (p.Nodes(), snapshot.go:67) to validate the process (start/end/instantiating-node checks). That same pass now clones each node (n.Clone()) into s.Nodes instead of sharing it by reference (snapshot.go:68), so the snapshot is born isolated — with no throwaway model-sharing intermediate and no redundant second pass over the definition (the return s.Clone() shape would build a shared snapshot only to re-clone it).

The graph still needs the post-node-clone wiring that Clone performs — relink the flows between the cloned nodes (flow.MustCloneFlow), remap gateway default flows, rebind boundary events onto their cloned hosts (snapshot.go:166-234). That wiring is identical whether the cloned nodes came from a process or from a snapshot, so it is the one piece extracted into an unexported helper (the node-cloning loop differs per source; the wiring does not):

// wireClonedGraph relinks the flow graph between an already-cloned node set,
// remaps gateway default flows, and rebinds boundary events onto their cloned
// hosts. Shared by New (cloning from the process definition) and Clone (cloning
// from the snapshot). Signature finalized in implementation; conceptually it
// takes the cloned nodes + the source nodes/flows and returns the cloned flows.
func wireClonedGraph(
    clonedNodes map[string]flow.Node,
    srcNodes map[string]flow.Node,
    srcFlows map[string]*flow.SequenceFlow,
) (map[string]*flow.SequenceFlow, error)

New clones nodes in its validation pass and then calls wireClonedGraph; Clone clones s.Nodes and calls the same helper. Neither duplicates the relink/remap/rebind logic, and New touches the definition only in its single existing pass. RegisterProcess then runs scanInstantiatingStarts on the isolated snapshot, so starters reference the frozen graph. Properties/CorrelationKeys stay shared (immutable; the Clone shares-Properties invariant anchored by TestSnapshotCloneSharesProperties on master remains true).

4. Analysis

4.1 New clones the definition directly; Clone-shared wiring (decided)

The snapshot is born isolated: New clones each node in the single validation pass it already makes over the definition, rather than sharing the model and then re-cloning it. The non-trivial post-clone wiring — relink flows, remap default flows, rebind boundary events — is identical for a process source and a snapshot source, so it is the one piece extracted into a shared helper (wireClonedGraph, §3.3); the per-source node-cloning loop stays in each of New and Clone. This gives single-pass construction with no duplicated wiring logic, and the two isolation hops still compose cleanly (instance graph ⊂ snapshot graph ⊂ model).

Rejected alternatives: (a) return s.Clone() from New — simplest to write, but builds a throwaway model-sharing snapshot and re-clones it, a redundant pass over the definition; (b) inline the full relink/remap/rebind in New as well as Clone — single-pass, but duplicates ~40 lines of tricky graph-wiring in two places that must stay correct in lockstep.

4.2 Version assignment is a per-key monotonic counter, not the slice index (decided)

The version is not the slice position. An earlier draft assigned version = len(registrations[key]) + 1 and looked up regs[version-1], coupling the number to the position — which breaks the moment a non-latest version is removed (FR-8): the slice [v1, v2, v3] becomes [v1, v3], so regs[version-1] returns the wrong snapshot for StartVersion(key, 2)/(key, 3), and the next registration reuses number 3. So version assignment uses a per-key monotonic counter (nextVersion map[string]int): each registration increments it and takes the new value, so a number is never reused while the key lives. The counter is dropped with the key on full unregistration (a fresh key restarts at v1). The slice stays append-ordered and removals preserve order, so it is always ascending by version — hence the latest (FR-6) is still regs[len-1] (O(1)), while lookup-by-version (FR-5) scans for the matching version (O(len), gap-safe) and enumeration (FR-10) returns a copy of the slice. Registration appends under t.m; the supersession hub calls run outside t.m (§4.4).

4.3 Latest-supersedes composes existing primitives (decided)

FR-7 is the existing teardown + register sequence applied across versions: on registering a new auto version when the engine is Started, unregister the prior latest's starters (eventHub.UnregisterEvent(st, st.eDef.ID()), as UnregisterVersion already does, thresher.go:545) and register the new version's (registerStarters, thresher.go:561). No new hub mechanism. This is the direct analogue of Camunda's "subscriptions of the previous version are canceled" (ADR-019 §2.5). Before Run (engine not yet Started) nothing is on the hub yet, so supersession is deferred to Run/registerAllStarters, which registers only each key's latest version's starters.

4.4 Lock discipline preserved (decided; hardening deferred to SRD-031.B)

This SRD keeps the current discipline: mutate registrations under t.m; run hub calls (register/unregister starters) and launchInstance outside t.m (FIX-002 RC2, thresher.go:498-501). The fragile-comment problem the audit flags (§2.6) is not fixed here — it is the sibling SRD-031.B's subject, landed next on the same branch so the hardening operates on this SRD's final registry shape.

4.5 seenKeys stays keyed by process id across versions (decided)

Correlation dedup namespaces by s.ProcessID (thresher.go:613), which is the key — shared across a key's versions. Because only the latest version auto-starts (FR-7), create-or-join stays coherent in practice. Cross-version correlation (a conversation begun under v1 routed into a v2 instance) is an ADR-019 §2.8 non-goal; seenKeys semantics are unchanged here.

4.6 The handle is engine-scoped (decided)

StartProcess/UnregisterVersion validate the handle is non-nil (NFR-1); UnregisterVersion additionally verifies the handle is a member of this engine's registrations (else ObjectNotFound), since removal is bookkeeping on this registry. StartProcess launches reg.snapshot directly. Passing a foreign handle is a programming error surfaced by the membership check on unregister. (UnregisterProcess is key-addressed, not handle-addressed, so it has no membership check — it errors ObjectNotFound when the key has no versions.)

5. Public API surface

// pkg/thresher
func (t *Thresher) RegisterProcess(p *process.Process, opts ...RegisterOption) (*ProcessRegistration, error)
func (t *Thresher) StartProcess(reg *ProcessRegistration) (*InstanceHandle, error)
func (t *Thresher) StartVersion(key string, version int) (*InstanceHandle, error)
func (t *Thresher) StartLatest(key string) (*InstanceHandle, error)
func (t *Thresher) UnregisterVersion(reg *ProcessRegistration) error
func (t *Thresher) UnregisterProcess(key string) error
func (t *Thresher) Registrations(key string) []*ProcessRegistration

func (r *ProcessRegistration) Key() string
func (r *ProcessRegistration) Version() int
func (r *ProcessRegistration) ID() string

RegisterProcess/StartProcess keep their names (parameters/returns change). The single-version teardown is renamed UnregisterProcess(id)UnregisterVersion(reg), and UnregisterProcess(key) is repurposed to remove the whole process (every version) — so the name now matches its scope. New: StartVersion/StartLatest/ Registrations/UnregisterProcess(key). snapshot.New's signature is unchanged (its behavior — deep copy — changes). Thresher.Instance(id) and the SRD-019 discovery surface are unaffected.

6. Test scenarios

# Scenario FR Where
T-1 After RegisterProcess, mutating the source process (Add a node) leaves the registration's snapshot unchanged; an instance started from it runs the registered shape FR-1 snapshot/snapshot_test.go (isolation) + thresher/versioning_test.go
T-2 snapshot.New graph nodes are distinct pointers from the model's nodes (isolation); a per-instance Clone of the snapshot still produces a working instance graph FR-1 snapshot/snapshot_test.go
T-3 RegisterProcess returns a handle with Key=process id, Version=1; ID non-empty FR-2 thresher/versioning_test.go
T-4 Re-registering the same key yields Version=2,3,…; both versions independently startable FR-3 thresher/versioning_test.go
T-5 A process with no explicit id is a singleton v1 (distinct UUID keys) FR-3 thresher/versioning_test.go
T-6 StartProcess(reg) starts the exact version; nil handle → self-identifying error FR-4, NFR-1 thresher/versioning_test.go
T-7 StartVersion(key, n) starts version n; unknown key/version → ObjectNotFound; version<1 → error FR-5, NFR-1 thresher/versioning_test.go
T-8 StartLatest(key) starts the newest version; unknown/empty key → error FR-6, NFR-1 thresher/versioning_test.go
T-9 Registering v2 (auto, message-start) moves the start subscription: a trigger spawns a v2 instance, not v1; a running v1 instance is unaffected FR-7 thresher/versioning_internal_test.go
T-10 UnregisterVersion(reg) removes that version; a running instance of it survives; foreign/nil handle → error FR-8, NFR-1 thresher/discovery_test.go (survives+removal) + thresher/instance_starter_internal_test.go (nil/foreign)
T-11 Unregistering the latest auto version promotes the now-newest remaining version to live auto-start — it owns the trigger again (a publish spawns its instance) FR-8 thresher/versioning_internal_test.go
T-12 go test -race ./pkg/thresher/... green under concurrent register/start NFR-2 existing + new
T-13 Removing a non-latest version leaves a gap: StartVersion(key,3) still starts v3 and (key,2) errors; re-register yields v4 (no reuse); StartLatest→v3; Registrations(key) reports [1,3][1,3,4]; full removal via UnregisterVersion resets to v1 FR-3, FR-5, FR-8, FR-10 thresher/versioning_test.go
T-14 UnregisterProcess(key) removes every version at once (Registrations empty, StartLatest errors); empty/unknown key → error; the counter resets so a later registration is v1 FR-11, NFR-1 thresher/versioning_test.go

7. Milestones

  • A1 — Snapshot isolation. New clones nodes in its validation pass and wires the graph via the shared wireClonedGraph helper (also used by Clone), so the snapshot is born isolated in one pass; T-1/T-2. (internal/instance/snapshot/.)
  • A2 — Handle + versioned registry. ProcessRegistration; registrations map; RegisterProcess returns the handle and appends a version (drop the no-op); T-3/T-4/T-5.
  • A3 — Start addressing. StartProcess(reg), StartVersion, StartLatest, with validation; T-6/T-7/T-8.
  • A4 — Latest-supersedes + unregister. Supersession on register; UnregisterProcess(reg) with promote-on-removal; plus the membroker subscription-teardown fix it depends on (a stopped waiter now unsubscribes, else a superseding version's publish is swallowed by the dead subscription); T-9/T-10/T-11.
  • A5 — Migration. All examples, pkg/thresher/*_test.go, README*.md, examples/README.md; examples build+run; CHANGELOG migration note (NFR-5).
  • A6 — Gapped versions + unregister granularity. Decouple the version number from the slice index: a per-key monotonic nextVersion counter (never reused; reset on full removal), StartVersion scans by number, and Registrations(key) enumerates the versions (FR-10). Split removal by scope: rename the single-version teardown UnregisterProcess(reg)UnregisterVersion(reg), and repurpose UnregisterProcess(key) to drop the whole process (FR-11). Hardens FR-3/FR-5/FR-8 against non-latest removal; T-13/T-14.
  • A7 — Verify & land. /check-style, /check-srd, make ci, fill §10, flip status. (T-12 throughout.)

8. Cross-doc

  • Implements ADR-019 v.1 — §2.1 key, §2.2 handle, §2.3 isolation, §2.4 three start modes, §2.5 latest-supersedes, §2.7 (concurrency → SRD-031.B), §2.8 deferrals.
  • ADR-009 v.1 — the per-instance clone model FR-1 extends with the missing model→snapshot hop.
  • ADR-015 v.1 — the instance-starters FR-7 supersedes across versions; the manual-start mode.
  • ADR-016 v.1 — the create-or-join dedup (§4.5) that stays coherent because only the latest auto-starts.
  • ADR-013 v.1 — the read-only InstanceHandle pattern ProcessRegistration mirrors.
  • Architecture audit 2026-06-11 — §3.3 resolved here (isolation); §2.6 is the sibling SRD-031.B.
  • Siblings SRD-006 (per-instance node graph / the Clone reused), SRD-018 (the handle), SRD-015 (the starters) — number-only sideways refs.

9. Definition of Done

  • [ ] FR-1..FR-11 wired and covered by T-1..T-14.
  • [ ] All examples build and run (go run) green; examples/README + both README*.md migrated.
  • [ ] pkg/thresher/*_test.go migrated; no reference to the removed StartProcess(id) form remains in current (non-frozen) code/docs.
  • [ ] CHANGELOG.md carries the breaking-change migration note (NFR-5).
  • [ ] /check-style clean; /check-srd PASS.
  • [ ] make ci green incl. diff-coverage ≥95% on touched files (NFR-4); go test -race ./pkg/thresher/... green (NFR-2).
  • [ ] §8 cross-doc pins consistent; frozen one-shot SRD/FIX (SRD-015/018/025, FIX-002) not retro-edited.
  • [ ] §10 filled; status flipped Draft → Accepted (user's call).

10. Implementation summary

Landed on feat/adr-019-definition-versioning (off master).

Milestones & commits - A1 — snapshot isolation (single-pass New clone + shared wireClonedGraph): 2a67914. - A2 — handle + versioned registry (ProcessRegistration, registrations map, RegisterProcess returns the handle): 14583dc. - A3 — start addressing (StartProcess(reg), StartVersion, StartLatest): ffaa668. - A4 — latest-supersedes + UnregisterProcess promote-on-removal: 8f1d4d9; the membroker subscription-teardown fix it depends on: d9a16c7; coverage completion: 56e2263. - A5 — caller/doc migration (examples were already on the new API; the README.md / README.ru.md / examples/README.md snippets; the CHANGELOG migration note; this §10): 47b5340. - A6 — gapped versions + unregister granularity: per-key monotonic nextVersion counter (never reused; reset on full removal), StartVersion scans by version number, new Registrations(key) (FR-10); rename UnregisterProcess(reg)UnregisterVersion(reg) and repurpose UnregisterProcess(key) for whole-process removal (FR-11); T-13/T-14: the A6 commit on this branch.

Key files - internal/instance/snapshot/snapshot.goNew clones nodes in its validation pass; shared wireClonedGraph (relink/remap/rebind) used by both New and Clone. - pkg/thresher/registration.go (new) — ProcessRegistration read-only handle (Key/Version/ID). - pkg/thresher/thresher.goregistrations map[string][]*ProcessRegistration + per-key nextVersion map[string]int; RegisterProcess (monotonic version + supersession), StartProcess / StartVersion (scan-by-number) / StartLatest, UnregisterVersion (single version, promote-on-removal; drops the counter on full removal) + UnregisterProcess(key) (whole-process removal), latest-only registerAllStarters. - pkg/thresher/discovery.goStarters() latest-only; Registrations(key) enumerates a key's versions (FR-10). - pkg/messaging/messagebroker.go + pkg/messaging/membroker/membroker.goSubscription.Unsubscribe(). - internal/eventproc/eventhub/waiters/message.go — waiter unsubscribes on stop (synchronously) and on the goroutine exit paths.

Verification (V-results) - T-1..T-12 implemented and green (file map in §6). - make ci green: golangci-lint 0 issues, -race across all modules, diff-coverage 99.2% of changed lines (min 95% — NFR-4), govulncheck clean. - All 18 examples/* run green (go run -C <dir> ., exit 0).

Open questions

None. Scope: in-memory definition versioning — isolation, handle, versioned registry, three start modes, latest-supersedes, unregister-by-handle, and the caller migration. The registry concurrency hardening (audit §2.6) is the sibling SRD-031.B; the model-editing API is a separate ADR; durable versioning / migration / cross-version correlation / version tags are ADR-019 §2.8 deferrals.