Problem
When a generated model - or a single property on an otherwise GA model - is marked [Experimental("XXXX001")], the generated serialization code references that member and the experimental diagnostic fires inside src/Generated/Models/*.Serialization.cs. Because that code is generated, library authors cannot annotate it: any hand-added #pragma is wiped on the next regen.
Today the only escape hatch for Azure SDK partner teams is to hand-maintain an approved suppression in eng/analyzerallowlist/<Package>.txt in azure-sdk-for-net, listing every affected generated member by DocumentationCommentId. That is manual, brittle (the ids change whenever the spec adds a parameter), and provides no review value.
Real entries currently checked in - eng/analyzerallowlist/Azure.AI.Projects.Agents.txt:
nowarn:AAIP001 M:Azure.AI.Projects.Agents.ProjectsAgentDefinition.DeserializeProjectsAgentDefinition(System.Text.Json.JsonElement,System.ClientModel.Primitives.ModelReaderWriterOptions)
nowarn:AAIP001 M:Azure.AI.Projects.Agents.ToolboxTool.DeserializeToolboxTool(System.Text.Json.JsonElement,System.ClientModel.Primitives.ModelReaderWriterOptions)
nowarn:AAIP001 M:Azure.AI.Projects.Agents.ProjectsAgentVersionCreationOptions.JsonModelWriteCore(System.Text.Json.Utf8JsonWriter,System.ClientModel.Primitives.ModelReaderWriterOptions)
nowarn:AAIP001 M:Azure.AI.Projects.Agents.ProjectsAgentVersion.#ctor(System.Collections.Generic.IDictionary{System.String,System.String},System.String,System.String,System.String,System.String,System.String,System.DateTimeOffset,Azure.AI.Projects.Agents.ProjectsAgentDefinition,System.Nullable{System.Boolean},System.Nullable{Azure.AI.Projects.Agents.AgentVersionStatus},Azure.AI.Projects.Agents.AgentIdentity,Azure.AI.Projects.Agents.AgentIdentity,Azure.AI.Projects.Agents.AgentBlueprintReference,System.String,System.Collections.Generic.IDictionary{System.String,System.BinaryData})
eng/analyzerallowlist/Azure.AI.Projects.txt spells out exactly why the workaround exists:
File-scoped pragmas would be wiped on every codegen regen of the ~27 affected generated files and add no review value.
The generator is the only party that knows both that a member is [Experimental] (it emits the attribute) and that the generated serialization code references it. It should emit the suppression itself.
Why suppressing here is safe
The [Experimental] diagnostic exists so consumers explicitly opt into a preview surface. Suppressing it inside a model's own serialization plumbing leaks nothing: an end user still gets the full diagnostic the moment they touch the experimental type, property, or model factory method. The suppression is scoped to code the user cannot call directly and did not write.
Cases to cover
Using Azure.AI.Projects.Agents as the reference:
1. Discriminated-union deserializer dispatching to experimental derived types
ProjectsAgentDefinition is GA; HostedAgentDefinition and WorkflowAgentDefinition carry [Experimental("AAIP001")]. The generated base deserializer references them:
internal static ProjectsAgentDefinition DeserializeProjectsAgentDefinition(JsonElement element, ModelReaderWriterOptions options)
{
...
switch (discriminator.GetString())
{
case "hosted":
return HostedAgentDefinition.DeserializeHostedAgentDefinition(element, options); // AAIP001
case "workflow":
return WorkflowAgentDefinition.DeserializeWorkflowAgentDefinition(element, options); // AAIP001
}
...
}
2. JsonModelWriteCore / DeserializeX touching an experimental property on a GA model
ProjectsAgentVersionCreationOptions is GA, but the generator emits [Experimental("AAIP001")] on its Draft property:
[Experimental("AAIP001")]
public bool? Draft { get; set; }
and then references it unguarded from the generated writer:
if (Optional.IsDefined(Draft)) // AAIP001
{
writer.WritePropertyName("draft"u8);
writer.WriteBooleanValue(Draft.Value); // AAIP001
}
3. The internal serialization constructor
The deserialization #ctor takes experimental-typed parameters and/or assigns experimental properties, so it fires as well - see the ProjectsAgentVersion.#ctor entry above.
Worth covering in the same pass: PersistableModelCreateCore / JsonModelCreateCore, the XML and multipart serialization variants, and the model factory, wherever they reference experimental symbols.
Proposal
Emit the suppression at the tightest stable scope: around the individual property-handling statements inside the generated serialization/deserialization bodies, not around the whole member. The generator builds these statements per property, so at the point each statement is constructed it already holds the PropertyProvider / ModelProvider whose [Experimental(id)] causes the diagnostic.
No new infrastructure is needed - SuppressionStatement already accepts an arbitrary MethodBodyStatement as its inner node:
public SuppressionStatement(MethodBodyStatement? inner, ValueExpression code, string justification)
Serializer - JsonModelWriteCore
MrwSerializationTypeDefinition.CreateWritePropertyStatement is called once per property from CreateWritePropertiesStatements. Wrap its result when that property carries [Experimental(id)]:
#pragma warning disable AAIP001 // Draft is experimental and may change in future versions.
if (Optional.IsDefined(Draft))
{
writer.WritePropertyName("draft"u8);
writer.WriteBooleanValue(Draft.Value);
}
#pragma warning restore AAIP001 // Draft is experimental and may change in future versions.
Every other property in JsonModelWriteCore keeps the analyzer live.
Serialization constructor
The generated deserializer reads into locals (bool? draft = default;), so an experimental property does not actually fire inside DeserializeX - it fires on the assignment in the serialization constructor, which is a single statement:
internal ProjectsAgentVersionCreationOptions(..., bool? draft, ...)
{
Metadata = metadata;
Description = description;
#pragma warning disable AAIP001 // Draft is experimental and may change in future versions.
Draft = draft;
#pragma warning restore AAIP001 // Draft is experimental and may change in future versions.
_additionalBinaryDataProperties = additionalBinaryDataProperties;
}
Discriminated-union deserializer
GetDiscriminatorSwitchCases builds one SwitchCaseStatement per derived model. Wrap only the arms whose derived model is experimental:
switch (discriminator.GetString())
{
#pragma warning disable AAIP001 // HostedAgentDefinition is experimental and may change in future versions.
case "hosted":
return HostedAgentDefinition.DeserializeHostedAgentDefinition(element, options);
#pragma warning restore AAIP001 // HostedAgentDefinition is experimental and may change in future versions.
case "prompt":
return DeclarativeAgentDefinition.DeserializeDeclarativeAgentDefinition(element, options);
}
Deserializer property blocks
BuildDeserializePropertiesStatements builds one if (prop.NameEquals("..."u8)) { ... } block per property. Wrap that block when the property's declared type is experimental (an experimental property alone does not fire here, per the locals note above).
Notes
- Collect the distinct set of ids referenced by the wrapped statement; emit one disable/restore pair per id.
- Skip emission when the containing type already carries the same
[Experimental(id)] - the pragma would be redundant on a fully-experimental model.
SuppressionStatement.Write currently only appends a newline after Inner when Inner is AttributeStatement; reusing it for body statements may need a small formatting tweak.
This already exists for the MRW context - just not for serialization
ModelReaderWriterContextDefinition already does precisely this for [ModelReaderWriterBuildable] registrations, in generator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ModelReaderWriterContextDefinition.cs:
if (experimentalOrObsoleteAttribute?.Type.Equals(typeof(ExperimentalAttribute)) == true)
{
string justification = $"{typeProvider.Type} is experimental and may change in future versions.";
attributes.Add(key, new SuppressionStatement(attributeStatement, experimentalOrObsoleteAttribute.Arguments[0], justification));
}
which produces this baseline (test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/ExperimentalModelsHaveAttributeSuppression.cs):
#pragma warning disable TEST001 // global::Sample.Models.ExperimentalModel is experimental and may change in future versions.
[global::System.ClientModel.Primitives.ModelReaderWriterBuildableAttribute(typeof(global::Sample.Models.ExperimentalModel))]
#pragma warning restore TEST001 // global::Sample.Models.ExperimentalModel is experimental and may change in future versions.
So this is extending an established, already-shipped pattern to MrwSerializationTypeDefinition (and its XML / multipart variants) rather than inventing a new one.
Building blocks already in the repo
generator/Microsoft.TypeSpec.Generator/src/Statements/SuppressionStatement.cs - wraps a statement in PragmaWarningDisableStatement / PragmaWarningRestoreStatement with a justification.
CodeWriter.WriteMethodDeclarationNoScope(MethodSignatureBase methodBase, params string[] disabledWarnings) already emits #pragma warning disable {disabledWarning} ahead of a method declaration (generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs).
ScmModelProvider / ScmMethodProviderCollection / ClientSettingsProvider already model experimental diagnostic ids (SCME0001, SCME0002, SCME0004, ...), so the plumbing for "this symbol is experimental with id X" is present.
Outcome
Partner teams stop hand-writing per-member allow-list entries for code they did not author. The suppression lives next to the code that needs it, regenerates automatically as the spec evolves, and stays invisible to end users - who continue to get the experimental warning at every public surface they actually touch.
🤖 m-nash-copilot
Problem
When a generated model - or a single property on an otherwise GA model - is marked
[Experimental("XXXX001")], the generated serialization code references that member and the experimental diagnostic fires insidesrc/Generated/Models/*.Serialization.cs. Because that code is generated, library authors cannot annotate it: any hand-added#pragmais wiped on the next regen.Today the only escape hatch for Azure SDK partner teams is to hand-maintain an approved suppression in
eng/analyzerallowlist/<Package>.txtinazure-sdk-for-net, listing every affected generated member by DocumentationCommentId. That is manual, brittle (the ids change whenever the spec adds a parameter), and provides no review value.Real entries currently checked in -
eng/analyzerallowlist/Azure.AI.Projects.Agents.txt:eng/analyzerallowlist/Azure.AI.Projects.txtspells out exactly why the workaround exists:The generator is the only party that knows both that a member is
[Experimental](it emits the attribute) and that the generated serialization code references it. It should emit the suppression itself.Why suppressing here is safe
The
[Experimental]diagnostic exists so consumers explicitly opt into a preview surface. Suppressing it inside a model's own serialization plumbing leaks nothing: an end user still gets the full diagnostic the moment they touch the experimental type, property, or model factory method. The suppression is scoped to code the user cannot call directly and did not write.Cases to cover
Using
Azure.AI.Projects.Agentsas the reference:1. Discriminated-union deserializer dispatching to experimental derived types
ProjectsAgentDefinitionis GA;HostedAgentDefinitionandWorkflowAgentDefinitioncarry[Experimental("AAIP001")]. The generated base deserializer references them:2.
JsonModelWriteCore/DeserializeXtouching an experimental property on a GA modelProjectsAgentVersionCreationOptionsis GA, but the generator emits[Experimental("AAIP001")]on itsDraftproperty:and then references it unguarded from the generated writer:
3. The internal serialization constructor
The deserialization
#ctortakes experimental-typed parameters and/or assigns experimental properties, so it fires as well - see theProjectsAgentVersion.#ctorentry above.Worth covering in the same pass:
PersistableModelCreateCore/JsonModelCreateCore, the XML and multipart serialization variants, and the model factory, wherever they reference experimental symbols.Proposal
Emit the suppression at the tightest stable scope: around the individual property-handling statements inside the generated serialization/deserialization bodies, not around the whole member. The generator builds these statements per property, so at the point each statement is constructed it already holds the
PropertyProvider/ModelProviderwhose[Experimental(id)]causes the diagnostic.No new infrastructure is needed -
SuppressionStatementalready accepts an arbitraryMethodBodyStatementas its inner node:Serializer -
JsonModelWriteCoreMrwSerializationTypeDefinition.CreateWritePropertyStatementis called once per property fromCreateWritePropertiesStatements. Wrap its result when that property carries[Experimental(id)]:Every other property in
JsonModelWriteCorekeeps the analyzer live.Serialization constructor
The generated deserializer reads into locals (
bool? draft = default;), so an experimental property does not actually fire insideDeserializeX- it fires on the assignment in the serialization constructor, which is a single statement:Discriminated-union deserializer
GetDiscriminatorSwitchCasesbuilds oneSwitchCaseStatementper derived model. Wrap only the arms whose derived model is experimental:Deserializer property blocks
BuildDeserializePropertiesStatementsbuilds oneif (prop.NameEquals("..."u8)) { ... }block per property. Wrap that block when the property's declared type is experimental (an experimental property alone does not fire here, per the locals note above).Notes
[Experimental(id)]- the pragma would be redundant on a fully-experimental model.SuppressionStatement.Writecurrently only appends a newline afterInnerwhenInner is AttributeStatement; reusing it for body statements may need a small formatting tweak.This already exists for the MRW context - just not for serialization
ModelReaderWriterContextDefinitionalready does precisely this for[ModelReaderWriterBuildable]registrations, ingenerator/Microsoft.TypeSpec.Generator.ClientModel/src/Providers/ModelReaderWriterContextDefinition.cs:which produces this baseline (
test/Providers/Definitions/TestData/ModelReaderWriterContextDefinitionTests/ExperimentalModelsHaveAttributeSuppression.cs):So this is extending an established, already-shipped pattern to
MrwSerializationTypeDefinition(and its XML / multipart variants) rather than inventing a new one.Building blocks already in the repo
generator/Microsoft.TypeSpec.Generator/src/Statements/SuppressionStatement.cs- wraps a statement inPragmaWarningDisableStatement/PragmaWarningRestoreStatementwith a justification.CodeWriter.WriteMethodDeclarationNoScope(MethodSignatureBase methodBase, params string[] disabledWarnings)already emits#pragma warning disable {disabledWarning}ahead of a method declaration (generator/Microsoft.TypeSpec.Generator/src/Writers/CodeWriter.cs).ScmModelProvider/ScmMethodProviderCollection/ClientSettingsProvideralready model experimental diagnostic ids (SCME0001,SCME0002,SCME0004, ...), so the plumbing for "this symbol is experimental with id X" is present.Outcome
Partner teams stop hand-writing per-member allow-list entries for code they did not author. The suppression lives next to the code that needs it, regenerates automatically as the spec evolves, and stays invisible to end users - who continue to get the experimental warning at every public surface they actually touch.
🤖 m-nash-copilot