Documentation
¶
Overview ¶
Package ownership implements the ADR-056 authoritative-semantic-state owner registry: the framework substrate that names exactly one owner per (entity-ID pattern, predicate group) and rejects two owners selecting the same cell in an owning write mode.
This is the W0 spine. It provides:
- The claim types — OwnerClaim (owned current state) and ForeignEdgeClaim (a relationship-producer claim), plus CoordinationWaiver — modelling the Decision-1 tuple (entity-ID pattern, predicate set, write mode, owner id).
- A SINGLE-EPOCH-KEY registry (the bare `_registry` key in the OWNER_CLAIMS bucket) advanced under UpdateWithRetry CAS, so cross-process overlap is detected: every registrant of ANY claim serializes through one key (Decision 2).
- Glob-vs-glob entity-ID-pattern intersection × exact-string predicate intersection (the overlap algorithm), including the Owner×ForeignEdge cross-type check (Decision 2 MEDIUM).
- Stale-owner compaction via a separate OWNER_PRESENCE heartbeat bucket (liveness over a dead owner's stale claim — the same call NATS KV TTLs make), and OwnerOf, the write-time lease lookup the graph-ingest mutation handlers consult.
W0 first consumer (landed — Decision 5 embed):
- EnsureBuckets creates the two framework buckets (OWNER_CLAIMS with audit history; OWNER_PRESENCE with the PresenceTTL staleness backstop) and returns a Registry. It is called EAGERLY in the framework boot path, before lifecycle registration — NOT in graph-ingest, which boots after.
- Heartbeater is the substrate-owned liveness ticker an embedder drives over its app-root context (no Close to adopt; ctx cancellation stops it).
- lifecycle.Manager embeds the registry via AttachOwnership: each registered Workflow derives an owner claim (phase=cas-transition, audit+writable fields=replace-owned) and registers through the shared epoch. The first consumer's runtime posture is OBSERVE-ONLY — a cross-owner overlap is logged, not bricked (Decision 5); the substrate still REJECTS it (the Manager swallows the rejection deliberately).
W0 4a landed (Decision 4 T2-seam reject, OBSERVE-ONLY):
- The inverse-gate is now ENFORCED at registration: RegisterOwner runs CheckInverseGate over the registration's foreign edges using an injected InverseResolver (set via EnsureBuckets; nil → skip-with-warn-once).
- FE-claim-only owners are EXEMPT from liveness compaction (see compactStale) — they are not leases. KNOWN 4b/4c follow-up: a dead FE-only owner now persists, so the cross-type overlap check could strand a live owning-claim registrant against it (not reachable today; see the compactStale comment).
- ClaimReader is the read-only view graph-ingest uses to classify foreign edges at the T2-seam (observe-only: meter unclaimed + route anyway). The graph-ingest seam wiring + the foreign_edge_unclaimed_total metric live in processor/graph-ingest.
What is deliberately NOT here yet (later W0 increments, each with its own review):
- The HARD T2-seam reject (4c) — 4a routes unclaimed foreign edges deprecated-on-arrival; the hard reject is gated on the metric reading zero over a bake window.
- The PENDING_EDGES Conditional-edge buffer + delete-after-apply drain + boot re-drain + the fourth-path (ensureReferencedEntityExists) fold + the counting crash-recovery flip-gate test (Decision 4 / 4b). EnsureBuckets does NOT create PENDING_EDGES yet (no consumer).
- The OwnerToken wire field on the graph mutation requests + the handler lease check returning ErrorCodeOwnerLeaseStale (Decision 2 write seam), and the post-registration Watch-revival fatal-halt. These two are the write-gating half of the story; until they land, an evicted-then-revived owner only churns the epoch (no dropped writes), which is why the embed ships observe-only rather than hard-fail.
- The hard-fail-on-overlap flip + observability metric for the Manager embed (gated behind explicit enforcement, alongside the write-lease above).
- The projection-contract auto-derivation wiring into graph-ingest boot (Decision 6): pkg/projection.Bind exists; graph-ingest does not yet call it.
See docs/adr/056-authoritative-semantic-state.md.
Index ¶
- Constants
- Variables
- func CheckInverseGate(resolve InverseResolver, claims ...ForeignEdgeClaim) error
- type ClaimReader
- type CoordinationWaiver
- type EdgeMode
- type ForeignEdgeClaim
- type Heartbeater
- type InverseResolver
- type OverlapError
- type OwnerClaim
- type Registration
- type Registry
- func (reg *Registry) ForeignEdgeClaimFor(ctx context.Context, messageType, predicate string) (ForeignEdgeClaim, bool, error)
- func (reg *Registry) Heartbeat(ctx context.Context, owner string) error
- func (reg *Registry) NewHeartbeater(interval time.Duration) *Heartbeater
- func (reg *Registry) OwnerOf(ctx context.Context, entityID, predicate string) (owner string, ok bool, err error)
- func (reg *Registry) RegisterOwner(ctx context.Context, r Registration) error
- func (reg *Registry) Resign(ctx context.Context, owner string) error
- type WriteMode
Constants ¶
const ( // BucketOwnerClaims holds the single `_registry` epoch key — the union of // every registered owner's claims, advanced under UpdateWithRetry CAS. BucketOwnerClaims = "OWNER_CLAIMS" // BucketOwnerPresence holds per-owner heartbeat keys // (`heartbeat.<owner_token>`) with a bucket-level TTL backstop, so a // crashed owner's claims are compacted out of the epoch by the next // registrant (availability over a dead owner's stale claim). BucketOwnerPresence = "OWNER_PRESENCE" // BucketPendingEdges buffers Conditional foreign edges whose target has not // yet been born (Decision 4). Declared here for the boot wiring; the buffer // itself lands in a later W0 increment. BucketPendingEdges = "PENDING_EDGES" )
KV bucket names for the ownership substrate (ADR-056 Decision 2/4). These are framework-owned buckets, created at graph-ingest boot (a later W0 increment wires creation; the names are fixed here so the registry and the boot wiring agree on one source of truth).
const ( // PresenceTTL is the bucket-level TTL on OWNER_PRESENCE, set at bucket // creation. A presence key not re-bumped within this window ages out, and // its owner becomes compactable out of the epoch by the next registrant. // It is the staleness floor the ADR pins at ttl_hint ≥ 3×max(boot_time, // gc_pause_budget): 120s comfortably exceeds a slow service boot or a long // GC pause, so a live owner mid-pause is never falsely evicted, while a // genuinely dead owner's claim frees within ~one TTL of the next boot. PresenceTTL = 120 * time.Second // HeartbeatInterval is how often a live owner re-bumps its presence key — // well under PresenceTTL (4 beats per window), so losing up to 3 // consecutive beats does not cross the staleness floor. HeartbeatInterval = 30 * time.Second )
Liveness tuning (ADR-056 Decision 2 staleness lifecycle). These are the values graph-ingest stamps as the OWNER_PRESENCE bucket TTL and that an embedder ticks Heartbeat on.
Variables ¶
var ErrInvalidClaim = errors.New("ownership: invalid claim")
ErrInvalidClaim is returned when a claim or waiver is malformed (bad pattern, empty predicate set, invalid mode). Registration fails fast — a malformed claim is a programming error, not a runtime condition.
var ErrOwnershipOverlap = errors.New("ownership: claim overlap")
ErrOwnershipOverlap is returned (wrapped in *OverlapError) when two claims select an overlapping (entity-ID pattern, predicate) cell in an owning write mode. Registration FAILS — no silent coexistence (ADR-056 Decision 2). Match with errors.Is; inspect the named owners/pattern/predicates via errors.As(&OverlapError{}).
Functions ¶
func CheckInverseGate ¶
func CheckInverseGate(resolve InverseResolver, claims ...ForeignEdgeClaim) error
CheckInverseGate enforces ADR-056 Decision 4's inverse-gate: a ForeignEdgeClaim whose mode reconstructs the inverse edge after a birth race (Conditional defers-and-applies, Backfill re-derives) REQUIRES the predicate to have a registered inverse — otherwise the inverse edge is unrecoverable and the graph silently loses a traversal edge (BLOCKING-B). Such a claim must instead be declared Strict (drop-if-absent) or NoBirthStub (materialise a stub). Strict and NoBirthStub modes need no inverse and are never gated.
Intended to run at boot, the same way payload registration is validated, over every registered ForeignEdgeClaim (graph-ingest wires it with a vocabulary-backed resolver). Returns the FIRST offending claim's error.
Types ¶
type ClaimReader ¶
type ClaimReader struct {
// contains filtered or unexported fields
}
ClaimReader is a READ-ONLY view of the registered ForeignEdgeClaims, for the ADR-056 Decision-4 T2-seam reject. graph-ingest is the WRITER of ENTITY_STATES but a READER of OWNER_CLAIMS: at the foreign-routing seam it classifies a producer's foreign-subject predicates as claimed or unclaimed, emitting the unclaimed reject metric.
Unlike Registry, a ClaimReader NEVER creates the bucket and NEVER registers — a reader must not conjure the substrate (that is EnsureBuckets' eager-boot job). It opens the existing OWNER_CLAIMS read-only and reads the epoch on demand. Production foreign-edge volume is zero today (OMS/StoredMessage emit no foreign edges), so the per-call epoch read costs nothing in production; a Watch-backed cache is a later (4b) optimisation for when foreign edges flow.
func NewClaimReader ¶
func NewClaimReader(ctx context.Context, client *natsclient.Client, logger *slog.Logger) (*ClaimReader, error)
NewClaimReader opens OWNER_CLAIMS read-only. It returns an error (the caller graceful-skips classification — the seam stays observe-only) when the bucket cannot be opened: a resourceless / unmigrated deploy that never ran EnsureBuckets, or a transient connection blip at boot. It does NOT create the bucket.
func (*ClaimReader) UnclaimedForeignEdges ¶
func (cr *ClaimReader) UnclaimedForeignEdges(ctx context.Context, producer string, predicates []string) ([]string, error)
UnclaimedForeignEdges reads the epoch ONCE and returns the deduped, sorted subset of `predicates` that have NO registered ForeignEdgeClaim covering (producer, predicate) — the foreign edges the T2-seam flags as unclaimed. An exact-producer claim wins; a Producer-empty ("any producer") claim is the fallback (epoch.foreignEdgeClaimFor). An empty/absent registry means nothing is claimed → every predicate is unclaimed. The classification is read-only and allocates nothing on the no-foreign-edges happy path (callers pass a non-empty predicates slice only when foreign triples exist).
type CoordinationWaiver ¶
type CoordinationWaiver struct {
Owner string `json:"owner"` // the owner declaring this half of the waiver
With string `json:"with"` // the other owner of the contested cell
Predicates []string `json:"predicates"` // the exact predicates the waiver covers
Reason string `json:"reason"`
ReviewBy string `json:"review_by,omitempty"` // expiry boundary (format TBD; not yet enforced)
Ref string `json:"ref,omitempty"` // link to the design/issue justifying it
}
CoordinationWaiver records a deliberate, predicate-scoped exemption from the overlap check between two owners (ADR-056 Decision 2). It is audit-stored at EPOCH scope (epoch.Waivers, not nested under an owner) so it survives the compaction of either party. A waiver covers a contested cell only when BOTH owners have declared a matching waiver (mutual consent — neither owner can unilaterally waive itself into the other's cell); see waived() in overlap.go.
ReviewBy is intended to be the expiry boundary enforced at the next registration (a review-obligation forcing function). NOTE: expiry parsing / enforcement is DEFERRED to the enforcement-wiring increment — the format (RFC3339 date vs release tag) is not yet pinned, so today an unexpired and an expired waiver are treated identically. Do not rely on ReviewBy to auto-lapse a waiver yet.
func (CoordinationWaiver) Validate ¶
func (w CoordinationWaiver) Validate() error
Validate checks the waiver names both owners, at least one predicate, and a reason — an unexplained waiver is not a waiver.
type EdgeMode ¶
type EdgeMode string
EdgeMode is the mode of a ForeignEdgeClaim (Decision 4). It governs the inverse-gate / pending-edge behaviour, NOT the Decision-2 overlap check. Decision 4 names three modes (Conditional / Backfill / Strict); the fourth, EdgeNoBirthStub, is the no-birth-target lane added by the fourth-path ruling (lane ii — sensorml children that have no independent birth to drain against).
const ( // EdgeConditional — deferred-apply via the pending-edge buffer; requires a // registered inverse predicate (Decision 4 inverse-gate). EdgeConditional EdgeMode = "conditional" // EdgeBackfill — the inverse is re-derivable from the forward edge. EdgeBackfill EdgeMode = "backfill" // EdgeStrict — the edge is dropped if its target does not yet exist // (edge-presence-insensitive consumers). EdgeStrict EdgeMode = "strict" // EdgeNoBirthStub — the target has no independent producer, so the edge is // materialised via the framework's envelope-bearing referential-stub lane // (Decision 4 lane ii — sensorml children). Load-bearing: the stub is the // only thing that ever materialises the target node. EdgeNoBirthStub EdgeMode = "no-birth-stub" )
type ForeignEdgeClaim ¶
type ForeignEdgeClaim struct {
Owner string `json:"owner"`
Predicate string `json:"predicate"` // single edge predicate
Mode EdgeMode `json:"mode"`
// Producer is the payload MessageType whose Graphable emits this foreign
// edge. The T2-regroup seam keys its reject on (message_type, predicate)
// (ADR-056 Decision 4), so a fully-specified claim names the producing type.
// Empty means "any producer" — the transitional shape before a producer has
// declared its type; ForeignEdgeClaimFor prefers an exact producer match.
Producer string `json:"producer,omitempty"`
// TargetPattern is the 6-part glob of entities the edge lands on (its
// foreign Subject). Empty means match-any — the conservative default for
// the cross-type check. cs-api declares its target pattern so its OwnerClaim
// and ForeignEdgeClaim on the same predicate (isHostedBy own→parent vs
// child→System) are not mistaken for a self-collision (same owner is always
// exempt; see checkOverlap).
TargetPattern string `json:"target_pattern,omitempty"`
}
ForeignEdgeClaim is a relationship-producer claim (ADR-056 Decision 1): the producer asserts an edge with Predicate whose Subject is a DIFFERENT entity than the one it owns. It carries a single edge predicate and an edge-mode, and is governed by Decision 4's inverse-gate — NOT by Decision 2's overlap check. Two ForeignEdgeClaims never overlap each other (both legitimately add edges); the cross-type Owner×ForeignEdge check (Decision 2 MEDIUM) is what stops an OwnerClaim's reconcile from silently stripping a foreign edge.
func (ForeignEdgeClaim) Validate ¶
func (f ForeignEdgeClaim) Validate() error
Validate checks the foreign-edge claim is well-formed.
type Heartbeater ¶
type Heartbeater struct {
// contains filtered or unexported fields
}
Heartbeater periodically refreshes the OWNER_PRESENCE keys of a set of owners so a live process is never compacted out of the epoch by a later registrant (ADR-056 Decision 2). Registry.Heartbeat's contract is "the caller runs this on a ticker"; Heartbeater is that ticker — a small substrate helper the embedder (lifecycle.Manager today; future non-Manager owners next) drives over its own lifetime context, so each embedder does not reinvent the loop.
Owners are added incrementally (one per registration) via Add, which is safe to call before or during Run. Run blocks until its context is cancelled.
func (*Heartbeater) Add ¶
func (h *Heartbeater) Add(owner string)
Add enrolls an owner id for heartbeating on every subsequent tick. Idempotent.
func (*Heartbeater) Run ¶
func (h *Heartbeater) Run(ctx context.Context)
Run ticks until ctx is cancelled, re-bumping every enrolled owner's presence key each tick. Blocks — run it in a goroutine. A failed bump is LOGGED, never returned: a single missed tick is absorbed by the TTL margin, and a loop that exited on the first transient blip would defeat the liveness it exists to maintain.
type InverseResolver ¶
InverseResolver reports whether a predicate has a registered inverse predicate (the vocabulary registry's GetInversePredicate returns non-empty). It is INJECTED so this package stays free of the vocabulary dependency and the inverse-gate is unit-testable with a fake. graph-ingest boot passes an adapter over vocabulary.GetInversePredicate.
type OverlapError ¶
type OverlapError struct {
Owner string // the registrant whose claim was rejected
With string // the incumbent owner it collides with
Pattern string // the registrant's pattern (the one that intersects)
WithPattern string // the incumbent's pattern
Predicates []string // the exact predicates in both sets
CrossType bool // true: Owner×ForeignEdge; false: OwnerClaim×OwnerClaim
}
OverlapError names both owners, the overlapping pattern, and the overlapping predicates of a rejected registration. CrossType is true when the collision is Owner×ForeignEdge (an OwnerClaim reconcile would strip a foreign edge) rather than OwnerClaim×OwnerClaim.
func (*OverlapError) Error ¶
func (e *OverlapError) Error() string
func (*OverlapError) Unwrap ¶
func (e *OverlapError) Unwrap() error
Unwrap lets errors.Is(err, ErrOwnershipOverlap) match an *OverlapError.
type OwnerClaim ¶
type OwnerClaim struct {
Owner string `json:"owner"`
Pattern string `json:"pattern"` // 6-part entity-ID glob
Predicates []string `json:"predicates"` // EXACT strings (no prefix/glob on predicates)
Mode WriteMode `json:"mode"`
}
OwnerClaim is owned current state: for entities matching Pattern, Owner is responsible for the current value of exactly Predicates, written in Mode (ADR-056 Decision 1). This is the claim Decisions 2/3/5 arbitrate.
func (OwnerClaim) Validate ¶
func (c OwnerClaim) Validate() error
Validate checks the claim is well-formed: a 6-part glob pattern, a non-empty exact-string predicate set, a valid mode, and a non-empty owner.
type Registration ¶
type Registration struct {
Owner string
Claims []OwnerClaim
ForeignEdges []ForeignEdgeClaim
Waivers []CoordinationWaiver
}
Registration is one owner's full set of claims, registered atomically against the epoch.
func (Registration) Validate ¶
func (r Registration) Validate() error
Validate checks structural well-formedness AND internal consistency: every claim names the registering owner, and no two of the registration's OWN owning claims select an overlapping (pattern, predicate) cell (a single owner declaring the same cell twice in an owning mode is ambiguous — which claim reconciles it?). Cross-OWNER overlap is checked separately at RegisterOwner against the epoch. Callers that DERIVE a registration (pkg/projection) call this at boot for early, owner-bound feedback.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry is the KV-backed owner registry (ADR-056 Decision 2): a single `_registry` epoch key in OWNER_CLAIMS advanced under CAS, plus a separate OWNER_PRESENCE heartbeat bucket for stale-owner compaction.
This is the distributed-enforcement SUBSTRATE only. Owners do not hand-build claims here as a parallel registry — claims are DERIVED from registered graph projection contracts and bound to an owner id at boot (ADR-056 Decision 6); RegisterOwner is the low-level entrypoint the derivation calls (and the escape hatch for owners with dynamic patterns, e.g. the lifecycle Manager).
func EnsureBuckets ¶
func EnsureBuckets(ctx context.Context, client *natsclient.Client, logger *slog.Logger, resolver InverseResolver) (*Registry, error)
EnsureBuckets idempotently creates the framework-owned ownership buckets and returns a Registry over them.
Call this EAGERLY in the framework boot path, BEFORE any lifecycle.Manager.Register — registration heartbeats into OWNER_PRESENCE and CAS-writes OWNER_CLAIMS, so both buckets must already exist. graph-ingest's initStorage runs AFTER registration in every binary's wiring (NewManager → Register → service start), so creating these alongside ENTITY_STATES there would make every registration a silent no-op (the buckets would not yet exist). That is why this is a standalone eager call, not graph-ingest work.
Bucket layout (ADR-056 Decision 2):
- OWNER_CLAIMS — the single `_registry` epoch key; History for audit, NO TTL (a TTL would age out the durable epoch between deploys).
- OWNER_PRESENCE — per-owner heartbeat keys; a bucket-level TTL = PresenceTTL IS the entire staleness backstop (an absent presence key means the owner has been silent beyond the grace window and is compactable). Without this TTL a crashed owner is never reaped and its stale claim blocks every future registrant forever — so the TTL is not optional.
PENDING_EDGES (BucketPendingEdges) is deliberately NOT created here: its consumer (the Decision-4 foreign-edge buffer) is a later increment, and a bucket created with no reader reads as a half-wired bug.
resolver is the Decision-4 inverse-gate's InverseResolver (an adapter over vocabulary.GetInversePredicate, wired by the caller so this package stays free of the vocabulary dependency). Pass nil to skip the gate (observe-only / read- only consumers) — RegisterOwner then warns once if a would-be-gated claim is registered, rather than silently admitting an unrecoverable foreign edge.
func NewRegistry ¶
func NewRegistry(claims, presence *natsclient.KVStore, logger *slog.Logger) *Registry
NewRegistry constructs a Registry over the two pre-opened KV stores. The caller (graph-ingest boot, a later increment) creates the buckets: OWNER_CLAIMS with history for audit, and — critically — OWNER_PRESENCE with a bucket TTL that IS the compaction grace window. That TTL must be ttl_hint ≥ 3×max(boot_time, gc_pause_budget) so a single missed heartbeat never evicts a live owner (compactStale treats presence-key absence as "silent beyond grace"; the TTL is what makes absence mean that).
func (*Registry) ForeignEdgeClaimFor ¶
func (reg *Registry) ForeignEdgeClaimFor(ctx context.Context, messageType, predicate string) (ForeignEdgeClaim, bool, error)
ForeignEdgeClaimFor returns the ForeignEdgeClaim covering a foreign-subject triple emitted by a Graphable of `messageType` carrying `predicate` — the T2-regroup seam reject lookup (ADR-056 Decision 4). ok=false means the foreign edge is UNCLAIMED, which the seam rejects (or routes deprecated-on-arrival with the foreign_edge_unclaimed_total metric until the producer migrates).
func (*Registry) Heartbeat ¶
Heartbeat (re)writes the owner's presence key, refreshing the bucket TTL. The caller runs this on a ticker (interval well under the TTL); a crashed owner stops, its key TTL-expires, and the next registrant compacts its claims. The value is the heartbeat unix-nanos timestamp, carried for observability and the later Watch-revival check.
func (*Registry) NewHeartbeater ¶
func (reg *Registry) NewHeartbeater(interval time.Duration) *Heartbeater
NewHeartbeater builds a Heartbeater over the registry. A non-positive interval falls back to HeartbeatInterval.
func (*Registry) OwnerOf ¶
func (reg *Registry) OwnerOf(ctx context.Context, entityID, predicate string) (owner string, ok bool, err error)
OwnerOf returns the owner of the (entityID, predicate) cell — the write-time lease lookup a mutation handler runs to verify a writer's owner identity against the live owner (ADR-056 Decision 2 write seam). The returned owner id IS the lease handle (no hash — identity is exact). ok is false when no owner claims that cell (un-claimed or append-evidence).
func (*Registry) RegisterOwner ¶
func (reg *Registry) RegisterOwner(ctx context.Context, r Registration) error
RegisterOwner registers (or re-registers, idempotently) an owner's claims against the single epoch key. Inside one UpdateWithRetry CAS callback (ADR-056 Decision 2): read epoch → compact stale owners by presence → drop the registrant's prior entry → update the registrant's half of the waiver set → check overlap of the candidate against every OTHER owner → on overlap FAIL (non-retryable), else merge + bump epoch → CAS-write at the read revision → retry on a concurrent registrant's write.
Returns a *OverlapError (errors.Is(err, ErrOwnershipOverlap)) on collision, or ErrInvalidClaim on a malformed registration.
type WriteMode ¶
type WriteMode string
WriteMode is the mode a claim's predicate group is written in (ADR-056 Decision 1 — the ADR-055 per-predicate matrix promoted to the ownership primitive). Only the two OWNING modes participate in overlap rejection; append-evidence has no single owner and is exempt.
const ( // ModeReplaceOwned — single-valued, re-emitted: the owner reconciles its // whole owned predicate group on every write (RemoveOwned + Add). ModeReplaceOwned WriteMode = "replace-owned" // ModeCASTransition — ExpectedRevision phase/read-modify-write (the // lifecycle Manager.Transition shape). ModeCASTransition WriteMode = "cas-transition" // ModeAppendEvidence — multi-valued, must-exist, NO owner: many writers may // append (web/ops back-links). Exempt from the overlap check. ModeAppendEvidence WriteMode = "append-evidence" )