core  /  state pkg/state
Chapter 02

The State Broker

State is the single source of truth and the only channel through which controllers communicate. It is a CRUD store plus a change stream. Every implementation — in-memory, bolt-backed, encrypted, or remote over gRPC — satisfies the same two interfaces, so your code never knows or cares which one it's talking to.

#Two interfaces: CoreState and State

COSI splits the contract in two. CoreState is the minimal API an implementation must provide. State is CoreState plus a layer of convenience methods that can be built on top of any CoreState via state.WrapCore(). So a new backend only implements ~8 methods; the ergonomic helpers come for free.

CoreState what a backend MUST implement Get(ptr) → Resource List(kind) → []Resource Create / Update / Destroy Watch(ptr, ch) WatchKind(kind, ch) WatchKindAggregated(kind, ch) inmem · bolt · gRPC client all do this State = WrapCore(CoreState) free helpers on top of any core UpdateWithConflicts(...) Modify / ModifyWithResult WatchFor(conditions...) Teardown · TeardownAndDestroy Add/RemoveFinalizer ContextWithTeardown all expressed via the 8 core methods
Implement the left; receive the right for free. Optional Teardowner interfaces let a backend override a helper for efficiency.
pkg/state/state.gogo
type CoreState interface {
    Get(context.Context, resource.Pointer, ...GetOption) (resource.Resource, error)
    List(context.Context, resource.Kind, ...ListOption) (resource.List, error)
    Create(context.Context, resource.Resource, ...CreateOption) error
    Update(ctx context.Context, newResource resource.Resource, opts ...UpdateOption) error
    Destroy(context.Context, resource.Pointer, ...DestroyOption) error
    Watch(context.Context, resource.Pointer, chan<- Event, ...WatchOption) error
    WatchKind(context.Context, resource.Kind, chan<- Event, ...WatchKindOption) error
    WatchKindAggregated(context.Context, resource.Kind, chan<- []Event, ...WatchKindOption) error
}

#Watch: the change stream

CRUD is the boring half. The watch family is what makes COSI a reactive system. There are three flavours, and choosing the right one matters:

MethodScopeDeliveryUse for
Watchone resource by IDone event at a timewaiting on a specific object
WatchKindall of a (ns, type)one event at a timewatching a whole collection
WatchKindAggregatedall of a (ns, type)[]Event batchesthe engine itself — high throughput

A watch always starts by sending the current state (initial Created events, or a Destroyed/tombstone if absent), optionally followed by a Bootstrapped marker, then a live tail of changes. So a fresh watcher and a watcher that's been running for hours converge to the same view — there is no separate "list then watch" race.

#Events, bookmarks & resume

Each change is an Event. Note Old — for Updated events you get both the new and previous resource, so a controller can diff them.

pkg/state/state.gogo
type Event struct {
    Resource resource.Resource // current value
    Old      resource.Resource // previous value (on Updated)
    Error    error             // set on Errored
    Bookmark Bookmark          // opaque resumable position
    Type     EventType         // Created|Updated|Destroyed|Bootstrapped|Errored|Noop
}

The Bookmark is the durability story. It's an opaque token marking a position in the change stream; pass it back via WithStartFromBookmark to resume exactly where you left off — no gaps, no duplicates. In the in-memory implementation a bookmark is a per-process cookie plus a write-position, validated on resume so a stale bookmark from a previous process is rejected rather than silently mis-replayed.

Backpressure & overruns.

The in-memory backend keeps a bounded circular history buffer per collection (capacity grows to a max). If a slow watcher falls more than a buffer-length behind, it receives an explicit Errored event rather than silently missing changes — the watcher is expected to re-establish. This bounded-history design is why COSI stays cheap in memory even with churny resources.

#The convenience layer

These are the methods you'll actually call 90% of the time. Each is a small composition over the core API.

Modify / UpdateWithConflicts

Read-modify-write with automatic conflict retry. You pass an empty resource and a mutation function; it does Get → apply → Update, looping on version conflicts, or Creates if absent. This is the controller's bread and butter.

WatchFor

Block until a resource satisfies conditions — specific phases, empty finalizers, a custom predicate, or particular event types. Great for "wait until the VM reports running."

TeardownAndDestroy

Runs the whole cooperative-deletion protocol from chapter 01 in one call: Teardown, wait for finalizers to drain, Destroy. A backend can implement it natively (one gRPC round-trip) or fall back to the generic sequence.

ContextWithTeardown

Returns a context.Context that cancels the moment a resource enters teardown. Lets long-running work tie its lifetime to a resource's lifetime — cancel cleanly when the thing you're managing is going away.

#The implementation stack

The canonical local state from main.go is one expression that hides a layered stack. Read it inside-out:

wiringgo
st := state.WrapCore(namespaced.NewState(inmem.Build))
//    └ State helpers   └ routes by namespace   └ per-namespace in-mem store
state.State (your code calls this) WrapCore — Modify, WatchFor, Teardown… namespaced.State dispatch (ns) → a per-namespace CoreState inmem.State map[id]Resource + circular event history + cond — optional BackingStore for persistence — bolt.BackingStore (bbolt: ns→type→id→bytes) compression encryption protobuf Marshaler (resource ⇄ bytes)
Each layer is independent and composable. Swap inmem for the gRPC client and the layers above don't change.

namespaced.State — composition by partition

namespaced.NewState(builder) takes a builder function and lazily constructs one underlying CoreState per namespace on first use. inmem.Build is just func(ns) → inmem.NewState(ns). This is how a single state handle serves vms, networks, and default as isolated stores while presenting one interface.

#Persistence, compression & encryption

The in-memory store can take an optional BackingStore for durability. The bolt implementation lays resources out as a namespace → type → id → bytes bucket tree in a bbolt file, loaded once on startup. The bytes are produced by a Marshaler — and marshalers wrap each other:

marshaler compositiongo
// resource → protobuf bytes → (zstd if large) → AES-256-GCM → bbolt
marshaler := encryption.NewMarshaler(
    compression.NewMarshaler(
        store.ProtobufMarshaler{},
        zstd.Compressor{}, minSize, // only compress payloads over a threshold
    ),
    cipher, // AES-256-GCM, key from a KeyProvider
)
backing := bolt.NewBackingStore(dbOpener, marshaler)
  • Compression prefixes a marker byte and only kicks in above a size threshold, so tiny resources stay uncompressed.
  • Encryption uses AES-256-GCM with a random per-record nonce; the key comes from a pluggable KeyProvider. The keystorage package (chapter 05) manages those keys with PGP-encrypted, rotatable key slots — this is how Talos encrypts its on-disk state at rest.

#Owned access & filtering

Two thin wrappers turn the raw state into a guarded state for controllers:

owned access

When the runtime hands a controller its state, it's wrapped so that writes set and check the controller's name as owner, and the controller can only touch resources it declared as outputs. This is where "single writer" is mechanically enforced — a controller physically cannot modify another's resources.

Filter

state.Filter(core, rule) wraps any CoreState with a per-operation authorization callback receiving an Access{namespace, type, id, verb}. This is the hook for RBAC — e.g. a gRPC server that only lets a client read its own namespace.

Mental checkpoint.

State = CRUD + a resumable change stream, behind one swappable interface. Watch always sends current-state-then-tail; bookmarks make it durable; WrapCore adds ergonomics; the marshaler/backing-store layers add persistence, compression, and encryption without touching the rest. Now — who consumes these change streams? Controllers.