SRD-018 — Instance observation: a public handle + lifecycle event stream¶
| Field | Value |
|---|---|
| Status | Accepted |
| Version | v.1 |
| Date | 2026-06-18 |
| Owner | Ruslan Gabitov |
| Implements | ADR-013 v.1 Instance Observability & Control |
This SRD lands the observe slice of ADR-013 v.1
(§2.1 observation surface, §2.2 the one lifecycle/token/node channel + async lossy delivery,
§2.4 the standard-named open state vocabulary). It gives a host a public
InstanceHandle returned from StartProcess — State(), a token-position
snapshot, read-only data, and WaitCompletion(ctx) — plus a single event
stream observers subscribe to, into which the instance loop and its tracks
publish lifecycle / token-movement / node-progress events. Control (Cancel,
Suspend/Resume) and engine lifecycle (Shutdown, UnregisterProcess)
are ADR-013's other slice and are out of scope here (a sibling SRD).
1. Background & motivation¶
1.1 Current state (verified against the code)¶
- The public API is write-only (audit 2.2).
Thresher.StartProcess(processID string) error(pkg/thresher/thresher.go:622) returns only anerror— no handle, no state, no tokens, no completion signal. A host that starts a process can learn nothing more. - Running instances are tracked but unreachable. The Thresher holds
instances map[string]instanceReg(thresher.go:109) whereinstanceReg{stop context.CancelFunc; inst *instance.Instance}(thresher.go:98-101), guarded bysync.Mutex m(thresher.go:113);launchInstancestorest.instances[inst.ID()]but hands nothing back to the caller. - The instance already exposes observation internally — lock-free.
Instance.State() State(internal/instance/instance.go:501) reads anatomic.Uint32(instance.go:100); the state enum isCreated → Active → Completed(+Terminating → Terminated) (instance.go:48-67).GetTokens() []Token(instance.go:720) andTokenHistory() []TokenPath(instance.go:740) read a copy-on-writetracksSnap atomic.Pointer[[]*track](instance.go:91) lock-free.RuntimeVar/RuntimeVarNames(instance.go:800,848) serveStartedAt / CurrState / TracksCount. Butinternal/instanceis import-closed to external hosts (Gointernal/rule + ADR-012), so none of this is reachable from a consumer ofpkg/thresher. - The token is a projection, not a stored entity.
track.Token() Token(internal/instance/track.go:191) whereToken{Node flow.Node; State TokenState}(token.go);TokenState∈Alive / WaitForEvent / Consumed / Withdrawn. Track state (trackState, 10 values incl.TrackWaitForEvent,internal/instance/track.go:54-86) projects to it. - There is no completion signal and no host-facing observer. The instance has an
internal
events chan trackEvent(instance.go:87, track→loop, kindsevFork/evEnded/evAwaiting/evMergedinevent.go:23-37) and aloopDone chan struct{}(instance.go:96) the loop closes, pluslastErr atomic.Pointer[error](instance.go:92) — all unexported. Examples learn a process finished only by threading a manualdone := make(chan struct{})through a service functor (thebasic-processexample) or bytime.Sleep(internal/instance/instance_test.goTestMonitoring). - A read-only data reader already exists, public.
service.DataReader(pkg/model/service/datareader.go:10-24):GetData(name),GetDataByID(id),GetSources(),List(path); the instance'sdataPlane *scope.Scope(instance.go:88,internal/scope/scope.go) backs it, serialized by the data-plane mutex (safe for concurrent external reads).
1.2 Problem¶
A host embedding gobpm is blind after StartProcess: it cannot read state, see
where tokens are, read process data, block until the instance finishes, or
follow progress live — it can only read logs. ADR-013 decided the fix; this SRD
implements the observe half of it without leaking the internal/instance
god-object.
2. Decision¶
Return a public thresher.InstanceHandle from StartProcess (and offer a
by-id lookup). The handle is a narrow window over the internal instance — it
holds the *instance.Instance unexported and exposes only read-only observation,
never the god-object's mutating surface (ADR-013 §4). It provides:
- pull observation —
State(),Tokens()(live positions),History()(every track's path incl. merged/consumed, with lineage + step timings),Data()— over the instance's existing lock-free accessors; andWaitCompletion(ctx)backed by the instance'sloopDoneclose +lastErr. - push observation —
Observe(...)registering an observer on one event stream the instance loop and its tracks publish lifecycle / token-movement / node-progress events into, with async best-effort lossy delivery (per-observer buffered channel + drain goroutine + non-blocking send + drop counter), so a slow observer never blocks a track and terminal completion is never dropped.
The public state/token vocabulary is standard-named and open (ADR-013 §2.4):
host code must tolerate unknown values (forward-compatible), so the deferred
states (Failing/Paused/Compensating) join additively later.
3. Functional requirements¶
Observe-by-pull (the handle):
- FR-1 —
StartProcessreturns a handle.StartProcess(processID string) (*InstanceHandle, error)(waserroronly,thresher.go:622). On success the handle wraps the launched instance. - FR-2 — by-id lookup.
Thresher.Instance(instanceID string) (*InstanceHandle, bool)returns the handle of a tracked running instance, orfalse. - FR-3 —
State().InstanceHandle.State() InstanceStatemaps the internalinstance.State(instance.go:501) to the public, standard-named openInstanceStatevocabulary. Lock-free. - FR-4 — live token snapshot.
InstanceHandle.Tokens() []TokenViewprojects the instance'sGetTokens()(instance.go:720, active tracks only —Alive/WaitForEvent) into a publicTokenView{NodeID, NodeName string; State TokenState}— where execution currently is. Lock-free (copy-on-write snapshot). - FR-4a — full token history.
InstanceHandle.History() []TokenPathprojects the instance'sTokenHistory()(instance.go:740) into a publicTokenPath{TrackID, ParentID string; Steps []StepVisit; Terminal TokenState}— every track, active and finished, each with its lineage (ParentID= fork parent) and per-step visit timings. This is the "include merged tokens" view;Tokens()(FR-4) stays the live-active snapshot. Vocabulary note (grounded): the token projection collapses ended / merged / canceled / failed tracks toConsumed(token.gotokenStateFor) — there is no distinctMergedtoken value; a merged track appears as aConsumedTokenPathwhoseStepsend at the join node, and itsParentIDreveals the fork lineage. PublicTokenStateis therefore the projected setAlive / WaitForEvent / Consumed / Withdrawn. Lock-free (copy-on-write). No single-track variant — the full list is filterable byTrackID. - FR-5 — read-only data.
InstanceHandle.Data() service.DataReaderexposes the instance's data plane (instance.go:88) read-only: process properties + RUNTIME vars (StartedAt/CurrState/TracksCount). No mutation surface. - FR-6 —
WaitCompletion.InstanceHandle.WaitCompletion(ctx context.Context) (InstanceState, error)blocks until the instance reaches a terminal state (Completed/Terminated) orctxis done; returns the terminal state + anylastErr(instance.go:92). Replaces the examples' manualdonechannel.
Observe-by-stream (the one channel):
- FR-7 — observer registration.
InstanceHandle.Observe(o Observer) *Subscriptionregisters an observer on the instance's single event stream; the returnedSubscriptionderegisters + drains viaCancel()and reports the drop count viaDropped().Observeris a one-method interface (OnEvent(Event)). - FR-8 — events the stream carries.
Event{Kind EventKind; InstanceID, NodeID, NodeName string; State string; At time.Time}for: instance lifecycle (created/active/completed/terminated), token movement (a token entering/leaving a node), and node-execution progress (entered/executing/left) — sourced at the instance loop's state transitions and the tracks'record()points (track.go:171). No payloads — ids/names/state/timestamps only (masking, ADR-010/011). - FR-9 — async best-effort lossy delivery. Per observer: a buffered channel
(size N) drained by one dedicated goroutine calling
OnEvent; the emitter sends non-blocking (select { case ch <- ev: default: dropped++ }). The track/loop never blocks on an observer; a slow observer drops events and exposes aDropped() uint64count; a panicking observer is recovered, not propagated. Terminal completion is never dropped — it is theWaitCompletionloopDoneclose (FR-6), independent of the lossy stream.
4. Non-functional requirements¶
- NFR-1 — no god-object leak. The handle exposes only read-only observation;
it never returns
*instance.Instancenor any mutating method (ADR-013 §4, ADR-012 public boundary).internal/instancestays internal. - NFR-2 — observation off the hot path. Pull reads use the existing lock-free
accessors (atomics / copy-on-write snapshot); push emission is a non-blocking
send — no new mutex on the track execution path (NFR matches the house
loop-owner + atomics style,
instance.go:91-100,track.go:160-164). - NFR-3 — forward-compatible vocabulary.
InstanceState/TokenState/EventKindare open sets with aString(); consumers must tolerate unknown values. Adding a state later is non-breaking (ADR-013 §2.4). - NFR-4 — coverage. Every file created/updated finishes ≥80% (target 100%)
diff-coverage;
make cigreen (lint incl. fieldalignment,-race, govulncheck).
5. Path analysis (alternatives)¶
- Return the handle from
StartProcessvs keeperror-only + a separateInstance(id)lookup. Chosen: both — return on start (the common path) and offer by-id lookup (FR-2) for later retrieval. Rejected error-only: forces every host through a second call for the thing it just created. - Expose
*instance.Instance/ its exportedState(),GetTokens()directly. Rejected:internal/instanceis import-closed to hosts and the type is the god-object with mutating methods (ADR-013 §4). The narrow public handle is the boundary. - Reuse
instance.Stateas the public type. Impossible — it lives underinternal/. A publicthresher.InstanceState(mapped 1:1 today) is required, and is what makes the open-vocabulary forward-compat possible. - Observer delivery: buffered-channel drop-newest + counter (chosen) vs a drop-oldest ring vs synchronous callback vs unbounded queue. ADR-013 §2.2 decided drop-newest + counter as the robust default (drop-oldest a later refinement behind the same contract); synchronous callback would let a slow observer block a track (forbidden); unbounded queue risks memory blowup.
- Channel-returning
Subscribe() <-chan EventvsObserve(Observer)callback. ChosenObserve(Observer)+ internal drain goroutine: the engine owns the buffer/drop/recover policy (FR-9) rather than exposing a raw channel a host could mishandle; a channel adapter can wrap it if wanted.
6. API (public surface, pkg/thresher)¶
// InstanceHandle is a read-only window onto one running process instance.
// It never exposes the engine's internal instance object or any mutating method.
type InstanceHandle struct{ /* unexported: inst *instance.Instance */ }
func (h *InstanceHandle) ID() string
func (h *InstanceHandle) State() InstanceState
func (h *InstanceHandle) Tokens() []TokenView // live active positions (Alive/WaitForEvent)
func (h *InstanceHandle) History() []TokenPath // every track incl. merged/consumed, lineage + timings
func (h *InstanceHandle) Data() service.DataReader
func (h *InstanceHandle) WaitCompletion(ctx context.Context) (InstanceState, error)
func (h *InstanceHandle) Observe(o Observer) *Subscription
// Subscription is a live observer registration.
type Subscription struct{ /* unexported */ }
func (s *Subscription) Cancel() // deregister + drain + stop
func (s *Subscription) Dropped() uint64 // events dropped when the observer fell behind
// InstanceState is the standard-named, OPEN lifecycle vocabulary (treat unknown
// values gracefully — the set grows additively, ADR-013 §2.4).
type InstanceState string
const (
StateCreated InstanceState = "Created"
StateActive InstanceState = "Active"
StateCompleted InstanceState = "Completed"
StateTerminating InstanceState = "Terminating"
StateTerminated InstanceState = "Terminated"
)
type TokenView struct {
NodeID string
NodeName string
State TokenState // "Alive" | "WaitForEvent" | "Consumed" | "Withdrawn" (open)
}
type TokenState string
// TokenPath is one track's full path — including merged/consumed/canceled tracks.
type TokenPath struct {
TrackID string
ParentID string // immediate fork parent ("" if root)
Steps []StepVisit
Terminal TokenState // projected: Consumed for ended/merged/canceled, else the live state
}
type StepVisit struct {
NodeID string
NodeName string
State TokenState
At time.Time
}
type Observer interface{ OnEvent(Event) }
type Event struct {
Kind EventKind
InstanceID string
NodeID string
NodeName string
State string
At time.Time
}
type EventKind string // "InstanceState" | "TokenMoved" | "NodeProgress" (open)
// Engine entry point (changed return):
func (t *Thresher) StartProcess(processID string) (*InstanceHandle, error)
func (t *Thresher) Instance(instanceID string) (*InstanceHandle, bool)
Internal support added to internal/instance (consumed only by pkg/thresher):
Instance.Done() <-chan struct{} (returns loopDone) and Instance.Err() error
(reads lastErr) for FR-6; an observer-registration + non-blocking fan-out hook on
the loop/track emit path for FR-7–9 (the loop already centralizes state transitions
at instance.go:569-654; tracks already centralize step transitions at
track.go:171 record).
7. Test plan¶
TestStartProcessReturnsHandle—StartProcessyields a non-nil handle whoseID()matches the tracked instance;Instance(id)finds it (FR-1, FR-2).TestHandleStateTransitions— handleState()reportsActivethenCompletedacross a run (FR-3).TestWaitCompletion—WaitCompletionreturnsCompletedfor a finishing process and respects a cancelledctx(returns ctx err); replaces thetime.SleepinTestMonitoring(FR-6).TestTokensSnapshot— a process parked at a wait shows aTokenViewwithState == WaitForEventat the expected node (FR-4).TestHistoryIncludesMerged— a process with a parallel fork+join yieldsHistory()entries for the merged tracks (terminalConsumed,Stepsending at the join,ParentIDlineage set) plus the survivor — proving the history view includes non-active tokens thatTokens()omits (FR-4a).TestHandleDataRead—Data().GetDatareads a process property and a RUNTIME var read-only (FR-5).TestObserverReceivesLifecycleEvents— an observer seesInstanceStateactive→completed and ≥1NodeProgress/TokenMovedevent (FR-7, FR-8).TestSlowObserverDropsNeverBlocks— a deliberately blocked observer drops events (Dropped() > 0) while the process still completes; the track never blocks (FR-9).TestObserverPanicRecovered— a panickingOnEventdoesn't crash the engine or other observers (FR-9).- Example update —
examples/basic-processusesWaitCompletioninstead of the manualdonechannel (proves the ergonomic win); smoke-run exits 0.
8. Cross-document consistency¶
- Implements ADR-013 v.1 §2.1 (observation surface), §2.2 (one channel + async lossy delivery), §2.4 (open vocabulary). §2.3/§2.5 (control + engine lifecycle) are the sibling SRD.
- ADR-011 v.5 §2.6 — the public
service.DataReaderthe handle'sData()returns. - ADR-012 v.1 — the public-
pkg/internal boundary the handle respects (nointernal/instanceleak). - ADR-001 v.5 — the instance lifecycle states the public vocabulary names, and the single-owner loop the emission rides.
- ADR-002 v.2 — the §4.7 public-API surface this handle joins; the Logger/Metrics extensions observers complement.
- References up/sideways, version-pinned; no downward refs.
9. Definition of Done¶
- FR-1…FR-9 (incl. FR-4a
History()) wired and exercised by the §7 tests. StartProcessreturns*InstanceHandle; all call sites (examples, tests) updated.- The handle exposes no mutating surface and does not return
*instance.Instance(NFR-1 — grep the public API). make cigreen (tidy, lint incl. fieldalignment, build,-race, diff-coverage ≥95% on changed lines, govulncheck); touched files ≥80% (target 100%).examples/basic-processsmoke-runs (exit 0) usingWaitCompletion.- §10 filled (commits, key files, V-results); status flipped to Accepted; RU twin added; linked docs synced.
10. Implementation summary¶
10.1 Commits (branch feat/instance-observability)¶
| Stage | Commit | Scope |
|---|---|---|
| doc | 79fdfee |
SRD-018 |
| M1 | c7c5fe7 |
pull handle — InstanceHandle (State/Tokens/History/Data/WaitCompletion), StartProcess→handle, Thresher.Instance lookup, instance Done()/DataReader() + a lifetime root reader, call-site + example updates |
| M2 | 85357eb |
event stream — Observe/*Subscription, async lossy delivery (buffered drain + drop counter + panic-recover), ObsEvent fan-out from setState+record, TokenState/ObsKind String() |
10.2 Key files¶
pkg/thresher/handle.go—InstanceHandle+ public vocab types.pkg/thresher/observer.go—Observer/Event/EventKind/Subscription+Observe.internal/instance/observer.go—ObsEvent/ObsKind+AddObserver/removeObserver/notify.internal/instance/instance.go—Done()/DataReader(), lifetime reader (inloadProperties),setStateemission;pkg/thresher/thresher.goStartProcess/launchInstance/Instance.internal/instance/track.go—record()emission.
10.3 V-results¶
make cigreen: lint (incl. fieldalignment, misspell), build,-race, diff-coverage 98.2% of 222 changed lines (≥95), govulncheck clean.- All §7 tests green; all 9 examples smoke-run exit 0 (
examples/basic-processmigrated toWaitCompletion); README quick-start updated to the handle + a finish-listening/observer example.
10.4 Deltas vs the draft¶
- Token vocabulary — the projection collapses ended/merged/canceled to
Consumed(no distinctMerged);History()surfaces merged tracks asConsumedwith their join-node path + lineage (§4.1a wording corrected pre-implementation). Observesignature — returns*Subscription(Cancel()/Dropped()) rather than a barecancel func(), since FR-9 surfaces the drop count (§6 reconciled).- Data reader — became an instance lifetime resource built once in
loadProperties(soDataReader()and the handle carry no error path), cleaner than a per-handle build. - Carried unchanged: ~4 unreachable defensive error guards in
launchInstance/loadProperties(a pre-validated snapshot + fresh instance + open scope can't fail them) account for the <100% diff-coverage; accepted as the deterministic 98.2%.
Document History¶
| Version | Date | Author | Change |
|---|---|---|---|
| v.1 | 2026-06-18 | Ruslan Gabitov | Accepted (landed via M1 c7c5fe7 + M2 85357eb, make ci green, diff-coverage 98.2%). Lands the observe slice of ADR-013 v.1 (§2.1/§2.2/§2.4): a public thresher.InstanceHandle returned from StartProcess (pull: State/Tokens live + History incl. merged/Data/WaitCompletion) + one async best-effort lossy event stream (Observe); standard-named open vocabulary. Control + engine lifecycle (§2.3/§2.5) deferred to a sibling SRD. Code-grounded against pkg/thresher, internal/instance, pkg/model/service. Implements ADR-013 v.1; refs ADR-001 v.5, ADR-002 v.2, ADR-011 v.5, ADR-012 v.1. |