v0.x: features as a value, and a root that owns its registries¶
Spec 0199, landing in phases. This note grows with them.
Phase 1: the snapshot replaces the seal¶
A new package, pkg/features, is the feature core: Registry, Snapshot,
Resolver, Set and Evaluator as interfaces with one default implementation
each, and no GTB import so it can leave for go/features later. props and
setup are re-implemented over it. What you write at init() does not change:
props.RegisterFeature, setup.Register, setup.RegisterChecks,
setup.RegisterAssets, setup.RegisterMiddleware and
setup.RegisterGlobalMiddleware keep their signatures and still target the
process-wide default registry, because a blank import can reach nothing else.
What changes is what protects a reader from a late writer. A seal used to turn a
registration after enumeration into a panic; now a reader takes an immutable
snapshot and a later registration is simply not in it. Nothing after main
begins panics.
| Gone | Instead |
|---|---|
props.SealFeatures, props.ErrRegistrySealed |
nothing: features.Default().Snapshot() is immutable |
setup.SealRegistry, setup.Seal, setup.IsSealed |
nothing |
setup.ResetRegistryForTesting |
build your own: r := features.NewRegistry(), contribute to it, read it with the *In accessors |
props.FeatureID as a distinct type |
an alias of features.ID; every constant and conversion compiles unchanged |
props.FeatureKind as a distinct type |
an alias of features.Kind |
Added, for tests and for hosts that hold their own registry:
features.NewRegistry(),features.Default(),features.MustDeclare.setup.RegisterOn(r, feature, ...)and the snapshot readerssetup.InitialisersIn,SubcommandsIn,FeatureFlagsIn,ChecksIn,AssetsIn,ChainIn. TheGet*andChainforms read the default registry's snapshot and are unchanged for callers.props.FeatureDescriptorimplementsfeatures.Descriptorand gains aDynamicfield (false for every built-in; see spec 0199 D7).
Before¶
func TestMyMiddleware(t *testing.T) {
setup.ResetRegistryForTesting()
t.Cleanup(setup.ResetRegistryForTesting)
setup.RegisterMiddleware("mine", mw)
wrapped := setup.Chain("mine", runE)
// ...
}
After¶
func TestMyMiddleware(t *testing.T) {
t.Parallel()
r := features.NewRegistry()
r.Contribute("mine", setup.SlotMiddleware, mw)
wrapped := setup.ChainIn(r.Snapshot(), "mine", runE)
// ...
}
A test that only builds a root and never registers anything needs no reset and no registry of its own: drop the two reset lines.
Interim: the root still contributes its three built-in global middlewares to the
default registry once per process (a sync.Once where the seal check was), so
every root in one process shares one chain closing over the first root's
Props. Phase 2 gives each root its own chain and removes this.
Phase 2: the root owns its set, its chain and its flags¶
The enabled set is a value on Props; the middleware chain and the init flag
targets belong to the root that built them; props.New is the construction
path the skeleton, gtb and the e2e binary use. Two roots in one process now
share nothing (#27, #37), and each root's telemetry middleware reports to its
own collector rather than the first root's.
| Gone | Instead |
|---|---|
Tool.IsEnabled(id), Tool.IsDisabled(id) |
p.GetFeatures().Enabled(id) (nil-safe; p.Features is the field New fills) |
props.FeatureDescriptors(), AllFeatures(), FeaturesOfKind(), DescriptorFor() |
features.Default().Snapshot() (Descriptors, OfKind, Lookup), or p.GetFeatures(); props.DescriptorsIn(e) narrows either to GTB's descriptors |
setup.GetInitialisers(), GetSubcommands(), GetFeatureFlags(), GetChecks(), GetAssets() |
features.ContributionsOf[T](p.GetFeatures(), id, setup.Slot*) for enabled features; the setup.*In(snapshot) readers for everything declared |
setup.Chain(feature, runE), setup.ChainIn |
the root's setup.Chainer (NewMiddlewareChain(builtin, set)), reached through Command.Register; root.WithChain replaces it |
setup.InitialiserProvider func(p *props.Props) Initialiser |
func(p *props.Props, flags *pflag.FlagSet) Initialiser: read your skip flag from flags (setup.FlagSkips) rather than a package variable |
a FeatureFlag binding cmd.Flags().BoolVar(&pkgVar, ...) |
bind a target the command owns: cmd.Flags().Bool(name, setup.CIDefault(), usage) |
GitHub's --skip-key |
the init command's own --skip-key (setup.SkipKeyFlag), honoured by every profile that offers an SSH key whichever forges are linked |
skeleton NewCmdRoot(v) (*setup.Command, *props.Props) |
(*setup.Command, *props.Props, error); main prints the error and exits 2 (errorhandling.ExitCodeUsage). gtb regenerate project rewrites the generated cmd/<name>/main.go to match (#84); a hand-wired main makes the change itself |
forge.Unlinked(tool props.Tool) |
forge.Unlinked(set features.Set) |
Added:
Props.Features features.Set,Props.Flags features.Evaluator(defaults toFeatures),Props.GetFeatures(),Props.GetFlags().props.NewoptionsWithFeatures(snapshot),WithResolver,WithSet,WithFlags;props.StatesOf,props.DescriptorsIn,props.Enumerator.root.WithRegistry,root.WithResolver,root.WithChain.setup.Chainer,setup.MiddlewareChain,setup.RunE,Command.UseChain,setup.ChainedAnnotation,setup.SkipKeyFlag,setup.FlagSkips,setup.CIDefault;forge.DisplaysIn.
Resolution follows OQ2: enabling a feature nothing declared fails props.New
(and main exits 2); disabling one is ignored and listed by
p.GetFeatures().Ignored(). A literal Props that skipped New drops the
unknown enable and carries on, since the root cannot return an error.
Before¶
func NewCmdRoot(v version.Info) (*setup.Command, *props.Props) {
p := &props.Props{Tool: props.Tool{Name: "mytool", Features: props.SetFeatures(...)}, Logger: l, FS: afero.NewOsFs()}
p.ErrorHandler = errorhandling.New(logger.ToSlog(l), p.Tool.Help)
return root.NewCmdRoot(p, serve.NewCmdServe(p)), p
}
if p.Tool.IsEnabled(props.AiCmd) { /* ... */ }
After¶
func NewCmdRoot(v version.Info) (*setup.Command, *props.Props, error) {
tool := props.Tool{Name: "mytool", Features: props.SetFeatures(...)}
p, err := props.New(tool, l, afero.NewOsFs(), props.WithVersion(v))
if err != nil {
return nil, nil, err
}
return root.NewCmdRoot(p, serve.NewCmdServe(p)), p, nil
}
if p.GetFeatures().Enabled(props.AiCmd) { /* ... */ }
A downstream main regenerated by gtb regenerate project picks the new
shape up; a hand-written one adds the error branch.
Phase 3: the generator derives its catalogue, keychain is a link kind, credentials are a slot¶
templates.FeatureCatalogue(a hand-written table) istemplates.Catalogue(), derived from a snapshot of the feature registry and narrowed to the scaffoldable kinds. Rows areprops.FeatureDescriptor(ID, notCmd).templates.CatalogueIn(e)andtemplates.CatalogueEntry(name)accompany it.props.KindLinkis a third kind: a feature whose only effect is a blank import.props.FeatureDescriptorgainsOrderso a declaring package can order its features after the built-ins (the forges do, so a chooser lists GitHub first).pkg/setup/keychainis new: blank-import it (notgo/credentials/keychaindirectly) to link the keychain backend; it declares thekeychainfeature as a link kind withkeychain.KeychainFeatureandkeychain.PackagePath. The generatedcmd/<name>/keychain.go, gtb's own and the e2e binary import it. A hand-written tool that importsgo/credentials/keychaindirectly still works but declares no feature, sodoctorand the catalogue do not see it; switch the import.credentialposture.Registercontributes to the feature registry under the consuming feature (credentialposture.SlotCredential,features.Globalwhen the descriptor names none).Registered()reads a snapshot of the default registry;RegisterOn(r, d),RegisteredIn(snapshot),DeclaredFor(set),ReportFor(ctx, cfg, set, include)andReport(ctx, cfg, descriptors, include)are added.ReportWherestays and reads every declaration;doctorusesReportForso the Set gates by feature and its predicate asks only whether a chat credential's provider is linked.- The generator's
KeychainFeatureconstant isstring(keychain.KeychainFeature);ToggleableFeaturesis every catalogue feature, the link included;DefaultSelectedFeaturesis every default-on feature plus every link kind.
Before¶
for _, d := range templates.FeatureCatalogue {
if d.ConstPackage == forge.PackagePath { backends = append(backends, d.Cmd) }
}
import _ "gitlab.com/phpboyscout/go/credentials/keychain"
After¶
for _, d := range templates.Catalogue() {
if d.Kind == props.KindForge { backends = append(backends, d.ID) }
}
import _ "gitlab.com/phpboyscout/go-tool-base/pkg/setup/keychain"
Phase 4: the dynamic seam¶
Additive. features.Backend (Resolve(ctx, id, fallback, ec), Init, Ready,
Watch, Close), features.Change, features.Dynamic(set, backend, opts...)
with WithInitTimeout and WithOnFallback, features.SetBackend(set), and
features.ErrBackendNotReady. GTB's first backend is
pkg/setup/flags.NewConfigBackend(store) (reads features.<id>.enabled,
forwards store reloads on Watch; flags.ErrNotABool), and
flags.RegisterBackend(controller, name, backend) rides a controls
controller's lifecycle. Props.Flags (an Evaluator, defaulting to the set)
is where a service installs a Dynamic. A feature opts in with
FeatureDescriptor.Dynamic: true; built-ins never do. See the
dynamic feature flags how-to.
Phase 5: the core is go/features¶
pkg/features is deleted, not shimmed: it existed for one day across three
minors and nothing outside this repository imported it. Every symbol moved
unchanged to gitlab.com/phpboyscout/go/features v0.1.0 (docs at
features.go.phpboyscout.uk); a
consumer changes the import path and nothing else. Sentinel kinds are
features.<name> rather than gtb.features.<name>. pkg/setup/flags,
pkg/setup/keychain, props and setup stay in GTB as the framework's use of
the module.
Before¶
After¶
Follow-up: adapter links are declared features¶
A generated tool's cmd/<name>/chat.go and forge.go gain an init that declares what they
import as link-kind features, props.DeclareLinks(props.ChatLinkPrefix, "claude", ...) and
props.DeclareLinks(props.ForgeLinkPrefix, "github", ...)
(#81). Regenerate rewrites both
files, so an existing project picks the declarations up on its next gtb regenerate project.
templates.SkeletonChatProviders(providers, modules, withDefaults)andtemplates.SkeletonForgeAdapters(forges, modules)take the names to declare before the modules to import.props.DeclareLinks,props.DeclareLinksOnandprops.LinkedNamesare new, with theprops.ChatLinkPrefixandprops.ForgeLinkPrefixconstants. A link descriptor needs noConstNameorConstPackage: nothing generates code for it.chat.LinkedProviders(set)returns the providers the set declares that the registry holds, and separately those it declares that nothing registered.init aianddoctorread it;doctor's "Chat providers" check fails on a declared-but-unregistered name.forge.Unlinked(set)reads declared forge links when the set has any and the registry alone when it has none. A hand-wired tool declaring nothing behaves as before.recoverChatProvidersrecovers the declared names exactly on a from-scratch manifest rebuild; achat.gowithout a declaration still recovers the widest module reading.