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)
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.