lifecycle

package
v1.0.0-beta.100 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 7, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package lifecycle provides a substrate convention layer for workflow-shaped entities — named instances with declared phases, restart recovery, operator visibility, and rule integration.

What this is (and is not)

This package is a SUBSTRATE CONVENTION LAYER, not a workflow engine. It provides:

  • A Participant interface apps implement on their domain state structs
  • A Manager harness that handles KV storage, restart recovery, and operator API integration
  • A Transitions table that declares valid phase transitions per workflow
  • Struct-tag-based operator-writability declaration (default-deny)

It does NOT provide:

  • A runtime, DSL, or state-machine interpreter (apps own work logic)
  • A separate event bus (uses existing NATS KV primitives)
  • A process orchestrator (orchestration stays in the rule engine)
  • A replacement for components (components remain the execution layer)

See ADR-047 for the full rationale.

Participation is per-entity, not per-deployment

The lifecycle harness has ZERO dependencies on agentic-loop or any other consumer class. Apps that ship no agentic features get the full harness benefit. This package MUST NOT import any processor/agentic-* package — verified by import lint.

Participant is opt-in per ENTITY-TYPE within a single app:

  • Drone missions, sensor lifecycles, manufacturing batches, scenario executions implement Participant
  • Raw telemetry, log entries, agent loops, and transient inputs stay outside the harness and pay zero cost

Usage

Apps annotate state-struct fields with the lifecycle struct tag:

type SystemState struct {
    EntityID_   string `json:"entity_id"    lifecycle:"id"`
    Phase_      string `json:"phase"        lifecycle:"phase,readonly"`
    OwnerOrgID  string `json:"owner_org_id" lifecycle:"operator_writable"`
    InternalState string `json:"internal_state,omitempty"`
    // no tag = not operator-writable (default-deny)
}

Tag values:

  • id — marks the EntityID field (one per struct)
  • phase — marks the Phase field (one per struct)
  • readonly — never operator-writable
  • operator_writable — opt-in operator-writability via Manager.UpdateFromOperator
  • indexable — flagged for v2 secondary indexing (v1 ignores)

Apps register a Workflow type at startup with its valid transitions:

transitions := lifecycle.Transitions{
    "planning":   {"flying", "aborted"},
    "flying":     {"capturing", "landing", "aborted"},
    "capturing":  {"flying"},
    "landing":    {"completed", "failed"},
    "completed":  {},  // terminal — no out-edges
    "failed":     {},
    "aborted":    {},
}
mgr.Register("drone-survey", func() Participant {
    return &MissionState{}
}, transitions)

See ADR-047 for the complete worked example.

Package lifecycle — graph-ingest emit wrapper.

The Manager state-change operations (Create, Transition, UpdateFromOperator, Complete, Fail) all funnel through graphEmitter — a thin wrapper over natsclient.Request on the graph-ingest mutation subjects. Centralizing the request shape + response classification + sentinel translation keeps the per-operation code in manager.go focused on validation and projection rather than transport plumbing.

Two methods:

  • update: targets graph.mutation.entity.update_with_triples — for existing entities, with optional CAS-on-condition via UpdateEntityWithTriplesRequest.ExpectedRevision.
  • create: targets graph.mutation.entity.create_with_triples — for entities that don't yet exist. Atomic create-or-fail; returns ErrAlreadyExists when the entity already exists. This is the primitive that closes the Manager.Create concurrent-create race (ADR-049 PR2 reviewer B2).

Error classification reads the stable ErrorCode field on MutationResponse — added in this pre-tag follow-up to replace fragile substring matching (R1).

Package lifecycle — query-ops surface (List, Watch, History, Children, References, LookupByEntityID, AssertRuleWritable, FieldType, GetWorkflowDefinition, ListWorkflows).

All query ops read from ENTITY_STATES via the direct KV handle — graph-ingest remains the single writer; the harness only emits through it via the Manager state-change operations defined in manager.go.

Package lifecycle — reflection-driven projection: triples ↔ Participant struct.

On every Get the Manager:

  1. Reads the entity from ENTITY_STATES (1 KV.Get via graph-gateway / direct bucket)
  2. Allocates a fresh Participant via reflect.New(Workflow.Schema)
  3. Walks the entity's triples, finds each predicate in the schema's FieldsByPredicate index, and SetX's the corresponding field

On every Transition / UpdateFromOperator / Create the Manager:

  1. Builds a small slice of message.Triple from the changes
  2. Emits via UpdateEntityWithTriplesRequest{AddTriples: ...} — graph-ingest does the per-predicate latest-wins merge into ENTITY_STATES on its side, so we never need to read-then-overwrite

The projection layer never owns whole-entity JSON. The Participant struct is a typed VIEW over triples, not a copy of them. Triples on the entity that the schema doesn't declare are preserved by graph-ingest (delta semantics) and ignored by projection.

Package lifecycle — Workflow schema declaration types (ADR-049).

A Workflow declaration is the contract a registered workflow type satisfies with the harness: identity pattern, phases, transitions, the phase predicate, operator-writable predicates, audit predicates, owned child workflows, and reference predicates. Apps construct one Workflow value per workflow type and pass it to Manager.Register.

Per ADR-049 the harness is a schema-and-discipline layer over ENTITY_STATES — Manager does NOT own a private KV bucket. The Workflow struct declares what triples belong to the workflow; the projection layer maps those triples into / out of the app's Participant struct.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrWorkflowNotRegistered is returned by Manager operations when the
	// referenced workflow type was never passed to Manager.Register. This
	// is always a programming error — registration belongs at startup,
	// before any Get/Create/Transition calls land.
	ErrWorkflowNotRegistered = errors.New("lifecycle: workflow not registered")

	// ErrWorkflowAlreadyRegistered is returned by Manager.Register when a
	// workflow type is registered twice. Registration must be idempotent
	// at startup, not a fire-and-forget — duplicate registration likely
	// indicates a wire-up bug, not a benign re-init.
	ErrWorkflowAlreadyRegistered = errors.New("lifecycle: workflow already registered")

	// ErrInvalidWorkflow is returned by Workflow.validate() at Register
	// time when the declaration itself is malformed (missing required
	// field, wrong-arity EntityIDPattern, duplicate ChildSpec
	// LinkPredicate, etc.). Distinct from ErrWorkflowNotRegistered —
	// that one fires at lookup time when a caller references a
	// workflow type that was never registered. Callers branching on
	// errors.Is(err, ErrInvalidWorkflow) can distinguish "you wrote
	// a bad Workflow{}" from "you asked for an unknown workflow."
	ErrInvalidWorkflow = errors.New("lifecycle: invalid workflow declaration")

	// ErrEntityNotFound is returned by Manager.Get when no entity exists
	// at the given EntityID in ENTITY_STATES. Distinct from
	// ErrEntityNotLifecycleManaged — that one fires when the entity
	// exists but has no phase triple (lifecycle never attached).
	ErrEntityNotFound = errors.New("lifecycle: entity not found")

	// ErrEntityNotLifecycleManaged is returned by Manager.Get / Transition
	// / Complete / Fail / UpdateFromOperator when the entity exists in
	// ENTITY_STATES but has no triple for the workflow's PhasePredicate
	// — i.e. Manager.Create was never called for it. Distinct from
	// ErrEntityNotFound so callers can distinguish "no such entity" from
	// "exists but not lifecycle-managed yet" (forward-reference case
	// per ADR-049 Q5).
	ErrEntityNotLifecycleManaged = errors.New("lifecycle: entity not lifecycle-managed (no phase triple)")

	// ErrAlreadyExists is returned by Manager.Create when the entity
	// already has a triple for the workflow's PhasePredicate (the
	// entity is already lifecycle-managed in this workflow). The
	// entity itself MAY exist with non-lifecycle triples; ADR-049's
	// Create semantics is "add lifecycle dimension," not "create
	// fresh entity."
	ErrAlreadyExists = errors.New("lifecycle: entity already lifecycle-managed")

	// ErrInvalidTransition is returned by Manager.Transition when the
	// requested (from → to) edge is not declared in the registered
	// Transitions table for the workflow. Surfaces misconfigured rules
	// at runtime rather than letting the state machine drift.
	ErrInvalidTransition = errors.New("lifecycle: invalid transition")

	// ErrTerminalPhase is returned by Manager.Transition when the current
	// phase has no declared out-edges (i.e. is terminal). Distinguishes
	// "you tried to transition from completed/failed" from a generic
	// invalid-edge error so operator dashboards can show the right hint.
	ErrTerminalPhase = errors.New("lifecycle: entity is in terminal phase")

	// ErrMissingIDField is returned by struct-tag parsing when a
	// registered Schema struct has no field tagged `lifecycle:"id"`.
	// Validates app-side wiring at Register time so the bug surfaces
	// during startup.
	ErrMissingIDField = errors.New("lifecycle: Schema struct missing field with lifecycle:\"id\" tag")

	// ErrMissingPhaseField mirrors ErrMissingIDField for the phase field.
	ErrMissingPhaseField = errors.New("lifecycle: Schema struct missing field with lifecycle:\"phase\" tag")

	// ErrFieldNotOperatorWritable is returned by Manager.UpdateFromOperator
	// when the patch attempts to mutate a field NOT tagged
	// `lifecycle:"operator_writable"`. Default-deny: unflagged fields are
	// not operator-writable.
	ErrFieldNotOperatorWritable = errors.New("lifecycle: field is not operator_writable")

	// ErrInvalidTransitionsTable is returned by Manager.Register when the
	// Transitions table is internally inconsistent (e.g. an out-edge
	// references a phase not declared as a key). Catches typos at startup.
	ErrInvalidTransitionsTable = errors.New("lifecycle: invalid transitions table")

	// ErrUpdateRetriesExhausted is returned by Manager.Update,
	// Transition, UpdateFromOperator, Complete, and Fail when the
	// per-call CAS-conflict retry budget is consumed under persistent
	// contention.
	//
	// Operationally this signals one of:
	//   - A stuck rule writing to the same entity in a tight loop
	//   - An upstream system hammering the same key past framework
	//     capacity
	//   - Misconfigured per-entity fan-in (multiple coordinators
	//     racing on one state)
	//
	// Callers wanting application-layer retry semantics distinct
	// from the framework's bounded retry can branch on
	// errors.Is(err, lifecycle.ErrUpdateRetriesExhausted) and apply
	// their own backoff + retry policy.
	ErrUpdateRetriesExhausted = errors.New("lifecycle: Update retry budget exhausted (persistent CAS contention)")

	// ErrEmitFailed is returned by Manager state-change operations
	// when the graph-ingest emit (NATS request/reply on the
	// UpdateEntityWithTriples subject) fails — typically because
	// graph-ingest is down, the request handler returns a non-CAS
	// error, or the NATS transport itself errors. Wraps the
	// underlying transport / handler error so callers can branch
	// on transient-vs-permanent.
	ErrEmitFailed = errors.New("lifecycle: emit to graph-ingest failed")
)

Package error sentinels. Callers compare with errors.Is.

Functions

This section is empty.

Types

type AuditSpec

type AuditSpec struct {
	Source string // typically "<workflow>.last_transition_source"
	At     string // typically "<workflow>.last_transition_at"
	From   string // typically "<workflow>.last_transition_from"
	Note   string // typically "<workflow>.last_transition_note"
}

AuditSpec declares the four framework-stamped audit predicates Manager.Transition writes on every phase change. All four are optional — omit a field to skip stamping that aspect.

On every successful transition, Manager.Transition stamps:

Source : transition source (rule | operator | component | framework)
At     : RFC3339Nano timestamp of the write
From   : the previous phase (the phase the entity transitioned out of)
Note   : the optional free-text note carried in the Transition call

History reads these back at each KV revision to populate TransitionEvent records. With no AuditPredicates declared, History still surfaces phase changes — Triggered defaults to framework, From is reconstructed from the previous revision's PhasePredicate, Note is empty.

type ChildOptions

type ChildOptions struct {
	// Workflow narrows to a specific child workflow type. Empty
	// means all child types.
	Workflow string

	// Limit bounds the result count (after Offset). Zero means
	// unbounded; operators paginating large subtrees should always
	// set a Limit.
	Limit int

	// Offset skips this many entries before applying Limit. Used
	// for paged subtree rendering.
	Offset int
}

ChildOptions filters the result of Manager.Children. Empty options returns every child of the parent across all declared ChildWorkflows.

type ChildResult

type ChildResult struct {
	// Workflow is the child's workflow type (the value of the
	// matching ChildSpec.Workflow).
	Workflow string `json:"workflow"`

	// State is the projected child Participant. Type is whatever
	// the child workflow registered as its Schema.
	State Participant `json:"state"`
}

ChildResult is one entry in the Manager.Children result. Carries the child's workflow type name + projected Participant.

type ChildSpec

type ChildSpec struct {
	// Workflow is the registered child workflow type name. Must
	// match the Name of a separately-registered Workflow. Children
	// is best-effort across the registered set: if a workflow named
	// in ChildSpec.Workflow isn't registered at Children call time,
	// the child is skipped with a Warn log rather than failing the
	// whole call (operator dashboards render missing-child
	// indicators).
	Workflow string

	// LinkPredicate is the triple predicate establishing the
	// parent→child link. Cardinality is many (a parent may own
	// multiple children of the same type); Manager.Children
	// enumerates every triple with this predicate.
	LinkPredicate string
}

ChildSpec declares a workflow type owned as a child of the declaring workflow. LinkPredicate is the triple predicate that carries the child's EntityID as its Object — Manager.Children enumerates triples matching this predicate on the parent and loads each child via the child workflow's projection.

type ListOptions

type ListOptions struct {
	// Phase filters to instances whose current phase equals this
	// value. Empty string means any phase.
	Phase string

	// Active=true filters to non-terminal instances. Active=false
	// (default) does not filter on terminal status. To list only
	// terminal instances, use Phase with a specific terminal phase
	// or Match on a discriminating field.
	Active bool

	// Match is a field-equality map (JSON field name → expected value).
	// Multi-tenancy and other app-specific filters live here. Field
	// resolution is reflection-based against the registered state
	// struct.
	Match map[string]any

	// Limit caps the returned slice size. 0 (default) means unlimited.
	Limit int

	// Offset skips the first Offset matching entries before applying
	// Limit. Use for paged operator-API responses.
	Offset int
}

ListOptions parameterizes Manager.List queries against a registered workflow type. All fields are optional; the zero value lists every instance in the workflow's KV bucket (subject to Limit / Offset).

Filter semantics:

  • Phase filters to entries whose Participant.Phase() equals the given value. Empty string means any phase.

  • Active=true filters to entries whose Participant.IsTerminal() returns false. Active=false (the zero value) does not filter on terminal status — to list only terminal entries, set ListOptions.Phase to a specific terminal phase OR add a Match entry on a field that distinguishes them.

  • Match is a field-equality map; key is the JSON field name (per `json:` struct tag), value is the expected value. Multi-tenancy is expressed app-side via Match{"org_id": "acme"} — the framework knows nothing about org/tenant semantics. Reflection is used to read field values at match time; this is acceptable for v1 linear-scan but flag fields as `lifecycle:"indexable"` for v2 secondary-index migration once an operator demonstrates a scaling bottleneck.

    Match comparison semantics are STRICT reflect.DeepEqual against the target field's concrete type. This means:

  • Numeric literals are type-strict: Match{"replica_count": 3} (Go int) does NOT match a field typed uint32(3). Callers constructing Match maps in Go code must use the exact field type (e.g. Match{"replica_count": uint32(3)}).

  • The zero value matches an unset omitempty field. Match {"deployed_to": ""} matches every instance whose DeployedTo field was never set. To skip a filter entirely, omit the key — there is no "match anything" sentinel.

  • String comparison is case-sensitive byte-equal. No fold, no trim, no glob.

    HTTP gateway components (PR 3 of the ADR-047 bundle) handle query-string-to-Go-type coercion at the gateway boundary — that is the right place for permissive parsing of operator-supplied filter values, NOT inside Manager.List. Keeping Manager strict makes its behavior predictable from Go callers AND keeps the wire-level coercion layer's responsibility cleanly separated.

  • Limit caps the returned slice size. 0 means unlimited (default).

  • Offset is a pagination cursor — skip the first Offset matching entries. Combine with Limit for paged operator-API responses.

v1 implementation is linear scan over the bucket; complexity is O(N) per List call where N is the total instance count. Operators hitting this cliff (~10K active instances is a reasonable guideline — file when an operator demonstrates the bottleneck) trigger the v2 secondary index work; the API stays stable across the migration.

Dropped from v1 surface — UpdatedAfter time.Time

An earlier draft exposed UpdatedAfter for incremental pagination of large active sets ("show me changes since T"). It was removed before v1 ship because plumbing the per-entity revision timestamp from the kvStore layer into the filter chain required widening kvStore.Get's return signature to leak store-layer concerns (CreatedAt) into the Participant model. Apps that need since-timestamp pagination today should store their own LastUpdatedAt unix-timestamp field on the state struct and Match against it. v2 secondary indexing will add the filter back as a first-class API when an operator demonstrates the need; the API stays additive across that migration.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager is the schema-and-discipline layer over ENTITY_STATES (ADR-049). Workflow types register at startup via Manager.Register; state changes route through graph-ingest via the standard UpdateEntityWithTriples wire (with CAS-on-condition via ExpectedRevision); reads project triples into the registered Schema struct.

Concurrency model: registrations map is protected by RWMutex (read-heavy: Register at startup, every Get/Transition reads). Per-entity concurrency is handled by graph-ingest's CAS — the Manager.Transition loop re-reads on ErrKVRevisionMismatch and re-validates until either the write commits or the retry budget exhausts.

func NewManager

func NewManager(client *natsclient.Client, logger *slog.Logger) *Manager

NewManager constructs a Manager that talks to NATS via the given client. Logger may be nil — falls back to slog.Default.

Workflow registration happens via Manager.Register at app startup; this constructor itself does not touch NATS (the ENTITY_STATES bucket handle initializes lazily on first read).

func (*Manager) AssertRuleWritable

func (m *Manager) AssertRuleWritable(workflow, fieldJSONName string) error

AssertRuleWritable returns nil when fieldJSONName is a field on the registered workflow that the rule layer's lifecycle_transition `set` clause is allowed to mutate. Returns ErrFieldNotOperatorWritable otherwise — same default-deny convention as UpdateFromOperator so rule definitions can't accidentally exceed operator authority.

func (*Manager) Children

func (m *Manager) Children(ctx context.Context, parentEntityID string, opts ChildOptions) ([]ChildResult, error)

Children returns ChildResult entries for every child link declared in the parent workflow's ChildWorkflows, optionally narrowed by opts.Workflow + paginated by Limit/Offset.

Cross-workflow: the parent and its children may be in different workflows. Manager.Children reads the parent's triples, walks each ChildSpec's LinkPredicate, and loads each linked entity via the child workflow's Get. Children whose child workflow isn't registered, or whose entity is gone, are skipped with a Warn log — one bad child shouldn't kill the whole response.

func (*Manager) Complete

func (m *Manager) Complete(ctx context.Context, workflow, entityID string) error

Complete transitions the entity to the first terminal phase REACHABLE from its current phase, deterministically. Errors with ErrTerminalPhase if the entity is already terminal, or ErrInvalidTransition if no terminal phase is reachable.

func (*Manager) Create

func (m *Manager) Create(ctx context.Context, initial Participant) error

Create attaches lifecycle to the entity at initial.EntityID() — the "add lifecycle dimension" semantics per ADR-049 Q5. The entity MAY already exist with non-lifecycle triples (e.g. a processor stamping `mission.command` before any lifecycle action fires); Create coexists with those triples without clobbering.

Returns ErrAlreadyExists if the entity already has a triple for the workflow's PhasePredicate (i.e. already lifecycle-managed in this workflow). The entity itself may exist with non-lifecycle triples and Create still succeeds.

Stamps the initial phase + all non-zero projection-mapped fields as triples in one atomic AddTriplesBatch via graph-ingest.

func (*Manager) Fail

func (m *Manager) Fail(ctx context.Context, workflow, entityID, reason string) error

Fail transitions the entity to the "failed" terminal phase, carrying the reason in the audit Note predicate. Errors if no "failed" phase is declared on the workflow (apps must call Transition explicitly with their preferred error-state phase otherwise).

reason must be non-empty — a Fail with no reason defeats the audit purpose.

func (*Manager) FieldType

func (m *Manager) FieldType(workflow, fieldJSONName string) (reflect.Type, error)

FieldType returns the reflect.Type of the named field on the registered Schema, for callers (the rule executor) that need to apply typed numeric ops (increment/decrement) without reimplementing field-name resolution.

func (*Manager) Get

func (m *Manager) Get(ctx context.Context, workflow, entityID string) (Participant, error)

Get reads the entity at entityID for the given workflow and projects its triples into a fresh Participant of the registered Schema type. Returns ErrEntityNotFound when the entity doesn't exist; ErrEntityNotLifecycleManaged when it exists but has no phase triple.

The returned Participant is a fresh instance — mutating it does NOT persist. Use Manager.Transition or Manager.UpdateFromOperator to commit changes.

func (*Manager) GetRaw

func (m *Manager) GetRaw(ctx context.Context, entityID string) (*graph.EntityState, error)

GetRaw is the debug escape hatch (ADR-049 P1): returns the full graph.EntityState for the given entityID without projection or workflow-scoping. Operator dashboards use this to render arbitrary triples on a lifecycle entity for debug purposes.

Bypasses the projection layer so it has no schema requirements — works on any entity in ENTITY_STATES regardless of workflow registration. Returns ErrEntityNotFound for unknown entities.

func (*Manager) GetWithRevision

func (m *Manager) GetWithRevision(ctx context.Context, workflow, entityID string) (Participant, uint64, error)

GetWithRevision is like Get but also returns the entity's current KV revision. Callers building their own CAS loops branch on the revision; the framework's own Transition / UpdateFromOperator uses it internally.

func (*Manager) GetWorkflowDefinition

func (m *Manager) GetWorkflowDefinition(workflow string) (WorkflowDef, error)

GetWorkflowDefinition returns the WorkflowDef for the given workflow type. Returns ErrWorkflowNotRegistered when the workflow isn't registered.

func (*Manager) History

func (m *Manager) History(ctx context.Context, workflow, entityID string) ([]TransitionEvent, error)

History returns the phase-transition history for the entity at entityID, derived from ENTITY_STATES revision replay. Each TransitionEvent represents one phase change.

Reconstruction reads each historical revision, decodes its EntityState, and extracts the phase + audit-predicate values. Triggered + Note come from the audit triples Manager.Transition stamped at write time — no parallel audit store needed.

The always-`framework` bug (the gap that ADR-049 closes) is structurally fixed: the real source is in the audit triple, so History reads it back rather than synthesizing a constant.

func (*Manager) List

func (m *Manager) List(ctx context.Context, workflow string, opts ListOptions) ([]Participant, error)

List returns Participants of the given workflow type matching opts.

Implementation: enumerates ENTITY_STATES keys, filters by the workflow's EntityIDPattern, loads + projects each match, applies the filter chain (Phase / Active / Match), and paginates with Limit + Offset.

Complexity is O(N) per call where N is the bucket size; consumers hitting the cliff (~10K active instances) trigger the v2 secondary-index work documented in ADR-049's deferred section. The API stays stable across that migration.

func (*Manager) ListWorkflows

func (m *Manager) ListWorkflows() []WorkflowDef

ListWorkflows returns the WorkflowDef for every registered workflow type, sorted by workflow name for deterministic operator-dashboard output.

func (*Manager) LookupByEntityID

func (m *Manager) LookupByEntityID(ctx context.Context, entityID string) (Participant, error)

LookupByEntityID resolves an entityID to a Participant by matching the entity against every registered EntityIDPattern. Returns the first matching workflow's projected Participant.

O(workflows) per call — typically a handful of registrations. Suitable for the rule engine's `lifecycle_*` action path and `$entity.lifecycle.*` substitution path.

func (*Manager) References

func (m *Manager) References(ctx context.Context, entityID string) ([]ReferenceStub, error)

References returns ReferenceStub entries for every ReferencePredicate triple on the entity. Stubs are light — Workflow + Phase are populated only when the target itself is lifecycle-managed (matches some registered EntityIDPattern).

Cost model: 1 read for the source entity plus 1 additional read per lifecycle-managed reference target (so a `Phase` peek can be filled in). References pointing at non-lifecycle entities skip the second read. References does NOT call References transitively — the returned stubs are depth-1 only; following them is the caller's job.

func (*Manager) Register

func (m *Manager) Register(workflow Workflow) error

Register declares a workflow to the Manager. Must be called at app startup, before any Get / Create / Transition calls land. Idempotent at the wiring level — re-registering the same workflow name returns ErrWorkflowAlreadyRegistered to surface a duplicate-init wiring bug.

The Workflow.Schema reflect.Type is reflected over at Register time to build the projection metadata (predicate→FieldIndex map); the resulting structMeta is cached for the lifetime of the Manager.

Returns ErrInvalidTransitionsTable if the table is internally inconsistent. Returns the wrapped tag-parsing error for unknown or contradictory lifecycle tags on Schema fields.

func (*Manager) Transition

func (m *Manager) Transition(ctx context.Context, workflow, entityID, newPhase string, source TransitionSource, note string) error

Transition moves the entity at entityID from its current phase to newPhase. Validates against the registered Transitions table and emits the phase change + audit triples atomically through graph-ingest with CAS-on-condition via ExpectedRevision. On CAS conflict, re-reads + re-validates + re-emits up to updateRetries.

func (*Manager) TransitionWith

func (m *Manager) TransitionWith(ctx context.Context, workflow, entityID, newPhase string, source TransitionSource, note string, mutator func(Participant) error) error

TransitionWith is Transition + a caller-supplied mutator that runs in the same projected-Participant context as the transition, allowing additional fields to be patched atomically with the phase change. The mutator runs AFTER transitions-table validation but BEFORE the phase delta is built; failures from the mutator abort the whole transition.

The mutator's mutations are diffed against the projection-extracted values and emitted as triple deltas alongside the phase change. Same atomic AddTriplesBatch write.

func (*Manager) UpdateFromOperator

func (m *Manager) UpdateFromOperator(ctx context.Context, workflow, entityID string, patch map[string]any) error

UpdateFromOperator applies a JSON-keyed patch to the entity, validating that every patched field is operator_writable. The patch is applied atomically — phase is NOT touched here (use Transition for phase changes). CAS retries on revision mismatch.

Patch values are wired to triples via the field's declared predicate. nil values map to RemoveTriples for that predicate.

Returns ErrEntityNotFound when the entity doesn't exist; ErrEntityNotLifecycleManaged when it exists but has no phase triple; ErrFieldNotOperatorWritable for the first protected key in the patch.

func (*Manager) Watch

func (m *Manager) Watch(ctx context.Context, workflow string) (<-chan Participant, error)

Watch streams Participant snapshots for every write to ENTITY_STATES whose key matches the workflow's EntityIDPattern. Bootstrap-then-live: the first batch is the snapshot of current state, then live updates as KV writes land.

Each delivered Participant is a fresh instance. Mutating it does NOT persist.

CALLER MUST CANCEL ctx when done iterating — the watcher goroutine and the underlying jetstream subscription pin until ctx.Done().

type Participant

type Participant interface {
	// EntityID returns the 6-part federated graph entity ID
	// (org.platform.domain.system.type.instance). Used as the
	// canonical identifier across the graph, KV, and operator API.
	EntityID() string

	// Workflow returns the workflow type identifier (e.g.
	// "mission", "csapi-system"). Must match a Workflow.Name
	// passed to Manager.Register at startup. Stable per
	// implementation type — never varies per instance.
	Workflow() string

	// Phase returns the current lifecycle phase (e.g. "planning",
	// "flying", "completed"). Must be a key in the Workflow's
	// Transitions table.
	Phase() string

	// IsTerminal returns true when the entity is in a phase with no
	// declared out-edges (i.e. completed, failed, aborted). Used by
	// Manager.List with ListOptions.Active to filter active vs
	// finished instances. By convention, derived from Phase() against
	// the registered Transitions table — see Transitions.IsTerminal.
	IsTerminal() bool

	// ParentEntityID returns the parent workflow instance's EntityID,
	// or empty string for root workflows. Enables explicit parent/child
	// workflow relationships in workflows that prefer a struct field
	// over a reference predicate. Implementations with no parent-child
	// relationships return "" unconditionally.
	//
	// For Tier-B workflows that prefer to declare children via the
	// schema's ChildWorkflows + ReferencePredicates, this can stay
	// empty; the harness uses the predicate-declared graph instead.
	ParentEntityID() string
}

Participant is the contract a lifecycle-tracked entity satisfies. Apps implement this on their domain state structs to get framework infrastructure (graph projection, restart recovery, rule integration, operator API).

The interface is small by design — four required methods plus one optional parent-link method. All state and behavior beyond the declared phases lives in the implementing struct's own fields and methods; the harness only reads what it needs to operate (identity, current phase, terminal-detection).

Per ADR-049 the harness lives over ENTITY_STATES — there is no private KV bucket. State changes emit through graph-ingest via the standard write path; the Participant is materialized via reflection-driven projection from the entity's triples (see Workflow.Schema). KVBucket() and KVKey() are no longer part of the contract.

Implementations are expected to be plain data structs with these methods. The projection layer round-trips Participant values through triples, NOT through JSON of the whole struct, so non-serializable runtime state on the Participant is fine — it just won't survive a Get/round-trip if not also projected from a declared predicate.

type ReferenceSpec

type ReferenceSpec struct {
	// Predicate is the triple predicate carrying the target
	// entity's EntityID as Object.
	Predicate string

	// TargetPattern is an optional 6-part glob narrowing what
	// shape of entity-ID is expected as the target (e.g.
	// "*.fleet.drone.*"). Informational today — Manager.References
	// uses it to determine whether the target is itself
	// lifecycle-managed (matches some registered workflow's
	// EntityIDPattern) so the stub can carry the target's phase.
	// Optional.
	TargetPattern string
}

ReferenceSpec declares a predicate linking this workflow to another entity WITHOUT lifecycle ownership. Manager.References projects matching triples as ReferenceStub records — light pointers, not full entity loads.

type ReferenceStub

type ReferenceStub struct {
	// EntityID is the target entity's ID.
	EntityID string `json:"entity_id"`

	// Predicate is the reference predicate (the value of the
	// matching ReferenceSpec.Predicate).
	Predicate string `json:"predicate"`

	// Workflow is the target's workflow type IF the target is
	// itself lifecycle-managed (matches a registered EntityIDPattern).
	// Empty otherwise — operator dashboards render the bare
	// entity_id and link to graph-gateway.
	Workflow string `json:"workflow,omitempty"`

	// Phase is the target's current phase IF the target is
	// lifecycle-managed. Empty otherwise.
	Phase string `json:"phase,omitempty"`
}

ReferenceStub is one entry in the Manager.References result. Light pointer to a referenced entity; the target is NOT loaded into memory beyond an identity-and-phase peek.

type TransitionEvent

type TransitionEvent struct {
	// From is the phase the entity was in before the transition.
	// Empty string for the Create event (entity didn't exist before).
	From string `json:"from"`

	// To is the phase the entity entered. Equal to the entity's
	// PhasePredicate value at the time of the transition.
	To string `json:"to"`

	// At is the wallclock time the transition was committed.
	// Sourced from the KV revision's metadata, not from app code
	// — so it's authoritative across restarts and clock skew.
	At time.Time `json:"at"`

	// Triggered identifies what caused the transition. Closed set
	// of values defined by TransitionSource. Read from the audit
	// triple stamped at write time; defaults to
	// TransitionSourceFramework when the workflow has no
	// AuditPredicates.Source declared.
	Triggered TransitionSource `json:"triggered"`

	// Note is an optional free-text annotation, stamped by
	// Manager.Transition if AuditPredicates.Note is declared.
	// Empty when omitted at write time or undeclared.
	Note string `json:"note,omitempty"`
}

TransitionEvent is one entry in an entity's phase-transition history. Manager.History returns these in chronological order.

History is derived from ENTITY_STATES revision replay filtered to phase-changing writes; source attribution is reconstructed from the AuditPredicates triples Manager.Transition stamped at write time. No parallel audit bucket is required.

type TransitionSource

type TransitionSource string

TransitionSource identifies what caused a phase transition. Closed set of typed constants — Manager.Transition takes a TransitionSource parameter, and rule actions / operator API / component direct calls each pass the appropriate constant rather than authoring a string literal at the call site (typos like "oprator" silently break dashboard filtering otherwise).

Wire-compatible with string serialization: string(TransitionSourceRule) is "rule", round-trips through JSON / KV as-is.

const (
	// TransitionSourceRule is set when a rule action invoked
	// Manager.Transition (the common case for state-machine
	// progression driven by the rule engine).
	TransitionSourceRule TransitionSource = "rule"

	// TransitionSourceOperator is set when the operator API
	// (POST /workflows/{type}/{id}/transition) invoked
	// Manager.Transition. Audit trails distinguish operator-
	// initiated transitions from automated ones via this value.
	TransitionSourceOperator TransitionSource = "operator"

	// TransitionSourceComponent is set when a component invoked
	// Manager.Transition directly (rare; usually rules orchestrate
	// transitions and components only call Update / Complete /
	// Fail). Reserved for cases where the component's work IS the
	// transition (e.g. landing-executor → landed phase).
	TransitionSourceComponent TransitionSource = "component"

	// TransitionSourceFramework is set by Manager.Create,
	// Manager.Complete, and Manager.Fail — i.e. the harness's own
	// transition-emitting operations rather than caller-driven ones.
	TransitionSourceFramework TransitionSource = "framework"
)

Defined TransitionSource values. The set is closed; adding a new kind requires both a constant here and a Manager call site that produces it.

type Transitions

type Transitions map[string][]string

Transitions declares the valid phase transitions for a workflow type. Keys are source phases; values are slices of valid destination phases. Terminal phases have empty out-edges.

Example (drone-survey mission):

transitions := lifecycle.Transitions{
    "planning":   {"flying", "aborted"},
    "flying":     {"capturing", "landing", "aborted"},
    "capturing":  {"flying"},
    "landing":    {"completed", "failed"},
    "completed":  {},  // terminal
    "failed":     {},  // terminal
    "aborted":    {},  // terminal
}

The table is checked structurally by Validate at Register time and then consulted by Manager.Transition on every transition request. Edges not declared in the table are rejected with ErrInvalidTransition.

func (Transitions) IsTerminal

func (t Transitions) IsTerminal(phase string) bool

IsTerminal returns true when the given phase has no declared out-edges in the table (or isn't declared at all — defensive fallback to treat unknown phases as terminal so a misconfigured instance can't transition unexpectedly).

Apps that derive Participant.IsTerminal from the Transitions table can use this helper directly:

func (m *MissionState) IsTerminal() bool {
    return missionTransitions.IsTerminal(m.Phase())
}

func (Transitions) IsValidTransition

func (t Transitions) IsValidTransition(from, to string) bool

IsValidTransition returns true when (from → to) is a declared edge in the table. Used by Manager.Transition to gate transitions, but safe for app-side use too (e.g. to filter operator-facing "available next phases" UI).

func (Transitions) Phases

func (t Transitions) Phases() []string

Phases returns the set of declared phases in sorted order. Useful for operator dashboards rendering state-machine diagrams or for validation tooling enumerating the workflow's surface.

func (Transitions) TerminalPhases

func (t Transitions) TerminalPhases() []string

TerminalPhases returns the subset of declared phases that are terminal (no out-edges), sorted. Operator dashboards use this to group instances by terminal-vs-active when ListOptions.Active is not set.

func (Transitions) Validate

func (t Transitions) Validate() error

Validate checks structural consistency of the transitions table. Returns ErrInvalidTransitionsTable wrapped with a descriptive message when:

  • The table is empty (no phases declared)
  • An out-edge references a phase that isn't a key in the table (typo catcher — every reachable phase must be declared, even terminal ones with empty out-edges)
  • A phase has duplicate out-edges (e.g. {"flying": ["landing", "landing"]}) — defends against copy-paste bugs in rule packs

Returns nil when the table is internally consistent.

Note: Validate does NOT check reachability from an initial phase (apps may have multiple entry points) or absence of cycles (cycles are legitimate — e.g. capturing → flying → capturing in the drone example).

type Workflow

type Workflow struct {
	// Name is the workflow type identifier (e.g. "mission",
	// "csapi-system"). Must match what the Participant's Workflow()
	// method returns. Required.
	Name string

	// EntityIDPattern is the 6-part federated-ID glob pattern that
	// identifies entities of this workflow type. Used by List, Watch,
	// and the cross-workflow LookupByEntityID path to filter
	// ENTITY_STATES by entity ID. Example: "*.lifecycle.gcs.mission.*"
	// — the org and instance segments are wildcarded; the platform /
	// domain / system / type segments are pinned. Required.
	EntityIDPattern string

	// Phases lists the workflow's declared lifecycle phases.
	// Informational — the authoritative phase graph is Transitions.
	// Optional but recommended for operator dashboards rendering
	// state-machine diagrams.
	Phases []string

	// Transitions declares the phase graph (which phases can move to
	// which). Terminal phases declare an empty out-edge list.
	// Validated against Transitions.Validate at Register time.
	// Required.
	Transitions Transitions

	// PhasePredicate is the triple predicate that carries the
	// entity's current phase (e.g. "mission.phase"). Manager.Transition
	// writes this predicate atomically with the audit predicates;
	// projection reads it to populate the Participant struct's
	// `lifecycle:"phase"` field. Required.
	PhasePredicate string

	// Schema is the reflect.Type of the app's Participant struct.
	// Used by the projection layer to allocate fresh instances
	// (reflect.New) and to populate fields by index. Required;
	// typically `reflect.TypeOf(MissionState{})`.
	Schema reflect.Type

	// OperatorWritablePredicates lists triple predicates that the
	// operator API (POST /workflows/{type}/{id}/state) may patch via
	// Manager.UpdateFromOperator. Default-deny: predicates not in
	// this list are protected. Optional — omit for workflows that
	// have no operator-writable surface (e.g. fully rule-driven).
	OperatorWritablePredicates []string

	// AuditPredicates declares the framework-stamped audit-trail
	// predicates. Manager.Transition writes these atomically with
	// the phase change; Manager.History reads them back to
	// reconstruct TransitionEvent records with source attribution.
	// Optional — omit to disable audit-triple stamping (History
	// then synthesizes Triggered=framework from KV revision
	// timestamps only).
	AuditPredicates AuditSpec

	// ChildWorkflows declares workflow types that this workflow OWNS
	// as children. Each ChildSpec declares the LinkPredicate that
	// establishes the parent→child relationship via a triple on the
	// parent. Optional — omit for workflows without owned children.
	ChildWorkflows []ChildSpec

	// ReferencePredicates declares predicates linking this workflow
	// to OTHER entities WITHOUT lifecycle ownership (the target has
	// its own lifetime). Manager.References returns light stubs for
	// these; operator dashboards render them as cross-workflow
	// hyperlinks. Optional.
	ReferencePredicates []ReferenceSpec
}

Workflow declares a workflow type to Manager.Register. All fields except Name + Phases + Transitions + PhasePredicate + Schema are optional — the harness degrades gracefully when richer schema information is absent (composed gateway view falls back to the bare entity; History stamps a synthetic source if no AuditPredicates are declared).

type WorkflowDef

type WorkflowDef struct {
	// Workflow is the workflow type identifier (matches the
	// Workflow.Name passed to Manager.Register and what
	// Participant.Workflow returns).
	Workflow string `json:"workflow"`

	// Transitions is the declared phase-transition table registered
	// for this workflow. The state-machine diagram is derivable
	// directly from this map.
	Transitions Transitions `json:"transitions"`

	// EntityIDPattern is the 6-part federated-ID glob this workflow
	// matches. Operator dashboards use this to scope discovery.
	EntityIDPattern string `json:"entity_id_pattern"`

	// PhasePredicate is the triple predicate that carries the
	// entity's phase.
	PhasePredicate string `json:"phase_predicate"`

	// OperatorWritableFields lists the JSON field paths that are
	// tagged `lifecycle:"operator_writable"` on the registered Schema
	// struct. Used by UpdateFromOperator to enforce default-deny;
	// also exposed in the operator API for clients to know which
	// fields are patchable.
	OperatorWritableFields []string `json:"operator_writable_fields"`

	// OperatorWritablePredicates lists the underlying predicate names
	// for the patchable fields. Operator clients that want to write
	// triples directly (via graph-gateway) can match on these.
	OperatorWritablePredicates []string `json:"operator_writable_predicates"`
}

WorkflowDef describes a registered workflow type. Returned by Manager.GetWorkflowDefinition and Manager.ListWorkflows; intended primarily for operator dashboard introspection (e.g. rendering a state-machine diagram derivable directly from the Transitions table).

Apps don't construct WorkflowDef directly — the framework synthesizes it from the Workflow declaration passed to Manager.Register.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL