|
| 1 | +--- |
| 2 | +applyTo: "internal/projectconfig/**/*.go,internal/fingerprint/**/*.go" |
| 3 | +description: "Rules for interacting with project config structs and the fingerprint substrate. IMPORTANT: read before adding or changing any field on a config struct, or touching projectV1 / golden vectors / fingerprint tags." |
| 4 | +--- |
| 5 | + |
| 6 | +# Config structs & fingerprinting |
| 7 | + |
| 8 | +Editing config structs (`internal/projectconfig/`) and the fingerprint substrate |
| 9 | +(`internal/fingerprint/`) has several **silent traps** - the compiler stays green |
| 10 | +while a field quietly vanishes or a frozen byte moves. Follow these rules. |
| 11 | + |
| 12 | +## `ComponentConfig.WithAbsolutePaths` hand-lists its fields |
| 13 | + |
| 14 | +`(*ComponentConfig).WithAbsolutePaths` in `internal/projectconfig/component.go` |
| 15 | +builds its result with an **explicit field-by-field struct literal**, not |
| 16 | +`deep.MustCopy`. It is called for *every* component at load time (via |
| 17 | +`mergeComponents` in `loader.go`), so any field missing from that literal is |
| 18 | +**silently zeroed on load** - it parses from TOML fine, then disappears before |
| 19 | +resolution, fingerprinting, or rendering. No error, no panic, just lost data. |
| 20 | + |
| 21 | +> It is hand-written (unlike every sibling `WithAbsolutePaths`, which uses |
| 22 | +> `deep.MustCopy`) for one reason: it must **alias** the cyclic, project-wide |
| 23 | +> `SourceConfigFile` pointer rather than deep-copy it. Do not "simplify" it to |
| 24 | +> a blanket `deep.MustCopy` - that reintroduces the cycle. |
| 25 | +
|
| 26 | +**Every new field on `ComponentConfig` MUST be added to this literal.** |
| 27 | + |
| 28 | +## Adding a field to a config struct |
| 29 | + |
| 30 | +1. **Add the field** with a `toml:` key and a **mandatory** `fingerprint:` tag. |
| 31 | + An absent tag fails `TestAllFingerprintedFieldsHaveDecision` |
| 32 | + (`internal/projectconfig/fingerprint_test.go`). |
| 33 | + - Build input → `fingerprint:"v1..*"` (measured). |
| 34 | + - Not a build input → `fingerprint:"-"` **and** register the field in |
| 35 | + `expectedExclusions` in that same test file. If the field's **parent struct is |
| 36 | + already excluded** (e.g. `ComponentBuildConfig.Failure`), do *not* tag the |
| 37 | + inner struct's fields - that subtree is pruned from both the decision walk and |
| 38 | + `projectV1`, so its fields are neither policed nor measured. |
| 39 | +2. **If the field is on `ComponentConfig` directly → add it to |
| 40 | + `WithAbsolutePaths`.** Skip this and it never loads. |
| 41 | +3. **If you added a new struct *type* to the fingerprinted graph**, add it to |
| 42 | + `FingerprintedStructTypes()` in `internal/fingerprint/project.go` - the single |
| 43 | + source of truth that **both** the mandatory-tag decision test |
| 44 | + (`internal/projectconfig/fingerprint_test.go`) and the emission probe |
| 45 | + (`measuredLeafEmitKeys` in `internal/fingerprint/project_internal_test.go`) walk. |
| 46 | + Add it there and nowhere else. |
| 47 | +4. **If measured, emit it in `projectV1`** (`internal/fingerprint/project.go`, |
| 48 | + hand-written as of v1) inside the correct sub-projector, under its **frozen `toml` |
| 49 | + emit-key** (or an explicit `key=` in the tag - never the Go field name). |
| 50 | + - Removing a measured field won't compile (good); a Go rename is byte-neutral |
| 51 | + **only if** the `toml` key / `key=` is unchanged. |
| 52 | +5. **Set the field non-zero in `emissionProbeConfig()`** |
| 53 | + (`internal/fingerprint/project_internal_test.go`). The emission probe FAILS |
| 54 | + until you do - that is the guard against "measured but forgot to emit". |
| 55 | +6. **Append a `<toml-key>-set` golden vector** via |
| 56 | + `go test ./internal/fingerprint -run TestGoldenVectors -update-golden`. |
| 57 | +7. **Regenerate the schema in all three places** if the field is user-facing (has |
| 58 | + a `toml` key / `jsonschema` tag): `mage docs` updates |
| 59 | + `schemas/azldev.schema.json` **and** the CLI reference docs, while |
| 60 | + **`mage scenarioUpdate`** updates the `config generate-schema` scenario |
| 61 | + snapshots (`TestSnapshots*config_generate-schema*`), which embed the schema |
| 62 | + again. Running only `mage docs` leaves those snapshots red. Also hand-update the |
| 63 | + relevant `docs/user/reference/config/` page (e.g. `components.md`). |
| 64 | + |
| 65 | +**Build-meaningful zero value?** A field whose *zero value* is itself a deliberate |
| 66 | +build setting (e.g. a `bool` where `false` carries meaning) is tagged |
| 67 | +`fingerprint:"!v1..*"` (always-emit) and wired with `builder.emitAlways(...)`, **not** |
| 68 | +`builder.emit(...)` - the omit-if-zero `emit` would drop it at its zero value and lose |
| 69 | +that signal. It MUST get a golden vector **at its zero value**; the |
| 70 | +differ-from-`minimal` guard in `golden_internal_test.go` then proves it actually emits. |
| 71 | + |
| 72 | +## Golden vectors: append-only, and the frozen/growing split |
| 73 | + |
| 74 | +- `maximalConfig()` is **FROZEN** - it is a golden-corpus config at the time of |
| 75 | + the initial implementation of that fingerprint algorithm, so its digest is |
| 76 | + pinned. **Never add a new field to `maximalConfig()`** (it would move the frozen |
| 77 | + `maximal` digest, a hard CI failure). Field growth goes in |
| 78 | + `emissionProbeConfig()` only. |
| 79 | +- Existing golden digests are **append-only**: `-update-golden` may add a new |
| 80 | + `(name -> digest)` entry, but a CI check FATALs any commit that *modifies* an |
| 81 | + existing digest. A moved frozen byte is a hard failure, not something a reviewer |
| 82 | + must eyeball. |
| 83 | +- A new **omit-if-zero measured field that no config sets is drift-neutral**: it |
| 84 | + emits no bytes, so no existing lock should move and no component version bump |
| 85 | + is needed. Only a component that actually sets the field drifts. |
| 86 | +- **One isolation vector per new field is enough.** Add a single `<toml-key>-set` |
| 87 | + vector that sets *only* the new field (like `single-overlay` / |
| 88 | + `defines-empty-value`). Projection slots are length-prefixed and independent, so |
| 89 | + a field's encoding is fully pinned in isolation - do **not** add a "maximal + new |
| 90 | + field" or "previous + new field" combinatorial vector (and you cannot touch the |
| 91 | + frozen `maximal`). Add *extra* vectors only for genuine internal discrimination |
| 92 | + cases the field introduces (empty-vs-absent, map-key membership, etc.). |
| 93 | + |
| 94 | +## Other config struct / fingerprinting pitfalls |
| 95 | + |
| 96 | +- **Strict parsing.** The loader runs `DisallowUnknownFields()` |
| 97 | + (`internal/projectconfig/loader.go`). Renaming or removing a `toml` key breaks |
| 98 | + every existing on-disk config that still uses it. Load-time config migration is |
| 99 | + deferred (lazy schema migration RFC PR E); until it exists, a `toml`-key rename |
| 100 | + needs a coordinated, version-pinned rollout, not a bare rename. |
| 101 | + (`fingerprint:"...,key=<old>"` keeps the *fingerprint* byte-neutral, but does |
| 102 | + **not** keep old TOML parseable.) |
| 103 | +- **`omitempty` + go-toml/v2 (`v2.3.1`).** Emptiness is decided by reflecting the |
| 104 | + **exported** fields *before* any `TextMarshaler` runs. A struct that marshals via |
| 105 | + `TextMarshaler` but holds its real value in an **unexported** field is treated as |
| 106 | + empty and **dropped even when set**. Keep round-tripped state in an exported field |
| 107 | + or a plain string. |
| 108 | +- **`MergeUpdatesFrom` uses mergo with `WithOverride` + `WithAppendSlice`.** Slices |
| 109 | + *append* across merge layers, and the same intent can resolve to `nil` *or* `[]` |
| 110 | + depending on merge order. That is exactly why the projection's omit predicate |
| 111 | + treats a nil-or-empty scalar slice as a zero value (both collapse to no emitted |
| 112 | + bytes) at the hash boundary - do not rely on raw slice identity for a |
| 113 | + fingerprinted field. |
| 114 | +- **A nested-struct field surfaces asymmetrically in output.** `encoding/json` |
| 115 | + `omitempty` does **not** drop an empty struct, so `component list -O json` shows |
| 116 | + `"foo":{}` on every component, while `config dump` (go-toml) *does* honor struct |
| 117 | + `omitempty` and omits it. Neither is fingerprint drift (the projector omits on |
| 118 | + projected emptiness) - don't mistake the JSON `{}` for churn. |
| 119 | + |
| 120 | +## Standing invariants (do not break) |
| 121 | + |
| 122 | +- **No reader recomputes a historical fingerprint.** Synthetic history and |
| 123 | + historic-overlay application read **stored** lock strings only |
| 124 | + (`synthistory.go`); only `computeCurrentFingerprint` (current tree) and the |
| 125 | + `update` re-stamp call `ComputeIdentity`. |
| 126 | +- **Stored fingerprint is the atomic `v1:sha256:<digest>` token**; lock *format* |
| 127 | + `version` stays `1` (the content version lives in the token prefix). A pre-`v1` |
| 128 | + (prefix-less) token reconciles by **force-rehash** - the existing string |
| 129 | + inequality re-stamps it; do not make that compare version-aware before the replay |
| 130 | + registry (RFC PR C) exists. |
| 131 | +- **`projectV1` output is frozen.** A new byte encoding is a new content version, |
| 132 | + never an edit to `projectV1`'s output. The golden vectors enforce this. The only |
| 133 | + allowed changes to `projectV1` are purely additive. |
0 commit comments