Clarify registration validation and invocation state contracts

This commit is contained in:
2026-10-10 15:40:08 +00:00
parent 0b5c1422ed
commit 892186d4ac

View File

@@ -193,7 +193,7 @@ and integration inputs. Configuration selectors additionally declare scope
applicability; definition selectors need no configuration-specific wrapper.
Explicit empty, false, and zero values remain distinguishable from omission.
An omitted `NewInput` or `NewContext` supplies the zero value of its respective
An omitted `NewInput` or `NewState` supplies the zero value of its respective
type. For pointer types that is nil; do not silently allocate arbitrary pointer
state or prohibit a loader that intentionally accepts nil. Applications supply a
factory when initialized state is needed. A supplied factory returning nil retains
@@ -201,7 +201,7 @@ its application-owned meaning.
Scope-specific parsed values stay separate from shared acquisition state through
the proposed `Suite[I, S]`: `NewInput` initializes each selected scope's input,
while `NewContext` initializes `S` once for the selected inspection invocation.
while `NewState` initializes `S` once for the selected inspection invocation.
Providers receive both explicitly. Pointer inputs are independently initialized,
not shallow copies of a single pointer. The detailed lifetime table below makes
this change from the comparison's mixed context model explicit; it does not
@@ -418,6 +418,12 @@ type FlagValues[I any] struct {
// Unexported representation.
}
// Non-nil sentinel errors; compare using errors.Is, including through wrapping.
var (
ErrFlagInput error // Missing required selected flag value.
ErrFlagWiring error // Invalid binding selection or destination.
)
func CaptureFlags[Outer, I any](
flags []Flag[I],
target func(*Outer) *FlagValues[I],
@@ -440,12 +446,30 @@ values without string-based retrieval by handlers. Selecting a binding absent
from the captured set or providing a nil destination pointer is an application/
integration wiring error. The zero `FlagValues` with no selected bindings is valid.
Copying a binding handle or capturing it in a command preserves its identity.
Constructing another flag with identical metadata creates a distinct binding;
matching names, parsers, or setters do not make it interchangeable with the
original. Reuse the original handles when selecting values to apply. The same
handle can be captured by separate declarations without sharing invocation values.
Execution-required checks for captured inner flags are deferred until `ApplyTo`
selects the applicable subset; a required app-only selector must not become
mandatory for a PromptKit-only scope request. Missing required selected values
are input-validation errors, routed through the standard command's ordinary
`CheckInput`/usage path before shared-state initialization or acquisition. Help
uses metadata without applying values or requiring execution inputs.
match `ErrFlagInput` through `errors.Is` and produce usage outcomes. An absent
binding or nil destination matches `ErrFlagWiring` and produces an operational
failure, not an operator usage error. Each error produced by `ApplyTo` identifies
one of these categories and includes relevant flag context without requiring
callers to parse its message.
The core's `CheckInput` error handling recognizes `ErrFlagWiring` through wrapping
and routes it to the operational path. `ErrFlagInput` and ordinary input-validation
errors use the usage path. Integrations may therefore return an `ApplyTo` error
from their input-validation path, adding family/scope context by wrapping it,
without misclassifying wiring defects. Both failures stop before shared-state
initialization or acquisition. Metadata wiring defects detectable during assembly
remain retained installation errors; these categories do not defer those checks
to execution. Help uses metadata without applying values or requiring execution
inputs.
Core-owned repeatable-value slices are independent per application of the values;
custom parsed objects and accessor effects retain application ownership. This
@@ -531,9 +555,9 @@ type Selector[I any] struct {
type Suite[I, S any] struct {
NewInput func() I
NewContext func() S
NewState func() S
Selectors []Selector[I]
CheckInput func(Selection, I) error
CheckInput func(scope string, selection Selection, input I) error
Scopes []Scope[I, S]
}
@@ -563,8 +587,12 @@ standard-flag collisions are declaration errors. Scope identifiers, capability
selection, defaults, and aggregate availability follow
[ADR 0009](../adr/0009-configuration-scopes.md). Non-applicable selectors are not
applied to a scope's input; this adds no new unused-selector rejection policy.
`CheckInput` validates the selection and initialized input for each requested
scope before acquisition. It may inspect presence without loading configuration.
`CheckInput` receives the registered name of each requested scope alongside its
selection and initialized, filtered input before acquisition. It can distinguish
a selector omitted for an applicable scope from one intentionally not delivered
to another scope, and apply domain rules accordingly without loading configuration.
The definition suite retains its scope-free validation signature; it has no
configuration scope to supply.
`FromConfigKit` declares the operations supported by the selected ConfigKit
adapter and records the original loader, including an invalid nil loader.
@@ -609,7 +637,7 @@ type ProfileRequest struct {
type Suite[I, S any] struct {
NewInput func() I
NewContext func() S
NewState func() S
Selectors []commandkit.Flag[I]
CheckInput func(ckconfig.Selection, I) error
InspectPrompt func(context.Context, PromptRequest, I, S) (commandkit.Acquired[*promptkit.PromptInspection], error)
@@ -662,7 +690,14 @@ change to unified construction or the independently owned suite principle.
### Factory timing
| Path | `NewInput` | Suite `NewContext` | Acquisition |
The execution rows describe the normal path after applicable conversion and
preflight checks succeed and execution reaches the indicated stage. They do not
require callbacks to run after a failure. An input-application or validation
failure can stop processing before later scopes are initialized;
shared state and acquisition are reached only after all requested input checks
succeed. The no-call rows remain unconditional for CommandKit-owned paths.
| Path | `NewInput` | Suite `NewState` | Acquisition |
| --- | --- | --- | --- |
| Construct declarations / `New` / `Register` | Not called | Not called | Not called |
| Help, version, `config scopes` | Not called | Not called | Not called |
@@ -674,15 +709,17 @@ change to unified construction or the independently owned suite principle.
Input conversion and scope/capability preflight use metadata without acquisition.
For configuration execution, initialize and populate each requested scope input,
then run its `CheckInput`; initialize the shared state before acquisition after
input checks succeed. The input/context callbacks do not run for unselected scopes
input checks succeed. The input/state callbacks do not run for unselected scopes
or unrelated families. This explicitly separates the former mixed-context role:
`NewContext` remains once per invocation, and the new `NewInput` initializes scope
storage independently. No existing callback is silently called once per scope.
`NewState` names the shared acquisition-state factory, distinct from
`context.Context`; it runs once when execution reaches that stage. `NewInput`
initializes scope storage independently. No shared-state callback is silently
called once per scope.
A missing factory supplies the zero value of its type. A nil pointer input/state
is permitted when the receiving binding callbacks and providers support it.
Applications requiring allocated pointer input supply `NewInput`; those requiring
a cache or other initialized state supply `NewContext`. Factories that deliberately
a cache or other initialized state supply `NewState`. Factories that deliberately
return shared mutable input objects retain caller-owned effects; CommandKit does
not promise to repair those aliases. For scope-independent CLI storage, return
fresh input storage as demonstrated below. Pointer shared state is supported and
@@ -695,6 +732,7 @@ not deeply cloned. Providers can retain their input pointer according to their
callback contract without a later scope overwriting it through a shared template.
Subsequent invocations initialize new input/state; registration does not retain
an invocation cache. No concurrent-dispatch guarantee follows from these rules.
## Complete consumer registration examples
These examples target the proposed APIs, not today's prototype. Each block is a
@@ -894,14 +932,21 @@ func App(
},
)
newInput := func() *InspectionInput { return &InspectionInput{} }
newContext := func() *RunCache {
newState := func() *RunCache {
return &RunCache{files: make(map[string][]byte)}
}
appScope := ckconfig.FromConfigKit("app", "Application settings.", loadApp)
engineScope := ckprompt.ConfigScope(loadEngine)
configCommand := ckconfig.Command(ckconfig.Suite[*InspectionInput, *RunCache]{
NewInput: newInput, NewContext: newContext,
NewInput: newInput, NewState: newState,
CheckInput: func(scope string, selection ckconfig.Selection, input *InspectionInput) error {
// Example application policy; it does not apply to engine inspection.
if scope == "app" && input.Campaign.Set && !input.Limit.Set {
return fmt.Errorf("application campaign inspection requires --limit")
}
return nil
},
Selectors: []ckconfig.Selector[*InspectionInput]{
{Binding: campaignFlag, Scopes: []string{"app", "promptkit"}},
{Binding: limitFlag, Scopes: []string{"app"}},
@@ -911,7 +956,7 @@ func App(
definitions := ckprompt.FromEngine(loadEngine)
definitions.NewInput = newInput
definitions.NewContext = newContext
definitions.NewState = newState
definitions.Selectors = []commandkit.Flag[*InspectionInput]{campaignFlag}
definitions.InspectPrompt = nil // Install profiles only; no prompt command.
definitionCommand := ckprompt.Command(definitions)
@@ -956,7 +1001,7 @@ For `example config show --scope all --campaign spring --limit=0`:
and `Campaign.Value == "spring"`.
3. Only the app input receives `Limit.Set == true` and `Limit.Value == 0`. The
engine input retains its independently initialized, unsupplied limit.
4. `newContext` produces one `*RunCache`, passed to both acquisitions. If both
4. `newState` produces one `*RunCache`, passed to both acquisitions. If both
loaders request the same path through `Source`, its bytes are read once within
that invocation. Sharing is application-owned, not a global CommandKit cache.
5. An app loader applies its limit override only when `input.Limit.Set`; omission
@@ -971,6 +1016,13 @@ acquired. The definition suite does not declare `--limit`, so that flag is not
accepted on the profile command. `session restore` invokes only the domain handler.
Help and scope discovery create neither cache nor pointer input.
The example's scope-aware validation permits
`example config show --scope promptkit --campaign spring` without `--limit`.
The same campaign selection for `--scope app` requires an explicitly supplied
limit, including zero. An aggregate request missing that limit fails input
validation before shared-state creation or either acquisition. This is the
example application's policy, not a new CommandKit selector requirement.
`restore` validation illustrates application wiring errors, not a requirement that
CommandKit inspect arbitrary closure internals. The application's declaration
factory records a known nil dependency because it wrapped that callback itself.
@@ -1090,7 +1142,7 @@ empty-family behavior before producing these declarations.
```go
scope := ckconfig.FromConfigKit("app", "Application settings.", loadApp)
suite := ckconfig.Suite[*InspectionInput, *RunCache]{
NewInput: newInput, NewContext: newContext,
NewInput: newInput, NewState: newState,
Selectors: []ckconfig.Selector[*InspectionInput]{
{Binding: campaignFlag, Scopes: []string{"app"}},
},
@@ -1117,8 +1169,9 @@ concurrent execution or clone a callback's object graph.
### Initialization and scope isolation
The composed example's selected aggregate inspection calls `newInput` twice and
`newContext` once. It delivers distinct `*InspectionInput` addresses but the same
When its preflight and input checks succeed, the composed example's selected
aggregate inspection calls `newInput` twice and `newState` once. It delivers
distinct `*InspectionInput` addresses but the same
`*RunCache` to the two providers. Retaining the app input in a provider does not
expose it to later engine-selector writes. Selecting one scope calls each factory
once; selecting profile inspection initializes only its own invocation.
@@ -1126,10 +1179,30 @@ once; selecting profile inspection initializes only its own invocation.
Without input selectors, a suite may deliberately use pointer `I` with no
`NewInput` and a loader accepting nil. With selectors whose accessors dereference
that pointer, it supplies the input factory, just as a pointer-shaped application
command does. Without `NewContext`, pointer `S` is nil; providers that require a
command does. Without `NewState`, pointer `S` is nil; providers that require a
cache supply the state factory. A fresh parsed-value store does not imply that
arbitrary application factories returning aliases are repaired or deep-copied.
### Scope validation, flag identity, and error categories
| Situation | Expected result |
| --- | --- |
| Campaign selected for the example's app scope, with no limit | Scope-aware `CheckInput` returns a usage error; no shared state or acquisition |
| Same campaign selected for only the engine scope | Validation receives scope `promptkit`; the app-only limit rule does not apply |
| A required applicable flag was not supplied | `ApplyTo` returns an error matching `ErrFlagInput`; usage outcome |
| A required flag applies only to an unselected scope | Its requirement is not enforced for the selected scope |
| A copied original binding is selected from its captured values | Identity matches; values apply to the initialized input |
| A newly constructed lookalike binding is selected instead | It is absent from that captured set; error matches `ErrFlagWiring` |
| The destination pointer passed to `ApplyTo` is nil | Error matches `ErrFlagWiring` |
| An `ApplyTo` error is wrapped with family/scope context | `errors.Is` still identifies the category; wiring failures remain operational |
| Input validation fails before later scopes are initialized | Later input factories need not run; `NewState` and acquisitions do not run |
The flag-required checks here describe binding metadata, while the campaign/limit
relationship is the example's application validation rule. Neither requires
examining error-message text. `ErrFlagInput` and `ErrFlagWiring` classify errors
reported by flag application; they do not replace captured installation errors
or upstream provider errors.
## Namespace reconciliation and implementation handoff
The user-selected lifetime is precise: help/version are always reserved;
@@ -1166,8 +1239,9 @@ The requested declaration/example work is now present:
selection.
4. Concrete `WithError`, `RootGroup`, and `NoCommand` contracts, with no delayed
preparation or private-state access.
5. Typed selector composition, distinct pointer inputs, explicit presence, and
one shared invocation cache with documented factory timing.
5. Typed selector composition with stable binding identity, scope-aware validation,
usage/wiring error categories, distinct pointer inputs, and one shared
invocation cache through `NewState`, with normal and failure-path timing.
6. Focused expected outcomes for invalid wiring, collisions, placement, emptiness,
capture, and reuse.
7. An explicit durable-document reconciliation and post-review implementation-plan