Skip to content

Tag all Rig-owned public Serde enums - #2281

Open
gold-silver-copper wants to merge 8 commits into
mainfrom
agent/tag-rig-owned-serde-enums
Open

Tag all Rig-owned public Serde enums#2281
gold-silver-copper wants to merge 8 commits into
mainfrom
agent/tag-rig-owned-serde-enums

Conversation

@gold-silver-copper

Copy link
Copy Markdown
Contributor

Summary

  • give every Rig-owned public data-bearing Serde enum an explicit stable discriminator
  • coordinate MultiTurnStreamItem, StreamedAssistantContent, and StreamedUserContent around nested adjacent type/content tagging
  • preserve intentional string-valued unit enums with explicit spellings
  • add exhaustive exact-shape/round-trip coverage, migration documentation, changelogs, and a stale-checked policy wall for provider-owned untagged exceptions

Why

PR #2277 tagged AssistantContent, but several other Rig domain enums still depended on Serde's implicit external representation or variant trial order. In particular, StreamedAssistantContent::Unknown could overlap with known object-shaped variants, allowing provider-native payloads that happened to contain fields such as text to be reclassified by shape.

This change makes semantic identity explicit. Known tags with malformed content fail loudly, while explicitly tagged Unknown payloads remain opaque and value-equal.

Impact

This is an intentional serialization break. Old untagged, externally tagged, and accidental PascalCase forms no longer load. MIGRATING.md documents before/after JSON for every changed public enum and the impact on persisted agent runs, completion responses, stream logs, filters, model-listing errors, output modes, and SQLite metric configuration.

Provider request and response JSON is unchanged. Provider-native wire enums retain their upstream schemas, and all 18 public enum-level untagged provider exceptions are documented in a stale-checked allowlist.

Follow-up to #609.

Validation

  • cargo fmt --all --check
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo test (full workspace)
  • cargo test -p rig-core --lib — 1,245 passed, 3 ignored
  • cargo test -p rig-agent
  • cargo test -p rig-sqlite
  • cargo test -p rig-core --test domain_serde_policy
  • cargo doc --workspace --no-deps
  • OpenAI cassette suite — 103 passed
  • Gemini cassette suite — 127 passed
  • Anthropic cassette suite — 71 passed
  • Bedrock cassette suite — 19 passed
  • no cassette fixture changes

Review

A separate full-diff review covered correctness, serialization boundaries, provider-wire leakage, unsafe edge cases, test completeness, and documentation drift. It found and fixed the policy-wall coverage gap, stale StreamFinal wording, and the legacy MediaType casing example. No confirmed P0/P1 findings remain.

@gold-silver-copper
gold-silver-copper marked this pull request as ready for review August 11, 2026 19:39
…r implicit tagging

Clears the four #2281 review findings (B-1..B-4 in the cassette-suite proposal).

## B-1 — Cohere tool_choice asymmetry, resolved against the live wire

The review reported that `Required`/`None` skip the advertised-tools check that
`Specific` performs, and predicted a remote 400. Rather than assume, both were
probed live. The first probe (command-r-plus-08-2024) returned 200 for both and
looked like a false report — but that model ignores tool_choice entirely. On a
current model the wire is asymmetric:

  command-a-03-2025, tool_choice with no `tools`:
    REQUIRED -> 400 invalid request: tool_choice 'required' can only be
                specified if 'tools' are specified
    NONE     -> 200

So `Required` is guarded (it would otherwise ship
`{"tool_choice":"REQUIRED"}` with no `tools` key, since `tools` carries
`skip_serializing_if = "Vec::is_empty"`), and `None` is deliberately NOT
guarded — "call nothing" is meaningful without a tool list and the provider
honours it. That answers the open question the finding left. A unit test pins
both halves and records the wire evidence; the `Required` half cannot be a
cassette because the request never leaves.

Noted in passing, not fixed here: `command-r-plus` now returns
`404 model 'command-r-plus' was removed on September 15, 2025`, and rig still
exports it plus five siblings. That is issue #2241 / PR #2263's territory.

## B-3 — a wall that guards the drift this PR fixes

`domain_serde_policy` flagged only `#[serde(untagged)]`. Every enum this PR
retagged carried no such attribute — implicit external tagging is a *default*,
not an attribute, and a blacklist cannot see a default. All of them would have
passed the wall unchanged.

Adds the complement: `rig_owned_public_data_bearing_enums_declare_a_representation`
requires every rig-owned public data-bearing Serde enum to declare `tag`,
`tag`+`content`, or an allowlisted `untagged`. Fieldless enums are exempt (they
serialize as plain strings). `src/providers/` is excluded structurally as
provider-owned wire shape, and `rig-bedrock/src/types/converse_output.rs` — 37
enums mirroring the aws-sdk Converse types one-for-one — is exempt by path with
a written justification and a staleness check.

An unparsed enum body is now reported rather than skipped: a silent skip reads
as a pass, which was one of the two ways the original scanner could go blind.

Validated by falsification, per the plan: run against `03643160^` it flags
exactly six — MediaType, ToolChoice, FinishReason, Filter, ToolCallDeltaContent,
ModelListingError — the five the review named plus one it missed. Green on this
tree.

## B-2 / B-4 — MIGRATING.md

Drops `"provider":null` from both columns of the `StreamedUserContent` row:
`ToolResult::provider` carries `skip_serializing_if = "Option::is_none"`, so rig
never emits that key and the documented "exact wire shape" was unmatchable.

Extends the adjacency rule to struct variants, which are most of the retagged
stream enums. Verified against the derive rather than asserted: the enums use
`#[serde(tag = "type", content = "content")]`, and serde's adjacent tagging
nests every variant kind under `content`, while the internally-tagged
`ToolChoice`/`ModelListingError` keep their fields top-level.

rig-core: 1249 lib tests, 4 policy tests, clippy clean.
Review of the wall I added in e9e370a found three ways it could be defeated or
made to cry wolf. All three reproduced before fixing; each now has a regression
test.

1. Prose defeated it silently. `attribute_block_before` deliberately included
   doc-comment lines, and the callers probe that block with
   `contains("tag")`. Any data-bearing enum whose rustdoc contained `tag` as a
   substring — `stage`, `advantage`, `vintage`, or the word "tagged" — exempted
   itself. Verified: an enum documented "Two-stage handshake result" with no
   representation was NOT flagged, which is exactly the regression class the
   wall exists to catch. The block now collects attribute lines only.

2. A blank line between `#[derive(...)]` and `pub enum` — legal Rust — emptied
   the block, failed the `Serialize` probe, and skipped the enum entirely. Blank
   lines and doc comments are now skipped rather than ending the block; only
   real code ends it.

3. Both brace scanners counted braces inside comments. A variant documented
   ``/// Rendered as `{`.`` either pinned `depth` above zero for the rest of the
   enum (every later variant reads as nested, the enum looks fieldless, and it
   is exempted) or ran `enum_body` past the closing brace and reported a
   spurious `UNPARSED BODY` failure — a CI break for a contributor whose only
   sin was a brace in prose. Both now strip line comments first, with a
   string-literal-aware stripper so `"http://x"` is not truncated.

Falsification re-run after the change, since it altered the attribute parsing:
against `03643160^` the wall still flags exactly the same six enums (MediaType,
ToolChoice, FinishReason, Filter, ToolCallDeltaContent, ModelListingError) and
stays green on this tree — so the stricter parsing neither lost coverage nor
started hiding anything.

rig-core: 1249 lib tests, 5 policy tests, clippy clean.
…text

Both scanners in this file were text heuristics, and both were defeated in
review. Patching them again would only trade one substring hazard for the next:
the previous fix stopped `contains("tag")` seeing doc comments, but
`#[serde(rename = "stage")]` is an *attribute*, so it would still have fooled the
probe. The parser is the only thing that agrees with rustc about what an
attribute is.

`syn::parse_file` now drives both checks, per the stated policy:

  1. parse every scanned .rs file
  2. visit every ItemEnum, descending inline `mod { … }` blocks — rig declares
     whole provider wire surfaces in them (`pub mod gemini_api_types`), and a
     top-level-only scan silently exempted every enum inside
  3. select `Visibility::Public` only
  4. read serde-ness from `#[derive(...)]` entries only, comparing the path's
     last segment so `serde::Serialize` counts
  5. data-bearing = any variant whose fields are not `Fields::Unit`
  6. read the representation from enum-level `#[serde(...)]` metadata *keys*
     only — `tag = "type"` yields the key `tag`, while a rename *value* of
     "untagged" or "stage" yields nothing. That distinction is exactly what a
     substring probe cannot make
  7. a parse failure panics; a file the scanner cannot read is a file the policy
     is not enforcing and must never read as compliance

Deletes `attribute_block_before`, `strip_line_comment`, `enum_body`,
`has_data_bearing_variant` and `public_untagged_enums` — every text heuristic in
the file, including the ones added one commit ago.

The unit tests now assert on syntax the old scanners got wrong: prose containing
`stage`, a blank line between derive and declaration, `rename_all` (a key that is
not a representation), `rename = "untagged"` (a value that spells one), a brace
in a doc comment, a fieldless enum, a non-serde enum, a private enum, and an enum
inside an inline module. Plus a direct test that an unparsable file fails.

Falsification re-run, since the scanner changed entirely: against `03643160^` it
still flags exactly the same six enums — MediaType, ToolChoice, FinishReason,
Filter, ToolCallDeltaContent, ModelListingError — and stays green here, with the
18-entry untagged allowlist unchanged and no stale rows.

Adds syn (full, parsing) and proc-macro2 (span-locations, for real line numbers
in violation reports) as rig-core dev-dependencies; both were already workspace
deps.

rig-core: 1249 lib tests, 5 policy tests, clippy clean.
…parse errors

P2 from review. `serde_keys` and `derives_serde` drove `parse_nested_meta` with a
callback that consumed `key = value` but not serde's documented **nested** form,
`key(...)`. syn then failed mid-attribute with "expected ',' after bound(...)",
and both callers discarded that error with `let _ = …`, so every key *after* the
nested entry went unseen.

Reproduced before fixing:

  #[serde(bound(serialize = "T: Serialize"), untagged)]  -> untagged = false
  #[serde(rename(serialize = "wire"), tag = "type")]     -> no representation

The first is the dangerous one. It is silent, and because provider directories
are excluded from the positive representation scan, an untagged enum there could
bypass the allowlist check entirely — the exact hole that wall exists to close.
The second is merely a spurious violation.

Both attribute readers now parse their arguments as a comma-separated
`Punctuated<Meta, Token![,]>` (`Punctuated<Path, …>` for derives), which admits
all three container-attribute forms serde documents — bare path (`untagged`),
name-value (`tag = "type"`), and nested list (`bound(...)`, `rename(...)`) — and
propagate every parse error up through `collect_enums` to `parsed_enums`, where
it panics. An attribute this scanner cannot read must fail the test, never
quietly narrow it.

Regression tests cover both nested forms, a nested-only attribute (`bound` alone
is neither tag nor untagged, so the enum still owes a representation), and a
malformed attribute that must fail rather than pass.

Falsification re-run, since the scanner changed again: against `03643160^` the
representation wall still flags exactly the same six enums. Running the whole
file there also surfaces the untagged wall correctly failing on AssistantContent,
StreamedAssistantContent and StreamedUserContent — untagged on that tree and
absent from today's allowlist — which earlier filtered runs had not shown.

On this tree the corrected parsing reveals no previously hidden untagged enum:
the allowlist is unchanged at 18 entries with no stale rows.

rig-core: 1249 lib tests, 7 policy tests, clippy clean.
…cape hatch

Four review findings, all low, all confirmed by reproduction first.

## The wall skipped the workspace-root `src/`

`rig_owned_public_serde_enums_are_not_untagged` walks SOURCE_ROOTS
(`["src", "crates"]`); the representation check I added walked only
`workspace.join("crates")`. The published `rig` facade at the workspace-root
`src/` was therefore outside the wall, and its own path filter would have
rejected the file anyway — `relative.contains("/src/")` never matches
`src/lib.rs`, which has no leading slash. A public data-bearing enum added there
would fall back to implicit external tagging and pass silently: precisely the
failure the wall exists to prevent.

Both roots are now scanned, the filter tests `starts_with("src/") ||
contains("/src/")`, and the check gains the floor-file assertion its sibling
already had — the absence of which is exactly why a missing root went unnoticed.

Verified by planting a `pub enum RootFacadeProbe { A(u32) }` in the root `src/`:
now reported as `src/_policy_probe.rs:2 RootFacadeProbe`, previously silent.

## Cohere: local guards rejected requests the provider honours

`CohereCompletionRequest::additional_params` is `#[serde(flatten)]`, so a
`"tools"` key there lands at the top level of the body — and because the typed
`tools` field is `skip_serializing_if = "Vec::is_empty"`, it can be the *only*
`tools` key on the wire. My `Required` guard tested `tools.is_empty()` against
the typed field alone and so failed a well-formed request locally.

`Specific` had the same blind spot, inherited rather than introduced: with the
typed list empty it validates requested names against an empty `available` set
and rejects every one. Both arms now consult the escape hatch via `carries_tools`
and skip validation they cannot perform on a partial view, rather than rejecting
on incomplete information.

## Stale fixture-function references

`decode_matrix_cases` was renamed to `stream_item_matrix_cases`; a comment and,
worse, a runtime assert message still named the old function — the exact text a
contributor reads off a red test.

rig-core 1250 lib tests, 7 policy tests; rig-agent 483; clippy clean.
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.

1 participant