|
| 1 | +# YAML Authoring Conventions |
| 2 | + |
| 3 | +Common YAML pitfalls in gitops resource files — shapes that look right but break at runtime, plus parser-specific scalar coercion gotchas. Read before authoring or auditing any `.yml` / `.md` frontmatter resource. |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +## YAML 1.1 Boolean Coercion |
| 8 | + |
| 9 | +YAML 1.1 (and many widely-used parsers) interprets these unquoted scalars as **booleans**, not the string values they appear to be: |
| 10 | + |
| 11 | +| Unquoted | YAML 1.1 type | YAML 1.2 type | |
| 12 | +|---|---|---| |
| 13 | +| `yes` / `no` | boolean true / false | string | |
| 14 | +| `on` / `off` | boolean true / false | string | |
| 15 | +| `true` / `false` | boolean | boolean | |
| 16 | +| `null` / `~` | null | null | |
| 17 | + |
| 18 | +This matters for any field whose runtime contract expects a specific string sentinel. A common pattern in resource APIs is `field: 'off' | { provider: ..., ... }` — the string `'off'` disables a feature, and a populated object configures it. If a YAML 1.1 parser silently coerces unquoted `off` to boolean `false`, the request body sends a boolean where the schema expects a string, and class-validator (or the equivalent) rejects it. |
| 19 | + |
| 20 | +```yaml |
| 21 | +field: off # ⚠️ becomes boolean `false` in YAML 1.1 parsers |
| 22 | +field: 'off' # ✅ stays the string "off" |
| 23 | +field: "off" # ✅ stays the string "off" |
| 24 | +``` |
| 25 | +
|
| 26 | +### Parsers that default to YAML 1.1 |
| 27 | +- Symfony YAML |
| 28 | +- ruamel.yaml (non-strict mode) |
| 29 | +- PyYAML (default — has been YAML 1.1 since the project started) |
| 30 | +- js-yaml v3 (legacy) |
| 31 | +
|
| 32 | +### Parsers that default to YAML 1.2 |
| 33 | +- js-yaml v4+ |
| 34 | +- libyaml-based parsers (most modern Go and Rust YAML libraries) |
| 35 | +
|
| 36 | +The gitops engine here uses js-yaml v4, which is YAML 1.2 — `off` parses as a string. But if your CI/CD pipeline pre-processes resource files (templating, validation, splicing) through a different tool, the round-trip can corrupt scalars before the engine ever sees them. |
| 37 | + |
| 38 | +### Defense |
| 39 | + |
| 40 | +**Default rule:** quote any string scalar that resembles a boolean / null sentinel. |
| 41 | + |
| 42 | +```yaml |
| 43 | +status: 'off' # not: status: off |
| 44 | +flag: 'no' # not: flag: no |
| 45 | +state: 'null' # only quote if 'null' is a literal string value, not actual null |
| 46 | +``` |
| 47 | + |
| 48 | +Better still, **omit the field entirely** when the desired state is "default / disabled / unset" — that is unambiguous regardless of parser: |
| 49 | + |
| 50 | +```yaml |
| 51 | +# Cleanest — relies on schema default |
| 52 | +assistant: |
| 53 | + name: "..." |
| 54 | + model: { ... } |
| 55 | + # (no `field` key — disabled by default) |
| 56 | + |
| 57 | +# Acceptable — explicit-off for audit visibility in diffs |
| 58 | +assistant: |
| 59 | + field: 'off' # always quote |
| 60 | + |
| 61 | +# Avoid |
| 62 | +assistant: |
| 63 | + field: off # ⚠️ parser-dependent |
| 64 | +``` |
| 65 | +
|
| 66 | +--- |
| 67 | +
|
| 68 | +## Whitespace-Only Strings Are Truthy |
| 69 | +
|
| 70 | +Many runtime code paths use truthiness checks (`if (config.field)`) to gate behavior. Empty strings are falsy, but **whitespace-only strings are truthy**: |
| 71 | + |
| 72 | +| Value | JS truthiness | |
| 73 | +|---|---| |
| 74 | +| `""` | falsy | |
| 75 | +| `" "` (single space) | **truthy** | |
| 76 | +| `"\n"` | **truthy** | |
| 77 | +| `null` / `undefined` | falsy | |
| 78 | + |
| 79 | +If you intend "do nothing / silent / disabled," use the empty string `""` or omit the field. A YAML field accidentally written as `field: ` with a trailing space then newline can produce `field: " "` after some parsers — which then triggers behavior the author thought was disabled. |
| 80 | + |
| 81 | +Audit pattern: when grepping for "disabled" config in resource files, search for both empty-string AND whitespace-only forms (`'^field:\s+$'` matches the dangerous case). |
| 82 | + |
| 83 | +--- |
| 84 | + |
| 85 | +## Discriminated Unions: Sentinels Live at the Parent Level |
| 86 | + |
| 87 | +Some fields accept either a sentinel string OR a structured object — e.g. `field: 'off' | { provider: '...', ... }`. The validator's discriminated union does NOT recognize a `provider: 'off'` object as the disable case. Use the top-level scalar. |
| 88 | + |
| 89 | +```yaml |
| 90 | +# ✅ Valid — top-level scalar sentinel |
| 91 | +field: 'off' |
| 92 | +
|
| 93 | +# ❌ Invalid — provider doesn't have an 'off' variant in the union |
| 94 | +field: |
| 95 | + provider: 'off' |
| 96 | +``` |
| 97 | + |
| 98 | +Generalized rule: when a field type is `Sentinel | Object`, the sentinel always lives at the parent level, never as an inner field of the object form. Treating "off" as just another provider is intuitive but wrong — disable is a top-level concept, not a per-provider setting. |
| 99 | + |
| 100 | +--- |
| 101 | + |
| 102 | +## Deprecated-Field Footguns |
| 103 | + |
| 104 | +When a schema migrates from `featureEnabled?: boolean` (deprecated) to `feature: 'off' | Object`, both fields may still be visible in dashboard / API responses simultaneously. The runtime usually short-circuits on the new field — so: |
| 105 | + |
| 106 | +```yaml |
| 107 | +feature: 'off' |
| 108 | +featureEnabled: true # ⚠️ ignored at runtime, but visible in dashboard UI |
| 109 | +``` |
| 110 | + |
| 111 | +Customer confusion is common because both fields appear in dashboard UI but only one drives runtime behavior. When authoring resource files, drop the deprecated field entirely if the schema offers a direct replacement. |
| 112 | + |
| 113 | +If you find yourself auditing a resource where the new field and the deprecated field disagree, the new field always wins — but fix the file to use only the new field so future readers don't have to know that. |
| 114 | + |
| 115 | +--- |
| 116 | + |
| 117 | +## Multi-Line Strings: `|` vs `|+` vs `|-` |
| 118 | + |
| 119 | +Three block-scalar indicators with different newline-trimming behavior: |
| 120 | + |
| 121 | +| Indicator | Trailing newlines | |
| 122 | +|---|---| |
| 123 | +| `\|` (clip, default) | Single `\n` preserved | |
| 124 | +| `\|-` (strip) | All trailing newlines removed | |
| 125 | +| `\|+` (keep) | All trailing newlines preserved | |
| 126 | + |
| 127 | +For prompt-body content that is consumed as a string (system prompts, tool descriptions), prefer `|-` if the consumer trims whitespace anyway, and `|+` if you want intentional padding (e.g., a trailing blank line before user content is appended). |
| 128 | + |
| 129 | +```yaml |
| 130 | +# Trailing-newline-sensitive — use the explicit form |
| 131 | +description: |- |
| 132 | + Hang up the call when the driver explicitly declines. |
| 133 | +
|
| 134 | +# Padding-intentional — use keep |
| 135 | +template: |+ |
| 136 | + Here is the transcript: |
| 137 | +
|
| 138 | + {{transcript}} |
| 139 | +
|
| 140 | +``` |
| 141 | + |
| 142 | +Mixing `|` and `|-` inconsistently across resources makes the rendered output hard to diff visually. Pick one convention per file or per field family. |
| 143 | + |
| 144 | +--- |
| 145 | + |
| 146 | +## Anchors and Aliases: Engine Support Varies |
| 147 | + |
| 148 | +YAML supports `&anchor` and `*alias` for DRY-ing repeated structures. **Many gitops engines do NOT round-trip anchors faithfully** — they parse them, but on save they expand the alias inline. This means: |
| 149 | + |
| 150 | +- A resource file you wrote with anchors will get rewritten with the anchors expanded after the next push. |
| 151 | +- The diff after `npm run pull` will look enormous because every alias becomes its full expansion. |
| 152 | +- Reviewers reading the rewritten file lose the "this section is intentionally a copy of X" signal. |
| 153 | + |
| 154 | +**Recommendation:** avoid anchors/aliases in gitops resource YAML even if your local parser supports them. Duplicate the content explicitly. The maintenance cost of duplication is real but visible; the maintenance cost of silently-expanding anchors is invisible until someone tries to update both copies and only finds one. |
| 155 | + |
| 156 | +--- |
| 157 | + |
| 158 | +## Quoting in Frontmatter Markdown Files |
| 159 | + |
| 160 | +Assistant resource files use YAML frontmatter (`---` fenced) followed by a Markdown prompt body. The frontmatter follows all the rules above, but with one extra gotcha: **the `---` close-fence must be on its own line, immediately followed by a newline**. A common mistake: |
| 161 | + |
| 162 | +```markdown |
| 163 | +--- |
| 164 | +name: My Assistant |
| 165 | +model: |
| 166 | + provider: openai |
| 167 | +---# Role ← BROKEN: fence not isolated |
| 168 | +``` |
| 169 | + |
| 170 | +Most parsers will treat this as a malformed frontmatter and fail to extract the YAML. Always: |
| 171 | + |
| 172 | +```markdown |
| 173 | +--- |
| 174 | +name: My Assistant |
| 175 | +model: |
| 176 | + provider: openai |
| 177 | +--- |
| 178 | +
|
| 179 | +# Role |
| 180 | +``` |
| 181 | + |
| 182 | +The blank line after `---` is conventional; the strict requirement is just that `---` ends the line cleanly. But humans grep for `\n---\n\n# ` patterns when looking for prompt boundaries, so the blank line aids readability. |
| 183 | + |
| 184 | +--- |
| 185 | + |
| 186 | +## Cross-references |
| 187 | + |
| 188 | +- `docs/learnings/assistants.md` — assistant-specific frontmatter authoring |
| 189 | +- `docs/learnings/tools.md` — tool YAML conventions (function descriptions, message blocks) |
| 190 | +- `docs/learnings/squads.md` — squad YAML conventions (member overrides, handoff destinations) |
0 commit comments