Skip to content

fix(mutate): compile-time exhaustiveness guard on render_artifact_yaml (#634)#661

Merged
avrabe merged 1 commit into
mainfrom
fix/issue-634-emission-exhaustiveness-guard
Jul 3, 2026
Merged

fix(mutate): compile-time exhaustiveness guard on render_artifact_yaml (#634)#661
avrabe merged 1 commit into
mainfrom
fix/issue-634-emission-exhaustiveness-guard

Conversation

@avrabe

@avrabe avrabe commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Closes #634.

What

render_artifact_yaml is 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:

Field Model Emitter today
Artifact.fields_per_variant (fields-per-variant, #255) model.rs:359 not emitted
Link.external (ExternalLinkTarget: org/contract/doc-id/last-synced/sha256/anchor, #543/#288) model.rs:69 flat target: only
Provenance.federation (FederationProvenance, cross-org, #288) model.rs:237 not emitted

They're not user-reachable through the write paths yet — every add / batch / MCP entry point constructs a fresh Artifact with those three hard-coded empty, and append_artifact_to_file is 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 reuses append_artifact_to_file to 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_yaml is 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:

let Artifact {
    id, artifact_type, title, description, status, release, tags, links, fields,
    // No add/batch/MCP entry point populates variant overlays today (#255,
    // #634). If a write path starts writing them, emit here as a
    // `fields-per-variant:` block to avoid the REQ-203/#476 silent-drop.
    fields_per_variant: _fields_per_variant,
    provenance,
    // `source_file` is `#[serde(skip)]` — the on-disk location the artifact
    // was loaded from is intentionally never round-tripped.
    source_file: _source_file,
} = artifact;

Same shape on Link (inside the link loop) and Provenance (inside the provenance block). Do NOT collapse the destructure into .. — that defeats the whole point.

The three currently-latent fields are bound to _field with 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 _field is right there next to the missing emission.

Companion regression tests

Three new tests in rivet-core/src/mutate.rs::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 surfaces loudly in this file, not silently in a downstream compliance report:

  • render_artifact_yaml_drops_fields_per_variant_when_populated_latent_gap_634
  • render_artifact_yaml_drops_link_external_when_populated_latent_gap_634
  • render_artifact_yaml_drops_provenance_federation_when_populated_latent_gap_634

Guard + 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)

# Criterion How this PR satisfies it
1 Pick the direction Option (b) — compile-time exhaustiveness guard. Rationale in the PR body above.
2 Round-trip fixture asserts an Artifact with all three currently-dropped fields populated round-trips Deliberately inverted: option (b) does not fix the current drops; the three companion tests lock the known-latent current shape (the round-trip drops the fields on purpose today). Flipping to a real round-trip is the follow-up when a write path actually populates them.
3 Regression test proven to fail without the fix Reverting the destructure guards has no runtime effect (they're pure compile-time), so the "fails without the fix" shape is a compile error, not a test failure. Demonstrated by adding a synthetic new field to Artifact on a scratch branch — the destructure fails to compile with error[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 in rivet-core`.
4 No regression on existing artifacts cargo test -p rivet-core --lib mutate:: — 25 passed. rivet validate on the rivet repo: PASS (same 497 warnings as main, byte-identical for any artifact that doesn't use the three latent fields — nothing does). cargo clippy -p rivet-core --lib -- -D warnings clean. cargo fmt --check clean.
5 Cross-checked safe paths (rivet sql UPDATE, rivet modify, link add/remove via yaml_edit) remain field-preserving Untouched — this PR only edits mutate::render_artifact_yaml. Their existing field-preservation tests continue to guard them.
6 Commit trailers: Fixes: REQ-203, Verifies: REQ-203, Refs: #634, #476, #255, #543, #288 Present on the commit.
7 Explicitly out of scope: wiring add/batch/MCP entry points to actually populate the three fields Deferred; this PR closes the emission-guard gap only. When a write path adds population, the compile-time destructure + the companion latent-gap tests together force the emission decision to be made visibly.

Blog 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 via rivet 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 — clean
  • cargo fmt --all --check — clean
  • rivet validate on this repo — PASS, 497 warnings (unchanged from main)
  • CI Format / Clippy / Test gates green on this branch
  • Downstream: run cargo build -p rivet-core on a scratch branch with an extra field added to Artifact / Link / Provenance — expect error[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

#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
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

📐 Rivet artifact delta

No artifact changes in this PR. Code-only changes (renderer, CLI wiring, tests) don't touch the artifact graph.

@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@avrabe avrabe merged commit 632eb8a into main Jul 3, 2026
27 of 29 checks passed
@avrabe avrabe deleted the fix/issue-634-emission-exhaustiveness-guard branch July 3, 2026 05:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

render_artifact_yaml allowlist drops fields_per_variant / Link.external / provenance.federation (latent data loss)

2 participants