ADR-0003: Canonical change model for JSON / YAML / HCL-tfvars¶
| Status | Accepted (partial: EntryRef / matcher domains per ADR-0017 §5; P2-E5) |
| Date | 2026-07-21 |
| Deciders | Konrad Heimel |
| Context links | ADR-0002 policy frontends |
Context¶
Policies must reason about what changed semantically, not about diff hunks. The tool targets
repos holding JSON, YAML, and HCL/tfvars. A YAML key reordering or comment edit is a no-op; a
nested field flipping from 3 to 1 is a bounded-change question; a removed map entry is a
deletion. Raw git diff cannot express any of this.
Options¶
| Option | Pros | Cons |
|---|---|---|
| Parse base & head per file into a generic value tree; structural diff → field-level ChangeSet (add/modify/delete with JSON-Pointer-style paths, old/new values) | format-agnostic policies; trivially serializable as PolicyInput; no-op edits (comments, ordering) disappear | needs per-format parser adapters; HCL expressions (non-literal) need a defined representation |
| Line-diff + per-rule parsing | no upfront model | every rule reinvents parsing; non-deterministic corner cases |
| Format-specific models (one per file type) | precise per format | policies stop being portable across repos/formats |
Decision (proposed)¶
Generic value-tree diff → canonical ChangeSet. Format adapters (JSON, YAML via mapping to
the same node type, HCL/tfvars via hashicorp/hcl) parse into one value tree; a structural
differ emits Change{path, kind: add|modify|delete, old, new} entries plus file-level events
(file added/deleted/renamed). Unparseable or unknown files surface as explicit
opaque-change events that policies must handle (default: require human review — fail safe).
HCL caveat to spec precisely: tfvars are literal-only (easy); full HCL with expressions is represented but expression evaluation is out of scope for v1.
Deletions and renames are first-class¶
- Entry deletion (a map key / resource removed within a file) emits
deletewith the full old value — so "topic is being deleted" is a plain match, and packs can attachblockorchallengeeffects to it (never silently folded into a modify). - File events (
added | deleted | renamed | modified | opaque) are tracked alongside field changes; file renames use git rename detection and preserve identity (from/to), so a rename is not reported as delete-everything + add-everything. - Resource renames (entry key changes but the value is identical/similar) are detected
heuristically within a file: a
delete+addpair with equal (or near-equal) values is folded intorename {oldPath, newPath}. Policies decide what a rename means (often:challenge— renames of live resources are usually destructive downstream). The heuristic's similarity threshold and its failure mode (fall back to the raw delete+add pair, which is stricter) must be golden-tested exhaustively — spec'd in Phase 3.
Consequences¶
- Policies are portable across formats and repos; the ChangeSet schema joins PolicyInput as a frozen public contract.
- Adding a format (TOML, properties, …) = one adapter + conformance fixtures, no policy changes.
Counterpoints considered¶
- "JSON-merge-patch or JSONPatch already exist." — JSONPatch is a good serialization
candidate for
Change, but alone it lacks old-values, file events, and opaque fallbacks; we may still adopt its path syntax (RFC 6901).
Amendment (2026-07-21, adversarial review F12)¶
Fold-to-rename is opt-in per class (classes[].renames: detect|raw, default raw),
because the similarity threshold is otherwise an attacker-tunable downgrade knob (craft the
paired add to sit just above the threshold and convert a block-able delete into a
resolvable challenge). Additionally, a rename can never be treated less strictly than
the delete of the same class: the engine applies the stricter of the class's delete/rename
effects. Golden tests must include adversarial near-threshold pairs, not only correctness
pairs.
Amendment 2 (2026-07-21, security review A-05 / review P2-11)¶
- Input limits: format adapters enforce max file size/count, nesting depth, and YAML
anchor/alias expansion caps (billion-laughs); symlinks and path-traversal names are
rejected; parse runs under a deadline. Any breach yields
opaque-change→ fail-safe REVIEW (never a crash, never a skip). - Source positions are first-class: every
Changecarries file + line/column spans for old and new values (adapters must preserve positions at parse time — retrofitting them later means rewriting the parsers). This is what makes forge inline/line-anchored comments possible;Finding.Pathsalone cannot anchor a thread to a diff line.