diff --git a/docs/roadmap/registration-proposal.md b/docs/roadmap/registration-proposal.md index cce19bf..34cf9d4 100644 --- a/docs/roadmap/registration-proposal.md +++ b/docs/roadmap/registration-proposal.md @@ -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