Summary
versioning.md guarantees that additive changes are safe within a MAJOR:
Within a compatible range, additive changes — new optional fields on existing types, new action types, new commands — are introduced in PATCH (or MINOR, while MAJOR is 0) bumps and MUST be ignored by older peers that do not understand them.
The reference clients do not honour that guarantee for enum values. Adding a value to any existing discriminant enum causes a whole-message decode failure on a peer built against an earlier version — not a field-level degrade.
Because SemVer attaches the compatibility promise at 1.0.0, and because a client that pinned 1.x is still broken by a 1.y enum addition, this has to be fixed before the guarantee takes effect. After 1.0 every enum extension effectively becomes a MAJOR change.
Evidence
Deserializing crafted payloads against clients/rust/crates/ahp-types at main (7144635c):
| Payload |
Result |
| Unknown extra field on an existing shape |
✅ tolerated |
Unknown action type |
✅ tolerated |
Unknown object-union variant (ToolCallState, MessageAttachment, …) |
✅ tolerated |
| Unknown enum value on a directly-typed field |
❌ hard failure |
AuthRequiredReason = "unauthorized" -> unknown variant `unauthorized`, expected `required` or `expired`
TurnState = "paused" -> unknown variant `paused`, expected one of `complete` …
ChangesetStatus = "stale" -> unknown variant `stale`, expected one of `computing` …
McpAuthRequiredReason= "revoked" -> unknown variant `revoked`, expected one of `required` …
ResourceChangeType = "renamed" -> unknown variant `renamed`, expected one of `added` …
ConfirmationOptionKind = "defer" -> unknown variant `defer`, expected `approve` or `deny`
MessageOrigin.kind = "integration" -> unknown variant `integration`, expected one of `user`, `agent`, `tool`, `systemNotification`
Repro:
// ahp-types from clients/rust
use ahp_types::notifications::AuthRequiredParams;
let json = r#"{"channel":"ahp-root://","resource":"https://example.com","reason":"unauthorized"}"#;
serde_json::from_str::<AuthRequiredParams>(json).unwrap(); // panics
Why this looks like an oversight rather than a decision
The forward-compatibility intent is clearly already there — it just stops one level short:
- 19 polymorphic unions in
ahp-types carry an explicit catch-all: /// Unknown or future variant — preserved as raw JSON for round-trip fidelity. → Unknown(serde_json::Value).
- Even bitsets preserve unknown bits (
state.rs: "Unknown/forward-compat bits are preserved across a decode→encode round-trip").
- But the 40 pure string enums — including the discriminants that select among those very unions — have no equivalent.
So an unknown ToolCallState variant degrades gracefully, while an unknown ToolCallStatus, TurnState, or AuthRequiredReason on a directly-typed field takes the whole message down.
versioning.md also only spells out the ignore-unknown rule for one case — "clients SHOULD silently ignore actions with unrecognized type values" — leaving nested discriminants, enum values on plain fields, and unknown error codes as implied-but-unstated.
Impact
This is not hypothetical for 1.0 — several open proposals extend existing enums, e.g. #362 (MessageAttachmentKind.Collection), #287 (a new CustomizationType), and any additional AuthRequiredReason for auth states that are neither "no token" nor "expired". Under the current clients each of those is a breaking change dressed as an additive one.
It also affects hosts and clients asymmetrically. Hosts are often remote and updated on their own schedule — exactly the case versioning.md calls out — so a host that adopts a newer MINOR can hard-break older clients that were promised compatibility.
Proposed fix (pre-1.0)
- Give every discriminant enum an unknown-value escape hatch in every generated client — Rust
#[serde(other)] Unknown, and the TypeScript / Kotlin / Swift / Go equivalents — mirroring the treatment the polymorphic unions already get. Preserving the raw value (rather than collapsing to a sentinel) keeps round-trip fidelity for relays and proxies.
- State the rule in
versioning.md for all discriminated unions, enum-valued fields, and unknown error codes — not just action type. Something like: a peer receiving an unrecognized enum value MUST NOT fail the message; it MUST preserve the value for round-trip and treat the carrier as unknown/no-op.
- Add conformance vectors so the behaviour is testable per client rather than incidental.
- Decide the companion question explicitly: whether command params and results are guaranteed to tolerate unknown top-level fields. Nothing in
schema/ sets additionalProperties: false today, so they do in practice — but that tolerance is currently accidental, and tightening it after 1.0 would itself be breaking.
Happy to help with the Rust side if that's useful.
Summary
versioning.mdguarantees that additive changes are safe within a MAJOR:The reference clients do not honour that guarantee for enum values. Adding a value to any existing discriminant enum causes a whole-message decode failure on a peer built against an earlier version — not a field-level degrade.
Because SemVer attaches the compatibility promise at
1.0.0, and because a client that pinned1.xis still broken by a1.yenum addition, this has to be fixed before the guarantee takes effect. After 1.0 every enum extension effectively becomes a MAJOR change.Evidence
Deserializing crafted payloads against
clients/rust/crates/ahp-typesatmain(7144635c):typeToolCallState,MessageAttachment, …)Repro:
Why this looks like an oversight rather than a decision
The forward-compatibility intent is clearly already there — it just stops one level short:
ahp-typescarry an explicit catch-all:/// Unknown or future variant — preserved as raw JSON for round-trip fidelity.→Unknown(serde_json::Value).state.rs: "Unknown/forward-compat bits are preserved across a decode→encode round-trip").So an unknown
ToolCallStatevariant degrades gracefully, while an unknownToolCallStatus,TurnState, orAuthRequiredReasonon a directly-typed field takes the whole message down.versioning.mdalso only spells out the ignore-unknown rule for one case — "clients SHOULD silently ignore actions with unrecognizedtypevalues" — leaving nested discriminants, enum values on plain fields, and unknown error codes as implied-but-unstated.Impact
This is not hypothetical for 1.0 — several open proposals extend existing enums, e.g. #362 (
MessageAttachmentKind.Collection), #287 (a newCustomizationType), and any additionalAuthRequiredReasonfor auth states that are neither "no token" nor "expired". Under the current clients each of those is a breaking change dressed as an additive one.It also affects hosts and clients asymmetrically. Hosts are often remote and updated on their own schedule — exactly the case
versioning.mdcalls out — so a host that adopts a newer MINOR can hard-break older clients that were promised compatibility.Proposed fix (pre-1.0)
#[serde(other)] Unknown, and the TypeScript / Kotlin / Swift / Go equivalents — mirroring the treatment the polymorphic unions already get. Preserving the raw value (rather than collapsing to a sentinel) keeps round-trip fidelity for relays and proxies.versioning.mdfor all discriminated unions, enum-valued fields, and unknown error codes — not just actiontype. Something like: a peer receiving an unrecognized enum value MUST NOT fail the message; it MUST preserve the value for round-trip and treat the carrier as unknown/no-op.schema/setsadditionalProperties: falsetoday, so they do in practice — but that tolerance is currently accidental, and tightening it after 1.0 would itself be breaking.Happy to help with the Rust side if that's useful.