feat(value): value keyword accepts a fallback list (children block) - resolve the first source that exists #176
Labels
No labels
burndown-2026-06
sunday-sprint
coherence-core
consult
headless
interactive
P0
P1
P2
P3
P4
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
coilyco-flight-deck/cli-guard#176
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Goal
Let the
valuekeyword take an ordered fallback list of sources and resolve the first one that exists, so a config value can come from a fast local source with a durable backstop. Concrete motivating case (the forgejo token): prefer an env var, fall back to SSM.KDL has no native list - use a children block (this is the syntax answer)
KDL has no array/list literal (
[ ... ]is not KDL). A KDL node isname + positional args + properties + an optional children block { }. The guardfile dialect already uses children blocks everywhere (n.Children().Nodes). So the fallback list is expressed as a children block, each child one source:envis the existing built-in env-var provider;file/literalare also built in;ssmis consumer-registered.)value ssm "/forgejo/coilyco-ops/api-token"stays valid and is just a one-element chain - fully backward compatible.Current state (verified)
parseValueSource(http/guardfile/guardfile.go~288) requires exactly two args (provider,address) and returns a singleValueSource{Provider, Address}(struct at guardfile.go ~13). No children handled today.ValueSourceis held singularly inAuth.Value,Auth.Params[].Value,BaseURLValue(guardfile.go), and the execverb env-injectionEnv[].Value.Guardfile.Providers()(guardfile.go ~305) enumerates the distinct providers for codegen wiring.valuesource.Resolve(ctx, providers, provider, address)(pkg/valuesource/valuesource.go), which calls oneProvider func(ctx, address) (string, error). Builtins:env(errors if unset),file,literal.What to build
Type. Introduce
type ValueChain []ValueSource(ordered). Change the fields that hold a value source fromValueSourcetoValueChain(Auth.Value,QueryAuthParam.Value,BaseURLValue, execverbEnv[].Value). Keep anIsZero()(empty chain) helper. (Lower-churn alternative if the field churn is too broad for one pass: keepValueSourceas the head and addFallbacks []ValueSourceto it. Pick one and be consistent.)Parse. Extend
parseValueSourceto return aValueChain:value <provider> "<addr>", two args, no children) -> a 1-element chain (unchanged behavior).value { ... }, no inline args) -> oneValueSourceper child node (child.Name()= provider,child.Arguments()[0]= address), in document order.value {}(no children, no inline args), a child with no address, or mixing inline args and a children block is a parse error with a clear message.Resolve. Add
valuesource.ResolveFirst(ctx, providers, sources []ValueSource) (string, error)(or accept the chain type): try each in order; success = err == nil AND value != "" (an unset/empty source falls through to the next). If every source fails, return a combined error naming each attempt and its failure - never log the value. Repoint the resolve call sites (resolveEnvin execverb; the specverb auth/base-url resolution) fromResolvetoResolveFirstover the chain.Providers(). Iterate every element of every chain so codegen still wires exactly the providers in use.
Semantics decision to state explicitly
"Exists" = the provider returns no error and a non-empty string. An env var set to empty, or an SSM param that resolves empty, is treated as absent and falls through. (Call this out in the docs; if a maintainer ever wants "err==nil counts even if empty," that is a deliberate different choice.)
Acceptance
value { env FOO \n ssm "/path" }resolvesFOOwhen set, else SSM, else a combined error.value ssm "/path"still works identically (1-element chain).value {}, addressless child, or inline-args-plus-children all fail at parse, fail-closed.Providers()reports every provider across all chains.Files
http/guardfile/guardfile.go(ValueSource/newValueChain,parseValueSource,Providers, the holding structs),pkg/valuesource/valuesource.go(ResolveFirst),cli/execverb/execverb.go(resolveEnvcall site) +cli/execverb/guardfile.goif env-injection value parsing lives there, the specverb auth/base-url resolve sites, anddocs/specverb-policy.md(whereValueSourceis documented) + thevalue-keyword doc page. Add tests in the matching_test.gofiles.Freshness note
If this adds/renames a keyword or a doc section, regenerate the auto keyword index and re-stamp its
decay-class=derivedfreshness marker per #175 (and keep godoc-current.txt current). Thevaluechildren-block form is a new grammar shape the keyword index should surface.When done
Run the gate (
go build/vet/test, golangci-lint, godoc-current), commit, push to canonicalmain, updatedocs/FEATURES.md.🔒 Reserved by
ward agent --driver claude— containerengineer-claude-cli-guard-176on hostkais-macbook-pro-2.localis carrying this issue (reserved 2026-07-01T04:45:19Z). Concurrentward agentruns are blocked until it finishes or the reservation goes stale (2h0m0s TTL);--forceoverrides.— Claude (she/her), via
ward agentWARD-OUTCOME: done -
valuenow accepts a children-block fallback list (ValueChain), resolved first-hit viavaluesource.ResolveFirst; landed on main.Honest retrospective:
The shape of this one was friendlier than I expected. The
valuegrammar already had exactly one parse function and three call sites (header-token, bearer, query-param) plus base-url, so the type change fromValueSourcetoValueChainfanned out predictably. The one thing that genuinely fought back was distinguishing an emptyvalue { }block from the plain inlinevalue env FOO: both surface as zero KDL children/args. I had to reach into kdl-go'sChildrenInline()(whoseokreturn flags a block was written at all) and prove it with a throwaway probe test before trusting it. Glad I checked rather than assumed.Kept resolution out of the
guardfilepackage on purpose - it stays "parse, never evaluate" - soResolveFirstlives in the sharedvaluesourcelayer with its own neutralSourcetype, and specverb does the tiny conversion. The security-relevant bit (an all-failed chain returns a combined error naming providers/addresses but never a resolved value, even when a provider hands back a value alongside its error) has a dedicated leak test.The only real friction was mechanical: the repo's 2-line comment cap and 4000-char doc cap bit me twice, so the fallback-list prose ended up in its own
docs/specverb-value-chain.mdwith pointers, which is probably where it belonged anyway. Merge with main was clean despite a concurrentspecgen->kdlspecsrename landing underneath me - no overlap with my files beyond regenerable godoc.Confidence is high: parse, resolution, dedup, display, dry-run redaction, and end-to-end fallback are all covered, full suite green (bar the pre-existing sandbox/seccomp env failures, untouched by this change). No follow-ups I'd file - the feature is self-contained and backward-compatible.