Your config struct
One struct per service is the whole configuration story. A registration
names the struct through an accessor, and the field values the factory
sets are the defaults. The core fills the same struct in place — there
is never a second config instance — and then calls Configured() as a
notification.
fw.NewRegistration(ID, func() *Mytool { return &Mytool{cfg: Config{Version: 1, Port: 8080}} }, func(m *Mytool) *Config { return &m.cfg }, // the accessor binds the struct ).Alias("mytool").Register()
Every schema is versioned#
A config struct must declare a version, from its very first release:
Version uint32 `json:"version"`
The requirement is a top-level uint32 field tagged json:"version" —
and it may carry only that tag; conf, env, usage and dump on it
are all errors. Version 0 is rejected: versions start at 1.
This is the price of admission for schemas that can evolve without breaking deployed config files. What that buys you is on Config migrations.
The exhaustive example#
Every supported type and annotation in one struct, for an applet whose
alias is mytool:
type Config struct { Version uint32 `json:"version"` // required, tag-only, starts at 1 // strings and numbers — every int/uint width and both floats work Name string `json:"name" conf:"name,n" usage:"who this instance is"` Port uint16 `json:"port" conf:"port,p" usage:"listen port"` Ratio float64 `json:"ratio" conf:"ratio" usage:"mix ratio 0..1"` Retries int8 `json:"retries" conf:"retries" usage:"attempts before giving up"` // bool: bare flag sets true, --verbose=false unsets Verbose bool `json:"verbose" conf:"verbose,v" usage:"chatty diagnostics"` // duration: unit suffix required in EVERY source (5s, 90m, 1h30m) Timeout time.Duration `json:"timeout" conf:"timeout" usage:"request timeout"` // slices of any supported scalar Tags []string `json:"tags" conf:"tag" usage:"labels, repeatable"` Weights []float64 `json:"weights" conf:"weight" usage:"weights, repeatable"` // env tag only: settable via environment and file, never on argv — // where secrets belong Token string `json:"token" env:"MYTOOL_TOKEN" usage:"deploy-time secret"` // env:"-" with conf: argument-only, the derived env var suppressed DryRun bool `json:"dryRun" conf:"dry-run" env:"-" usage:"plan, don't act"` // no conf, no env: file-only Comment string `json:"comment"` // dump:"-": run-scoped — excluded from --write-config output and // REFUSED from config files (loud startup error if a file sets it) Unsafe bool `json:"unsafe" conf:"unsafe" dump:"-" usage:"skip safety checks"` // positionals: declared, validated, and shown in --help Pattern string `json:"pattern" pos:"0" usage:"the search pattern"` Files []string `json:"files" pos:"rest" usage:"files to search"` // nested struct: file and environment, but no conf tags below the top TLS TLSConfig `json:"tls"` } type TLSConfig struct { Cert string `json:"cert"` Key string `json:"key"` }
Supported types: string, bool, all int/uint widths, float32,
float64, time.Duration, and slices of these. The machinery operates
on kinds, so named types over a supported kind — type Level string,
type Port uint16 — work as-is. Unsupported kinds — maps, custom
non-scalar types — and embedded fields are registration errors, reported
at startup together with every other violation.
What each tag means#
| Tag | Meaning |
|---|---|
json:"key" | required on every exported field — the config-file key, and the source of derived env segments |
conf:"long[,short]" | the one operator name: grants --long value, --long=value, -s value, and feeds the derived env var |
env:"NAME" | explicit env var, verbatim; env:"-" opts out entirely; omitted → derived |
usage:"…" | help text for --help, rendered through Tr() |
pos:"0" / pos:"rest" | bind a positional argument instead of a flag |
dump:"-" | run-scoped: excluded from generated configs, refused from config files |
Long names are lowercase, at least two characters, letter-first (letters/digits/dashes, no trailing dash); short forms are a single ASCII letter or digit. A duplicate long name anywhere in the closure is a startup error; short names are first-come, first-served, and the loser simply keeps its long form.
The
arg:tag is gone.conf:replaces it, and it is not a silent rename: a leftoverarg:tag is a startup violation telling you to rename it. One tag now names the flag and derives the env var, so there is a single operator name per field instead of two that could drift apart.
Positional arguments#
Positionals are part of the contract rather than a bag of leftover
strings. pos:"N" binds an indexed scalar — required unless you write
pos:"N,optional" — and pos:"rest" collects the tail into a slice:
$ mytool 'error' access.log app.log # Pattern="error", Files=[access.log app.log]
The rules are checked at startup: indices contiguous from zero, no
duplicates, at most one rest, and no required index after an optional
one. A missing required positional and an unclaimed surplus are both
loud errors, and --help renders the shape — <pattern>, <mode> (optional), <files...>.
Only applets may declare positionals. A plain service with a
pos:tag is a registration error — it never reaches startup. Flags and env vars are namespaced per service, so any number of services can own their own; the positional tail has no namespace. There is exactly one command line, and it belongs to the applet being run. If two services in a closure could both claim argument 0, the winner would depend on composition order — precisely the class of silent, order-dependent behaviour this release exists to remove.When a service genuinely needs a value that arrives positionally, the applet declares it and hands it over — as a normal injected dependency, in the open.
Positionals are also only bound for the applet actually being run: another applet's declarations lie dormant in the same binary, so a multi-applet tool has one positional contract per applet rather than a merged one.
Being positional implies run-scoped — no env var, refused from config
files. fw.Positionals() is gone; the tail is now typed struct fields
like everything else.
Nested structs#
Nested fields participate in files and the environment, but conf:
tags are not allowed below the top level — the error tells you to
mirror the value into a top-level field yourself. A flag namespace that
nests arbitrarily deep produces names nobody wants to type; the flat
operator surface is deliberate.
Setting values with arguments#
$ mytool --name alice -p 8080 --ratio=0.5 # --long value, --long=value, -s value $ mytool -v # bool: presence means true $ mytool --verbose=false # ...and =false unsets $ mytool -vn alice # bundling: all bools except the last $ mytool --tag a --tag b # slices: repetition appends $ mytool --timeout 1h30m # durations need a unit; '90' is rejected $ mytool --dry-run -- 'error' input.txt # -- ends flags; the rest is positional
Setting values from the environment#
A field with a conf: tag derives ALIAS__LONG — the alias of the
applet being run, two underscores, then the long name with dashes
folded to single underscores:
$ MYTOOL__NAME=alice MYTOOL__PORT=8080 mytool $ MYTOOL__TAG=a,b,c mytool # slices: comma-separated $ MYTOOL__TAG= mytool # empty value = empty slice (the only way from env) $ MYTOOL__TIMEOUT=90m mytool # unit suffix required here too
Note MYTOOL__TAG, singular: derivation uses the conf: long name, never
the field or json name. DryRun has none (env:"-"), and Token uses
its explicit MYTOOL_TOKEN verbatim.
A field without a conf: tag derives from its json path instead,
qualified by the owning service's section:
ALIAS__SECTION__PATH. So TLS.Cert on a service whose section is
filesink, run under applet alias cat, is CAT__FILESINK__TLS__CERT.
When the section and the running alias are the same, that middle segment
is dropped — MYTOOL__TLS__CERT here.
The double underscore is a structural boundary, which is why names can
never contain a run of separators: a--b is rejected everywhere, so a
name cannot forge a boundary.
Setting values in config files#
JSON is the native format — always enabled, nothing to accept. Each service's keys live in a section named after its alias, carrying the schema version; durations are strings (never numbers); slices are arrays:
{ "mytool": { "version": 1, "name": "alice", "port": 8080, "ratio": 0.5, "verbose": true, "timeout": "1h30m", "tags": ["a", "b"], "weights": [0.25, 0.75], "token": "s3cr3t", "comment": "file-only field, happily set here", "tls": { "cert": "/etc/ssl/mytool.pem", "key": "/etc/ssl/mytool.key" } } }
Other formats come from format providers. With configfmt/yaml accepted
into the composition, the same configuration as YAML:
mytool: version: 1 name: alice port: 8080 timeout: 1h30m tags: [a, b] tls: cert: /etc/ssl/mytool.pem key: /etc/ssl/mytool.key
Adding a format is a small job — the
format provider demo teaches the binary Java
.properties files in one page.
Three file-side rules worth repeating: a dump:"-" field and any
positional appearing in a config file are loud startup errors, and so
is any unknown key — misconfiguration never passes silently.
Metadata and value domains#
Tags cover the wire format; Metadata adds a declarative layer on top —
long-form documentation, advisory hints, and enforced value domains:
fw.NewRegistration(ID, newMytool, configOf). Alias("mytool"). Metadata(&fw.Metadata{ Description: "Serves a directory over HTTP.", Fields: map[string]any{ // keyed by Go field name; "TLS.Cert" nests "Name": fw.FieldMetadata[string]{ Allowed: []string{"alice", "bob", "carol"}, Doc: "Instance identity, used in greetings and logs.", }, "Dir": fw.FieldMetadata[string]{ Hint: fw.HintDirectory, // advisory: it names a directory Doc: "Document root served over HTTP.", }, }, }). Register()
Docis the long-form field description;usage:stays the one-liner.- A non-empty
Alloweddeclares a closed value domain, enforced by the framework on every source: arguments, environment, config files, positionals and even migrated output are all checked, and a value outside the set is a loud startup violation naming the source and the allowed values. Slice fields are checked per element, and a registered default outside its own declared domain is caught too. - A
Hintdeclares what a value denotes, for tooling only. - Metadata is validated with everything else, all at once: unknown field
keys, an
Allowedelement type that doesn't match the field, field metadata on a config-less service — collected startup violations.
Enforced vs advisory#
Allowed and Hint sit in different trust classes, deliberately:
Allowed | Hint | |
|---|---|---|
| Meaning | the value must be one of these | the value denotes this kind of thing |
| Framework | enforces on every source | never enforces — data for tooling |
| Consumed by | validation + completion | completion, documentation |
The available hints are HintFile ("names a file, existing or to be
created"), HintDirectory, and HintServiceID ("names a service
registered in this binary" — completable from the Introspector).
A hint cannot be enforced, and that is the point: --config new.yaml
legitimately names a file that does not exist yet, and an unknown
service id gets a better error from resolution than a value check could
give. Hints are the advisory sibling of Allowed, in the same trust
class as Doc.
The core dogfoods the mechanism: its own --config declares
HintFile, and --disable/--enable declare HintServiceID.
Two more collected violations here: a hint on a non-string field (paths
are strings), and a hint combined with a non-empty Allowed on the same
field — a closed enum and "it's a file" contradict each other, so
declare one.
Declare once, get it everywhere: the same declarations feed the
introspection surface (ArgInfo.Allowed, ArgInfo.Hint, Describe)
that completion and documentation services consume — see
Services & injection.
Precedence#
defaults < config files < environment < argumentsMerging is field-by-field. For slices the rule is concrete: the first argument occurrence of a flag replaces whatever files or environment provided; further repetitions append to that.
Where files are searched is covered in
Config discovery & hardening; the --config
override and the rest of the core's surface live in
Core config & arguments.