feat(value): value keyword accepts a fallback list (children block) - resolve the first source that exists #176

Closed
opened 2026-06-26 17:23:27 +00:00 by coilysiren · 2 comments
Owner

Goal

Let the value keyword 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 is name + 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:

value {
    env FORGEJO_API_TOKEN
    ssm "/forgejo/coilyco-ops/api-token"
}
  • Each child node: name = provider, first arg = address. (env is the existing built-in env-var provider; file/literal are also built in; ssm is consumer-registered.)
  • Sources are tried top to bottom; the first that resolves (no error, non-empty) wins.
  • The current inline form 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 single ValueSource{Provider, Address} (struct at guardfile.go ~13). No children handled today.
  • ValueSource is held singularly in Auth.Value, Auth.Params[].Value, BaseURLValue (guardfile.go), and the execverb env-injection Env[].Value. Guardfile.Providers() (guardfile.go ~305) enumerates the distinct providers for codegen wiring.
  • Resolution goes through valuesource.Resolve(ctx, providers, provider, address) (pkg/valuesource/valuesource.go), which calls one Provider func(ctx, address) (string, error). Builtins: env (errors if unset), file, literal.

What to build

  1. Type. Introduce type ValueChain []ValueSource (ordered). Change the fields that hold a value source from ValueSource to ValueChain (Auth.Value, QueryAuthParam.Value, BaseURLValue, execverb Env[].Value). Keep an IsZero() (empty chain) helper. (Lower-churn alternative if the field churn is too broad for one pass: keep ValueSource as the head and add Fallbacks []ValueSource to it. Pick one and be consistent.)

  2. Parse. Extend parseValueSource to return a ValueChain:

    • Inline form (value <provider> "<addr>", two args, no children) -> a 1-element chain (unchanged behavior).
    • Children form (value { ... }, no inline args) -> one ValueSource per child node (child.Name() = provider, child.Arguments()[0] = address), in document order.
    • Fail closed: an empty 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.
  3. 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 (resolveEnv in execverb; the specverb auth/base-url resolution) from Resolve to ResolveFirst over the chain.

  4. 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" } resolves FOO when set, else SSM, else a combined error.
  • Inline value ssm "/path" still works identically (1-element chain).
  • Empty value {}, addressless child, or inline-args-plus-children all fail at parse, fail-closed.
  • Providers() reports every provider across all chains.
  • Values are never logged on success or on the all-failed error.
  • Tests cover: first-present wins, fall-through on error, fall-through on empty, all-fail combined error, back-compat inline, parse errors.

Files

http/guardfile/guardfile.go (ValueSource/new ValueChain, parseValueSource, Providers, the holding structs), pkg/valuesource/valuesource.go (ResolveFirst), cli/execverb/execverb.go (resolveEnv call site) + cli/execverb/guardfile.go if env-injection value parsing lives there, the specverb auth/base-url resolve sites, and docs/specverb-policy.md (where ValueSource is documented) + the value-keyword doc page. Add tests in the matching _test.go files.

Freshness note

If this adds/renames a keyword or a doc section, regenerate the auto keyword index and re-stamp its decay-class=derived freshness marker per #175 (and keep godoc-current.txt current). The value children-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 canonical main, update docs/FEATURES.md.

## Goal Let the `value` keyword 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 is `name + 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: ```kdl value { env FORGEJO_API_TOKEN ssm "/forgejo/coilyco-ops/api-token" } ``` - Each child node: **name = provider**, **first arg = address**. (`env` is the existing built-in env-var provider; `file`/`literal` are also built in; `ssm` is consumer-registered.) - Sources are tried **top to bottom**; the first that resolves (no error, non-empty) wins. - The current inline form `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 single `ValueSource{Provider, Address}` (struct at guardfile.go ~13). No children handled today. - `ValueSource` is held singularly in `Auth.Value`, `Auth.Params[].Value`, `BaseURLValue` (guardfile.go), and the execverb env-injection `Env[].Value`. `Guardfile.Providers()` (guardfile.go ~305) enumerates the distinct providers for codegen wiring. - Resolution goes through `valuesource.Resolve(ctx, providers, provider, address)` (`pkg/valuesource/valuesource.go`), which calls one `Provider func(ctx, address) (string, error)`. Builtins: `env` (errors if unset), `file`, `literal`. ## What to build 1. **Type.** Introduce `type ValueChain []ValueSource` (ordered). Change the fields that hold a value source from `ValueSource` to `ValueChain` (`Auth.Value`, `QueryAuthParam.Value`, `BaseURLValue`, execverb `Env[].Value`). Keep an `IsZero()` (empty chain) helper. *(Lower-churn alternative if the field churn is too broad for one pass: keep `ValueSource` as the head and add `Fallbacks []ValueSource` to it. Pick one and be consistent.)* 2. **Parse.** Extend `parseValueSource` to return a `ValueChain`: - **Inline form** (`value <provider> "<addr>"`, two args, no children) -> a 1-element chain (unchanged behavior). - **Children form** (`value { ... }`, no inline args) -> one `ValueSource` per child node (`child.Name()` = provider, `child.Arguments()[0]` = address), in document order. - **Fail closed:** an empty `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. 3. **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 (`resolveEnv` in execverb; the specverb auth/base-url resolution) from `Resolve` to `ResolveFirst` over the chain. 4. **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" }` resolves `FOO` when set, else SSM, else a combined error. - Inline `value ssm "/path"` still works identically (1-element chain). - Empty `value {}`, addressless child, or inline-args-plus-children all fail at parse, fail-closed. - `Providers()` reports every provider across all chains. - Values are never logged on success or on the all-failed error. - Tests cover: first-present wins, fall-through on error, fall-through on empty, all-fail combined error, back-compat inline, parse errors. ## Files `http/guardfile/guardfile.go` (`ValueSource`/new `ValueChain`, `parseValueSource`, `Providers`, the holding structs), `pkg/valuesource/valuesource.go` (`ResolveFirst`), `cli/execverb/execverb.go` (`resolveEnv` call site) + `cli/execverb/guardfile.go` if env-injection value parsing lives there, the specverb auth/base-url resolve sites, and `docs/specverb-policy.md` (where `ValueSource` is documented) + the `value`-keyword doc page. Add tests in the matching `_test.go` files. ## Freshness note If this adds/renames a keyword or a doc section, regenerate the auto keyword index and re-stamp its `decay-class=derived` freshness marker per #175 (and keep godoc-current.txt current). The `value` children-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 canonical `main`, update `docs/FEATURES.md`.
Member

🔒 Reserved by ward agent --driver claude — container engineer-claude-cli-guard-176 on host kais-macbook-pro-2.local is carrying this issue (reserved 2026-07-01T04:45:19Z). Concurrent ward agent runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); --force overrides.

— Claude (she/her), via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --driver claude` — container `engineer-claude-cli-guard-176` on host `kais-macbook-pro-2.local` is carrying this issue (reserved 2026-07-01T04:45:19Z). Concurrent `ward agent` runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); `--force` overrides. <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Author
Owner

WARD-OUTCOME: done - value now accepts a children-block fallback list (ValueChain), resolved first-hit via valuesource.ResolveFirst; landed on main.

Honest retrospective:

The shape of this one was friendlier than I expected. The value grammar already had exactly one parse function and three call sites (header-token, bearer, query-param) plus base-url, so the type change from ValueSource to ValueChain fanned out predictably. The one thing that genuinely fought back was distinguishing an empty value { } block from the plain inline value env FOO: both surface as zero KDL children/args. I had to reach into kdl-go's ChildrenInline() (whose ok return 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 guardfile package on purpose - it stays "parse, never evaluate" - so ResolveFirst lives in the shared valuesource layer with its own neutral Source type, 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.md with pointers, which is probably where it belonged anyway. Merge with main was clean despite a concurrent specgen->kdlspecs rename 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.

WARD-OUTCOME: done - `value` now accepts a children-block fallback list (`ValueChain`), resolved first-hit via `valuesource.ResolveFirst`; landed on main. Honest retrospective: The shape of this one was friendlier than I expected. The `value` grammar already had exactly one parse function and three call sites (header-token, bearer, query-param) plus base-url, so the type change from `ValueSource` to `ValueChain` fanned out predictably. The one thing that genuinely fought back was distinguishing an *empty* `value { }` block from the plain inline `value env FOO`: both surface as zero KDL children/args. I had to reach into kdl-go's `ChildrenInline()` (whose `ok` return 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 `guardfile` package on purpose - it stays "parse, never evaluate" - so `ResolveFirst` lives in the shared `valuesource` layer with its own neutral `Source` type, 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.md` with pointers, which is probably where it belonged anyway. Merge with main was clean despite a concurrent `specgen`->`kdlspecs` rename 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.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
coilyco-flight-deck/cli-guard#176
No description provided.