Derived fields
A DSN you assemble yourself, after Get(), is built once:
cfg := w.Get()
dsn := fmt.Sprintf("postgres://%s:%s@%s/app", cfg.User, cfg.Pass.Reveal(), cfg.Host)
pool := connect(dsn)
Three weeks later the password rotates. w.Get().Pass returns the new one immediately, but dsn still holds the old one, because nothing ever asked it to rebuild. Your pool keeps using a credential that is about to be revoked, and w.Status() reports every field healthy, because mamori reconciled everything it knows about and stopped where you took over.
The fix
WithDerive moves the assembly inside mamori, so it reruns on every update:
w, err := mamori.Watch[Config](ctx,
mamori.WithDerive(func(c *Config) error {
c.DSN = secret.NewString((&url.URL{
Scheme: "postgres",
User: url.UserPassword(c.User, c.Pass.Reveal()),
Host: c.Host,
Path: "/app",
}).String())
return nil
}, "DSN"),
)
The hook runs on every Load and every reconciled update, after fields are decoded and before validation. DSN is rebuilt from whatever User, Pass, and Host hold right now.
Use net/url rather than fmt.Sprintf. A rotated password containing @ or / silently breaks a Sprintf-built DSN, parsing into the wrong host or path. net/url escapes it correctly.
Declare what it writes
"DSN", the trailing argument, is the field path the hook writes. mamori cannot read your function to see what it assigns, so the hook says so itself. Declaring it is what makes the field visible to ev.Changed() and Status(), and it is the ordinary way to call this option.
React to it
mamori.OnChange(func(ev mamori.Change[Config]) {
if ev.Changed("DSN") {
pool.Rotate(ev.New.DSN.Reveal())
}
})
ev.Changed("DSN") is true whenever the rebuilt value differs from the one it replaced, whatever input caused it. Trigger on DSN itself; there is no need to also watch Pass.
Worth knowing
- Use
secret.Stringorsecret.Bytesfor anything embedding a credential, so the rebuilt value stays redacted infmt, JSON, andslog. mamori never reports a derived field’s value, so this protects your own logs.mamori vetflags a hook that reveals a secret and writes the plaintext into a plain field, the same mistake it already flags on asource:tag. - An error rejects the whole update.
Get()keeps returning the last valid config andOnErrorreceives a*DeriveError. A config whose derived fields did not build is not one to serve, and half-applying it would leave a rotated credential beside a value still derived from the old one. - Multiple hooks run in registration order, each keeping its own declared writes. A field derived from another derived field works for free, since the second hook sees the first one’s output.
- Do not call back into the same
Watcher. A hook runs on the reconciler goroutine, soPinandRefreshfrom inside returnErrReentrantCall,PinCurrentreturns0, andUnpindoes nothing.Get()is fine, and aPinfrom another goroutine is unaffected.
Where it shows up
mamori status and mamori doctor give a derived field its own DERIVED column:
$ mamori status --endpoint unix:///run/app-admin.sock
PATH SCHEME REF VERSION STALE LAST_KIND LAST_ERROR SENSITIVE DERIVED
Host env env://DB_HOST 3 false - - false false
Pass aws-sm aws-sm://prod/db-pw 3 false - - true false
DSN a3f9c1e2 false - - true true
The blank SCHEME and REF say mamori maintains this field but never fetched it from anywhere. VERSION is a content hash of the value your hook produced, so it moves the moment the credential rotates, even for a secret nested inside a struct.
explain, schema, and diff list your derived fields too, with the ref and scheme columns empty, exactly as status shows above. policy leaves them out: there is no backend behind a derived field, so there is no permission to grant.
Write the path as a literal string, as "DSN" is above. If you build it at runtime, from a variable or a slice, these commands cannot see it, and they say so rather than quietly listing fewer fields than you have.
None of them show the value. For that, ask a running process with status. To fail a build on a hook that errors, run the Doctor preflight in CI.
See also
- Rotation safety -
WithDeriveandPreApplytogether, so a rebuilt value is also a proven one. - Options reference -
WithDerivealongside every other option and its default. - Watch for changes -
Change,Changed, andOnChangein full. - Observability - the full
Report/FieldStatusshape a derived field joins.