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.
Teardowner interfaces let a backend override a helper for efficiency.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:
| Method | Scope | Delivery | Use for |
|---|---|---|---|
Watch | one resource by ID | one event at a time | waiting on a specific object |
WatchKind | all of a (ns, type) | one event at a time | watching a whole collection |
WatchKindAggregated | all of a (ns, type) | []Event batches | the 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.
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.
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:
st := state.WrapCore(namespaced.NewState(inmem.Build))
// └ State helpers └ routes by namespace └ per-namespace in-mem store
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:
// 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. Thekeystoragepackage (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.
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.