Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ type Component interface {
}
```

`ComponentInfo` gives the component a stable operational identity. Names must be unique within a registry and safe for use in path-oriented operations surfaces. Kinds and attribute keys should be stable, low-cardinality safe tokens, but Opskit leaves stricter validation to presentation and telemetry layers.
`ComponentInfo` gives the component a stable operational identity. Names must be unique within a registry and safe for use in path-oriented operations surfaces. Kinds, labels, and attribute keys should be stable, low-cardinality safe tokens, but Opskit leaves stricter validation to presentation and telemetry layers. Labels are stable identity metadata for passive inventory and filtering; attributes on status, inspection, checks, and commands carry runtime or result-specific metadata.

`Status` reports the current component state. It should normally be a fast cached or local snapshot. Expensive work such as dependency pings, reloads, active checks, command dispatch, remote calls, or state transitions belongs in explicit check, command, worker, or application execution paths.

Expand Down Expand Up @@ -346,6 +346,9 @@ type CommandDescriber interface {
}
```

The handler method is intentionally named `HandleCommand` rather than `Command`
because it is the active operation on a handler, distinct from command metadata.

`CommandDescriber` lets admin surfaces, CLIs, worker runtimes, and docs
generators list supported commands without invoking them.

Expand Down
26 changes: 19 additions & 7 deletions component.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,21 @@ import "context"
//
// Name must be stable and unique within a Registry. Names must be safe single
// path segments containing only ASCII letters, ASCII digits, dots, underscores,
// and hyphens. Kind should be a low-cardinality category such as "config",
// "worker_runtime", "clients", "dependencies", or "state". Kind is not
// validated by Opskit; prefer stable, safe tokens because presentation and
// and hyphens. Use ValidateComponentName or IsValidComponentName to check names
// before registration. Kind should be a low-cardinality category such as
// "config", "worker_runtime", "clients", "dependencies", or "state". Kind is
// not validated by Opskit; prefer stable, safe tokens because presentation and
// telemetry layers may use it in filters, labels, dashboards, or routes.
//
// Labels are stable identity metadata for passive inventory, routing,
// filtering, dashboards, and admin presentation. Labels must be safe to expose
// anywhere ComponentInfo appears. Do not use labels for secrets, user data,
// request IDs, dynamic health details, or high-cardinality values.
type ComponentInfo struct {
Name string `json:"name"`
Kind string `json:"kind,omitempty"`
Description string `json:"description,omitempty"`
Name string `json:"name"`
Kind string `json:"kind,omitempty"`
Description string `json:"description,omitempty"`
Labels []Attribute `json:"labels,omitempty"`
}

// Component is the minimum operational contract for something that can be
Expand Down Expand Up @@ -103,7 +110,7 @@ type ComponentFunc struct {

// ComponentInfo returns the component identity.
func (c ComponentFunc) ComponentInfo() ComponentInfo {
return c.Info
return cloneComponentInfo(c.Info)
}

// Status returns the component status.
Expand All @@ -116,3 +123,8 @@ func (c ComponentFunc) Status(ctx context.Context) Status {

return c.Fn(ctx)
}

func cloneComponentInfo(info ComponentInfo) ComponentInfo {
info.Labels = cloneAttributes(info.Labels)
return info
}
17 changes: 15 additions & 2 deletions component_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@ func TestComponentInfoJSONIncludesAllFields(t *testing.T) {
Name: "cache",
Kind: "dependency",
Description: "primary cache",
Labels: []Attribute{
Attr("tier", "critical"),
},
}

requireJSON(t, info, `{"name":"cache","kind":"dependency","description":"primary cache"}`)
requireJSON(t, info, `{"name":"cache","kind":"dependency","description":"primary cache","labels":[{"key":"tier","value":"critical"}]}`)
}

func TestReadinessPolicyValues(t *testing.T) {
Expand Down Expand Up @@ -73,6 +76,9 @@ func TestComponentEntryJSON(t *testing.T) {
Component: ComponentInfo{
Name: "cache",
Kind: "dependency",
Labels: []Attribute{
Attr("tier", "critical"),
},
},
Registration: ComponentRegistration{
ReadinessPolicy: ReadinessOptional,
Expand All @@ -82,7 +88,7 @@ func TestComponentEntryJSON(t *testing.T) {
},
}

requireJSON(t, entry, `{"component":{"name":"cache","kind":"dependency"},"registration":{"readiness_policy":"optional"},"capabilities":{"checker":true}}`)
requireJSON(t, entry, `{"component":{"name":"cache","kind":"dependency","labels":[{"key":"tier","value":"critical"}]},"registration":{"readiness_policy":"optional"},"capabilities":{"checker":true}}`)
}

func TestComponentSnapshotJSONOmitsPointerViews(t *testing.T) {
Expand Down Expand Up @@ -204,16 +210,23 @@ func TestComponentFuncComponentInfo(t *testing.T) {
Info: ComponentInfo{
Name: "cache",
Kind: "dependency",
Labels: []Attribute{
Attr("tier", "critical"),
},
},
}

info := component.ComponentInfo()
info.Labels[0] = Attr("tier", "mutated")
if info.Name != "cache" {
t.Fatalf("Name = %q, want cache", info.Name)
}
if info.Kind != "dependency" {
t.Fatalf("Kind = %q, want dependency", info.Kind)
}
if component.Info.Labels[0] != Attr("tier", "critical") {
t.Fatalf("ComponentInfo returned mutable labels, Labels = %+v", component.Info.Labels)
}
}

func TestComponentFuncStatus(t *testing.T) {
Expand Down
42 changes: 32 additions & 10 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,16 +111,22 @@ operational concern.

```go
type ComponentInfo struct {
Name string `json:"name"`
Kind string `json:"kind,omitempty"`
Description string `json:"description,omitempty"`
Name string `json:"name"`
Kind string `json:"kind,omitempty"`
Description string `json:"description,omitempty"`
Labels []Attribute `json:"labels,omitempty"`
}

func ValidateComponentName(name string) error
func IsValidComponentName(name string) bool
```

`Name` must be unique within a registry. It must be one safe path segment using
ASCII letters, ASCII digits, dots, underscores, and hyphens. Empty names, spaces,
slashes, `.` and `..`, colons, and other path-hostile characters are rejected at
registration time.
registration time. Use `ValidateComponentName` when callers need the same
sentinel errors returned by registration. Use `IsValidComponentName` when only a
boolean predicate is needed.

`Kind` should be low-cardinality, such as `config`, `worker_runtime`,
`dependencies`, `clients`, `state`, or `build`. Opskit does not validate
Expand All @@ -129,12 +135,13 @@ underscores, and hyphens because presentation and telemetry layers may use
kinds in filters, labels, dashboards, or routes. `Description` is optional human
context for admin surfaces.

`ComponentInfo` does not currently include labels. Stable operational metadata
belongs on `Attribute` fields in the relevant read model, such as status,
readiness, inspection, check descriptors or results, command descriptors,
command requests or results, and future event records if Opskit later defines
an event envelope. Opskit may add identity-level labels later if sibling kits
demonstrate a concrete need before those read models are available.
`Labels` are stable identity metadata for passive inventory, routing, filtering,
dashboards, and admin presentation. Labels must be safe to expose anywhere
`ComponentInfo` appears. Do not use labels for secrets, user data, request IDs,
dynamic health details, or high-cardinality values.

Use `Attribute` fields on status, inspection, checks, commands, and future event
records for runtime or result-specific metadata.

### `ComponentFunc`

Expand Down Expand Up @@ -250,6 +257,7 @@ Helpers:
func ReadyReadiness(reason string, components ...ReadinessItem) Readiness
func NotReadyReadiness(reason string, components ...ReadinessItem) Readiness
func ReadinessFromItems(reason string, items ...ReadinessItem) Readiness
func ReadinessFromPolicyItems(reason string, items ...ReadinessItem) Readiness
func ReadinessFromStatus(ComponentInfo, Status) Readiness
func ReadinessItemFromStatus(ComponentInfo, Status) ReadinessItem
```
Expand All @@ -258,6 +266,10 @@ The helpers defensively copy component slices. `ReadyReadiness` and
`NotReadyReadiness` create explicit aggregate readiness results.
`ReadinessFromItems` derives aggregate readiness from child items and is the
safer helper when every child item must be ready for the aggregate to be ready.
`ReadinessFromPolicyItems` derives aggregate readiness from required child
items. Optional and informational child items are included in the result but do
not block the aggregate. Missing or unknown child item policy is treated as
`required`.
`ReadinessFromStatus` produces a single-item readiness result derived from
`Status.Ready`, `Status.State`, and `Status.Message`.

Expand Down Expand Up @@ -384,6 +396,13 @@ readiness is not ready with reason `"no required readiness components
registered"`, even when optional components are ready. This prevents a service
from accidentally becoming ready with no required readiness contract.

Registry-level readiness policy and child item policy are separate layers.
Registration policy controls whether the registered component blocks service
readiness. `ReadinessItem.Policy` is for contributor-owned child aggregation,
such as dependency groups where some children are required and others are
optional. Contributors that need child policy should return readiness built with
`ReadinessFromPolicyItems`.

`Snapshot` returns the combined view of one component:

```go
Expand Down Expand Up @@ -642,6 +661,9 @@ type CommandDescriber interface {
type CommandHandlerFunc func(context.Context, CommandRequest) CommandResult
```

The handler method is intentionally named `HandleCommand` rather than `Command`
because it is the active operation on a handler, distinct from command metadata.

Nil `CommandHandlerFunc` values return an unknown, rejected result instead of
panicking. Nil contexts are normalized.

Expand Down
11 changes: 8 additions & 3 deletions docs/composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,14 @@ Register components where the service is assembled.
Keep component names stable. Operational consumers may put names in paths, logs,
alerts, dashboards, and tests.

Keep component kinds and attribute keys stable and low-cardinality. Opskit does
not validate them because each presentation or telemetry layer may have its own
field, label, or route constraints.
Use `opskit.ValidateComponentName` or `opskit.IsValidComponentName` when sibling
kits or applications need to check component names before registration or route
exposure.

Keep component kinds, labels, and attribute keys stable and low-cardinality.
Opskit does not validate them because each presentation or telemetry layer may
have its own field, label, or route constraints. Use labels for stable
identity-level metadata and attributes for runtime or result-specific metadata.

Use required readiness sparingly but deliberately. If a component blocks serving
traffic, make it required. If it is useful but non-critical, make it optional. If
Expand Down
33 changes: 21 additions & 12 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,17 +165,19 @@ type Component interface {
}
```

`ComponentInfo` gives the component a stable operational identity. Identity is
intentionally limited to name, kind, and description for now. Stable operational
metadata should be represented with `Attribute` values on status, readiness,
inspection, check descriptors or results, command descriptors, command requests
or results, and future event records if Opskit later defines an event envelope.

Opskit may add `ComponentInfo` labels later if sibling kits demonstrate a
concrete need for stable identity-level metadata before active status,
inspection, check, command, or event data is available. Until then, labels in
the broader Kit Series architecture are safe operational attributes attached to
the relevant read model, not part of the `ComponentInfo` compatibility contract.
`ComponentInfo` gives the component a stable operational identity. Identity
includes name, kind, description, and optional labels.

Labels are stable identity metadata for passive inventory, routing, filtering,
dashboards, and admin presentation. Labels must be safe to expose anywhere
`ComponentInfo` appears. They are not runtime state. Do not use labels for
secrets, user data, request IDs, dynamic health details, or high-cardinality
values.

Runtime or result-specific metadata should be represented with `Attribute`
values on status, inspection, check descriptors or results, command descriptors,
command requests or results, and future event records if Opskit later defines
an event envelope.

`Status` reports the component's current local state. Status should be cheap,
descriptive, and safe to expose.
Expand Down Expand Up @@ -383,7 +385,7 @@ sequenceDiagram
end

Registry->>Registry: fill missing child item policy from registration policy
Registry->>Registry: required items decide aggregate Ready
Registry->>Registry: registration policy decides whether component blocks aggregate Ready
end
end

Expand All @@ -398,6 +400,13 @@ When a readiness contributor returns child readiness items, Opskit preserves any
explicit child item policy. If a child item omits policy, Opskit fills it from
the component's registration policy.

Contributor-owned child item policy is separate from registry-level registration
policy. Registration policy decides whether a registered component blocks
service readiness. Child item policy helps a contributor compute its own
aggregate readiness when it represents a group, such as clients or dependencies
where some children are required and others are optional. Use
`ReadinessFromPolicyItems` for that contributor-side aggregation.

## Inspection Is Safe Diagnostic Detail

Status and readiness should stay compact. Inspection is for richer operational
Expand Down
4 changes: 4 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ names include `config`, `worker_runtime`, `cache.primary`, and
`WorkerA`. Invalid names include empty strings, names with spaces, names with
slashes, `.`, `..`, and path-hostile punctuation.

Use `opskit.ValidateComponentName` when code needs the same sentinel errors
returned by registration. Use `opskit.IsValidComponentName` when only a boolean
predicate is needed.

Component kinds and attribute keys should also be stable, low-cardinality safe
tokens, but Opskit does not validate them. Presentation and telemetry layers may
apply their own field, label, route, or dashboard constraints.
Expand Down
68 changes: 68 additions & 0 deletions readiness.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,60 @@ func ReadinessFromItems(reason string, items ...ReadinessItem) Readiness {
}
}

// ReadinessFromPolicyItems builds a readiness result whose aggregate readiness
// is derived from required readiness items.
func ReadinessFromPolicyItems(reason string, items ...ReadinessItem) Readiness {
components := normalizePolicyReadinessItems(items)

if len(components) == 0 {
if reason == "" {
reason = "no readiness items"
}
return Readiness{
Ready: false,
Reason: reason,
}
}

required := 0
ready := true
for _, item := range components {
if !blocksReadiness(item.Policy) {
continue
}

required++
if !item.Ready {
ready = false
}
}

if required == 0 {
if reason == "" {
reason = "no required readiness items"
}
return Readiness{
Ready: false,
Reason: reason,
Components: components,
}
}

if reason == "" {
if ready {
reason = "all required readiness items ready"
} else {
reason = "one or more required readiness items are not ready"
}
}

return Readiness{
Ready: ready,
Reason: reason,
Components: components,
}
}

// ReadinessFromStatus builds a readiness result from component status.
func ReadinessFromStatus(info ComponentInfo, status Status) Readiness {
reason := "component ready"
Expand Down Expand Up @@ -136,3 +190,17 @@ func normalizeReadinessItems(items []ReadinessItem) []ReadinessItem {
}
return cloned
}

func normalizePolicyReadinessItems(items []ReadinessItem) []ReadinessItem {
if len(items) == 0 {
return nil
}

cloned := make([]ReadinessItem, len(items))
for i, item := range items {
item.Policy = normalizeReadinessPolicy(item.Policy)
item.State = normalizeReadinessItemState(item.Ready, item.State)
cloned[i] = item
}
return cloned
}
Loading