diff --git a/README.md b/README.md index 7baa00e..7dc09cd 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Opskit pulls the common language into one small package. A component can say: - whether it supports active checks - whether it supports grouped checks - whether it supports operational commands -- what safe attributes it emits +- what safe attributes it exposes The application still owns policy. Opskit only gives the policy a stable shape. diff --git a/check.go b/check.go index 0c13744..5bf16b1 100644 --- a/check.go +++ b/check.go @@ -34,7 +34,12 @@ type CheckDescriptor struct { Attributes []Attribute `json:"attributes,omitempty"` } -// Checker performs one operational check. +// Checker performs one active operational check. +// +// Check is an execution hook. Opskit does not schedule it, wrap it with +// timeout or panic recovery, retry it, authorize it, audit it, limit its +// concurrency, or export telemetry for it. Callers that invoke Check own those +// policies. type Checker interface { Check(context.Context) CheckResult } @@ -61,7 +66,12 @@ type NamedCheck struct { Result CheckResult `json:"result"` } -// CheckGroup performs a group of operational checks. +// CheckGroup performs a group of active operational checks. +// +// CheckAll is an execution hook. Opskit does not schedule it, wrap it with +// timeout or panic recovery, retry it, authorize it, audit it, limit its +// concurrency, or export telemetry for it. Callers that invoke CheckAll own +// those policies. type CheckGroup interface { CheckAll(context.Context) CheckSummary } diff --git a/command.go b/command.go index 925eaec..3f67980 100644 --- a/command.go +++ b/command.go @@ -21,6 +21,19 @@ type CommandRequest struct { Attributes []Attribute `json:"attributes,omitempty"` } +// NewCommandRequest returns a command request with the current UTC timestamp. +// +// NewCommandRequest does not validate the command name or payload. Callers that +// need a custom RequestedAt value can construct CommandRequest directly. +func NewCommandRequest(name string, payload json.RawMessage, attrs ...Attribute) CommandRequest { + return CommandRequest{ + Name: name, + Payload: cloneRawMessage(payload), + RequestedAt: nowUTC(), + Attributes: cloneAttributes(attrs), + } +} + // CommandDescriptor describes one supported operational command. // // Descriptors are passive metadata for presentation, documentation, and @@ -62,7 +75,12 @@ type CommandResult struct { Attributes []Attribute `json:"attributes,omitempty"` } -// CommandHandler handles an operational command. +// CommandHandler handles an active operational command. +// +// HandleCommand is an execution hook. Opskit does not dispatch it, wrap it with +// timeout or panic recovery, retry it, authorize it, audit it, limit its +// concurrency, validate its payload, or export telemetry for it. Callers that +// invoke HandleCommand own those policies. type CommandHandler interface { HandleCommand(context.Context, CommandRequest) CommandResult } @@ -85,6 +103,16 @@ func cloneCommandDescriptors(commands []CommandDescriptor) []CommandDescriptor { return cloned } +func cloneRawMessage(payload json.RawMessage) json.RawMessage { + if len(payload) == 0 { + return nil + } + + cloned := make(json.RawMessage, len(payload)) + copy(cloned, payload) + return cloned +} + // CommandHandlerFunc adapts a function into a CommandHandler. type CommandHandlerFunc func(context.Context, CommandRequest) CommandResult diff --git a/command_test.go b/command_test.go index b5d77c6..b1bb2cd 100644 --- a/command_test.go +++ b/command_test.go @@ -59,6 +59,50 @@ func TestCommandRequestPayloadRawMessageRoundTrip(t *testing.T) { } } +func TestNewCommandRequest(t *testing.T) { + payload := json.RawMessage(`{"force":true}`) + attrs := []Attribute{ + Attr("requested_by", "admin"), + } + + request := NewCommandRequest("cache/refresh", payload, attrs...) + payload[9] = 'f' + attrs[0] = Attr("requested_by", "mutated") + + if request.Name != "cache/refresh" { + t.Fatalf("Name = %q, want cache/refresh", request.Name) + } + if string(request.Payload) != `{"force":true}` { + t.Fatalf("Payload = %s, want original payload", request.Payload) + } + if request.RequestedAt == nil { + t.Fatal("RequestedAt is nil") + } + if request.RequestedAt.Location() != time.UTC { + t.Fatalf("RequestedAt location = %q, want UTC", request.RequestedAt.Location()) + } + if len(request.Attributes) != 1 { + t.Fatalf("Attributes length = %d, want 1", len(request.Attributes)) + } + if request.Attributes[0] != Attr("requested_by", "admin") { + t.Fatalf("Attributes[0] = %+v, want requested_by admin", request.Attributes[0]) + } +} + +func TestNewCommandRequestWithEmptyPayload(t *testing.T) { + request := NewCommandRequest("", nil) + + if request.Name != "" { + t.Fatalf("Name = %q, want empty", request.Name) + } + if request.Payload != nil { + t.Fatalf("Payload = %s, want nil", request.Payload) + } + if request.RequestedAt == nil { + t.Fatal("RequestedAt is nil") + } +} + func TestCommandDescriptorJSON(t *testing.T) { descriptor := CommandDescriptor{ Name: "cache/refresh", diff --git a/docs/api.md b/docs/api.md index ba2afd8..21a4419 100644 --- a/docs/api.md +++ b/docs/api.md @@ -131,10 +131,10 @@ 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, -inspection, check descriptors, 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. +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. ### `ComponentFunc` @@ -681,12 +681,19 @@ type CommandRequest struct { RequestedAt *time.Time `json:"requested_at,omitempty"` Attributes []Attribute `json:"attributes,omitempty"` } + +func NewCommandRequest(name string, payload json.RawMessage, attrs ...Attribute) CommandRequest ``` `Payload` is command-specific raw JSON. Presentation layers that accept payloads from users must perform authentication, authorization, validation, and size limits before constructing a `CommandRequest`. +`NewCommandRequest` sets `RequestedAt` to the current UTC time and defensively +copies payload bytes and attributes. It does not validate command names or +payloads. Callers that need a custom `RequestedAt` can construct +`CommandRequest` directly. + ### `CommandResult` ```go diff --git a/docs/design.md b/docs/design.md index 189786f..07340dc 100644 --- a/docs/design.md +++ b/docs/design.md @@ -167,9 +167,9 @@ 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, inspection, -check descriptors, command descriptors, command requests or results, and future -event records if Opskit later defines an event envelope. +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, diff --git a/examples/commands/main.go b/examples/commands/main.go index 7da4366..6f3d0ee 100644 --- a/examples/commands/main.go +++ b/examples/commands/main.go @@ -74,10 +74,7 @@ func main() { log.Fatal(err) } - request := opskit.CommandRequest{ - Name: "cache/refresh", - Payload: json.RawMessage(`{"force":true}`), - } + request := opskit.NewCommandRequest("cache/refresh", json.RawMessage(`{"force":true}`)) fmt.Println("described commands") printJSON(commands) diff --git a/registry.go b/registry.go index 9c71f3d..4e3825a 100644 --- a/registry.go +++ b/registry.go @@ -389,6 +389,11 @@ func (r *Registry) Inspect(ctx context.Context, name string) (Inspection, error) } // Checker returns a registered component as a Checker. +// +// The returned Checker is a raw active execution handle. Registry.Checker only +// discovers capability support; it does not invoke Check or provide timeout, +// panic recovery, retry, authorization, auditing, concurrency control, or +// telemetry. func (r *Registry) Checker(name string) (Checker, error) { component, ok := r.Component(name) if !ok { @@ -445,6 +450,11 @@ func (r *Registry) Checks(ctx context.Context, name string) ([]CheckDescriptor, } // CheckGroup returns a registered component as a CheckGroup. +// +// The returned CheckGroup is a raw active execution handle. Registry.CheckGroup +// only discovers capability support; it does not invoke CheckAll or provide +// timeout, panic recovery, retry, authorization, auditing, concurrency control, +// or telemetry. func (r *Registry) CheckGroup(name string) (CheckGroup, error) { component, ok := r.Component(name) if !ok { @@ -460,6 +470,12 @@ func (r *Registry) CheckGroup(name string) (CheckGroup, error) { } // CommandHandler returns a registered component as a CommandHandler. +// +// The returned CommandHandler is a raw active execution handle. +// Registry.CommandHandler only discovers capability support; it does not invoke +// HandleCommand or provide dispatch, timeout, panic recovery, retry, +// authorization, auditing, concurrency control, payload validation, or +// telemetry. func (r *Registry) CommandHandler(name string) (CommandHandler, error) { component, ok := r.Component(name) if !ok {