Skip to content

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 readers setup.InitialisersIn, SubcommandsIn, FeatureFlagsIn, ChecksIn, AssetsIn, ChainIn. The Get* and Chain forms read the default registry's snapshot and are unchanged for callers.
  • props.FeatureDescriptor implements features.Descriptor and gains a Dynamic field (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 to Features), Props.GetFeatures(), Props.GetFlags().
  • props.New options WithFeatures(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.

  • templates.FeatureCatalogue (a hand-written table) is templates.Catalogue(), derived from a snapshot of the feature registry and narrowed to the scaffoldable kinds. Rows are props.FeatureDescriptor (ID, not Cmd). templates.CatalogueIn(e) and templates.CatalogueEntry(name) accompany it.
  • props.KindLink is a third kind: a feature whose only effect is a blank import. props.FeatureDescriptor gains Order so a declaring package can order its features after the built-ins (the forges do, so a chooser lists GitHub first).
  • pkg/setup/keychain is new: blank-import it (not go/credentials/keychain directly) to link the keychain backend; it declares the keychain feature as a link kind with keychain.KeychainFeature and keychain.PackagePath. The generated cmd/<name>/keychain.go, gtb's own and the e2e binary import it. A hand-written tool that imports go/credentials/keychain directly still works but declares no feature, so doctor and the catalogue do not see it; switch the import.
  • credentialposture.Register contributes to the feature registry under the consuming feature (credentialposture.SlotCredential, features.Global when the descriptor names none). Registered() reads a snapshot of the default registry; RegisterOn(r, d), RegisteredIn(snapshot), DeclaredFor(set), ReportFor(ctx, cfg, set, include) and Report(ctx, cfg, descriptors, include) are added. ReportWhere stays and reads every declaration; doctor uses ReportFor so the Set gates by feature and its predicate asks only whether a chat credential's provider is linked.
  • The generator's KeychainFeature constant is string(keychain.KeychainFeature); ToggleableFeatures is every catalogue feature, the link included; DefaultSelectedFeatures is 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

import "gitlab.com/phpboyscout/go-tool-base/pkg/features"

After

import "gitlab.com/phpboyscout/go/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) and templates.SkeletonForgeAdapters(forges, modules) take the names to declare before the modules to import.
  • props.DeclareLinks, props.DeclareLinksOn and props.LinkedNames are new, with the props.ChatLinkPrefix and props.ForgeLinkPrefix constants. A link descriptor needs no ConstName or ConstPackage: 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 ai and doctor read 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.
  • recoverChatProviders recovers the declared names exactly on a from-scratch manifest rebuild; a chat.go without a declaration still recovers the widest module reading.