Skip to content

.NET SDK strips __-prefixed keys from invoke return values #1102

Description

@EmmittJ

What happened?

I was creating a provider resource whose input is a descriptor object keyed by a __-prefixed type
discriminator, using the helper invoke the provider ships for building that descriptor. The
documented pattern is to pass the invoke's result directly into the resource, as the TypeScript and
YAML examples do.

In C# this fails:

error: property permissions value {map[permissions:{[...]}]} has a problem:
       failed to parse permission descriptor: type '' not recognized

The behaviour originates in the .NET SDK rather than the provider.
Deserializer.DeserializeStruct drops every __-prefixed key when materializing invoke return
values, so the discriminator is removed before the result reaches user code. Passing that value into
the resource then fails, because the shape no longer matches what the provider expects.

Expected: invoke results are deserialized as the provider's schema defines them, including any
__-prefixed fields.

Actual: __-prefixed fields are removed, without an error or warning. The failure surfaces
later, reported by the provider and referencing the provider and property names.

This appears to be the .NET counterpart of pulumi/pulumi#22738 ("Python SDK strips __-prefixed
keys from invoke return values"), which was accepted and fixed. That issue states that
"TS/Go/.NET/Java/YAML do not strip these keys"; the repro below shows .NET does strip them. The .NET
implementation also differs from Python's pre-fix behaviour in two ways: there is no
keep_internal-style parameter, and no __provider exemption.

Resources involved: any provider invoke whose result schema uses a __-prefixed field. I
encountered it on pulumiservice.OrganizationRole via its BuildAllowPermissions invoke, though
nothing about the behaviour is specific to that provider.

Example

Self-contained: no provider plugin, no cloud account, no credentials, no stack. The only package
reference is Pulumi, and IMocks supplies the invoke result directly.

repro.csproj:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Pulumi" Version="3.110.0" />
  </ItemGroup>

</Project>

Program.cs:

using System.Collections.Immutable;
using Pulumi;
using Pulumi.Testing;

internal sealed class Mocks : IMocks
{
    public static string DescriptorKeysSeenByProvider = "(resource never created)";

    public Task<(string? id, object state)> NewResourceAsync(MockResourceArgs args)
    {
        if (args.Inputs.TryGetValue("descriptor", out var d) && d is IDictionary<string, object> map)
            DescriptorKeysSeenByProvider = string.Join(", ", map.Keys.OrderBy(k => k));

        return Task.FromResult<(string?, object)>((args.Id ?? "id", new Dictionary<string, object>()));
    }

    // What the provider returns from the invoke.
    public Task<object> CallAsync(MockCallArgs args)
        => Task.FromResult<object>(new Dictionary<string, object>
        {
            ["__type"] = "PermissionDescriptorAllow",
            ["permissions"] = new[] { "stack:read" },
        });
}

internal sealed class ProbeArgs : ResourceArgs
{
    [Input("descriptor")]
    public InputMap<object>? Descriptor { get; set; }
}

internal sealed class Probe : CustomResource
{
    public Probe(string name, ProbeArgs args) : base("test:index:Probe", name, args, null) { }
}

internal sealed class EmptyArgs : InvokeArgs;

internal sealed class ReproStack : Stack
{
    public static ImmutableDictionary<string, object?>? Received;

    public ReproStack()
    {
        Received = Deployment.Instance
            .InvokeAsync<ImmutableDictionary<string, object?>>("test:index:buildPermissions", new EmptyArgs())
            .GetAwaiter().GetResult();

        // Same key, travelling the other direction: user code -> provider.
        _ = new Probe("probe", new ProbeArgs
        {
            Descriptor = new InputMap<object>
            {
                ["__type"] = "PermissionDescriptorAllow",
                ["permissions"] = new[] { "stack:read" },
            },
        });
    }
}

internal static class Program
{
    public static async Task<int> Main()
    {
        await Deployment.TestAsync<ReproStack>(new Mocks());

        var got = ReproStack.Received;
        Console.WriteLine($"Pulumi SDK: {typeof(Deployment).Assembly.GetName().Version}");
        Console.WriteLine("-- invoke RESULT (provider -> user code) --");
        Console.WriteLine("  provider returned : __type, permissions");
        Console.WriteLine($"  user code received: {string.Join(", ", got?.Keys.OrderBy(k => k) ?? Enumerable.Empty<string>())}");

        var hasType = got?.ContainsKey("__type") ?? false;
        Console.WriteLine(hasType ? "  => __type survived" : "  => __type STRIPPED  <-- discriminator lost");

        Console.WriteLine("-- resource INPUT (user code -> provider) --");
        Console.WriteLine("  user code declared: __type, permissions");
        Console.WriteLine($"  mock provider saw : {Mocks.DescriptorKeysSeenByProvider}");

        return hasType ? 0 : 1;
    }
}

Run with dotnet run. Actual output:

Pulumi SDK: 3.110.0.0
-- invoke RESULT (provider -> user code) --
  provider returned : __type, permissions
  user code received: permissions
  => __type STRIPPED  <-- discriminator lost
-- resource INPUT (user code -> provider) --
  user code declared: __type, permissions
  mock provider saw : permissions

The first block shows the reported behaviour: __type was returned by the provider and is absent
from the result. The program exits non-zero while that is the case.

The second block reflects the test harness rather than the wire path — see Additional context.

Observed on both currently published versions:

Pulumi package Result
3.110.0 latest stable __type removed
3.110.1-alpha.6a6ecf5 newest prerelease, from commit 6a6ecf5 __type removed

Output of pulumi about

Backend identity redacted. The repro needs no backend, credentials or stack — the "No current
stack" warning is expected.

CLI
Version      3.255.0
Go Version   go1.26.5
Go Compiler  gc

Plugins
KIND      NAME    VERSION
language  dotnet  unknown

Host
OS       Microsoft Windows 11 Enterprise
Version  10.0.26200 Build 26200
Arch     x86_64

This project is written in dotnet: executable='C:\Program Files\dotnet\dotnet.exe' version='10.0.110'

Backend
Name           pulumi.com
URL            https://app.pulumi.com/<redacted>
User           <redacted>
Organizations  <redacted>
Token type     personal

Dependencies:
NAME    VERSION
Pulumi  3.110.0

Pulumi locates its logs in C:\Users\<redacted>\AppData\Local\Temp by default
warning: Failed to get information about the current stack: No current stack

Additional context

Where it happens

Deserializer.DeserializeStruct
sdk/Pulumi/Serialization/Deserializer.cs#L94-L101:

foreach (var (key, element) in v.StructValue.Fields)
{
    // Unilaterally skip properties considered internal by the Pulumi engine.
    // These don't actually contribute to the exposed shape of the object, do
    // not need to be passed back to the engine, and often will not match the
    // expected type we are deserializing into.
    if (key.StartsWith("__", StringComparison.Ordinal))
        continue;
    ...
}

Invoke results reach that code via:

  1. Deployment_Invoke.cs#L215
    (and #L240 for Call) → Converter.ConvertValue<T>(...)
  2. Converter.cs#L37
    Deserializer.Deserialize(value)
  3. DeserializeStruct → the filter above

The rationale in the comment holds for resource state, where __-prefixed keys are engine
bookkeeping. It appears not to apply to invoke/call results, where the payload shape is defined
by the provider's schema and __-prefixed names may be provider data. As far as I can tell the
engine does not inject anything into an invoke response.

This code is unchanged on main at time of writing: the filter is still present and unconditional,
and no keepInternal-style parameter exists in the file. That is consistent with the prerelease
result above, which was built from main.

Input and output paths behave differently

Serializer.cs
has no equivalent filter, so __-prefixed keys are accepted on the way out (user code →
provider) and only removed on the way back.

One consequence is that a provider can accept a __type-keyed descriptor as a resource input while
the same shape does not survive the helper invoke it ships for building that input. Writing the
literal directly works:

var permissions = new InputMap<object>
{
    ["__type"] = "PermissionDescriptorAllow",
    ["permissions"] = scopes,
};

…while using the provider's helper invoke does not.

It is also worth noting that BuildAllowPermissionsResult.Permissions is an
ImmutableDictionary<string, object>, so the discriminator is an ordinary key within that map
rather than a modelled property, and is filtered along with the rest.

Secondary: IMocks does not observe __-prefixed inputs

The second half of the repro output comes from a different code path and may warrant separate
treatment.

MockMonitor.ToDictionary
passes registration inputs back through the same Deserializer, so MockResourceArgs.Inputs also
has __-prefixed keys removed. The wire path does not do this — Serializer sends them unmodified,
and a provider receives them.

A unit test written against the mock harness therefore observes different inputs than a provider
receives at runtime. This also means the existing harness cannot express a regression test for the
primary behaviour without this being addressed as well.

Current impact

The provider I encountered this with has since renamed its SDK-boundary discriminator from __type
to kind (pulumi/pulumi-pulumiservice#778), so it no longer exercises this path. That rename was
motivated by the Python instance of the same behaviour.

To be clear about scope: no currently released provider version is broken by this. What remains
is an SDK behaviour that removes provider-defined data from invoke results without signalling it,
and which one provider has already changed its public schema to avoid. A provider that uses a
__-prefixed field in an invoke result schema would encounter it again, and in .NET the observable
result is an altered value rather than an error.

Possible fix

One option is to mirror the Python fix (keep_internal=True on the invoke/call deserialization path
only): thread a flag through so invoke and call results retain internal keys, while resource-state
deserialization continues to strip them.

  • Add an optional bool keepInternal = false to Deserializer.Deserialize / DeserializeStruct
    and to Converter.ConvertValue.
  • Pass keepInternal: true from the two invoke/call sites in Deployment_Invoke.cs.
  • Leave resource-state deserialization on the current default.
  • Optionally have MockMonitor.ToDictionary pass it as well when materializing inputs for IMocks,
    so the harness matches the wire path.

If preserving the current default is preferable, an opt-in on InvokeOptions would be an
alternative. The trade-off between the two approaches is discussed in the Python issue.

Related

Contributing

Vote on this issue by adding a 👍 reaction.
To contribute a fix for this issue, leave a comment (and link to your pull request, if you've opened one already).

Metadata

Metadata

Assignees

Labels

kind/bugSome behavior is incorrect or out of specneeds-triageNeeds attention from the triage team

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions