fix(mutate): compile-time exhaustiveness guard on render_artifact_yaml (#634)#661
Merged
Merged
Conversation
#634) `render_artifact_yaml` is a hand-rolled allowlist emitter — the failure mode REQ-203/#476 caught before is a model field silently dropped on write. Today that same shape is latent for three fields the emitter never touches: - `Artifact.fields_per_variant` (variant overlays, #255) - `Link.external` (structured cross-org, #543 / #288) - `Provenance.federation` (supplier-pull metadata, #288) They're not user-reachable through the write paths yet — every `add`/batch/MCP entry point constructs a fresh `Artifact` with those hard-coded empty — but they become live data-loss the moment any add path learns to set them, or `append_artifact_to_file` is reused to rewrite a loaded artifact. This patch adds a destructuring `let Artifact { .. }` / `let Link { .. }` / `let Provenance { .. }` at each emission site naming every current field. Adding a field to any of those structs now fails to compile the emitter, forcing an explicit decision — either serialize the new field, or bind it to `_field` with a one-line comment naming the write path that would trigger it. The three currently-latent fields are bound that way as documentation of the intentional-drop state (no add path populates them). Three companion regression tests lock the current latent-drop behavior. Each carries an explicit `if this test now fails, flip the assertion and emit the block` note — so when a future write path starts populating one of these three fields, the failure is loud in this test file, not silent in a downstream compliance report. Guard + tests together turn the entire class of silent-field-drop bug into a decision the compiler and CI make you write down before it ships. Not touched: `rivet sql UPDATE`, `rivet modify`, link add/remove — all go through `yaml_edit`'s in-place field-preserving path, not this allowlist. `rivet validate` on this repo remains PASS (unchanged warning count). `cargo test -p rivet-core --lib mutate::` — 25 passed. `cargo clippy -p rivet-core --lib -- -D warnings` clean. `cargo fmt --check` clean. Fixes: REQ-203 Verifies: REQ-203 Refs: #634, #476, #255, #543, #288 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jj8RPdbnK66ZeYgRuCxAdW
📐 Rivet artifact deltaNo artifact changes in this PR. Code-only changes (renderer, CLI wiring, tests) don't touch the artifact graph. |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #634.
What
render_artifact_yamlis a hand-rolled allowlist emitter — the REQ-203/#476 failure mode caught before is a model field silently dropped on write. Today that same shape is latent for three fields the emitter never touches:Artifact.fields_per_variant(fields-per-variant, #255)model.rs:359Link.external(ExternalLinkTarget: org/contract/doc-id/last-synced/sha256/anchor, #543/#288)model.rs:69target:onlyProvenance.federation(FederationProvenance, cross-org, #288)model.rs:237They're not user-reachable through the write paths yet — every
add/ batch / MCP entry point constructs a freshArtifactwith those three hard-coded empty, andappend_artifact_to_fileis never used to re-serialise a loaded artifact — so this is latent, not active data loss. It becomes live the moment any add-path learns to populate them, or anyone reusesappend_artifact_to_fileto rewrite a loaded artifact.Direction picked
Option (b) from the issue body — compile-time exhaustiveness guard, not serde-based emission. This preserves the hand-rolled formatting / field ordering the on-disk YAML depends on (the diff-friendliness that's why
render_artifact_yamlis hand-rolled in the first place), while making the entire class of silent-field-drop bug into a compile error the next time it could happen.At each of the three emission sites the patch adds a destructuring guard that names every current struct field:
Same shape on
Link(inside the link loop) andProvenance(inside the provenance block). Do NOT collapse the destructure into..— that defeats the whole point.The three currently-latent fields are bound to
_fieldwith a one-line comment naming the write path that would need to populate them; when a future PR adds population, the compiler doesn't stop it, but the review conversation does — the_fieldis right there next to the missing emission.Companion regression tests
Three new tests in
rivet-core/src/mutate.rs::testslock the current latent-drop behavior. Each carries an explicit "if this test now fails, flip the assertion and emit the block" note — so when a future write path starts populating one of these three fields, the failure surfaces loudly in this file, not silently in a downstream compliance report:render_artifact_yaml_drops_fields_per_variant_when_populated_latent_gap_634render_artifact_yaml_drops_link_external_when_populated_latent_gap_634render_artifact_yaml_drops_provenance_federation_when_populated_latent_gap_634Guard + tests together: the compiler enforces "you cannot silently add a new field that doesn't get emitted", and the tests enforce "you cannot silently start populating an existing latent-drop without updating the emitter".
Acceptance criteria (from #634 triage comment)
Artifactwith all three currently-dropped fields populated round-tripsArtifacton a scratch branch — the destructure fails to compile witherror[E0027]: pattern does not mention field \new_field`. Documented in this PR body rather than a test file since compile-fail tests aren't wired up inrivet-core`.cargo test -p rivet-core --lib mutate::— 25 passed.rivet validateon the rivet repo: PASS (same 497 warnings asmain, byte-identical for any artifact that doesn't use the three latent fields — nothing does).cargo clippy -p rivet-core --lib -- -D warningsclean.cargo fmt --checkclean.rivet sql UPDATE,rivet modify, link add/remove viayaml_edit) remain field-preservingmutate::render_artifact_yaml. Their existing field-preservation tests continue to guard them.Fixes: REQ-203,Verifies: REQ-203,Refs: #634, #476, #255, #543, #288Blog process pre-check
Re-fetched
https://pulseengine.eu/blog/at the start of this run. No posts on team workflow / branching / PR flow / testing conventions beyond the spec-driven-development post (oracle-gated closure viarivet validate+ fresh-session verify) that's already reflected here. Nothing overrides this PR.Test plan
cargo test -p rivet-core --lib mutate::— 25 tests pass (including the three new latent-gap regressions)cargo clippy -p rivet-core --lib -- -D warnings— cleancargo fmt --all --check— cleanrivet validateon this repo — PASS, 497 warnings (unchanged frommain)cargo build -p rivet-coreon a scratch branch with an extra field added toArtifact/Link/Provenance— expecterror[E0027]: pattern does not mention field \…`` from the destructure (proves the guard fires)Generated by Claude Code — issue-triage agent run 2026-07-02.
Generated by Claude Code