|
| 1 | +# Proposal: CUE Value Injection |
| 2 | + |
| 3 | +Roger Peppe |
| 4 | + |
| 5 | +Date: 2026-03-05 |
| 6 | + |
| 7 | +Discussion at https://github.com/cue-lang/cue/discussions/4294. |
| 8 | + |
| 9 | +## Abstract |
| 10 | + |
| 11 | +We propose an injection mechanism that allows Go code to supply CUE |
| 12 | +values to specific locations in CUE source, identified by `@inject` |
| 13 | +attributes. This provides a controlled, explicit way for host programs |
| 14 | +to parameterize CUE configurations with values that cannot be expressed |
| 15 | +in CUE alone, such as user-provided functions and validators. |
| 16 | + |
| 17 | +## Background |
| 18 | + |
| 19 | +Go programs that use CUE often need to supply values that originate |
| 20 | +outside the CUE configuration. Today this is done primarily through |
| 21 | +`Value.FillPath`, which works well for simple cases but has |
| 22 | +limitations. The calling code must know the exact path where a value |
| 23 | +should be placed, which creates tight coupling between Go code and CUE |
| 24 | +structure. When a CUE package wants to declare that it expects an |
| 25 | +externally-provided value, there is no standard way to express this |
| 26 | +intent. |
| 27 | + |
| 28 | +The companion proposal for [user-provided functions and |
| 29 | +validators](./4293-user-functions-and-validators.md) makes injection |
| 30 | +particularly important. Functions and validators cannot be expressed in |
| 31 | +CUE syntax -- they must come from Go. A CUE package that uses a |
| 32 | +custom function needs a way to declare the dependency and have it |
| 33 | +fulfilled by the host program. |
| 34 | + |
| 35 | +CUE already has the `@extern` attribute mechanism for integrating with |
| 36 | +external code. The injection mechanism builds on this existing |
| 37 | +infrastructure, using `@extern(inject)` to opt in at the file level |
| 38 | +and `@inject` attributes to mark individual fields. |
| 39 | + |
| 40 | +## Proposal |
| 41 | + |
| 42 | +### The Injector type |
| 43 | + |
| 44 | +We add a new type to the `cue/cuecontext` package: |
| 45 | + |
| 46 | +```go |
| 47 | +type Injector struct { ... } |
| 48 | + |
| 49 | +func NewInjector() *Injector |
| 50 | +``` |
| 51 | + |
| 52 | +An `Injector` maintains a registry of named CUE values and an |
| 53 | +authorization function that controls which injections are permitted. |
| 54 | + |
| 55 | +### Registering values |
| 56 | + |
| 57 | +```go |
| 58 | +func (j *Injector) Register(name string, v cue.Value) |
| 59 | +``` |
| 60 | + |
| 61 | +`Register` associates a CUE value with a name. If a value is already |
| 62 | +registered under that name, the new value is unified with the existing |
| 63 | +one. This allows multiple registrations to accumulate constraints |
| 64 | +incrementally: |
| 65 | + |
| 66 | +```go |
| 67 | +j := cuecontext.NewInjector() |
| 68 | +j.Register("example.com/config", ctx.CompileString(`{port: int}`)) |
| 69 | +j.Register("example.com/config", ctx.CompileString(`{port: 8080}`)) |
| 70 | +// The registered value is now {port: 8080} |
| 71 | +``` |
| 72 | + |
| 73 | +### Authorization |
| 74 | + |
| 75 | +Injection is a potentially security-sensitive operation: it allows Go |
| 76 | +code to unify values into CUE configurations. The `Injector` requires |
| 77 | +explicit authorization before any injection can take effect. |
| 78 | + |
| 79 | +```go |
| 80 | +func (j *Injector) Allow(f func(inst *build.Instance, name string) error) |
| 81 | +func (j *Injector) AllowAll() |
| 82 | +``` |
| 83 | + |
| 84 | +`Allow` sets a function that is called for each injection site. It |
| 85 | +receives the build instance (identifying the CUE package) and the |
| 86 | +injection name. Returning a non-nil error prevents the injection. |
| 87 | +`AllowAll` is a convenience that permits all injections. |
| 88 | + |
| 89 | +If no authorization function is set, all injections fail with an |
| 90 | +error. This fail-closed default ensures that injections cannot happen |
| 91 | +accidentally. |
| 92 | + |
| 93 | +### Connecting to a Context |
| 94 | + |
| 95 | +```go |
| 96 | +func Inject(j *Injector) Option |
| 97 | +``` |
| 98 | + |
| 99 | +`Inject` returns a `cuecontext.Option` that registers the injector |
| 100 | +with a CUE context. The injector is activated as an interpreter for |
| 101 | +`@extern(inject)` attributes. |
| 102 | + |
| 103 | +```go |
| 104 | +j := cuecontext.NewInjector() |
| 105 | +j.AllowAll() |
| 106 | +ctx := cuecontext.New(cuecontext.Inject(j)) |
| 107 | +``` |
| 108 | + |
| 109 | +### CUE-side syntax |
| 110 | + |
| 111 | +On the CUE side, a file opts into injection with a file-level |
| 112 | +`@extern(inject)` attribute. Individual fields are marked with |
| 113 | +`@inject(name="...")`: |
| 114 | + |
| 115 | +```cue |
| 116 | +@extern(inject) |
| 117 | +
|
| 118 | +package myapp |
| 119 | +
|
| 120 | +// validate is a user-provided validator, injected from Go. |
| 121 | +validate: _ @inject(name="example.com/myapp/validate") |
| 122 | +``` |
| 123 | + |
| 124 | +When the injector has a value registered for the given name, that value |
| 125 | +is unified with the field's CUE-side value. When no value is registered, |
| 126 | +the field retains its original value; a default value could be provided |
| 127 | +but that's unlikely to be very useful unless there's another |
| 128 | +suitable function to use. |
| 129 | +
|
| 130 | +Without the file-level `@extern(inject)` attribute, `@inject` |
| 131 | +attributes are silently ignored. This ensures that injection is an |
| 132 | +explicit opt-in at the file level. |
| 133 | +
|
| 134 | +### Combining with user-provided functions |
| 135 | +
|
| 136 | +The primary motivating use case is injecting functions and validators |
| 137 | +from the companion [user-provided functions |
| 138 | +proposal](./4293-user-functions-and-validators.md): |
| 139 | +
|
| 140 | +```go |
| 141 | +j := cuecontext.NewInjector() |
| 142 | +j.AllowAll() |
| 143 | +ctx := cuecontext.New(cuecontext.Inject(j)) |
| 144 | +
|
| 145 | +j.Register("example.com/myapp/validate", |
| 146 | + cue.ValidatorFunc(func(s string) error { |
| 147 | + if !isValidHostname(s) { |
| 148 | + return fmt.Errorf("invalid hostname: %q", s) |
| 149 | + } |
| 150 | + return nil |
| 151 | + })) |
| 152 | +``` |
| 153 | +
|
| 154 | +```cue |
| 155 | +@extern(inject) |
| 156 | +
|
| 157 | +package myapp |
| 158 | +
|
| 159 | +#validHost: _ @inject(name="example.com/myapp/validate") |
| 160 | +
|
| 161 | +servers: [...{ |
| 162 | + host: #validHost & string |
| 163 | +}] |
| 164 | +``` |
| 165 | +
|
| 166 | +This pattern cleanly separates concerns: the CUE code declares that it |
| 167 | +expects an external validator and specifies where it should be applied, |
| 168 | +while the Go code provides the implementation. |
| 169 | +
|
| 170 | +## Rationale |
| 171 | +
|
| 172 | +### Why not just use FillPath? |
| 173 | +
|
| 174 | +`FillPath` requires the Go code to know the exact CUE path where a |
| 175 | +value should be placed. This works for simple top-level values but |
| 176 | +becomes fragile as CUE structure evolves. The injection mechanism |
| 177 | +inverts the dependency: the CUE code declares what it needs, and the Go |
| 178 | +code provides it by name. |
| 179 | +
|
| 180 | +More importantly, `FillPath` operates on already-compiled CUE values. |
| 181 | +Injection happens during compilation, which means injected values |
| 182 | +participate fully in CUE's evaluation from the start. This is |
| 183 | +particularly important for functions, which need to be available when |
| 184 | +expressions referencing them are first evaluated. |
| 185 | + |
| 186 | +### Why require @extern(inject)? |
| 187 | + |
| 188 | +The file-level opt-in serves two purposes. First, it makes injection |
| 189 | +visible at a glance: a reader can see immediately that a file depends |
| 190 | +on external values. Second, it integrates with CUE's existing |
| 191 | +`@extern` infrastructure, which is designed precisely for this kind of |
| 192 | +external integration. |
| 193 | +
|
| 194 | +### Why an authorization function? |
| 195 | +
|
| 196 | +Injection allows Go code to unify values into CUE configurations. |
| 197 | +In environments where CUE configurations come from untrusted sources |
| 198 | +(for example, user-submitted schemas), unrestricted injection could be |
| 199 | +a vector for unexpected behavior. The authorization function gives the |
| 200 | +host program fine-grained control over what can be injected and where. |
| 201 | +
|
| 202 | +The fail-closed default (requiring explicit `Allow` or `AllowAll`) |
| 203 | +ensures that a forgotten authorization step results in clear errors |
| 204 | +rather than silent security holes. |
| 205 | +
|
| 206 | +### Why use names rather than paths? |
| 207 | +
|
| 208 | +Names decouple the injection site from its position in the CUE value |
| 209 | +tree. A name like `"example.com/myapp/validate"` is stable even if the |
| 210 | +CUE code is refactored. It also allows the same value to be injected |
| 211 | +at multiple sites by using the same name. |
| 212 | +
|
| 213 | +### What about Go code in CUE modules? |
| 214 | +
|
| 215 | +This proposal is just a starting point. It allows some custom-written |
| 216 | +Go code to inject values (and functions in particular) into a CUE evaluation |
| 217 | +but it remains agnostic as to how that actually happens, and it |
| 218 | +does not propose a mechanism for allowing implementation code |
| 219 | +to be co-located with a CUE package that specifies an `@inject` tag. |
| 220 | +That will be the subject of a future proposal. |
| 221 | +
|
| 222 | +## Compatibility |
| 223 | +
|
| 224 | +This proposal adds new public API to the `cue/cuecontext` package. It |
| 225 | +does not change existing CUE evaluation semantics. The `@inject` |
| 226 | +attribute is new; existing code that does not use `@extern(inject)` is |
| 227 | +entirely unaffected. |
| 228 | +
|
| 229 | +The `@extern(inject)` attribute uses the existing `@extern` |
| 230 | +infrastructure, which is designed to be extensible with new interpreter |
| 231 | +kinds. |
| 232 | +
|
| 233 | +## Implementation |
| 234 | +
|
| 235 | +The implementation adds: |
| 236 | +
|
| 237 | +- `cue/cuecontext/inject.go`: The `Injector` type and its methods, |
| 238 | + plus the `injectInterpreter` and `injectCompiler` types that |
| 239 | + integrate with `runtime.Interpreter`. |
| 240 | +- The injector compiles `@inject` attributes by looking up the |
| 241 | + registered value and returning it as an `adt.Expr`, or returning |
| 242 | + `&adt.Top{}` when no value is registered. |
| 243 | +
|
| 244 | +A proof-of-concept implementation exists. |
| 245 | +
|
| 246 | +## Open issues |
| 247 | +
|
| 248 | +- **Name structure.** The current design uses opaque strings as |
| 249 | + injection names. It may be worth imposing structure on these names, |
| 250 | + for example requiring them to follow a URL-like or module-path-like |
| 251 | + convention. This would make authorization policies easier to express |
| 252 | + (allowing all injections from a particular domain) and reduce the |
| 253 | + risk of name collisions. |
| 254 | +
|
| 255 | +- **Versioning.** If injection names follow a structured convention, |
| 256 | + it may be useful to include version information, allowing a CUE |
| 257 | + package to declare that it requires a particular version of an |
| 258 | + injected interface. |
| 259 | +
|
| 260 | +- **Discoverability.** There is currently no way for a Go program to |
| 261 | + discover what injections a CUE package expects without inspecting |
| 262 | + the source. A future mechanism for declaring injection requirements |
| 263 | + in module metadata could address this. |
0 commit comments