yamldoc: preserve anchors on Set, and make Bytes() validation resolve aliases¶
- Authors
- Matt Cockayne, Claude Fable 5 (AI drafting assistant)
- Date
- 2026-07-23
- Status
- DRAFT — pending review
- Related
- architectural review (HIGH yamldoc finding), config module extraction (the config family this module underpins)
1. Problem¶
yamldoc's core promise is refuse-rather-than-corrupt: mutate a document surgically, and never emit bytes that damage it. Anchored values break both halves of that promise at once.
go/yamldoc/set.go:39-56—setExistinghandles the scalar-in-place case viaassignScalar, otherwise replacesentry.Valuewholesale withbuildValueNode(...). An*ast.AnchorNodematches neitherassignScalar's type switch nor any anchor-aware path, so the anchor node — and with it the&namedefinition — is discarded along with the old value.go/yamldoc/yamldoc.go:119-131—Bytes()'s safety net only re-parses the emitted output (parser.ParseBytes). goccy accepts an alias to an undefined anchor at parse time, so the damage sails through.
Verified on:
Set("defaults", map[string]any{"retries": 9}) succeeds, and Bytes()
returns without error — but the &d anchor is gone, *d now dangles (any
consumer decoding the output fails, or silently changes meaning under lenient
decoders), and the replacement is mis-indented because blockAnchor aligned
against the old &d token position. Silent corruption of a valid document is
exactly the failure class this package exists to prevent, and the
otherwise-strong adversarial suite has no anchor-mutation cases.
Related LOW (same root): Set("defaults.retries", 5) — editing inside an
anchored mapping — fails with a misleading "defaults" is not a mapping
(ErrNotFound) because asMappingNode (path.go:80-93) does not unwrap
*ast.AnchorNode.
2. Proposed change¶
Two alternatives for the write path:
A. Preserve the anchor (recommended). Teach setExisting (and the path
resolver) to see through *ast.AnchorNode: when the target value is anchored,
build the replacement node, re-attach it under the original anchor (keeping
&name and its token position for blockAnchor alignment), and mutate
scalars in place through the anchor wrapper so comments/quoting survive as
they do today. Aliases elsewhere keep resolving to the new value —
which is standard YAML anchor semantics and almost always what a config-editing
caller means. Also fix asMappingNode to unwrap AnchorNode, making
Set("defaults.retries", 5) work naturally.
B. Refuse with ErrUnsupported. Detect an anchored target (or an
anchored ancestor) in Set/Remove and return
fmt.Errorf("%w: cannot replace anchored value %q", ErrUnsupported, path).
Safe and small, but it makes the library unusable on legitimately anchored
config files and pushes the problem to every caller.
Recommendation: A, with B's refusal retained as the fallback for the cases
A cannot yet do safely (e.g. Remove of an anchor definition whose aliases
remain — that genuinely must refuse, naming the dangling alias).
Independently of A/B, harden the safety net. Bytes() validation becomes
a full decode, not just a parse: after parser.ParseBytes succeeds, run
yaml.Unmarshal (alias-resolving) over the output and wrap any failure in
ErrEmitInvalid. This catches dangling aliases from any future mutation bug,
not just this one — the safety net finally covers semantic damage. Bounded
alias expansion must be configured (goccy's alias-depth/count limits) so the
validator itself is not a billion-laughs vector.
3. Scope & release plan¶
- Module:
gitlab.com/phpboyscout/go/yamldoc(currently v0.1.3).fix(set):+fix(bytes):(+fix(path):for the anchor-unwrap) → patch/minor release via releaser-pleaser. - Downstream propagation: yamldoc sits under the whole config family —
go/configpinsyamldoc v0.1.3in its go.mod and the YAML write path (Apply, comment-preserving in-place writes) flows through it. Pickup is a config-core dependency bump (its own releaser-pleaser release), which the config-format satellites and GTB then pick up via routine Renovate bumps. No API change is expected for either alternative A or the hardenedBytes(); alternative B would surface newErrUnsupportedcases to configApplycallers and must be called out in the config release notes. - Docs: yamldoc's design docs gain an "anchors & aliases" section stating the supported semantics (replace-under-anchor, refusal cases); config docs note the behaviour for anchored user config files.
4. Acceptance criteria¶
- Anchor-mutation adversarial cases added to the adversarial suite:
- Replace an anchored scalar and an anchored mapping → output keeps
&nameon the new value, aliases resolve to the new value, indentation correct. - In-place scalar edit through an anchor (
Set("defaults.retries", 5)) preserves the anchor, the sibling comments, and quoting style. Removeof an anchor definition with a live alias elsewhere → refuses withErrUnsupported, message naming the alias path; document unchanged.- Alias target edits (
Seton a path whose value is an*ast.AliasNode) → defined behaviour: refuse (an alias edit is ambiguous) with a clear error. - Safety-net regression: a crafted mutation that produces a dangling alias
(e.g. via the pre-fix bug reproduced in a white-box test) is rejected by
Bytes()withErrEmitInvalid— proving the decode-level net catches what the parse-level net missed. - Billion-laughs guard: a document with deeply nested alias expansion is
either handled within bounds or refused —
Bytes()never hangs or balloons memory. - Full existing suite (round-trip, comment-ownership, hardening) green;
go/config's own write-path tests green against the bumped yamldoc before the config release is cut.
5. Open questions¶
- Anchor semantics under alternative A: replacing an anchored value changes
what every alias resolves to. Is that ever surprising enough to warrant an
opt-in (
SetOption) instead of the default? Recommendation: default-on — it is standard YAML semantics — but confirm before release. - Should the
Bytes()full-decode validator be applied toParseinput as well, so a document that arrives with a dangling alias is reported immediately rather than at emit time? Leaning yes (report viaUnsupported()), but it widens the change.