File: Publishing\RadiusInfrastructureBuilder.cs
Web Access
Project: src\src\Aspire.Hosting.Radius\Aspire.Hosting.Radius.csproj (Aspire.Hosting.Radius)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASPIRERADIUS004 // Experimental: ConfigureRadiusInfrastructure escape-hatch construct types are consumed internally by the publisher.
 
#pragma warning disable ASPIRECONNECTIONSTRINGS001 // Connection-string reference metadata is experimental.
#pragma warning disable ASPIRECOMPUTE002 // GetEndpointPropertyExpression/GetHostAddressExpression are experimental compute-environment APIs the publisher relies on.
#pragma warning disable ASPIRERADIUS006 // Secret-store model types (RadiusSecretStoreResource, etc.) are experimental; consumed internally by the publisher.
#pragma warning disable ASPIREPROJECTS001
 
using System.Globalization;
using System.Net.Sockets;
using System.Runtime.CompilerServices;
using System.Text;
using Aspire.Dashboard.Model;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Radius.Publishing.Constructs;
using Aspire.Hosting.Radius.ResourceMapping;
using Aspire.Hosting.Radius.Secrets;
using Azure.Provisioning;
using Azure.Provisioning.Expressions;
using Azure.Provisioning.Primitives;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.Radius.Publishing;
 
/// <summary>
/// Builds an Azure.Provisioning Infrastructure AST from a <see cref="DistributedApplicationModel"/>
/// for a specific Radius environment. Generates typed <c>ProvisionableResource</c> constructs
/// (environments, applications, recipe packs, resource type instances, containers) that are
/// compiled to Bicep via <c>Infrastructure.Build().Compile()</c>.
/// </summary>
internal sealed class RadiusInfrastructureBuilder
{
    private readonly RadiusEnvironmentResource _environment;
    private readonly DistributedApplicationModel _model;
    private readonly ResourceTypeMapper _typeMapper;
    private readonly ILogger _logger;
 
    /// <summary>
    /// Publish-mode execution context used to resolve container environment variables and
    /// service-discovery values. Set at the start of <see cref="BuildAsync"/>.
    /// </summary>
    private DistributedApplicationExecutionContext _executionContext = null!;
    private CancellationToken _cancellationToken;
 
    // Bicep parameters allocated for secret/parameter values referenced by container env vars.
    // Keyed by the Aspire parameter name so repeated references reuse a single param declaration.
    // These are emitted as top-level Bicep `param`s (secure when the source parameter is secret)
    // instead of inlining values, so no literal secret is written to the published artifact.
    private readonly Dictionary<string, ProvisioningParameter> _envParametersByName = new(StringComparer.Ordinal);
 
    // Maps the emitted Bicep parameter identifier to its originating Aspire ParameterResource, so
    // the deploy step can resolve each value at deploy time and pass it via `rad deploy --parameters`.
    private readonly Dictionary<string, ParameterResource> _deployParametersByIdentifier = new(StringComparer.Ordinal);
    // One parameter can be the user name of several brokers: sharing an input parameter across UDT
    // resources is supported, so every owner is kept rather than the last one written. Dropping the
    // earlier owners would let a surviving broker deploy with 'guest' unchecked once a callback
    // removed whichever broker happened to be recorded last.
    private readonly Dictionary<ParameterResource, List<IResource>> _rabbitMqUserNames = new(ReferenceEqualityComparer.Instance);
 
    // Bicep `param`s allocated for recipe-parameter and inline-secret values that bind an Aspire
    // ParameterResource. Keyed by the Aspire parameter name so repeated references reuse a single
    // declaration; secure when the source parameter is secret so no value is written to the artifact.
    private readonly Dictionary<string, ProvisioningParameter> _recipeParameters = new(StringComparer.Ordinal);
 
    // Maps the emitted recipe/inline-secret Bicep parameter identifier to its originating Aspire
    // ParameterResource, unioned into RadiusDeployParametersAnnotation so the deploy step resolves a
    // value for every valueless `param` at deploy time.
    private readonly Dictionary<string, ParameterResource> _recipeParameterBindings = new(StringComparer.Ordinal);
 
    // Guards against two distinct Aspire parameter names sanitizing to the same Bicep identifier,
    // which would emit duplicate `param` declarations (ASPIRERADIUS028). Keyed by Bicep identifier.
    private readonly Dictionary<string, string> _recipeParameterIdentifiers = new(StringComparer.Ordinal);
 
    // Recipe parameters are user-supplied object graphs, so bound traversal to avoid
    // unbounded recursion from accidental cycles or pathological nesting.
    private const int MaxRecipeParameterNestingDepth = 32;
 
    // Radius resource-type instances emitted by this environment, keyed by Aspire resource name.
    // Backing-resource values (host/port/credentials) are projected off these constructs instead of
    // being derived from Aspire endpoints. See https://github.com/microsoft/aspire/issues/18935.
    private readonly Dictionary<string, RadiusResourceTypeConstruct> _typeInstancesByResourceName = new(StringComparer.Ordinal);
 
    // The emitted Radius type string per Aspire resource name. The projection shape depends on the
    // *emitted* type, because the legacy Applications.* and the new Radius.* UDT schemas for the
    // same Aspire resource expose different properties and different secret mechanisms.
    private readonly Dictionary<string, string> _radiusTypeByResourceName = new(StringComparer.Ordinal);
 
    // Aspire ParameterResources that must be replaced by a recipe-generated secret. A legacy
    // backing resource's password is created by its Radius recipe, so the parameter Aspire
    // generates for local run mode is not the deployed password; substituting the parameter with
    // `<resource>.listSecrets().password` keeps every composed value (connection string, URI,
    // splatted *_PASSWORD) correct without duplicating any connection-string format here.
    private readonly Dictionary<ParameterResource, ProjectedValue> _recipeSecretSubstitutions = [];
 
    // Credential parameters whose backing resource's recipe deploys the workload with no
    // authentication at all (RadiusCredentialMode.NoCredential). There is no projection to read the
    // value from, so every reference resolves to the empty string — the value the deployed workload
    // actually has. Kept separate from _recipeSecretSubstitutions because that map's value type is
    // a projection off a construct, and no construct publishes this.
    private readonly HashSet<ParameterResource> _emptyCredentialSubstitutions = [];
    private readonly Dictionary<ParameterResource, (IResource Owner, bool IsProjectionSubstitution)> _recipeCredentialOwners = [];
 
    // Tracks (resource, parameter) pairs that have already produced an unrelated-use warning, so a
    // parameter referenced by the same resource in multiple env vars only warns once.
    private readonly HashSet<(IResource Resource, ParameterResource Parameter)> _warnedUnrelatedSubstitutions = [];
    private readonly List<ProjectedEnvValue> _projectedEnvValues = [];
    private readonly List<ProjectedTypeProperty> _projectedTypeProperties = [];
 
    // Names of resources whose connection string was actually resolved into some *other* resource's
    // value during this publish. This is the observed counterpart to the annotation-derived
    // GetReferencedResourceNames: a `WithEnvironment(ctx => ...)` callback that composes a
    // connection string inline records no ResourceRelationshipAnnotation, so annotations alone
    // report such a consumer as unreferenced. Populated while environment values are resolved, and
    // therefore only complete after every container's environment has been built.
    private readonly HashSet<string> _resolvedConnectionStringConsumption = new(StringComparer.Ordinal);
 
    // Backing resources whose emitted type cannot create the AddDatabase(...) children the model
    // declares. Collected while credentials are applied and reported once environment resolution has
    // finished, so callback-only consumers are seen. See WarnForDatabasesNotCreatedByTheRecipe.
    private readonly List<(IResource Resource, string RadiusType)> _databasesNotCreatedByTheRecipe = [];
 
    // Manifest expressions of every database child of a backing resource this environment emits,
    // keyed to the child's name. Built on first use; complete from the moment the emitted-type table
    // is filled, which is before any credential is wired.
    // See RecordConnectionStringExpressionConsumption.
    private Dictionary<string, string>? _databaseChildConnectionStringExpressions;
 
    // The single database each recipe-backed resource was told to provision, recorded while
    // credentials are applied and validated once environment resolution has finished. The selection
    // can only consider WithReference annotations, because it must run before any container
    // environment is resolved; a WithEnvironment callback that consumes a *different* database child
    // records no annotation and is therefore invisible at selection time. See
    // ValidateRecipeDatabaseSelections.
    private readonly List<(IResource Resource, string SelectedDatabaseName)> _recipeDatabaseSelections = [];
 
    // Radius.Security/secrets resources emitted to carry a credential that a UDT backing resource
    // consumes by resource ID, paired with the resource and property that consume them. Recorded so
    // a ConfigureRadiusInfrastructure callback that renames either side can be repaired — the
    // `<secret>.id` reference would otherwise silently point at a symbol that no longer exists.
    private readonly List<SecretResourceCredential> _secretResourceCredentials = [];
    private readonly List<ContainerEnvSecret> _containerEnvSecrets = [];
    private readonly List<ContainerEnvSecretReference> _containerEnvSecretReferences = [];
 
    /// <summary>
    /// The <c>resource-types-contrib</c> commit whose recipe publish is pinned for types that have
    /// no stable recipe release yet. Every push to that repository's <c>main</c> publishes an
    /// immutable tag named after its commit SHA, which is the pin Radius itself uses.
    /// </summary>
    private const string UnreleasedRecipeSha = "ebdeec9509036f2b2f271e41661e6fcfe45eda89";
 
    /// <summary>
    /// Default recipe template paths per resource type.
    /// </summary>
    private static readonly Dictionary<string, string> s_defaultRecipeTemplates = new(StringComparer.Ordinal)
    {
        // The Radius.* UDT recipes are published under kube-recipes/, not the legacy
        // recipes/local-dev/ prefix that serves the Applications.* portable types. Pairing a UDT
        // with a local-dev recipe both fails to pull and would ignore the credentials Aspire sets,
        // because only the UDT recipe reads them from context.resource.properties.
        //
        // rediscaches and rabbitmq are pinned by commit SHA rather than :latest because they have
        // no :latest to pin to. resource-types-contrib moves :latest only on a stable recipe
        // release and publishes an immutable <sha> tag on every push to main, and these two types
        // post-date the last stable release — `ghcr.io/radius-project/kube-recipes/rabbitmq:latest`
        // is a 404, which surfaces as a RecipeDownloadFailed at `rad deploy` rather than at publish.
        // The SHA below is the newest publish carrying both (resource-types-contrib ebdeec95,
        // "Make RabbitMQ password optional"), and it is byte-identical to `edge` today; `edge`
        // itself is unusable here because it floats, and a generated artifact must keep deploying
        // the recipe it was published against. Move both to :latest once a stable release includes
        // them. See
        // https://github.com/radius-project/resource-types-contrib/blob/main/.github/workflows/publish-bicep-recipes.yaml
        // for the tag contract.
        [RadiusResourceTypes.RedisCaches] = "ghcr.io/radius-project/kube-recipes/rediscaches:" + UnreleasedRecipeSha,
        // See https://github.com/radius-project/resource-types-contrib/blob/main/Data/postgreSqlDatabases/recipes/kubernetes/bicep/kubernetes-postgresql.bicep.
        [RadiusResourceTypes.PostgreSqlDatabases] = "ghcr.io/radius-project/kube-recipes/postgresqldatabases:latest",
        // Deliberately absent: Radius.Data/mongoDatabases. It has no published kube-recipes
        // artifact yet, so there is no default recipe to register for it — the same situation as
        // Radius.Data/sqlServerDatabases below. The LegacyMongoDatabases row further down is what
        // ResourceTypeMapper actually emits, and pointing the UDT at that local-dev recipe here
        // would register a recipe that cannot serve it the moment the legacy fallback is dropped.
        [RadiusResourceTypes.RabbitMQ] = "ghcr.io/radius-project/kube-recipes/rabbitmq:" + UnreleasedRecipeSha,
        // Radius.Security/secrets is recipe-backed like any other type. Registered on demand when
        // a resource's credential is carried by a secret resource — see the call site in BuildAsync.
        [RadiusResourceTypes.SecuritySecrets] = "ghcr.io/radius-project/kube-recipes/secrets:latest",
        // The Radius.Compute/containers UDT needs a recipe registered in the env's recipe pack;
        // shipped Radius does not include one by default, so register the published container
        // recipe so native containers deploy without a manually-authored recipe.
        [RadiusResourceTypes.Containers] = "ghcr.io/radius-project/kube-recipes/containers:latest",
        // Legacy fallback types also get default recipes
        [RadiusResourceTypes.LegacyMongoDatabases] = "ghcr.io/radius-project/recipes/local-dev/mongodatabases:latest",
        // Paired with LegacySqlDatabases, which is what SqlServerServerResource emits. The
        // Radius.Data/sqlServerDatabases UDT has no published kube-recipes artifact yet, so there is
        // no default recipe to register for it.
        [RadiusResourceTypes.LegacySqlDatabases] = "ghcr.io/radius-project/recipes/local-dev/sqldatabases:latest",
        [RadiusResourceTypes.LegacyDaprStateStores] = "ghcr.io/radius-project/recipes/local-dev/daprstatestores:latest",
        [RadiusResourceTypes.LegacyDaprPubSubBrokers] = "ghcr.io/radius-project/recipes/local-dev/daprpubsubbrokers:latest",
    };
 
    /// <summary>
    /// The default recipe table, exposed so <c>BackingResourceContractTests</c> can hold it to the
    /// same contract the connection-schema table has: total over every emitted backing type, and
    /// with each recipe prefix matching its type's namespace. A missing or mismatched row is
    /// otherwise invisible until <c>rad deploy</c> reports <c>RecipeDownloadFailed</c>.
    /// </summary>
    internal static IReadOnlyDictionary<string, string> DefaultRecipeTemplates => s_defaultRecipeTemplates;
 
    internal RadiusInfrastructureBuilder(
        RadiusEnvironmentResource environment,
        DistributedApplicationModel model,
        ResourceTypeMapper typeMapper,
        ILogger logger)
    {
        _environment = environment;
        _model = model;
        _typeMapper = typeMapper;
        _logger = logger;
    }
 
    /// <summary>
    /// Builds the Bicep AST and populates a <see cref="RadiusInfrastructureOptions"/> with
    /// typed constructs. Runs <c>ConfigureRadiusInfrastructure</c> callbacks last (last-write-wins).
    /// </summary>
    /// <param name="executionContext">
    /// Publish-mode execution context used to resolve container environment variables and
    /// service-discovery values from the application model.
    /// </param>
    /// <param name="cancellationToken">A token to cancel the build.</param>
    internal async Task<RadiusInfrastructureOptions> BuildAsync(
        DistributedApplicationExecutionContext executionContext,
        CancellationToken cancellationToken)
    {
        _executionContext = executionContext;
        _cancellationToken = cancellationToken;
 
        var options = new RadiusInfrastructureOptions();
        var envIdentifier = BicepPostProcessor.SanitizeIdentifier(_environment.Name);
 
        // Classify resources for this environment. ResolveResourceType is computed once per
        // resource here and reused below — calling it repeatedly would re-emit the
        // ResourceTypeMapper Info/Warning logs (legacy fallback / unmapped type) for every
        // resource, producing duplicate noise on every publish.
        var (radiusResources, computeResources, resolvedTypes) = ClassifyResources();
 
        // 1. UDT recipe pack (created first so environment can reference its ID)
        var recipePackIdentifier = "recipepack";
        var udtRecipeEntries = new Dictionary<string, RecipeEntry>(StringComparer.Ordinal);
        var legacyRecipeEntries = new Dictionary<string, Dictionary<string, RecipeEntry>>(StringComparer.Ordinal);
 
        // Radius binds one recipe per resource type per environment. Each type gets its default
        // in-cluster recipe: UDT (Radius.*) types via the shared recipe pack, legacy
        // Applications.* types via inline named recipes on the legacy environment. Per-instance
        // and custom recipe overrides are not part of this PR — they arrive with the follow-up
        // that reintroduces the recipe customization API.
        foreach (var resource in radiusResources)
        {
            var (resourceType, _) = resolvedTypes[resource];
 
            if (IsLegacyResourceType(resourceType))
            {
                AddLegacyRecipeEntry(legacyRecipeEntries, resourceType);
            }
            else
            {
                AddRecipeEntry(udtRecipeEntries, resourceType);
 
                // Radius.Security/secrets is itself a recipe-backed type, not a control-plane
                // primitive: the official Kubernetes pack registers it alongside the workload
                // types (see recipe-packs/kubernetes/default-recipepack.bicep in
                // radius-project/resource-types-contrib). A resource whose credential is carried
                // by a secret resource therefore pulls a *second* recipe into the pack, and
                // omitting it leaves the secret with no recipe to resolve — the deploy fails on
                // the secret, not on the resource that referenced it.
                //
                // This is decided here, before the pack is built, rather than in
                // ApplySecretResourceCredentialsAsync where the secret constructs are actually
                // created, because the pack is already sealed by then.
                if (RadiusBackingConnections.GetSchema(resourceType)?.Credentials
                    is RadiusBackingConnections.RadiusCredentialMode.SecretResourceReference)
                {
                    AddRecipeEntry(udtRecipeEntries, RadiusResourceTypes.SecuritySecrets);
                }
            }
        }
 
        // Partition flags.
        var hasUdtResources = radiusResources.Any(r =>
            !IsLegacyResourceType(resolvedTypes[r].ResourceType));
        var hasLegacyResources = radiusResources.Any(r =>
            IsLegacyResourceType(resolvedTypes[r].ResourceType));
        var hasComputeResources = computeResources.Any();
 
        // Radius secret stores routed to this environment. Applications.Core/secretStores is a
        // legacy Applications.Core resource, so its presence forces the legacy environment/
        // application chain (which it references for scope). No-op when no store is declared,
        // keeping the default path byte-for-byte unchanged.
        var secretStoresForScope = GetSecretStoresForScope().ToList();
        var hasSecretStores = secretStoresForScope.Count > 0;
 
        // Secret-store consumers (recipeConfig auth / envSecrets) also require the legacy
        // Applications.Core/environments chain, since recipeConfig lives on that resource.
        var secretStoresAnnotation = _environment.Annotations
            .OfType<Annotations.RadiusSecretStoresAnnotation>()
            .FirstOrDefault();
        var hasSecretStoreConsumers = secretStoresAnnotation is { Consumers.Count: > 0 };
 
        // Compute workloads always route to the UDT compute container type
        // (Radius.Compute/containers), which forces the UDT environment/application chain.
        var computeForcesUdtChain = hasComputeResources;
 
        // 2. UDT environment + application — emitted only when we have UDT
        // radius resources or any UDT-bound compute workload. Pure-legacy
        // publishes (Redis-only) skip the UDT chain entirely so older Radius
        // installs aren't forced to understand `Radius.Core/*`.
        RadiusRecipePackConstruct? recipePackConstruct = null;
        RadiusEnvironmentConstruct? envConstruct = null;
        RadiusApplicationConstruct? appConstruct = null;
        var appIdentifier = "app";
 
        if (hasUdtResources || computeForcesUdtChain)
        {
            // UDT containers route to Radius.Compute/containers, which the control plane
            // provisions through a recipe. Register the default container recipe in the
            // pack so native containers deploy on shipped Radius without a hand-authored
            // recipe — mirroring how backing resources get their default recipes.
            if (computeForcesUdtChain)
            {
                AddRecipeEntry(udtRecipeEntries, RadiusResourceTypes.Containers);
            }
 
            recipePackConstruct = CreateRecipePackConstruct(recipePackIdentifier, udtRecipeEntries);
            options.RecipePacks.Add(recipePackConstruct);
 
            envConstruct = CreateEnvironmentConstruct(envIdentifier, recipePackConstruct);
            options.Environments.Add(envConstruct);
 
            appConstruct = CreateApplicationConstruct(appIdentifier, envConstruct);
            options.Applications.Add(appConstruct);
        }
 
        // 3. Legacy parents are emitted lazily — only if any legacy backing
        // resource, secret store, or secret-store consumer is present. Legacy
        // env/app share the *resource name* with the UDT pair so Radius still
        // sees them as the same logical app/environment; only the Bicep
        // identifiers differ.
        LegacyApplicationEnvironmentConstruct? legacyEnvConstruct = null;
        LegacyApplicationConstruct? legacyAppConstruct = null;
 
        if (hasLegacyResources || hasSecretStores || hasSecretStoreConsumers)
        {
            // If the UDT chain is also emitted we suffix legacy identifiers with
            // `_legacy`; otherwise (pure-legacy publish) legacy can claim the
            // unsuffixed identifiers.
            var legacyEnvIdentifier = (hasUdtResources || computeForcesUdtChain)
                ? envIdentifier + "_legacy" : envIdentifier;
            var legacyAppIdentifier = (hasUdtResources || computeForcesUdtChain)
                ? appIdentifier + "_legacy" : appIdentifier;
 
            legacyEnvConstruct = CreateLegacyEnvironmentConstruct(
                legacyEnvIdentifier, legacyRecipeEntries);
            options.LegacyEnvironments.Add(legacyEnvConstruct);
 
            legacyAppConstruct = CreateLegacyApplicationConstruct(
                legacyAppIdentifier, appIdentifier, BuildIdExpression(legacyEnvConstruct));
 
            options.LegacyApplications.Add(legacyAppConstruct);
        }
 
        // Secret stores (Applications.Core/secretStores) — emitted after the legacy chain they
        // reference for scope. No-op when no store is declared.
        var secretStoreConstructs = EmitSecretStores(options, secretStoresForScope, legacyEnvConstruct, legacyAppConstruct);
 
        // 4. Resource type instances — parent wiring depends on legacy vs UDT.
        // Track each builder-created instance's parent pair so RewireIdReferences
        // can re-resolve `.id` after callbacks without clobbering resources that
        // a callback added itself.
        var instanceParents = new Dictionary<RadiusResourceTypeConstruct, (ProvisionableResource? Env, ProvisionableResource App)>();
 
        foreach (var resource in radiusResources)
        {
            var (resourceType, apiVersion) = resolvedTypes[resource];
            var identifier = BicepPostProcessor.SanitizeIdentifier(resource.Name);
 
            var isLegacy = IsLegacyResourceType(resourceType);
 
            ProvisionableResource? parentEnv = isLegacy ? legacyEnvConstruct : envConstruct;
            ProvisionableResource parentApp = isLegacy ? legacyAppConstruct! : appConstruct!;
 
            var typeInstance = CreateResourceTypeConstruct(
                identifier, resource.Name, resourceType, apiVersion,
                parentApp, parentEnv);
            options.ResourceTypeInstances.Add(typeInstance);
            _typeInstancesByResourceName[resource.Name] = typeInstance;
            _radiusTypeByResourceName[resource.Name] = resourceType;
            instanceParents[typeInstance] = (parentEnv, parentApp);
        }
 
        // 4b. Wire backing-resource credentials before any container env var is resolved, so the
        // substitutions below are in place by the time connection strings are composed.
        await ApplyBackingResourceCredentialsAsync(radiusResources, options, envConstruct, appConstruct).ConfigureAwait(false);
 
        // 5. Container workloads always route to the UDT compute container type
        // (Radius.Compute/containers) parented to the UDT application.
        var containerConnectionTargets = new Dictionary<RadiusContainerConstruct, Dictionary<string, RadiusResourceTypeConstruct>>();
 
        // Records the literal container ports (endpoint name -> port + protocol) that service
        // discovery was derived from, keyed by the immutable container map key (the resource name),
        // so a ConfigureRadiusInfrastructure callback that later changes/removes a port — or replaces
        // or drops the whole container — can be rejected after callbacks run. Keying by the stable
        // map key (not the construct instance) means a callback that swaps in a new construct for the
        // same workload is still validated. See ValidatePostCallbackContainerInvariants.
        var containerPortSnapshots = new Dictionary<string, Dictionary<string, (int Port, string Protocol)>>(StringComparer.Ordinal);
        foreach (var resource in computeResources)
        {
            var identifier = BicepPostProcessor.SanitizeIdentifier(resource.Name);
            var image = GetContainerImage(resource);
            var connectionTargets = GetConnectionTargets(resource, radiusResources, _typeInstancesByResourceName);
            WarnIfImageMayNotPull(resource.Name, image);
 
            // Resolve the resource's environment variables (config, connection strings, OTEL_*,
            // WithEnvironment, and `services__*` service discovery) and its endpoint ports the
            // same way the Kubernetes publisher does, so the deployed container behaves like the
            // local run. Secret/parameter values are routed to Bicep `param`s (never literals).
            var projectedStart = _projectedEnvValues.Count;
            var secretRefStart = _containerEnvSecretReferences.Count;
            var env = await ResolveEnvironmentAsync(resource, options, envConstruct, appConstruct).ConfigureAwait(false);
            var ports = ResolvePorts(resource);
 
            var containerConstruct = CreateContainerConstruct(
                identifier, resource.Name, image, appConstruct!, envConstruct, connectionTargets, env, ports);
 
            // Associate the values projected above with the construct that now owns them, so a
            // callback that replaces or drops the workload can be told apart from one that renames
            // a backing resource. See RebuildProjectedEnvValues.
            for (var i = projectedStart; i < _projectedEnvValues.Count; i++)
            {
                _projectedEnvValues[i].Container = containerConstruct;
            }
 
            for (var i = secretRefStart; i < _containerEnvSecretReferences.Count; i++)
            {
                _containerEnvSecretReferences[i].Container = containerConstruct;
            }
            options.Containers.Add(containerConstruct);
            containerConnectionTargets[containerConstruct] = connectionTargets;
            containerPortSnapshots[resource.Name] = ports.ToDictionary(
                kv => kv.Key,
                kv => (
                    ((IBicepValue)kv.Value.ContainerPort).LiteralValue is int literalPort ? literalPort : -1,
                    ((IBicepValue)kv.Value.Protocol).LiteralValue is string literalProtocol ? literalProtocol : string.Empty),
                StringComparer.Ordinal);
        }
 
        // Every container's environment has now been resolved, so consumption of a database child
        // through a WithEnvironment callback (which records no reference annotation) is finally
        // visible. Report the databases the recipe cannot create against that complete picture, and
        // reject a model whose consumers connect to a database other than the one selected.
        var consumptionAwareReferences = GetReferencedResourceNames();
        WarnForDatabasesNotCreatedByTheRecipe(consumptionAwareReferences);
        ValidateRecipeDatabaseSelections(consumptionAwareReferences);
 
        // A container whose environment carries a credential emits its own Radius.Security/secrets
        // resource, and that is only known once every container's environment has been resolved —
        // after the pack was built above. Top the pack up here rather than pre-scanning: resolving
        // an environment runs the resource's EnvironmentCallbackAnnotation callbacks, so a pre-pass
        // would run every user callback twice. Still before ConfigureRadiusInfrastructure, so a
        // callback sees the finished pack. No-op when the entry is already present (a backing
        // resource with a SecretResourceReference credential registers it above).
        EnsureSecretsRecipeRegistered(options, recipePackConstruct, udtRecipeEntries);
 
        // Emit the Bicep parameters allocated for secret/parameter-backed container env vars as
        // top-level `param`s, before ConfigureRadiusInfrastructure runs so callbacks can see them.
        options.Parameters.AddRange(_envParametersByName.Values);
 
        // 6. Snapshot every identifier that rewiring depends on, then run
        // ConfigureRadiusInfrastructure callbacks (last-write-wins). We only
        // re-resolve a `.id` reference below if its target was *renamed* by a
        // callback; references the callback set explicitly are preserved.
        var identifierSnapshot = new IdentifierSnapshot(
            envConstruct?.BicepIdentifier,
            appConstruct?.BicepIdentifier,
            legacyEnvConstruct?.BicepIdentifier,
            legacyAppConstruct?.BicepIdentifier,
            options.RecipePacks.ToDictionary(p => p, p => p.BicepIdentifier),
            instanceParents.ToDictionary(
                kv => kv.Key,
                kv => (EnvId: kv.Value.Env?.BicepIdentifier,
                       AppId: kv.Value.App.BicepIdentifier)),
            containerConnectionTargets.ToDictionary(
                kv => kv.Key,
                kv => kv.Value.ToDictionary(
                    tkv => tkv.Key, tkv => tkv.Value.BicepIdentifier)),
            secretStoreConstructs.Values.ToDictionary(c => c, c => c.BicepIdentifier));
 
        RunConfigureCallbacks(options);
 
        // Validate the post-callback container set. A ConfigureRadiusInfrastructure callback can
        // rename containers, mutate/remove ports, add ports to a previously portless container, or
        // replace/drop a workload entirely. Service discovery (`services__*` URLs and the recipe
        // Service name/port) was derived from the pre-callback model, so all of these can silently
        // break cross-container calls or emit an invalid manifest. Validate the final state and fail
        // fast on any detectable divergence.
        ValidatePostCallbackContainerInvariants(options, containerPortSnapshots);
 
        // Container env values that read a backing resource's recipe outputs capture that
        // resource's Bicep identifier, so a callback rename breaks them the same way it breaks a
        // `.id` reference. Repair them before the `.id` rewiring below.
        RebuildProjectedEnvValues(options);
 
        // 7. Rewire `.id` cross-references for targets whose BicepIdentifier
        // was changed by a callback; leave everything else (including callback
        // edits to references) alone.
        RewireIdReferences(options, appConstruct, envConstruct,
            legacyAppConstruct, legacyEnvConstruct, instanceParents,
            containerConnectionTargets,
            identifierSnapshot);
 
        // Secret stores participate in the same escape-hatch surface, so their consumer references
        // (recipeConfig `<store>.id`) and parent scope IDs must be rewired too when a callback
        // renames a store construct or the legacy application/environment it is scoped to.
        RewireSecretStoreReferences(secretStoresForScope, secretStoreConstructs,
            legacyAppConstruct, legacyEnvConstruct, identifierSnapshot);
 
        // Radius.Security/secrets resources are consumed by `<secret>.id` on the resource whose
        // credential they carry. A callback that renamed the secret leaves that reference pointing
        // at a symbol that no longer exists, so repair it the same way.
        RewireSecretResourceCredentials(options, envConstruct, appConstruct);
 
        // Containers read credential-bearing env values from their own Radius.Security/secrets
        // resource, by resource *name*. Repair those references, and the secrets' scope references.
        RewireContainerEnvSecrets(options, envConstruct, appConstruct);
 
        // A callback can add a Radius.Security/secrets resource of its own, and the type is
        // recipe-backed, so the pack may need the entry even when the publisher emitted no secret.
        EnsureSecretsRecipeRegistered(options, recipePackConstruct, udtRecipeEntries);
 
        // Names and data keys are copied verbatim into the Kubernetes Secret by the recipe, and are
        // freely mutable through the callback surface. Validate the final state — after the rewiring
        // above, so the repaired names are the ones checked.
        ValidateFinalSecretShapes(options);
        ValidateNoPhysicalSecretCollisions(options);
 
        // Surface recipe-parameter scopes that target a resource type with no emitted recipe
        // entry, and register any ParameterResource-backed recipe/inline-secret Bicep params.
        WarnUnmatchedResourceTypeScopes(udtRecipeEntries.Keys.Concat(legacyRecipeEntries.Keys));
        foreach (var (name, parameter) in _recipeParameters)
        {
            options.RecipeParameters[name] = parameter;
        }
 
        // Surface the param-identifier -> ParameterResource bindings so the deploy step can
        // resolve a value for every valueless `param` at deploy time (rad deploy --parameters).
        foreach (var (identifier, parameter) in _recipeParameterBindings)
        {
            options.RecipeParameterBindings[identifier] = parameter;
        }
 
        RecordDeployParameters(options);
 
        return options;
    }
 
    // Records the emitted Bicep parameter identifier → ParameterResource mapping on the
    // environment resource so the deploy step can resolve each value at deploy time and pass it
    // via `rad deploy --parameters`. Replaces any prior annotation so a re-publish (e.g. repeated
    // BuildAsync calls) stays idempotent rather than accumulating stale mappings.
    private void RecordDeployParameters(RadiusInfrastructureOptions options)
    {
        foreach (var existing in _environment.Annotations.OfType<RadiusDeployParametersAnnotation>().ToList())
        {
            _environment.Annotations.Remove(existing);
        }
 
        // Persist the union of PR1 container-env parameters and PR2 recipe/inline-secret
        // parameter bindings. A parameter referenced by both a container env var and a recipe/
        // secret value must resolve to exactly one deploy binding, so merge rather than replace.
        var deployParameters = new Dictionary<string, ParameterResource>(_deployParametersByIdentifier, StringComparer.Ordinal);
        foreach (var (identifier, parameter) in _recipeParameterBindings)
        {
            deployParameters[identifier] = parameter;
        }
 
        if (deployParameters.Count > 0)
        {
            _environment.Annotations.Add(new RadiusDeployParametersAnnotation(
                deployParameters,
                BuildLiveRabbitMqUserNames(options)));
        }
    }
 
    // _rabbitMqUserNames is populated while the broker's recipe inputs are projected, which happens
    // before ConfigureRadiusInfrastructure callbacks run. A callback is free to drop a broker from
    // options.ResourceTypeInstances, and its user-name parameter can still reach the deploy step
    // through another consumer (a container env var, say). Carrying the stale mapping forward would
    // fail `aspire deploy` with ASPIRERADIUS082 naming a broker the deployment does not contain, so
    // only brokers whose construct survived the callbacks are validated at deploy time.
    //
    // A callback that swaps the construct out for a different instance is treated as a removal here,
    // matching how RebuildProjectedTypeProperties reads liveInstances. That fails open — the guest
    // check is skipped for the replacement — which is the safe direction: the publisher no longer
    // owns the substituted resource, so it cannot claim the user name it emitted is still the one
    // being provisioned.
    private Dictionary<ParameterResource, IReadOnlyList<IResource>> BuildLiveRabbitMqUserNames(RadiusInfrastructureOptions options)
    {
        var live = new Dictionary<ParameterResource, IReadOnlyList<IResource>>(ReferenceEqualityComparer.Instance);
        if (_rabbitMqUserNames.Count == 0)
        {
            return live;
        }
 
        var liveInstances = new HashSet<RadiusResourceTypeConstruct>(options.ResourceTypeInstances);
        foreach (var (parameter, owners) in _rabbitMqUserNames)
        {
            // The parameter stays under validation while *any* of its brokers survives: the value is
            // shared, so one live broker is enough for 'guest' to reach a deployed workload.
            var liveOwners = owners
                .Where(owner => _typeInstancesByResourceName.TryGetValue(owner.Name, out var construct) &&
                                liveInstances.Contains(construct))
                .ToList();
 
            if (liveOwners.Count > 0)
            {
                live[parameter] = liveOwners;
            }
        }
 
        return live;
    }
 
    /// <summary>
    /// Pre-callback snapshot of every construct identifier the builder wired
    /// references against. After callbacks run, <see cref="RewireIdReferences"/>
    /// compares each target's current identifier against the snapshot and only
    /// rewires the ones that changed — preserving any direct reference edits a
    /// callback performed.
    /// </summary>
    private sealed record IdentifierSnapshot(
        string? EnvId,
        string? AppId,
        string? LegacyEnvId,
        string? LegacyAppId,
        Dictionary<RadiusRecipePackConstruct, string> RecipePackIds,
        Dictionary<RadiusResourceTypeConstruct, (string? EnvId, string AppId)> InstanceParentIds,
        Dictionary<RadiusContainerConstruct, Dictionary<string, string>> ContainerConnectionTargetIds,
        Dictionary<RadiusSecretStoreConstruct, string> SecretStoreIds);
 
    /// <summary>
    /// Returns <c>true</c> when <paramref name="resourceType"/> is a legacy
    /// <c>Applications.*</c> type that should be parented to
    /// <c>Applications.Core/environments</c> rather than <c>Radius.Core/environments</c>.
    /// </summary>
    private static bool IsLegacyResourceType(string resourceType) =>
        resourceType.StartsWith("Applications.", StringComparison.Ordinal);
 
    /// <summary>
    /// After callbacks run, re-resolve each builder-created <c>.id</c>
    /// cross-reference only when its target's <c>BicepIdentifier</c> was
    /// actually changed by a callback. References the callback edited directly
    /// (without renaming the target) are preserved — honouring the public
    /// "last-write-wins" contract on <c>ConfigureRadiusInfrastructure</c>.
    /// </summary>
    private static void RewireIdReferences(
        RadiusInfrastructureOptions options,
        RadiusApplicationConstruct? appConstruct,
        RadiusEnvironmentConstruct? envConstruct,
        LegacyApplicationConstruct? legacyAppConstruct,
        LegacyApplicationEnvironmentConstruct? legacyEnvConstruct,
        Dictionary<RadiusResourceTypeConstruct, (ProvisionableResource? Env, ProvisionableResource App)> instanceParents,
        Dictionary<RadiusContainerConstruct, Dictionary<string, RadiusResourceTypeConstruct>> containerConnectionTargets,
        IdentifierSnapshot snapshot)
    {
        // UDT env → recipe packs. Rebuild only if any builder-created pack was
        // renamed. (New packs added by a callback and removed packs are left to
        // the callback to wire up — this method only fixes broken refs.)
        if (envConstruct is not null)
        {
            var anyPackRenamed = false;
            foreach (var (pack, snapId) in snapshot.RecipePackIds)
            {
                if (!string.Equals(pack.BicepIdentifier, snapId, StringComparison.Ordinal))
                {
                    anyPackRenamed = true;
                    break;
                }
            }
 
            if (anyPackRenamed)
            {
                envConstruct.RecipePacks.Clear();
                foreach (var pack in options.RecipePacks)
                {
                    envConstruct.RecipePacks.Add(BuildIdExpression(pack));
                }
            }
        }
 
        // UDT app → UDT env.
        if (appConstruct is not null && envConstruct is not null &&
            IdentifierChanged(envConstruct, snapshot.EnvId))
        {
            appConstruct.EnvironmentId = BuildIdExpression(envConstruct);
        }
 
        // Legacy app → legacy env.
        if (legacyAppConstruct is not null && legacyEnvConstruct is not null &&
            IdentifierChanged(legacyEnvConstruct, snapshot.LegacyEnvId))
        {
            legacyAppConstruct.EnvironmentId = BuildIdExpression(legacyEnvConstruct);
        }
 
        // Resource type instances: rewire each parent ref only if *that*
        // parent's identifier was renamed.
        foreach (var instance in options.ResourceTypeInstances)
        {
            if (!instanceParents.TryGetValue(instance, out var parents))
            {
                continue;
            }
 
            if (!snapshot.InstanceParentIds.TryGetValue(instance, out var snapIds))
            {
                continue;
            }
 
            if (!string.Equals(parents.App.BicepIdentifier, snapIds.AppId, StringComparison.Ordinal))
            {
                instance.ApplicationId = BuildIdExpression(parents.App);
            }
 
            if (parents.Env is not null &&
                !string.Equals(parents.Env.BicepIdentifier, snapIds.EnvId, StringComparison.Ordinal))
            {
                instance.EnvironmentId = BuildIdExpression(parents.Env);
            }
        }
 
        // Containers — rewire ApplicationId only if the UDT app was renamed;
        // rewire each connection source only if its target was renamed.
        foreach (var container in options.Containers)
        {
            if (!containerConnectionTargets.TryGetValue(container, out var targets))
            {
                // Callback-added container; leave its refs alone.
                continue;
            }
 
            if (appConstruct is not null && IdentifierChanged(appConstruct, snapshot.AppId))
            {
                container.ApplicationId = BuildIdExpression(appConstruct);
            }
 
            if (envConstruct is not null && IdentifierChanged(envConstruct, snapshot.EnvId))
            {
                container.EnvironmentId = BuildIdExpression(envConstruct);
            }
 
            if (targets.Count == 0 ||
                !snapshot.ContainerConnectionTargetIds.TryGetValue(container, out var targetSnapIds))
            {
                continue;
            }
 
            foreach (var (connectionName, targetConstruct) in targets)
            {
                if (!targetSnapIds.TryGetValue(connectionName, out var snapTargetId))
                {
                    continue;
                }
 
                if (string.Equals(targetConstruct.BicepIdentifier, snapTargetId, StringComparison.Ordinal))
                {
                    continue;
                }
 
                // Target was renamed — replace the stale connection entry.
                container.Connections[connectionName] = new ConnectionConstruct
                {
                    Source = BuildIdExpression(targetConstruct),
                };
            }
        }
    }
 
    private static bool IdentifierChanged(ProvisionableResource resource, string? snapshotId)
        => !string.Equals(resource.BicepIdentifier, snapshotId, StringComparison.Ordinal);
 
    /// <summary>
    /// After callbacks run, rewire secret-store cross-references whose target was renamed:
    /// <list type="bullet">
    /// <item>a store's <c>ApplicationId</c>/<c>EnvironmentId</c> parent scope, if the legacy
    /// application/environment it points at was renamed; and</item>
    /// <item>the environment's <c>recipeConfig</c>, which references consumed stores by
    /// <c>&lt;identifier&gt;.id</c>, if any store construct was renamed.</item>
    /// </list>
    /// Mirrors <see cref="RewireIdReferences"/> for the secret-store surface exposed via
    /// <see cref="RadiusInfrastructureOptions.SecretStores"/>.
    /// </summary>
    private void RewireSecretStoreReferences(
        IReadOnlyList<RadiusSecretStoreResource> stores,
        IReadOnlyDictionary<string, RadiusSecretStoreConstruct> storeConstructs,
        LegacyApplicationConstruct? legacyAppConstruct,
        LegacyApplicationEnvironmentConstruct? legacyEnvConstruct,
        IdentifierSnapshot snapshot)
    {
        if (storeConstructs.Count == 0)
        {
            return;
        }
 
        // Parent scope IDs: an application-scoped store references the legacy application, an
        // environment-scoped store the legacy environment. If a callback renamed that parent
        // construct, the store's ApplicationId/EnvironmentId still points at the old symbol.
        var legacyAppRenamed = legacyAppConstruct is not null && IdentifierChanged(legacyAppConstruct, snapshot.LegacyAppId);
        var legacyEnvRenamed = legacyEnvConstruct is not null && IdentifierChanged(legacyEnvConstruct, snapshot.LegacyEnvId);
 
        if (legacyAppRenamed || legacyEnvRenamed)
        {
            foreach (var store in stores)
            {
                if (!storeConstructs.TryGetValue(store.Name, out var construct))
                {
                    continue;
                }
 
                // Mirror the scope selection used when the store was emitted (see EmitSecretStores).
                if (store.Scope == RadiusSecretStoreScope.Application && legacyAppConstruct is not null)
                {
                    if (legacyAppRenamed)
                    {
                        construct.ApplicationId = BuildIdExpression(legacyAppConstruct);
                    }
                }
                else if (legacyEnvConstruct is not null && legacyEnvRenamed)
                {
                    construct.EnvironmentId = BuildIdExpression(legacyEnvConstruct);
                }
            }
        }
 
        // recipeConfig references each consumed store by `<identifier>.id`. It is a single serialized
        // object (not individually addressable per store), so — unlike the per-reference constructs
        // above — the consistent way to honor a store rename is to rebuild the whole recipeConfig from
        // the current constructs. Only do so when a store was actually renamed, preserving direct
        // callback edits in every other case.
        var anyStoreRenamed = false;
        foreach (var (construct, snapId) in snapshot.SecretStoreIds)
        {
            if (!string.Equals(construct.BicepIdentifier, snapId, StringComparison.Ordinal))
            {
                anyStoreRenamed = true;
                break;
            }
        }
 
        if (anyStoreRenamed && legacyEnvConstruct is not null)
        {
            ApplySecretStoreConsumers(legacyEnvConstruct, storeConstructs);
        }
    }
 
    /// <summary>
    /// After callbacks run, repair the <c>&lt;secret&gt;.id</c> references that credential-carrying
    /// <c>Radius.Security/secrets</c> resources are consumed through, and the secrets' own scope
    /// references, when a callback renamed either side.
    /// </summary>
    /// <remarks>
    /// Mirrors <see cref="RewireIdReferences"/>: a reference the callback set itself is preserved
    /// (last-write-wins), and only a value still exactly as the publisher generated it is repaired.
    /// A callback that removed the secret outright is rejected — the consuming property is required,
    /// so silently leaving a dangling reference would fail only at deploy time.
    /// </remarks>
    private void RewireSecretResourceCredentials(
        RadiusInfrastructureOptions options,
        RadiusEnvironmentConstruct? envConstruct,
        RadiusApplicationConstruct? appConstruct)
    {
        if (_secretResourceCredentials.Count == 0)
        {
            return;
        }
 
        var liveSecrets = new HashSet<RadiusSecuritySecretConstruct>(options.SecuritySecrets);
        var liveInstances = new HashSet<RadiusResourceTypeConstruct>(options.ResourceTypeInstances);
 
        // Scope references are repaired per *secret*, ahead of the per-credential loop below, for
        // the same two reasons as RewireContainerEnvSecrets. First, the last-write-wins comparison
        // only holds against the value the publisher wrote, so repairing once per credential would
        // mis-detect on the second credential sharing a secret. Second — and this is why the repair
        // cannot live inside the loop below — the secret outlives its consumer: the loop skips a
        // credential whose consumer a callback removed or whose property it reassigned, but the
        // secret itself stays in options.SecuritySecrets and is still emitted. Repairing under
        // those guards left it pointing at a symbol the rename retired, which reaches the artifact
        // as a dangling reference that only Bicep compilation rejects, with nothing naming the
        // callback that caused it.
        foreach (var credential in _secretResourceCredentials.DistinctBy(c => c.Secret))
        {
            if (!liveSecrets.Contains(credential.Secret))
            {
                continue;
            }
 
            // Only repair a scope the callback has not set itself — `options.SecuritySecrets` is
            // part of the callback surface, so re-scoping a generated secret is legitimate.
            if (envConstruct is not null &&
                string.Equals(RenderBicepValue(credential.Secret.EnvironmentId), credential.OriginalEnvironmentId, StringComparison.Ordinal))
            {
                credential.Secret.EnvironmentId = BuildIdExpression(envConstruct);
            }
 
            if (appConstruct is not null &&
                credential.Secret.ApplicationId is { } currentApplicationId &&
                string.Equals(RenderBicepValue(currentApplicationId), credential.OriginalApplicationId, StringComparison.Ordinal))
            {
                credential.Secret.ApplicationId = BuildIdExpression(appConstruct);
            }
        }
 
        foreach (var credential in _secretResourceCredentials)
        {
            // The consumer is gone: the callback owns that decision, and the *consumer
            // relationship* is left alone rather than second-guessed. The secret's own scope
            // references were already repaired by the loop above, because the secret outlives the
            // consumer and is still emitted.
            if (!liveInstances.Contains(credential.Consumer))
            {
                continue;
            }
 
            // The callback took ownership of the property, so this relationship is no longer the
            // publisher's to enforce — last-write-wins, exactly as for container env values and
            // projected type properties. This has to be decided *before* the checks below: those
            // reject removing the secret or changing the credential, which are legitimate once the
            // consumer no longer reads from it. The credential schema properties are internal, so
            // the typed surface offers no way to reassign one, but the `ProvisionableProperties`
            // dictionary inherited from Azure.Provisioning is public and reaches the same values.
            if (credential.Consumer.GetSchemaProperty(credential.PropertyName) is not { } currentProperty ||
                !string.Equals(RenderBicepValue(currentProperty), credential.OriginalPropertyValue, StringComparison.Ordinal))
            {
                continue;
            }
 
            if (!liveSecrets.Contains(credential.Secret))
            {
                throw new InvalidOperationException(
                    $"Radius resource '{credential.Consumer.BicepIdentifier}' reads its '{credential.PropertyName}' from " +
                    $"the '{RadiusResourceTypes.SecuritySecrets}' resource '{credential.OriginalSecretIdentifier}', but a " +
                    $"ConfigureRadiusInfrastructure callback removed it. The property is required, so the deployment would " +
                    $"be rejected. Keep the secret, or point '{credential.PropertyName}' at a secret of your own. " +
                    $"Diagnostic: ASPIRERADIUS074.");
            }
 
            // The consumer still reads this secret, so the entry carrying the credential has to
            // survive intact. Unlike a container env secret — whose only reader is the variable that
            // points at it, so a callback replacing the value is self-consistent — this value is
            // handed to the *recipe* that provisions the server, while the matching credential was
            // already composed into every consumer's connection string from Aspire's own parameter.
            // Removing it prevents the recipe from starting; changing it provisions a server with a
            // password no consumer was told about, which fails only as an authentication error at
            // runtime. Neither can be repaired here, so both are rejected.
            if (!credential.Secret.Data.TryGetValue(credential.SecretKey, out var liveEntry))
            {
                throw new InvalidOperationException(
                    $"Radius resource '{credential.Consumer.BicepIdentifier}' reads its '{credential.PropertyName}' from " +
                    $"key '{credential.SecretKey}' of the '{RadiusResourceTypes.SecuritySecrets}' resource " +
                    $"'{credential.Secret.BicepIdentifier}', but a ConfigureRadiusInfrastructure callback removed that " +
                    $"key. The recipe cannot provision the resource without it. Keep the key, or point " +
                    $"'{credential.PropertyName}' at a secret of your own. Diagnostic: ASPIRERADIUS089.");
            }
 
            // Both an entry swapped for a new construct and one mutated in place are rejected: the
            // credential Aspire projected to consumers is fixed at this point either way. The
            // encoding is checked alongside the value because it decides how the recipe interprets
            // that value — flipping `string` to `base64` makes the recipe decode before writing the
            // Kubernetes Secret, so the provisioned credential diverges from the one consumers hold
            // even though the value is byte-identical.
            if (!ReferenceEquals(liveEntry?.Value, credential.Entry) ||
                !string.Equals(RenderBicepValue(credential.Entry.Value), credential.OriginalEntryValue, StringComparison.Ordinal) ||
                !string.Equals(RenderBicepValue(credential.Entry.Encoding), credential.OriginalEntryEncoding, StringComparison.Ordinal))
            {
                throw new InvalidOperationException(
                    $"A ConfigureRadiusInfrastructure callback changed the value or encoding of key '{credential.SecretKey}' " +
                    $"on the '{RadiusResourceTypes.SecuritySecrets}' resource '{credential.Secret.BicepIdentifier}', which supplies " +
                    $"'{credential.PropertyName}' for '{credential.Consumer.BicepIdentifier}'. Consumers were already given " +
                    $"the original credential in their connection strings, so the deployed resource would require a " +
                    $"credential no consumer has. Supply the credential as a parameter instead, or point " +
                    $"'{credential.PropertyName}' at a secret of your own. Diagnostic: ASPIRERADIUS089.");
            }
 
            if (string.Equals(credential.Secret.BicepIdentifier, credential.OriginalSecretIdentifier, StringComparison.Ordinal))
            {
                continue;
            }
 
            credential.Consumer.SetSchemaProperty(
                credential.PropertyName,
                new BicepValue<object>(BuildIdExpression(credential.Secret)));
        }
    }
 
    /// <summary>
    /// After callbacks run, repair the container-to-secret references behind
    /// <c>valueFrom.secretKeyRef</c> environment variables, and the secrets' own scope references.
    /// </summary>
    /// <remarks>
    /// Mirrors <see cref="RewireSecretResourceCredentials"/>. The reference names the secret by
    /// <em>resource name</em> rather than by Bicep identifier, so renaming the symbol needs no
    /// repair — but renaming the resource, removing the secret, or removing the key it points at
    /// all leave the container reading a Kubernetes <c>Secret</c> that is never created. Radius
    /// accepts that artifact and the failure surfaces as a pod that will not start, so it is
    /// rejected here instead, while the cause is still attributable to a callback.
    /// </remarks>
    private void RewireContainerEnvSecrets(
        RadiusInfrastructureOptions options,
        RadiusEnvironmentConstruct? envConstruct,
        RadiusApplicationConstruct? appConstruct)
    {
        if (_containerEnvSecretReferences.Count == 0)
        {
            return;
        }
 
        var liveSecrets = new HashSet<RadiusSecuritySecretConstruct>(options.SecuritySecrets);
        var liveContainers = new HashSet<RadiusContainerConstruct>(options.Containers);
 
        // Scope references are repaired per *secret*, not per reference: a secret holds many
        // entries, and the last-write-wins comparison below only holds against the value the
        // publisher wrote, so repairing it once per entry would mis-detect after the first repair.
        foreach (var tracked in _containerEnvSecrets)
        {
            if (!liveSecrets.Contains(tracked.Secret))
            {
                continue;
            }
 
            // Only repair a scope the callback has not set itself — `options.SecuritySecrets` is
            // part of the callback surface, so re-scoping a generated secret is legitimate.
            if (envConstruct is not null &&
                string.Equals(RenderBicepValue(tracked.Secret.EnvironmentId), tracked.OriginalEnvironmentId, StringComparison.Ordinal))
            {
                tracked.Secret.EnvironmentId = BuildIdExpression(envConstruct);
            }
 
            if (appConstruct is not null &&
                tracked.Secret.ApplicationId is { } currentApplicationId &&
                string.Equals(RenderBicepValue(currentApplicationId), tracked.OriginalApplicationId, StringComparison.Ordinal))
            {
                tracked.Secret.ApplicationId = BuildIdExpression(appConstruct);
            }
        }
 
        foreach (var reference in _containerEnvSecretReferences)
        {
            // A callback that dropped or replaced the workload, removed the variable, or replaced
            // the variable's construct owns the result — last-write-wins, as everywhere else.
            if (reference.Container is null ||
                !liveContainers.Contains(reference.Container) ||
                !reference.Container.Env.TryGetValue(reference.Key, out var currentEnvVar) ||
                // BicepDictionary wraps each entry, so unwrap before comparing construct identity.
                !ReferenceEquals(currentEnvVar?.Value, reference.EnvVar))
            {
                continue;
            }
 
            // The callback re-pointed the reference itself.
            if (!string.Equals(RenderBicepValue(reference.EnvVar.SecretName), reference.OriginalSecretName, StringComparison.Ordinal))
            {
                continue;
            }
 
            if (!liveSecrets.Contains(reference.Secret))
            {
                throw new InvalidOperationException(
                    $"Environment variable '{reference.Key}' on container '{reference.ResourceName}' holds a credential " +
                    $"and reads it from the '{RadiusResourceTypes.SecuritySecrets}' resource " +
                    $"'{reference.Secret.BicepIdentifier}', but a ConfigureRadiusInfrastructure callback removed that " +
                    $"resource. Keep the resource, or set '{reference.Key}' explicitly in the callback. " +
                    $"Diagnostic: ASPIRERADIUS084.");
            }
 
            // Validate the key the variable *currently* carries rather than the one the publisher
            // wrote. A callback can re-point the key alone — leaving SecretName aimed at this
            // generated secret and so passing the guard above — and checking the original key would
            // find it present and publish a `secretKeyRef` naming a key that does not exist. Radius
            // accepts that artifact and the failure surfaces as a pod that never starts.
            //
            // Two shapes are deliberately left alone: a key rendered as a Bicep expression only
            // resolves at deploy time, so there is nothing to compare it against, and a cleared key
            // is already rejected by the SecretName/SecretKey pairing check (ASPIRERADIUS087).
            if (IsBicepExpression(reference.EnvVar.SecretKey) ||
                RenderBicepLiteral(reference.EnvVar.SecretKey) is not { } currentSecretKey)
            {
                continue;
            }
 
            var keyRepointed = !string.Equals(currentSecretKey, reference.SecretKey, StringComparison.Ordinal);
 
            if (!reference.Secret.Data.TryGetValue(currentSecretKey, out var liveEntry))
            {
                throw new InvalidOperationException(keyRepointed
                    ? $"Environment variable '{reference.Key}' on container '{reference.ResourceName}' holds a credential " +
                      $"and a ConfigureRadiusInfrastructure callback pointed it at key '{currentSecretKey}' of the " +
                      $"'{RadiusResourceTypes.SecuritySecrets}' resource '{reference.Secret.BicepIdentifier}', which has " +
                      $"no such key. Point it at an existing key, add that key to the resource, or set " +
                      $"'{reference.Key}' explicitly in the callback. Diagnostic: ASPIRERADIUS084."
                    : $"Environment variable '{reference.Key}' on container '{reference.ResourceName}' holds a credential " +
                      $"and reads it from key '{reference.SecretKey}' of the '{RadiusResourceTypes.SecuritySecrets}' " +
                      $"resource '{reference.Secret.BicepIdentifier}', but a ConfigureRadiusInfrastructure callback " +
                      $"removed that key. Keep the key, or set '{reference.Key}' explicitly in the callback. " +
                      $"Diagnostic: ASPIRERADIUS084.");
            }
 
            // A callback that supplied its own entry for this key owns the value; the reference
            // still resolves, so there is nothing to repair.
            if (!ReferenceEquals(liveEntry?.Value, reference.Entry))
            {
                continue;
            }
 
            // Re-sync if the callback renamed the secret *resource* — the reference is by name.
            reference.EnvVar.SecretName = reference.Secret.SecretName;
        }
    }
 
    /// <summary>
    /// After callbacks run, reject two emitted secrets that would materialize as the <em>same</em>
    /// Kubernetes <c>Secret</c> object — the same name in the same namespace.
    /// </summary>
    /// <remarks>
    /// Radius scopes uniqueness by resource type, so an <c>Applications.Core/secretStores</c> and a
    /// <c>Radius.Security/secrets</c> can carry names that are distinct to Radius yet collapse onto
    /// one cluster object. The publisher itself can produce that pair: a container named <c>Api</c>
    /// generates a store named <c>api-env-secret</c>, and
    /// <c>AddRadiusSecretStore("api-env-secret")</c> is a distinct Bicep symbol that passes the
    /// existing duplicate-identifier check while naming the same object. Whichever the deploy
    /// applies second overwrites the first, so the surviving object carries only one set of keys and
    /// the other consumer reads a key that is not there.
    /// <para>
    /// A resource that only <em>references</em> an object the cluster already has (a secret store in
    /// the existing mode) never overwrites it, so two of those naming the same object are
    /// legitimate — exposing different keys from one Secret is the point of the mode. Only a pair
    /// where at least one side materializes the object is rejected. A sealed store also carries a
    /// <c>resource</c> reference but <em>is</em> a materializer, because deploy applies its manifest
    /// with <c>kubectl apply</c>. Its claim is taken from the validated manifest rather than from
    /// the emitted construct: <see cref="SealedSecretApplyStep"/> selects sealed stores from the
    /// application model, so the manifest is applied even if a callback removed or replaced the
    /// construct, and dropping the claim along with the construct would hide a real collision.
    /// </para>
    /// <para>
    /// This is reported rather than auto-renamed: both names are user-visible (one is the resource
    /// name, the other was passed explicitly), and renaming either would silently break a reference
    /// held outside the app model — an existing/sealed store deliberately names an object the
    /// cluster already has.
    /// </para>
    /// <para>
    /// Only candidates whose namespace <em>and</em> name are both statically resolvable are
    /// compared. Namespace comes from walking the scope chain to the owning environment construct,
    /// whose namespace a callback may itself have changed; it is never assumed to be the environment
    /// the publisher started from. Anything dynamic is skipped rather than guessed, because a false
    /// collision would fail a publish that deploys correctly.
    /// </para>
    /// </remarks>
    private static void ValidateNoPhysicalSecretCollisions(RadiusInfrastructureOptions options)
    {
        // Rendered `<symbol>.id` of every scope construct, so a scope reference can be walked back
        // to the environment that supplies the namespace. Applications carry no namespace of their
        // own, so they resolve through their own EnvironmentId.
        var namespaceByScopeId = new Dictionary<string, string?>(StringComparer.Ordinal);
        var environmentIdByScopeId = new Dictionary<string, string?>(StringComparer.Ordinal);
 
        foreach (var environment in options.Environments)
        {
            namespaceByScopeId[BuildIdExpression(environment).ToString()] =
                RenderBicepLiteral(environment.KubernetesNamespace);
        }
 
        foreach (var legacyEnvironment in options.LegacyEnvironments)
        {
            namespaceByScopeId[BuildIdExpression(legacyEnvironment).ToString()] =
                RenderBicepLiteral(legacyEnvironment.ComputeNamespace);
        }
 
        foreach (var application in options.Applications)
        {
            environmentIdByScopeId[BuildIdExpression(application).ToString()] =
                RenderBicepValue(application.EnvironmentId);
        }
 
        foreach (var legacyApplication in options.LegacyApplications)
        {
            environmentIdByScopeId[BuildIdExpression(legacyApplication).ToString()] =
                RenderBicepValue(legacyApplication.EnvironmentId);
        }
 
        string? ResolveNamespace(string? scopeId)
        {
            if (scopeId is null)
            {
                return null;
            }
 
            if (environmentIdByScopeId.TryGetValue(scopeId, out var environmentId))
            {
                scopeId = environmentId;
            }
 
            return scopeId is not null && namespaceByScopeId.TryGetValue(scopeId, out var ns) ? ns : null;
        }
 
        // Only a resource that *materializes* the object can overwrite another's contents. Two
        // reference-only stores naming the same existing object are legitimate and common — one
        // exposing `username`, another exposing `password` from the same cluster Secret — so a
        // reference collides with a materializer but never with another reference.
        var claimed = new Dictionary<(string Namespace, string Name), (string Description, bool Materializes)>();
 
        void Claim(string? ns, string? name, string description, bool materializes)
        {
            if (ns is null || name is null)
            {
                return;
            }
 
            if (claimed.TryGetValue((ns, name), out var existing))
            {
                if (materializes || existing.Materializes)
                {
                    // Both writing and one-writes-one-reads are failures, but for different reasons,
                    // so say which one the author is actually looking at.
                    var consequence = materializes && existing.Materializes
                        ? "whichever is applied second overwrites the first, and a consumer of the overwritten secret " +
                          "reads a key that is no longer there"
                        : "one of them creates the object the other only references, so the reference resolves to " +
                          "contents it did not expect and the keys it expects may not be there";
 
                    throw new InvalidOperationException(
                        $"{description} and {existing.Description} both resolve to the Kubernetes Secret '{name}' in " +
                        $"namespace '{ns}'. Radius scopes names by resource type, so these are distinct resources to " +
                        $"Radius but one object in the cluster: {consequence}. Rename one of them. " +
                        $"Diagnostic: ASPIRERADIUS090.");
                }
 
                return;
            }
 
            claimed[(ns, name)] = (description, materializes);
        }
 
        // Claimed first so the reference-only pass below can recognize a reference *to* a sealed
        // object, which is the intended way to consume one rather than a collision.
        var sealedObjects = new HashSet<(string Namespace, string Name)>();
 
        // Two stores may legitimately be populated from the *same* manifest file — e.g. one file
        // carrying both `username` and `password`, with each store exposing one key. Both deploy
        // steps then apply byte-identical validated content, and SealedSecretApplyStep's re-apply
        // is deliberately idempotent, so there is no overwrite to warn about. Coalesce those
        // writers and reserve ASPIRERADIUS090 for genuinely *distinct* manifests targeting one
        // object, which is the case that really does clobber contents.
        var sealedContentByObject = new Dictionary<(string Namespace, string Name), ReadOnlyMemory<byte>>();
 
        foreach (var (storeName, manifest) in options.SealedSecretManifests)
        {
            var manifestObject = (manifest.Metadata.Namespace, manifest.Metadata.Name);
            sealedObjects.Add(manifestObject);
 
            if (sealedContentByObject.TryGetValue(manifestObject, out var claimedContent) &&
                claimedContent.Span.SequenceEqual(manifest.Content.Span))
            {
                continue;
            }
 
            sealedContentByObject[manifestObject] = manifest.Content;
 
            Claim(
                manifest.Metadata.Namespace,
                manifest.Metadata.Name,
                $"The sealed secret store '{storeName}'",
                materializes: true);
        }
 
        foreach (var secret in options.SecuritySecrets)
        {
            // `ApplicationId` is optional but its getter always returns a non-null BicepValue in an
            // unset state, so presence has to be decided by whether it renders to anything.
            var scopeId = RenderBicepValue(secret.ApplicationId) ?? RenderBicepValue(secret.EnvironmentId);
 
            Claim(
                ResolveNamespace(scopeId),
                RenderBicepLiteral(secret.SecretName),
                $"The '{RadiusResourceTypes.SecuritySecrets}' resource '{secret.BicepIdentifier}'",
                materializes: true);
        }
 
        foreach (var store in options.SecretStores)
        {
            var scopeId = RenderBicepValue(store.ApplicationId) ?? RenderBicepValue(store.EnvironmentId);
            var description = $"The secret store '{store.BicepIdentifier}'";
 
            // An existing/sealed store names an object by `resource` rather than by `StoreName`,
            // which in those modes is only the Radius-side resource name.
            if (RenderBicepLiteral(store.ResourceReference) is { } resourceReference)
            {
                // `resource` is `<namespace>/<name>`, or a bare name meaning the scope's namespace.
                var separator = resourceReference.IndexOf('/');
                var referencedNamespace = separator >= 0 ? resourceReference[..separator] : ResolveNamespace(scopeId);
                var referencedName = separator >= 0 ? resourceReference[(separator + 1)..] : resourceReference;
 
                // A sealed store's own `resource` points at the object its manifest applies, and a
                // store may also deliberately reference a sealed-managed object. Either way the
                // sealed manifest already claimed it as the writer; claiming it again as a reference
                // would report the store as colliding with the very thing that creates it.
                if (referencedNamespace is null || !sealedObjects.Contains((referencedNamespace, referencedName)))
                {
                    Claim(referencedNamespace, referencedName, description, materializes: false);
                }
 
                continue;
            }
 
            if (IsBicepExpression(store.ResourceReference))
            {
                continue;
            }
 
            Claim(ResolveNamespace(scopeId), RenderBicepLiteral(store.StoreName), description, materializes: true);
        }
    }
 
    /// <summary>
    /// After callbacks run, validate the final shape of every emitted secret: the Kubernetes object
    /// name each one materializes as, and every data key it carries.
    /// </summary>
    /// <remarks>
    /// Both are values the secrets recipe copies <em>verbatim</em> into a Kubernetes <c>Secret</c>
    /// (<c>metadata.name</c> and the <c>data</c> keys), and Radius validates neither, so an invalid
    /// value compiles as Bicep and surfaces only when the API server rejects the object at deploy.
    /// <para>
    /// The publisher's own names and keys are already checked where they are generated
    /// (<see cref="ToSecretKey"/>, and resource names are constrained by Aspire's model-name rules),
    /// but <see cref="RadiusSecuritySecretConstruct.SecretName"/>,
    /// <see cref="RadiusSecretStoreConstruct.StoreName"/> and the <c>Data</c> dictionaries
    /// are public and freely mutable, so the final state is only knowable here.
    /// </para>
    /// <para>
    /// Only <em>literal</em> names are validated. A callback may legitimately assign a Bicep
    /// expression: <see cref="RewireContainerEnvSecrets"/> assigns the secret's own
    /// <see cref="BicepValue{T}"/> to the consuming variable's <c>secretName</c>, so both sides
    /// evaluate to the same value at deploy time and the reference stays coherent. Rejecting a name
    /// merely because it cannot be checked statically would contradict the escape hatch's
    /// last-write-wins contract — the publisher only rejects a non-literal where it has already
    /// emitted a fixed literal that must match (service discovery, see
    /// <c>ValidatePostCallbackContainerInvariants</c>).
    /// </para>
    /// </remarks>
    private static void ValidateFinalSecretShapes(RadiusInfrastructureOptions options)
    {
        foreach (var secret in options.SecuritySecrets)
        {
            // An unset name renders as null, which the literal check below skips entirely — the
            // resource would then publish successfully and emit a `Radius.Security/secrets` block
            // with no `name` at all, failing only once the deployment reaches the API server. This
            // is deliberately a separate gate from the DNS-1123 check so an expression-backed name
            // (which cannot be validated statically, and which RewireContainerEnvSecrets assigns on
            // purpose) still passes.
            if (RenderBicepValue(secret.SecretName) is null && !IsBicepExpression(secret.SecretName))
            {
                throw new InvalidOperationException(
                    $"The '{RadiusResourceTypes.SecuritySecrets}' resource '{secret.BicepIdentifier}' has no name. " +
                    $"The recipe uses '{nameof(RadiusSecuritySecretConstruct.SecretName)}' verbatim as the Secret's " +
                    $"'metadata.name', so the deployment would be rejected. Set " +
                    $"'{nameof(RadiusSecuritySecretConstruct.SecretName)}' to a DNS-1123 subdomain. " +
                    $"Diagnostic: ASPIRERADIUS088.");
            }
 
            if (RenderBicepValue(secret.SecretName) is { } secretName &&
                !IsBicepExpression(secret.SecretName) &&
                !KubernetesName.IsDns1123Subdomain(secretName))
            {
                throw new InvalidOperationException(
                    $"The '{RadiusResourceTypes.SecuritySecrets}' resource '{secret.BicepIdentifier}' has the name " +
                    $"'{secretName}', which is not a valid Kubernetes object name. The recipe uses it verbatim as the " +
                    $"Secret's 'metadata.name', so the deployment would be rejected. A name must be a DNS-1123 " +
                    $"subdomain: 1-253 characters of lowercase letters, digits, '-' and '.', starting and ending " +
                    $"alphanumeric. Diagnostic: ASPIRERADIUS088.");
            }
 
            foreach (var (key, _) in secret.Data)
            {
                ValidateSecretDataKey(key, RadiusResourceTypes.SecuritySecrets, secret.BicepIdentifier);
            }
 
            ValidateSecretKindRequiredKeys(secret);
            ValidateSecuritySecretRequiredFields(secret);
        }
 
        // Secret stores reach the same Kubernetes `data` map, and an inline store's `StoreName`
        // becomes the object's `metadata.name`. AddRadiusSecretStore validates both at the API
        // boundary, but the construct is part of the callback surface too, so the final state still
        // has to be checked. Both population modes key `Data` identically — an inline entry carries
        // a value and an existing-secret entry is an empty object naming a key to expose — so the
        // key contract is the same for both.
        foreach (var store in options.SecretStores)
        {
            // Only an inline store materializes an object under its own `StoreName`; an
            // existing/sealed store names its object through `resource`, leaving `StoreName` as the
            // Radius-side resource name only. The gate mirrors ValidateNoPhysicalSecretCollisions,
            // which claims exactly these stores with `materializes: true`.
            if (RenderBicepLiteral(store.ResourceReference) is null &&
                !IsBicepExpression(store.ResourceReference) &&
                RenderBicepLiteral(store.StoreName) is { } storeName &&
                !KubernetesName.IsDns1123Subdomain(storeName))
            {
                throw new InvalidOperationException(
                    $"The secret store '{store.BicepIdentifier}' has the name '{storeName}', which is not a valid " +
                    $"Kubernetes object name. The recipe uses it verbatim as the Secret's 'metadata.name', so the " +
                    $"deployment would be rejected. A name must be a DNS-1123 subdomain: 1-253 characters of " +
                    $"lowercase letters, digits, '-' and '.', starting and ending alphanumeric. " +
                    $"Diagnostic: ASPIRERADIUS088.");
            }
 
            foreach (var (key, _) in store.Data)
            {
                ValidateSecretDataKey(key, "secret store", store.BicepIdentifier);
            }
 
            ValidateSecretStoreTypeRequiredKeys(store);
        }
    }
 
    /// <summary>
    /// Rejects a literal <see cref="RadiusSecuritySecretConstruct.Kind"/> whose recipe-required keys
    /// are not all present in <c>data</c>, or which is the one spelling known to be a migration
    /// mistake. Every other unrecognized literal is passed through.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <c>Kind</c> is public and freely mutable, so a callback can select a kind that carries a
    /// data-shape contract the builder API's enum-typed surface would have enforced — for example
    /// <c>basicAuthentication</c> requires <c>username</c> and <c>password</c>, and
    /// <c>certificate-pem</c> requires <c>tls.crt</c>/<c>tls.key</c>. The control plane does not
    /// enforce those keys; the pinned secrets recipe does, and it turns the missing-fields error
    /// into the Kubernetes Secret's <c>metadata.name</c>, so publish succeeds and the failure only
    /// surfaces during deployment as an unrelated-looking name error.
    /// </para>
    /// <para>
    /// The bare <c>certificate</c> spelling gets its own message. It is valid on the legacy
    /// <c>Applications.Core/secretStores</c> type this one replaces but is not a member of this
    /// type's enum, so it is a migration mistake rather than a kind a newer control plane might
    /// know. Every other unrecognized kind is allowed through, and expressions are not evaluated
    /// statically.
    /// </para>
    /// </remarks>
    private static void ValidateSecretKindRequiredKeys(RadiusSecuritySecretConstruct secret)
    {
        if (IsBicepExpression(secret.Kind) || RenderBicepLiteral(secret.Kind) is not { } kind)
        {
            return;
        }
 
        if (kind is RadiusSecuritySecretKinds.LegacyCertificate)
        {
            throw new InvalidOperationException(
                $"The '{RadiusResourceTypes.SecuritySecrets}' resource '{secret.BicepIdentifier}' declares kind " +
                $"'{RadiusSecuritySecretKinds.LegacyCertificate}', which belongs to the legacy " +
                $"'Applications.Core/secretStores' type this one replaces. " +
                $"'{RadiusResourceTypes.SecuritySecrets}' accepts " +
                $"'{RadiusSecuritySecretKinds.CertificatePem}' or 'certificate-pkcs12' instead, so Radius would " +
                $"reject the deployment. Diagnostic: ASPIRERADIUS092.");
        }
 
        if (!RadiusSecuritySecretKinds.All.Contains(kind))
        {
            throw new InvalidOperationException(
                $"The '{RadiusResourceTypes.SecuritySecrets}' resource '{secret.BicepIdentifier}' declares kind " +
                $"'{kind}', which is not one of the kinds the type accepts " +
                $"('{string.Join("', '", RadiusSecuritySecretKinds.All)}'). The generated bicepconfig.json pins the " +
                $"Radius extension to {RadiusBicepExtension.Version}, whose schema declares 'kind' as a closed set, so " +
                $"'bicep build' would reject the artifact during deployment. Diagnostic: ASPIRERADIUS092.");
        }
 
        if (!RadiusSecuritySecretKinds.TryGetRequiredKeys(kind, out var requiredKeys))
        {
            return;
        }
 
        var missing = requiredKeys.Where(required => !secret.Data.ContainsKey(required)).ToList();
        if (missing.Count == 0)
        {
            return;
        }
 
        throw new InvalidOperationException(
            $"The '{RadiusResourceTypes.SecuritySecrets}' resource '{secret.BicepIdentifier}' declares kind " +
            $"'{kind}', which requires the data {(missing.Count == 1 ? "key" : "keys")} " +
            $"{string.Join(", ", missing.Select(key => $"'{key}'"))}, but 'data' does not contain " +
            $"{(missing.Count == 1 ? "it" : "them")}. Radius does not reject the missing fields directly — the " +
            $"secrets recipe surfaces them as an invalid Kubernetes object name at deploy time — so add the " +
            $"missing {(missing.Count == 1 ? "entry" : "entries")} or use kind " +
            $"'{RadiusSecuritySecretKinds.Generic}'. Diagnostic: ASPIRERADIUS092.");
    }
 
    /// <summary>
    /// The legacy <c>Applications.Core/secretStores</c> counterpart of
    /// <see cref="ValidateSecretKindRequiredKeys"/>, checking
    /// <see cref="RadiusSecretStoreConstruct.StoreType"/> against that type's own vocabulary.
    /// </summary>
    /// <remarks>
    /// <see cref="RadiusSecretStoreExtensions.AddRadiusSecretStore"/> enforces this at the API boundary through the
    /// <see cref="RadiusSecretStoreType"/> enum, but <c>StoreType</c> is a public, freely settable
    /// string on the construct, so a callback can reach a shape the builder would have rejected.
    /// The two vocabularies are kept apart on purpose: <c>certificate</c> is valid here and invalid
    /// on the replacement type, and <c>certificate-pem</c> is the reverse.
    /// </remarks>
    private static void ValidateSecretStoreTypeRequiredKeys(RadiusSecretStoreConstruct store)
    {
        if (IsBicepExpression(store.StoreType) ||
            RenderBicepLiteral(store.StoreType) is not { } storeTypeString)
        {
            return;
        }
 
        // Closed enum, enforced by the Bicep compiler against the pinned extension types exactly as
        // for Radius.Security/secrets `kind` — see RadiusSecuritySecretKinds.All. An unparseable
        // literal cannot deploy, so it is reported here rather than emitted.
        if (!RadiusSecretStoreTypeExtensions.TryParseRadiusTypeString(storeTypeString, out var storeType))
        {
            throw new InvalidOperationException(
                $"The secret store '{store.BicepIdentifier}' declares type '{storeTypeString}', which is not one of " +
                $"the types 'Applications.Core/secretStores' accepts " +
                $"('{string.Join("', '", RadiusSecretStoreTypeExtensions.AllRadiusTypeStrings)}'). The generated " +
                $"bicepconfig.json pins the Radius extension to {RadiusBicepExtension.Version}, whose schema declares " +
                $"'type' as a closed set, so 'bicep build' would reject the artifact during deployment. " +
                $"Diagnostic: ASPIRERADIUS092.");
        }
 
        var missing = storeType.RequiredKeys().Where(required => !store.Data.ContainsKey(required)).ToList();
        if (missing.Count == 0)
        {
            return;
        }
 
        throw new InvalidOperationException(
            $"The secret store '{store.BicepIdentifier}' declares type '{storeTypeString}', which requires the " +
            $"data {(missing.Count == 1 ? "key" : "keys")} {string.Join(", ", missing.Select(key => $"'{key}'"))}, " +
            $"but 'data' does not contain {(missing.Count == 1 ? "it" : "them")}. Add the missing " +
            $"{(missing.Count == 1 ? "entry" : "entries")} or use type 'generic'. Diagnostic: ASPIRERADIUS092.");
    }
 
    /// <summary>
    /// Rejects a <see cref="RadiusSecuritySecretConstruct"/> that is missing a field the
    /// <c>Radius.Security/secrets</c> schema requires, or that carries the one literal
    /// <c>encoding</c> known to be a migration mistake.
    /// </summary>
    /// <remarks>
    /// <para>
    /// The publisher always writes <see cref="RadiusSecuritySecretConstruct.EnvironmentId"/> and a
    /// <see cref="RadiusSecuritySecretDataEntryConstruct.Value"/> for every entry, so every state
    /// rejected here comes from a callback: both properties are public and independently settable,
    /// and an unset <see cref="BicepValue{T}"/> is simply omitted from the emitted Bicep rather than
    /// producing a compile error. The result is a resource block that is syntactically fine and is
    /// rejected only by Radius schema validation at <c>rad deploy</c> time, with a message that
    /// points at the generated artifact rather than at the callback that produced it.
    /// </para>
    /// <para>
    /// The encoding vocabulary is the one place the new type diverges from the legacy
    /// <c>Applications.Core/secretStores</c> type it replaces: <c>Radius.Security/secrets</c>
    /// accepts <c>string</c> and <c>base64</c>, where the legacy type accepted <c>raw</c> and
    /// <c>base64</c>. Radius rejects every value outside its own enum, but only <c>raw</c> is
    /// rejected here: it is the legacy vocabulary rather than a value a newer control plane might
    /// introduce, so it is the one spelling that can be called wrong without risking a false
    /// positive on a gate the AppHost author cannot opt out of.
    /// </para>
    /// </remarks>
    private static void ValidateSecuritySecretRequiredFields(RadiusSecuritySecretConstruct secret)
    {
        if (RenderBicepValue(secret.EnvironmentId) is null && !IsBicepExpression(secret.EnvironmentId))
        {
            throw new InvalidOperationException(
                $"The '{RadiusResourceTypes.SecuritySecrets}' resource '{secret.BicepIdentifier}' has no " +
                $"'{nameof(RadiusSecuritySecretConstruct.EnvironmentId)}'. The type requires " +
                $"'properties.environment', so Radius would reject the deployment. Assign the environment scope, " +
                $"or remove the resource. Diagnostic: ASPIRERADIUS093.");
        }
 
        foreach (var (key, entry) in secret.Data)
        {
            // A callback can leave a hole by assigning null, or by adding a key it never populated.
            // Either way the entry carries no value, which is the same defect the Value check below
            // catches — reported here because there is no construct left to inspect. An entry whose
            // whole wrapper is an expression is a different case: it resolves at deploy time and is
            // skipped rather than rejected, the same way an expression-valued property is.
            if (entry is null || (entry.Value is null && !IsBicepExpression(entry)))
            {
                throw new InvalidOperationException(
                    $"The '{RadiusResourceTypes.SecuritySecrets}' resource '{secret.BicepIdentifier}' has a data " +
                    $"entry '{key}' with no value. The type requires a value for every entry, so Radius would " +
                    $"reject the deployment. Assign a " +
                    $"'{nameof(RadiusSecuritySecretDataEntryConstruct)}', or remove the entry. " +
                    $"Diagnostic: ASPIRERADIUS093.");
            }
 
            if (entry.Value is not { } dataEntry)
            {
                continue;
            }
 
            if (RenderBicepValue(dataEntry.Value) is null && !IsBicepExpression(dataEntry.Value))
            {
                throw new InvalidOperationException(
                    $"The '{RadiusResourceTypes.SecuritySecrets}' resource '{secret.BicepIdentifier}' has a data " +
                    $"entry '{key}' with no '{nameof(RadiusSecuritySecretDataEntryConstruct.Value)}'. The type " +
                    $"requires a value for every entry, so Radius would reject the deployment. Assign the value — " +
                    $"normally a reference to a valueless '@secure()' parameter so no credential is written into the " +
                    $"published artifacts — or remove the entry. Diagnostic: ASPIRERADIUS093.");
            }
 
            // `encoding` is a closed union of string literals in the pinned extension's types.json
            // (see RadiusSecuritySecretKinds.All for the mechanism), so any literal outside it fails
            // `bicep build` during deployment. The legacy `raw` spelling keeps its own message
            // because it is the specific migration mistake worth naming.
            if (!IsBicepExpression(dataEntry.Encoding) &&
                RenderBicepLiteral(dataEntry.Encoding) is { } encoding)
            {
                if (encoding is RadiusSecuritySecretKinds.LegacyRawEncoding)
                {
                    throw new InvalidOperationException(
                        $"The '{RadiusResourceTypes.SecuritySecrets}' resource '{secret.BicepIdentifier}' has a data " +
                        $"entry '{key}' with the encoding '{RadiusSecuritySecretKinds.LegacyRawEncoding}', which belongs " +
                        $"to the legacy 'Applications.Core/secretStores' type this one replaces. " +
                        $"'{RadiusResourceTypes.SecuritySecrets}' accepts 'string' or 'base64', so Radius would reject " +
                        $"the deployment. Diagnostic: ASPIRERADIUS093.");
                }
 
                if (!RadiusSecuritySecretKinds.Encodings.Contains(encoding))
                {
                    throw new InvalidOperationException(
                        $"The '{RadiusResourceTypes.SecuritySecrets}' resource '{secret.BicepIdentifier}' has a data " +
                        $"entry '{key}' with the encoding '{encoding}', which is not one of the encodings the type " +
                        $"accepts ('{string.Join("', '", RadiusSecuritySecretKinds.Encodings)}'). The generated " +
                        $"bicepconfig.json pins the Radius extension to {RadiusBicepExtension.Version}, whose schema " +
                        $"declares 'encoding' as a closed set, so 'bicep build' would reject the artifact during " +
                        $"deployment. Diagnostic: ASPIRERADIUS093.");
                }
            }
        }
    }
 
    private static void ValidateSecretDataKey(string key, string ownerType, string ownerIdentifier)
    {
        if (KubernetesName.IsValidSecretDataKey(key))
        {
            return;
        }
 
        throw new InvalidOperationException(
            $"The {ownerType} resource '{ownerIdentifier}' carries the data key '{key}', which is not a valid " +
            $"Kubernetes secret key. The recipe copies it verbatim into the Secret's 'data', so the deployment would " +
            $"be rejected. A key must be 1-253 characters of letters, digits, '-', '_' and '.', and may not be '.' " +
            $"or '..' or start with '..'. Diagnostic: ASPIRERADIUS088.");
    }
 
    /// <summary>
    /// Whether a Bicep value carries an expression rather than a literal, so a static check on its
    /// text would be checking the expression's source rather than the value it deploys as.
    /// </summary>
    private static bool IsBicepExpression<T>(BicepValue<T> value) =>
        value is IBicepValue { Expression: not null };
 
    private (string ResourceType, string ApiVersion) ResolveResourceType(IResource resource)
    {
        return _typeMapper.MapResource(resource);
    }
    private (List<IResource> radiusResources, List<IResource> computeResources, Dictionary<IResource, (string ResourceType, string ApiVersion)> resolvedTypes) ClassifyResources()
    {
        var radiusTypes = new List<IResource>();
        var compute = new List<IResource>();
        var resolved = new Dictionary<IResource, (string ResourceType, string ApiVersion)>();
        var seen = new HashSet<string>(StringComparer.Ordinal);
 
        foreach (var resource in _model.Resources)
        {
            // Skip the Radius environment itself
            if (resource is RadiusEnvironmentResource)
            {
                continue;
            }
 
            // Check deployment target: only include resources targeted to this environment
            // or resources with no explicit target (default to this environment)
            if (!IsTargetedToThisEnvironment(resource))
            {
                continue;
            }
 
            // Resolve child resources to parent
            var resolvedResource = ResolveToParent(resource);
            if (resolvedResource != resource)
            {
                // Child resources (e.g., SqlServerDatabaseResource) are represented
                // via their parent; skip the child itself
                continue;
            }
 
            // Avoid duplicates
            if (!seen.Add(resource.Name))
            {
                continue;
            }
 
            // Use ResourceTypeMapper to determine classification:
            // - Explicit container/project resources with Containers mapping → compute workloads
            // - Resources with a specific resource type mapping → resource type instances
            // - Unmapped resources (ParameterResource, etc.) → skip
            var resolvedType = ResolveResourceType(resource);
            resolved[resource] = resolvedType;
            var resourceType = resolvedType.ResourceType;
 
            if (resource is IDotnetProgramResource ||
                (resource is ContainerResource && resourceType == RadiusResourceTypes.Containers))
            {
                compute.Add(resource);
            }
            else if (resourceType != RadiusResourceTypes.Containers)
            {
                radiusTypes.Add(resource);
            }
            // else: unmapped resource (e.g., ParameterResource) — skip
        }
 
        return (radiusTypes, compute, resolved);
    }
 
    private bool IsTargetedToThisEnvironment(IResource resource)
    {
        // The PrepareDeploymentTargets pipeline step (RadiusInfrastructure.PrepareDeploymentTargetsAsync)
        // attaches a DeploymentTargetAnnotation to every compute resource that belongs to this
        // environment, with ComputeEnvironment set to OwningComputeEnvironment ?? this. With multiple
        // compute environments in the model, untargeted resources are rejected upstream by
        // ValidateComputeEnvironments before this code runs.
        //
        // Use the framework's canonical lookup (Aspire.Hosting.ApplicationModel.ResourceExtensions
        // .GetDeploymentTargetAnnotation) so behaviour stays in sync with manifest/publish paths
        // and so the lookup honours ComputeEnvironmentAnnotation overrides set via WithComputeEnvironment.
        var targetComputeEnvironment = _environment.OwningComputeEnvironment ?? _environment;
        return resource.GetDeploymentTargetAnnotation(targetComputeEnvironment) is not null;
    }
 
    /// <summary>
    /// Resolves a child resource (e.g., SqlServerDatabaseResource) to its parent.
    /// Returns the resource itself if it has no parent.
    /// </summary>
    private static IResource ResolveToParent(IResource resource)
    {
        if (resource is IResourceWithParent childResource)
        {
            return childResource.Parent;
        }
 
        return resource;
    }
 
    /// <summary>
    /// Builds a <c>.id</c> expression for a resource, e.g., <c>envIdentifier.id</c>.
    /// </summary>
    private static BicepExpression BuildIdExpression(Azure.Provisioning.Primitives.ProvisionableResource resource)
    {
        return new MemberExpression(new IdentifierExpression(resource.BicepIdentifier), "id");
    }
 
    private RadiusEnvironmentConstruct CreateEnvironmentConstruct(
        string identifier, RadiusRecipePackConstruct recipePackConstruct)
    {
        var construct = new RadiusEnvironmentConstruct(identifier);
        construct.EnvironmentName = _environment.Name;
        construct.KubernetesNamespace = _environment.Namespace;
        construct.RecipePacks.Add(BuildIdExpression(recipePackConstruct));
        ApplyCloudProviders(construct);
        return construct;
    }
 
    private void ApplyCloudProviders(RadiusEnvironmentConstruct construct)
    {
        var annotation = _environment.Annotations
            .OfType<Annotations.RadiusCloudProvidersAnnotation>()
            .FirstOrDefault();
        if (annotation is null)
        {
            return;
        }
 
        if (annotation.Azure is { } azure)
        {
            construct.AzureSubscriptionId = azure.SubscriptionId;
            construct.AzureResourceGroupName = azure.ResourceGroup;
        }
 
        if (annotation.Aws is { } aws)
        {
            construct.AwsAccountId = aws.AccountId;
            construct.AwsRegion = aws.Region;
        }
    }
 
    // The legacy Applications.Core/environments schema carries cloud providers under the
    // same properties.providers.{azure,aws}.scope paths as the UDT environment. Apply them
    // here too so a pure-legacy publish (e.g. a managed Redis with no UDT compute) still
    // emits the provider configuration that the publish-time ASPIRERADIUS020 check requires.
    private void ApplyCloudProviders(LegacyApplicationEnvironmentConstruct construct)
    {
        var annotation = _environment.Annotations
            .OfType<Annotations.RadiusCloudProvidersAnnotation>()
            .FirstOrDefault();
        if (annotation is null)
        {
            return;
        }
 
        if (annotation.Azure is { } azure)
        {
            construct.AzureScope = BuildAzureScope(azure);
        }
 
        if (annotation.Aws is { } aws)
        {
            construct.AwsScope = BuildAwsScope(aws);
        }
    }
 
    private static string BuildAzureScope(CloudProviders.AzureRadiusProviderConfig azure)
        => $"/subscriptions/{azure.SubscriptionId}/resourceGroups/{azure.ResourceGroup}";
 
    private static string BuildAwsScope(CloudProviders.AwsRadiusProviderConfig aws)
        => $"/planes/aws/aws/accounts/{aws.AccountId}/regions/{aws.Region}";
 
    private static RadiusApplicationConstruct CreateApplicationConstruct(
        string identifier, RadiusEnvironmentConstruct? envConstruct)
    {
        var construct = new RadiusApplicationConstruct(identifier);
        construct.ApplicationName = identifier;
        construct.EnvironmentId = BuildIdExpression(envConstruct!);
        return construct;
    }
 
    private static RadiusResourceTypeConstruct CreateResourceTypeConstruct(
        string identifier, string resourceName, string resourceType, string apiVersion,
        ProvisionableResource appConstruct, ProvisionableResource? envConstruct)
    {
        var construct = new RadiusResourceTypeConstruct(identifier, resourceType, apiVersion);
        construct.ResourceName = resourceName;
        construct.ApplicationId = BuildIdExpression(appConstruct);
        construct.EnvironmentId = BuildIdExpression(envConstruct!);
 
        // Every instance binds its resource type's single default recipe (UDT types via the
        // shared recipe pack, legacy types via the "default" entry on the legacy environment),
        // so no per-instance recipe name is emitted here. Per-instance / named recipe overrides
        // are deferred to the follow-up that reintroduces the recipe customization API.
        return construct;
    }
 
    private void AddRecipeEntry(
        Dictionary<string, RecipeEntry> entries,
        string resourceType)
    {
        if (s_defaultRecipeTemplates.TryGetValue(resourceType, out var defaultTemplate))
        {
            // Don't overwrite a custom entry a ConfigureRadiusInfrastructure callback may add.
            entries.TryAdd(resourceType, new RecipeEntry("bicep", defaultTemplate));
        }
        else
        {
            _logger.LogWarning(
                "No default recipe template found for resource type '{ResourceType}'. " +
                "Register a recipe for this type via ConfigureRadiusInfrastructure().",
                resourceType);
        }
    }
 
    /// <summary>
    /// Registers the <c>Radius.Security/secrets</c> recipe in the pack when any such resource is
    /// emitted and the entry is not already present.
    /// </summary>
    /// <remarks>
    /// <c>Radius.Security/secrets</c> is recipe-backed rather than a control-plane primitive (see
    /// <c>recipe-packs/kubernetes/default-recipepack.bicep</c> in
    /// <c>radius-project/resource-types-contrib</c>), so a secret with no registered recipe fails
    /// the deploy on the secret rather than on whatever referenced it.
    /// <para>
    /// Called twice: once after container environments are resolved (the pack is built before that,
    /// and pre-scanning would run every user <c>EnvironmentCallbackAnnotation</c> twice), and again
    /// after <c>ConfigureRadiusInfrastructure</c>, which can add a secret of its own. A callback
    /// that supplied its own entry for the type keeps it — the <c>ContainsKey</c> check makes this
    /// last-write-wins like the rest of the escape hatch.
    /// </para>
    /// <para>
    /// The second pass is not only for callback-added secrets: <c>Recipes</c> is a mutable
    /// dictionary, so a callback can also <em>remove</em> the entry the first pass registered while
    /// leaving the secret that needs it in place. That is repaired rather than rejected, and is a
    /// deliberate exception to last-write-wins: the type is recipe-backed, so a surviving secret
    /// with no registered recipe fails the deploy on the secret itself rather than on whatever
    /// referenced it. Replacing the entry is still fully supported — only removing it while a
    /// consumer remains is undone, because that state has no valid deployment.
    /// </para>
    /// </remarks>
    private void EnsureSecretsRecipeRegistered(
        RadiusInfrastructureOptions options,
        RadiusRecipePackConstruct? recipePackConstruct,
        Dictionary<string, RecipeEntry> udtRecipeEntries)
    {
        // A callback can replace the pack wholesale, in which case it owns its contents.
        if (recipePackConstruct is null ||
            !options.RecipePacks.Contains(recipePackConstruct) ||
            options.SecuritySecrets.Count == 0 ||
            recipePackConstruct.Recipes.ContainsKey(RadiusResourceTypes.SecuritySecrets))
        {
            return;
        }
 
        AddRecipeEntry(udtRecipeEntries, RadiusResourceTypes.SecuritySecrets);
        var entry = udtRecipeEntries[RadiusResourceTypes.SecuritySecrets];
        recipePackConstruct.Recipes[RadiusResourceTypes.SecuritySecrets] =
            BuildRecipeEntryConstruct(RadiusResourceTypes.SecuritySecrets, entry);
    }
 
    /// <summary>
    /// Builds the <see cref="RecipeEntryConstruct"/> for one recipe entry, with the environment's
    /// <c>WithRecipeParameters</c> values for <paramref name="type"/> applied.
    /// </summary>
    /// <remarks>
    /// Shared by <see cref="CreateRecipePackConstruct"/> and
    /// <see cref="EnsureSecretsRecipeRegistered"/> so an entry added on the later path cannot
    /// silently lose its parameters: environment-wide parameters are documented as applying to
    /// every entry, and the type is recorded in the entry map either way, so
    /// <see cref="WarnUnmatchedResourceTypeScopes"/> would report nothing.
    /// </remarks>
    private RecipeEntryConstruct BuildRecipeEntryConstruct(string type, RecipeEntry entry)
    {
        var recipeEntry = new RecipeEntryConstruct
        {
            RecipeKind = entry.RecipeKind,
            RecipeLocation = entry.RecipeLocation,
        };
 
        // Environment-wide parameters merged with any resource-type-scoped overrides. No-op when
        // none are declared.
        var parameters = GetEffectiveRecipeParameters(type);
        if (parameters is not null)
        {
            ApplyRecipeParameters(recipeEntry.Parameters, parameters);
        }
 
        return recipeEntry;
    }
 
    private RadiusRecipePackConstruct CreateRecipePackConstruct(
        string identifier, Dictionary<string, RecipeEntry> recipeEntries)
    {
        var construct = new RadiusRecipePackConstruct(identifier);
        construct.PackName = "default";
 
        foreach (var (type, entry) in recipeEntries)
        {
            construct.Recipes[type] = BuildRecipeEntryConstruct(type, entry);
        }
 
        return construct;
    }
 
    private void AddLegacyRecipeEntry(
        Dictionary<string, Dictionary<string, RecipeEntry>> entries,
        string resourceType)
    {
        // Legacy Applications.* types register their recipe under the "default" name on the
        // legacy environment. Radius keys recipes by name within a type, so the inner map is
        // keyed that way even though only the default recipe is emitted today; a type that grows
        // named recipes then adds entries rather than changing the shape.
        const string recipeName = "default";
 
        if (!entries.TryGetValue(resourceType, out var byName))
        {
            byName = new Dictionary<string, RecipeEntry>(StringComparer.Ordinal);
            entries[resourceType] = byName;
        }
 
        if (s_defaultRecipeTemplates.TryGetValue(resourceType, out var defaultTemplate))
        {
            byName.TryAdd(recipeName, new RecipeEntry("bicep", defaultTemplate));
        }
        else
        {
            _logger.LogWarning(
                "No default recipe template found for legacy resource type '{ResourceType}'. " +
                "Register a recipe for this type via ConfigureRadiusInfrastructure().",
                resourceType);
        }
    }
 
    private LegacyApplicationEnvironmentConstruct CreateLegacyEnvironmentConstruct(
        string identifier,
        Dictionary<string, Dictionary<string, RecipeEntry>> legacyRecipeEntries)
    {
        var construct = new LegacyApplicationEnvironmentConstruct(identifier);
        // Resource name intentionally matches the UDT environment so Radius
        // treats both parents as the same logical environment scope.
        construct.EnvironmentName = _environment.Name;
        construct.ComputeKind = "kubernetes";
        construct.ComputeNamespace = _environment.Namespace;
        ApplyCloudProviders(construct);
 
        foreach (var (resourceType, byName) in legacyRecipeEntries)
        {
            var inner = new BicepDictionary<LegacyRecipeEntryConstruct>();
            var parameters = GetEffectiveRecipeParameters(resourceType);
            foreach (var (recipeName, entry) in byName)
            {
                var legacyEntry = new LegacyRecipeEntryConstruct
                {
                    TemplateKind = entry.RecipeKind,
                    TemplatePath = entry.RecipeLocation,
                };
 
                // Apply environment-level WithRecipeParameters for this legacy resource type.
                // No-op when none are declared.
                if (parameters is not null)
                {
                    ApplyRecipeParameters(legacyEntry.Parameters, parameters);
                }
 
                inner[recipeName] = legacyEntry;
            }
            construct.Recipes[resourceType] = inner;
        }
 
        return construct;
    }
 
    private static LegacyApplicationConstruct CreateLegacyApplicationConstruct(
        string identifier, string applicationName,
        BicepValue<string> environmentId)
    {
        var construct = new LegacyApplicationConstruct(identifier);
        // The legacy application is the same Radius application as the UDT one, so it must carry
        // the same `name:`; only the Bicep identifier is suffixed with `_legacy`, to keep the two
        // declarations from colliding in the generated template.
        construct.ApplicationName = applicationName;
        construct.EnvironmentId = environmentId;
        return construct;
    }
 
    private static string GetContainerImage(IResource resource)
    {
        var imageAnnotation = resource.Annotations.OfType<ContainerImageAnnotation>().FirstOrDefault();
 
        if (imageAnnotation is not null)
        {
            var image = imageAnnotation.Image;
            if (!string.IsNullOrEmpty(imageAnnotation.Tag))
            {
                image = $"{image}:{imageAnnotation.Tag}";
            }
 
            if (!string.IsNullOrEmpty(imageAnnotation.Registry))
            {
                image = $"{imageAnnotation.Registry}/{image}";
            }
 
            return image;
        }
 
        // .NET program resources have no ContainerImageAnnotation by default — the integration does
        // not (yet) build and push project images. Failing fast at publish time with a clear
        // remediation prevents the silent `aspire publish && aspire deploy` → in-cluster
        // ImagePullBackOff failure mode, which is opaque to the user (Radius/Kubernetes
        // surface it, not Aspire). Mirrors the CLI behaviour guideline that errors should
        // name the specific action the user must take.
        if (resource is IDotnetProgramResource)
        {
            throw new InvalidOperationException(
                $".NET program resource '{resource.Name}' cannot be published to Radius because no container image " +
                "has been associated with it. The Aspire.Hosting.Radius integration does not yet build or push " +
                "project images. As a workaround, build and push an image to a registry the target cluster can " +
                "pull from, then attach it via WithContainerImage(\"<registry>/<image>:<tag>\") on the .NET program " +
                "resource. Tracking issue: https://github.com/microsoft/aspire/issues/16844.");
        }
 
        // Non-project, non-container resources reach this path only in misconfiguration
        // (the resource type mapping would normally skip them). Fall back to a placeholder
        // image with a logged warning via WarnIfImageMayNotPull so the publish still
        // produces inspectable output.
        return $"{resource.Name}:latest";
    }
 
    /// <summary>
    /// Wires up how each backing resource's credentials reach the consumer, so every value composed
    /// from them (the connection string, the URI, the splatted <c>*_PASSWORD</c> variable) is
    /// consistent with what the recipe actually provisions.
    /// </summary>
    /// <remarks>
    /// Two mechanisms, chosen by the emitted Radius type:
    /// <list type="bullet">
    /// <item><b>Legacy <c>Applications.*</c> types</b> generate their own credentials inside the
    /// recipe and expose them through <c>listSecrets()</c>. Aspire's own generated password is
    /// therefore meaningless at deploy time, so the parameter is substituted for the secret
    /// accessor wherever it appears.</item>
    /// <item><b><c>Radius.*</c> UDTs</b> have no <c>listSecrets()</c>, and their
    /// <c>username</c>/<c>password</c> are <em>required schema properties</em> on the resource
    /// itself that are redacted on read. Aspire writes its own parameters into those properties, so
    /// the deployed credentials are the ones Aspire already composed into the connection string —
    /// the two agree by construction. This also fills in required inputs that were previously never
    /// supplied at all, which the type's schema rejects outright.</item>
    /// </list>
    /// Because both mechanisms operate on the *values* Aspire's own connection-string expressions
    /// are built from, no connection-string format is duplicated here.
    /// </remarks>
    private async Task ApplyBackingResourceCredentialsAsync(
        List<IResource> radiusResources,
        RadiusInfrastructureOptions options,
        RadiusEnvironmentConstruct? envConstruct,
        RadiusApplicationConstruct? appConstruct)
    {
        var referencedResourceNames = GetReferencedResourceNames();
 
        foreach (var resource in radiusResources)
        {
            if (!_radiusTypeByResourceName.TryGetValue(resource.Name, out var radiusType) ||
                !ResourceTypeMapper.IsBackingResource(resource))
            {
                continue;
            }
 
            // A backing resource this environment emits but the schema table does not describe
            // cannot have its credentials wired at all, so every consumer would silently receive the
            // password Aspire generated for local run mode instead of the one the recipe creates.
            // That is exactly https://github.com/microsoft/aspire/issues/18935, so fail here rather
            // than waiting for a consumer to reference it (ApplyBackingResourceCredentials runs for
            // every emitted resource; TryProjectBackingEndpoint only runs for referenced ones).
            //
            // Unreachable through the public API today, which is why no test pins it:
            // EveryEmittedBackingType_HasAConnectionSchema asserts the schema table is
            // total over every type ResourceTypeMapper can emit, so a missing row fails at test
            // time instead. This stays a hard failure because that guard lives in the test suite,
            // not in the type system — a mapping added without a schema row must not fall through
            // to the run-mode password.
            if (RadiusBackingConnections.GetSchema(radiusType) is not { } schema)
            {
                throw new RadiusBackingResourceProjectionException(
                    resource,
                    $"Resource '{resource.Name}' is emitted as Radius type '{radiusType}', for which Aspire has no " +
                    $"connection schema, so its recipe-generated credentials cannot be projected to consumers. Map the " +
                    $"resource to a type Aspire describes, or set the connection values explicitly with WithEnvironment. " +
                    $"Diagnostic: ASPIRERADIUS071.");
            }
 
            if (resource is not IResourceWithConnectionString withConnectionString ||
                !_typeInstancesByResourceName.TryGetValue(resource.Name, out var construct))
            {
                continue;
            }
 
            switch (schema.Credentials)
            {
                case RadiusBackingConnections.RadiusCredentialMode.ListSecrets(var passwordSecret):
                    ApplyListSecretsCredentials(resource, withConnectionString, construct, schema, passwordSecret);
                    break;
 
                case RadiusBackingConnections.RadiusCredentialMode.RecipeInputProperties:
                    await ApplyRecipeInputPropertyCredentialsAsync(
                        resource, withConnectionString, construct, referencedResourceNames).ConfigureAwait(false);
                    break;
 
                case RadiusBackingConnections.RadiusCredentialMode.SecretResourceReference(var propertyName, var secretKey):
                    await ApplySecretResourceCredentialsAsync(
                        resource, withConnectionString, construct, options, envConstruct, appConstruct,
                        propertyName, secretKey).ConfigureAwait(false);
                    break;
 
                case RadiusBackingConnections.RadiusCredentialMode.NoCredential(var noCredentialReason):
                    ApplyNoCredential(resource, withConnectionString, noCredentialReason);
                    break;
 
                case RadiusBackingConnections.RadiusCredentialMode.NotProjected:
                    // Nothing to wire: the type carries no address or credential Aspire composes.
                    // TryProjectBackingEndpoint still fails loudly if a consumer asks for one.
                    break;
            }
 
            RecordIfDatabaseIsNotCreatedByTheRecipe(resource, radiusType);
        }
    }
 
    /// <summary>
    /// Wires a type whose recipe deploys the workload without authentication: the password Aspire
    /// generated for run mode has no deployed counterpart, so every value composed from it carries
    /// an empty credential rather than a value that is wrong or unresolvable.
    /// </summary>
    /// <remarks>
    /// The alternative — leaving the parameter to route normally — would emit Aspire's run-mode
    /// password as the deployed one, which is <see href="https://github.com/microsoft/aspire/issues/18935"/>
    /// itself. Emitting <c>listSecrets().password</c> is no better: the recipe records no secrets,
    /// so ARM fails the deployment on a property the returned object does not have. An empty value
    /// matches what the recipe provisions.
    /// <para>
    /// A password Aspire generated for run mode and one the AppHost author supplied are not the
    /// same case, so they are not treated the same way. A generated password carries no intent —
    /// there is nothing for the author to have expected of it in a deployment — so discarding it
    /// with a warning is proportionate. An explicitly supplied password *is* the author asking for
    /// an authenticated deployment, and this recipe cannot deliver one; downgrading that request to
    /// an unauthenticated workload behind a warning is a security decision the publisher must not
    /// make silently, so it fails the publish instead.
    /// </para>
    /// </remarks>
    private void ApplyNoCredential(
        IResource resource,
        IResourceWithConnectionString withConnectionString,
        string reason)
    {
        if (TryGetCredentialParameter(withConnectionString, "password") is not { } passwordParameter)
        {
            return;
        }
 
        // `Default is GenerateParameterDefault` distinguishes the two only in publish mode:
        // ParameterResourceBuilderExtensions.CreateGeneratedParameter rewrites Default to an
        // internal user-secrets wrapper in run mode. This code only ever runs while publishing —
        // the same caveat WarnIfUserSuppliedCredentialIsReplaced documents.
        if (passwordParameter.Default is not GenerateParameterDefault)
        {
            throw new InvalidOperationException(
                $"A password was supplied for '{resource.Name}', but the Radius recipe that provisions it deploys the " +
                $"workload without authentication: {reason}. The parameter '{passwordParameter.Name}' cannot be " +
                $"applied, so '{resource.Name}' would be deployed unauthenticated while its consumers are handed an " +
                $"empty password. Remove the password to accept an unauthenticated deployment, or provision " +
                $"'{resource.Name}' yourself if the deployed workload must require one. Diagnostic: ASPIRERADIUS085.");
        }
 
        // Registered as a substitution even though the replacement is a literal: the parameter's own
        // value is discarded everywhere it appears, so sharing it with a resource that keeps its
        // value is the same silent-mismatch hazard RegisterRecipeCredential exists to reject.
        //
        // WarnIfUserSuppliedCredentialIsReplaced is deliberately not reused here: its message says
        // the recipe generates its own credential, which is the opposite of what happens for this
        // mode. The warning below covers the generated password that reaches this point — a
        // user-supplied one has already failed the publish above — because unlike a substituted
        // credential it has no deployed counterpart.
        RegisterRecipeCredential(passwordParameter, resource, isProjectionSubstitution: true);
        _emptyCredentialSubstitutions.Add(passwordParameter);
 
        _logger.LogWarning(
            "Radius resource '{ResourceName}' is deployed by a recipe that provisions no credential: {Reason}. " +
            "Consumers receive an empty password, and the password Aspire generated for '{ResourceName}' is not " +
            "applied to the deployed workload. Diagnostic: ASPIRERADIUS075.",
            resource.Name,
            reason,
            resource.Name);
    }
 
    /// <summary>
    /// Warns when a referenced <c>AddDatabase(...)</c> child names a database the recipe does not
    /// create, so the consumer's connection string points at something that will not exist.
    /// </summary>
    /// <remarks>
    /// <para>
    /// Only the legacy <c>Applications.Datastores/sqlDatabases</c> type has this problem. Its
    /// built-in recipe takes a <c>database</c> parameter but merely echoes it back in the recipe's
    /// own outputs: the workload it deploys is a plain SQL Edge Deployment plus Service with the
    /// <c>sa</c> login and no init container or job, so nothing ever runs <c>CREATE DATABASE</c>.
    /// Meanwhile <c>SqlServerDatabaseResource.ConnectionStringExpression</c> appends the
    /// <c>AddDatabase(...)</c> name, and <c>AddDatabase</c> only creates the database in run mode.
    /// See <see href="https://github.com/radius-project/recipes/blob/main/local-dev/sqldatabases.bicep"/>.
    /// </para>
    /// <para>
    /// This warns rather than failing. Provisioning the database is not something the publisher can
    /// do, and the alternative — remapping SQL Server to the contrib
    /// <c>Radius.Data/sqlServerDatabases</c> type, whose schema does declare <c>database</c> as
    /// required and created — is not possible while that type has no published Kubernetes recipe
    /// (<c>ghcr.io/radius-project/kube-recipes/sqlserverdatabases</c> does not exist). Failing the
    /// publish would break models that deploy successfully today whenever the application creates
    /// the database itself, for example through EF Core's <c>EnsureCreated</c>/<c>Migrate</c>.
    /// </para>
    /// <para>
    /// Scoped to <em>referenced</em> children, matching the <c>ASPIRERADIUS072</c> precedent: an
    /// unreferenced <c>AddDatabase(...)</c> is inert and produces no consumer connection string.
    /// "Referenced" is the union of two signals, because neither is complete on its own: the
    /// <see cref="ResourceRelationshipAnnotation"/>s that <c>WithReference</c> records, and the
    /// consumption actually observed while resolving environment values. A
    /// <c>WithEnvironment(ctx =&gt; ctx.EnvironmentVariables["CS"] = db)</c> callback builds its
    /// value inline and records no annotation, so it is invisible to the first signal; the second
    /// only becomes complete after every container's environment has been resolved, which is why
    /// this runs as a deferred pass rather than inline with the credential wiring.
    /// </para>
    /// <para>
    /// A callback that assigns the child's <c>ConnectionStringExpression</c> itself rather than the
    /// child leaves the child absent from both signals — that expression is composed from the
    /// <em>server's</em> expression plus the database name as a literal, so the child never appears
    /// as a node while resolving, and no annotation is recorded either. It is recovered by
    /// <see cref="RecordConnectionStringExpressionConsumption"/>, which matches the resolved
    /// expression against each child's own, and feeds the same consumption signal.
    /// </para>
    /// </remarks>
    private void RecordIfDatabaseIsNotCreatedByTheRecipe(IResource resource, string radiusType)
    {
        if (!string.Equals(radiusType, RadiusResourceTypes.LegacySqlDatabases, StringComparison.Ordinal))
        {
            return;
        }
 
        _databasesNotCreatedByTheRecipe.Add((resource, radiusType));
    }
 
    /// <summary>
    /// Emits the <c>ASPIRERADIUS080</c> warnings recorded by
    /// <see cref="RecordIfDatabaseIsNotCreatedByTheRecipe"/>, once every consumer of a database
    /// child is known.
    /// </summary>
    private void WarnForDatabasesNotCreatedByTheRecipe(HashSet<string> referencedResourceNames)
    {
        foreach (var (resource, radiusType) in _databasesNotCreatedByTheRecipe)
        {
            var referenced = FindDatabaseChildren(resource)
                .Where(d => referencedResourceNames.Contains(d.Name) ||
                            _resolvedConnectionStringConsumption.Contains(d.Name))
                .Select(d => d.Name)
                .ToList();
 
            if (referenced.Count == 0)
            {
                continue;
            }
 
            _logger.LogWarning(
                "Radius resource '{ResourceName}' is emitted as '{RadiusType}', whose recipe starts a SQL Server but does " +
                "not create databases. Consumers of '{Databases}' receive a connection string naming a database the " +
                "deployment will not contain unless the application creates it itself. Have the application create the " +
                "database on startup, or deploy SQL Server outside the Radius environment. Diagnostic: ASPIRERADIUS080.",
                resource.Name,
                radiusType,
                string.Join("', '", referenced));
        }
    }
 
    /// <summary>
    /// Fails when a consumer connects to a database child other than the single one the resource's
    /// recipe was told to provision.
    /// </summary>
    /// <remarks>
    /// The selection in <see cref="ApplyRecipeInputPropertyCredentialsAsync"/> has to run before any
    /// container environment is resolved, so the only consumption signal available to it is the
    /// <see cref="ResourceRelationshipAnnotation"/> set that <c>WithReference</c> records. A mixed
    /// model defeats that: with <c>WithReference(first)</c> plus a <c>WithEnvironment</c> callback
    /// resolving <c>second.ConnectionStringExpression</c>, the selection sees only <c>first</c> and
    /// provisions it, while the consumer receives a connection string naming <c>second</c> — a
    /// database the recipe never creates, failing at run time with nothing in the generated Bicep to
    /// explain it. Running the check here, once <see cref="_resolvedConnectionStringConsumption"/> is
    /// complete, is what makes the callback-only consumer visible.
    /// </remarks>
    private void ValidateRecipeDatabaseSelections(HashSet<string> referencedResourceNames)
    {
        foreach (var (resource, selectedDatabaseName) in _recipeDatabaseSelections)
        {
            // Union of both signals, because either one on its own is incomplete: annotations miss a
            // callback-only consumer, and the resolved-consumption set misses a database that is
            // referenced but whose value no container happened to resolve.
            var wronglyConsumed = FindDatabaseChildren(resource)
                .Where(d => !string.Equals(GetPhysicalDatabaseName(d), selectedDatabaseName, StringComparison.Ordinal))
                .Where(d => referencedResourceNames.Contains(d.Name) ||
                            _resolvedConnectionStringConsumption.Contains(d.Name))
                .Select(GetPhysicalDatabaseName)
                .Distinct(StringComparer.Ordinal)
                .ToList();
 
            if (wronglyConsumed.Count == 0)
            {
                continue;
            }
 
            throw new InvalidOperationException(
                $"Resource '{resource.Name}' has a Radius recipe that provisions the single database " +
                $"'{selectedDatabaseName}', but '{string.Join("', '", wronglyConsumed)}' " +
                $"{(wronglyConsumed.Count == 1 ? "is" : "are")} also consumed by the application, so those consumers " +
                $"would receive a connection string naming a database the deployment will not contain. Consume only " +
                $"'{selectedDatabaseName}', or declare one database per resource. Diagnostic: ASPIRERADIUS072.");
        }
    }
 
    /// <summary>
    /// Wires a type whose recipe generates its own credentials and exposes them through
    /// <c>listSecrets()</c>: Aspire's parameters are substituted for the recipe's own values
    /// wherever they appear, so every composed value carries what is actually deployed.
    /// </summary>
    private void ApplyListSecretsCredentials(
        IResource resource,
        IResourceWithConnectionString withConnectionString,
        RadiusResourceTypeConstruct construct,
        RadiusBackingConnections.RadiusConnectionSchema schema,
        string passwordSecret)
    {
        var passwordParameter = TryGetCredentialParameter(withConnectionString, "password");
 
        if (passwordParameter is not null)
        {
            WarnIfUserSuppliedCredentialIsReplaced(resource, passwordParameter, "password");
            RegisterRecipeCredential(passwordParameter, resource, isProjectionSubstitution: true);
            _recipeSecretSubstitutions[passwordParameter] =
                new ProjectedValue(construct, passwordSecret, IsSecret: true, IsNumeric: false);
        }
 
        // The user name is a plain top-level property, not a listSecrets() key: the legacy
        // Applications.Datastores/mongoDatabases and Applications.Messaging/rabbitMQQueues types
        // return only connectionString and password from listSecrets(), and expose the user the
        // recipe created at properties.username.
        //
        // Known gap: this only works when the AppHost supplied a user-name *parameter*. The default
        // user names ("admin" for MongoDB, "guest" for RabbitMQ) are appended through
        // ReferenceExpressionBuilder.AppendFormatted(string?, string?), which formats immediately
        // and writes the result into the format string, so they arrive here as opaque literal text
        // with no value provider to substitute. Those connection strings keep the default user name.
        if (schema.UserNameProperty is { } userNameProperty &&
            TryGetCredentialParameter(withConnectionString, "username") is { } userNameParameter)
        {
            // Substitutions are keyed by parameter identity — that is all a value provider exposes
            // when an env var is resolved — so one parameter cannot stand for two different
            // recipe-generated values. Assigning both would silently keep only the later one and
            // hand consumers `properties.username` where they asked for the password.
            if (passwordParameter is not null && ReferenceEquals(passwordParameter, userNameParameter))
            {
                throw new InvalidOperationException(
                    $"Parameter '{userNameParameter.Name}' is used as both the user name and the password of " +
                    $"'{resource.Name}'. Its Radius recipe generates a separate value for each, and a single parameter " +
                    $"cannot be substituted for both, so consumers would receive the same value for both. Give the user " +
                    $"name and the password their own parameters. Diagnostic: ASPIRERADIUS070.");
            }
 
            WarnIfUserSuppliedCredentialIsReplaced(resource, userNameParameter, "user name");
            RegisterRecipeCredential(userNameParameter, resource, isProjectionSubstitution: true);
            _recipeSecretSubstitutions[userNameParameter] =
                new ProjectedValue(construct, userNameProperty, IsSecret: false, IsNumeric: false);
        }
    }
 
    /// <summary>
    /// Wires a type whose <c>username</c>/<c>password</c> are required schema properties on the
    /// resource: Aspire writes its own parameters there, so the deployed credentials are the ones
    /// already composed into the connection string.
    /// </summary>
    /// <remarks>
    /// The values go under <c>properties</c> directly, not under <c>properties.recipe.parameters</c>.
    /// The resource-type manifests declare <c>username</c>/<c>password</c> as <c>required</c> schema
    /// properties and the recipes read them as <c>context.resource.properties.&lt;name&gt;</c>, so a
    /// resource that only carried them as recipe parameters is rejected by schema validation before
    /// any recipe runs.
    /// See <see href="https://github.com/radius-project/resource-types-contrib/blob/main/Data/postgreSqlDatabases/postgreSqlDatabases.yaml"/>.
    /// </remarks>
    private async Task ApplyRecipeInputPropertyCredentialsAsync(
        IResource resource,
        IResourceWithConnectionString withConnectionString,
        RadiusResourceTypeConstruct construct,
        HashSet<string> referencedResourceNames)
    {
        var recipePassword = TryGetCredentialParameter(withConnectionString, "password");
 
        if (recipePassword is not null)
        {
            RegisterRecipeCredential(recipePassword, resource, isProjectionSubstitution: false);
            construct.SetSchemaProperty("password", GetOrAddEnvParameter(recipePassword));
        }
        else
        {
            // The credential is not a bare parameter (a composed expression, or a literal), so there
            // is no ParameterResource to hand to GetOrAddEnvParameter. `password` is a *required*
            // schema property on these types, so omitting it produces an artifact Radius rejects
            // before any recipe runs. Resolve it the way `username` and `database` are resolved:
            // the result is a Bicep expression built from `@secure()` param references, so a
            // credential still never lands in the artifact as a literal.
            await SetTypePropertyAsync(construct, "password", withConnectionString, "password").ConfigureAwait(false);
        }
 
        // The user name has to be registered too, even though it is written straight onto the
        // resource rather than substituted. Sharing it with a resource that *does* use the
        // listSecrets() substitution is unsafe in exactly the same way as sharing the password: this
        // resource would keep the parameter's own value while the substitution rewrote every
        // consumer reference to the other resource's recipe secret. Registering it lets
        // RegisterRecipeCredential see the collision instead of letting it through.
        if (TryGetCredentialParameter(withConnectionString, "username") is { } recipeUserName)
        {
            // Both roles are written straight onto the resource here (neither is a listSecrets()
            // substitution), so RegisterRecipeCredential's same-owner check alone would not catch
            // one parameter used for both: it only rejects sharing across *different* owners. A
            // single value published for both properties is never correct, exactly as for the
            // listSecrets() types above, so reject it the same way.
            if (recipePassword is not null && ReferenceEquals(recipePassword, recipeUserName))
            {
                throw new InvalidOperationException(
                    $"Parameter '{recipeUserName.Name}' is used as both the user name and the password of " +
                    $"'{resource.Name}'. Give the user name and the password their own parameters. " +
                    $"Diagnostic: ASPIRERADIUS070.");
            }
 
            RegisterRecipeCredential(recipeUserName, resource, isProjectionSubstitution: false);
        }
 
        await SetTypePropertyAsync(construct, "username", withConnectionString, "username").ConfigureAwait(false);
 
        // `username` and `password` are marked `required` in the resource-type manifests, so an
        // artifact missing either is rejected by schema validation at deploy time with nothing at
        // publish time to explain it. Both assignments above are conditional on the resource
        // actually exposing the connection property, so assert the outcome rather than trusting the
        // shape of today's resources.
        foreach (var requiredProperty in (string[])["username", "password"])
        {
            if (construct.GetSchemaProperty(requiredProperty) is null)
            {
                throw new RadiusBackingResourceProjectionException(
                    resource,
                    $"Resource '{resource.Name}' is emitted as a Radius type that requires '{requiredProperty}' as a " +
                    $"schema property, but '{resource.Name}' exposes no '{requiredProperty}' connection property for " +
                    $"Aspire to supply it, so the deployment would be rejected by schema validation. Expose the " +
                    $"property on the resource's connection string, or map the resource to a type that does not " +
                    $"require it. Diagnostic: ASPIRERADIUS076.");
            }
        }
 
        // The recipe provisions exactly one database, so it has to be told which one Aspire's
        // consumers will connect to — otherwise the connection string names a database the recipe
        // never created. Aspire models databases as child resources, and the database *name* can
        // differ from the child resource name, so read it from the child's own connection properties
        // rather than assuming they match.
        var databaseChildren = FindDatabaseChildren(resource);
        if (databaseChildren.Count == 0)
        {
            // A server with no AddDatabase(...) child is a valid and common model, so this is a
            // warning rather than a failure. But omitting the property is not safe: the server-level
            // connection string carries no database name (PostgresServerResource appends `/{db}`
            // only when a database child exists), and libpq/Npgsql then default `dbname` to the
            // *user name*, while the recipe would default its own database to `postgres_db`. For a
            // user such as `appuser` the consumer would open a database the recipe never created.
            // Emitting the user name as the database keeps the two ends in agreement — the schema
            // marks `database` optional precisely so it can be supplied here.
            await SetTypePropertyAsync(construct, "database", withConnectionString, "username").ConfigureAwait(false);
 
            _logger.LogWarning(
                "Radius resource '{ResourceName}' declares no database, so its recipe is asked to create a database " +
                "named after the user: the server-level connection string carries no database name and clients default " +
                "it to the user name. Add a database with AddDatabase(...) if consumers expect a specific database name.",
                resource.Name);
            return;
        }
 
        // Only a database a consumer actually references can produce a wrong connection string, so
        // scope the failure to those. An unreferenced extra AddDatabase(...) is inert and must not
        // break a model that published before.
        var referenced = databaseChildren.Where(d => referencedResourceNames.Contains(d.Name)).ToList();
 
        // Group by the *deployed* database name rather than the child resource name. Two children
        // can be distinct Aspire resources that name one physical database — AddDatabase("orders-a",
        // "orders") alongside AddDatabase("orders-b", "orders") — and the single database the recipe
        // provisions satisfies every one of their consumers, so that model must keep publishing.
        var referencedDatabaseNames = referenced
            .Select(GetPhysicalDatabaseName)
            .Distinct(StringComparer.Ordinal)
            .ToList();
 
        if (referencedDatabaseNames.Count > 1)
        {
            // Emitting one of them would leave every consumer of the others pointed at a database
            // the recipe never created — a connection failure at run time with nothing in the
            // generated Bicep to explain it.
            throw new InvalidOperationException(
                $"Resource '{resource.Name}' has {referencedDatabaseNames.Count} referenced databases " +
                $"('{string.Join("', '", referencedDatabaseNames)}'), but its Radius recipe provisions a single " +
                $"database. Reference at most one database per resource, or split them across separate resources. " +
                $"Diagnostic: ASPIRERADIUS072.");
        }
 
        var distinctDatabaseNames = databaseChildren
            .Select(GetPhysicalDatabaseName)
            .Distinct(StringComparer.Ordinal)
            .ToList();
 
        if (referenced.Count == 0 && distinctDatabaseNames.Count > 1)
        {
            // Annotations are the only reference signal available this early, and a WithEnvironment
            // callback that composes a database's connection string inline records none. So "no
            // referenced database" does not mean "no consumer": picking the first child here would
            // create `first` while a consumer connects to `second`. Fail instead of guessing —
            // but only when the model actually contains something that could be that hidden
            // consumer. With no consumer at all there is provably no wrong connection string to
            // protect against, and a model that published before must keep publishing.
            if (ModelHasPotentialConsumer(resource, databaseChildren))
            {
                throw new InvalidOperationException(
                    $"Resource '{resource.Name}' declares {distinctDatabaseNames.Count} databases " +
                    $"('{string.Join("', '", distinctDatabaseNames)}') and its Radius recipe provisions a " +
                    $"single database, but none of them is referenced through WithReference, so Aspire cannot tell which one " +
                    $"to create. Reference the database consumers connect to with WithReference, or declare one database per " +
                    $"resource. Diagnostic: ASPIRERADIUS072.");
            }
        }
 
        // Any referenced child is a better choice than the first declared one, and every referenced
        // child is equally correct here: the ASPIRERADIUS072 check above has already rejected the
        // case where they name more than one physical database, so what remains is a set of aliases
        // for the single database the recipe provisions. Selecting `databaseChildren[0]` once
        // `referenced` is non-empty would pick an unreferenced child that happens to be declared
        // first, configuring the recipe for a database no consumer connects to.
        var databaseChild = referenced.Count > 0 ? referenced[0] : databaseChildren[0];
 
        // The server-level connection string carries no database name (see the no-child branch
        // above), so a consumer that references the *server* rather than one of its databases opens
        // the client's default database — the user name — which is not the one being created here.
        // In run mode both exist, because the Postgres image creates a database named after
        // POSTGRES_USER in addition to the ones Aspire creates for each AddDatabase(...) child. A
        // recipe provisions exactly one, so the parity cannot be preserved and the reference is left
        // pointing at a database that will not exist.
        if (referencedResourceNames.Contains(resource.Name))
        {
            _logger.LogWarning(
                "Radius resource '{ResourceName}' is referenced directly, but its recipe creates only the database " +
                "'{Selected}'. A server-level connection string names no database, so consumers default to the one " +
                "named after the user, which the recipe does not create. Reference the database with " +
                "WithReference({ResourceName}Database) instead of the server.",
                resource.Name,
                databaseChild.Name,
                resource.Name);
        }
 
        if (distinctDatabaseNames.Count > 1)
        {
            _logger.LogWarning(
                "Radius resource '{ResourceName}' declares {Count} databases but its recipe provisions one; " +
                "'{Selected}' was passed as the 'database' property. The others are not created.",
                resource.Name,
                distinctDatabaseNames.Count,
                GetPhysicalDatabaseName(databaseChild));
        }
 
        await SetTypePropertyAsync(construct, "database", databaseChild, "databasename").ConfigureAwait(false);
 
        // Validated rather than acted on here: a WithEnvironment callback that consumes a different
        // database child is only observable once every container's environment has been resolved,
        // which happens after this runs. See ValidateRecipeDatabaseSelections.
        _recipeDatabaseSelections.Add((resource, GetPhysicalDatabaseName(databaseChild)));
    }
 
    /// <summary>
    /// Wires a type whose credential is supplied as the resource ID of a separate
    /// <c>Radius.Security/secrets</c> resource rather than as a literal property value.
    /// </summary>
    /// <remarks>
    /// <para>
    /// As with <see cref="ApplyRecipeInputPropertyCredentialsAsync"/>, the value written is Aspire's
    /// own parameter, so the credential the recipe provisions and the one Aspire composes into the
    /// connection string agree by construction. The difference is purely mechanical: the property
    /// expects a secret <em>resource ID</em>, so writing the password there directly would be
    /// rejected by Radius at deploy time.
    /// </para>
    /// <para>
    /// The emitted secret is dedicated to this one credential. It deliberately does not hold a
    /// composed connection string: a resource cannot consume a secret that is itself composed from
    /// that resource's outputs without creating a cycle in the deployment graph.
    /// </para>
    /// </remarks>
    private async Task ApplySecretResourceCredentialsAsync(
        IResource resource,
        IResourceWithConnectionString withConnectionString,
        RadiusResourceTypeConstruct construct,
        RadiusInfrastructureOptions options,
        RadiusEnvironmentConstruct? envConstruct,
        RadiusApplicationConstruct? appConstruct,
        string propertyName,
        string secretKey)
    {
        // The secret's environment scope is required by the type. A resource emitted as a Radius.*
        // UDT is always parented to the UDT environment, so this is unreachable in practice —
        // assert it rather than emitting a secret Radius would reject for a missing required scope.
        if (envConstruct is null)
        {
            throw new RadiusBackingResourceProjectionException(
                resource,
                $"Resource '{resource.Name}' is emitted as a Radius type whose credential requires a " +
                $"'{RadiusResourceTypes.SecuritySecrets}' resource, but no Radius environment was emitted to scope it to. " +
                $"Diagnostic: ASPIRERADIUS094.");
        }
 
        BicepValue<object>? secretValue = null;
 
        var credentialParameter = TryGetCredentialParameter(withConnectionString, propertyName);
        if (credentialParameter is not null)
        {
            RegisterRecipeCredential(credentialParameter, resource, isProjectionSubstitution: false);
            secretValue = GetOrAddEnvParameter(credentialParameter);
        }
        else if (await TryResolveConnectionPropertyAsync(withConnectionString, propertyName).ConfigureAwait(false) is { } resolved)
        {
            // Not a bare parameter (a composed expression, or a literal). The resolved value is
            // still built from `@secure()` param references, so no credential lands in the artifact
            // as a literal.
            secretValue = resolved.Value;
        }
 
        if (secretValue is null)
        {
            throw new RadiusBackingResourceProjectionException(
                resource,
                $"Resource '{resource.Name}' is emitted as a Radius type that requires '{propertyName}', but " +
                $"'{resource.Name}' exposes no '{propertyName}' connection property for Aspire to supply it, so the " +
                $"deployment would be rejected by schema validation. Expose the property on the resource's " +
                $"connection string, or map the resource to a type that does not require it. " +
                $"Diagnostic: ASPIRERADIUS076.");
        }
 
        // The `_secret` suffix keeps this clear of the Bicep identifier the resource's own password
        // parameter claims: AddRabbitMQ("rabbit") generates a parameter named `rabbit-password`,
        // which sanitizes to `rabbit_password` — exactly what `{resource}_{property}` would produce.
        // A remaining collision is still caught by ASPIRERADIUS056 rather than silently emitted.
        var secretIdentifier = BicepPostProcessor.SanitizeIdentifier($"{resource.Name}_{propertyName}_secret");
        var secret = new RadiusSecuritySecretConstruct(secretIdentifier)
        {
            SecretName = BuildKubernetesSecretName($"{resource.Name}-{propertyName}-secret"),
            EnvironmentId = BuildIdExpression(envConstruct),
        };
 
        if (appConstruct is not null)
        {
            secret.ApplicationId = BuildIdExpression(appConstruct);
        }
 
        var credentialEntry = new RadiusSecuritySecretDataEntryConstruct
        {
            // The 0.60 vocabulary is `string`/`base64` — the legacy secret-store `raw` is rejected.
            Encoding = "string",
            Value = new BicepValue<string>(secretValue.Compile()),
        };
 
        secret.Data[secretKey] = credentialEntry;
 
        options.SecuritySecrets.Add(secret);
 
        var idExpression = new BicepValue<object>(BuildIdExpression(secret));
        construct.SetSchemaProperty(propertyName, idExpression);
 
        _secretResourceCredentials.Add(new SecretResourceCredential(
            secret,
            construct,
            propertyName,
            secretKey,
            credentialEntry,
            RenderBicepValue(credentialEntry.Value),
            RenderBicepValue(credentialEntry.Encoding),
            secret.BicepIdentifier,
            RenderBicepValue(idExpression)!,
            RenderBicepValue(secret.EnvironmentId),
            secret.ApplicationId is { } applicationId ? RenderBicepValue(applicationId) : null));
 
        // The user name is a plain input on these types, so it is written straight onto the
        // resource. Omitting it would let the UDT apply its own default (`radius`), which disagrees
        // with the user name Aspire already composed into the connection string.
        var userNameParameter = TryGetCredentialParameter(withConnectionString, "username");
        if (userNameParameter is not null)
        {
            // Both roles are written straight onto the resource here, so RegisterRecipeCredential's
            // same-owner check alone would not catch one parameter used for both: it only rejects
            // sharing across *different* owners. A single value published for both is never correct.
            if (credentialParameter is not null && ReferenceEquals(credentialParameter, userNameParameter))
            {
                throw new InvalidOperationException(
                    $"Parameter '{userNameParameter.Name}' is used as both the user name and the password of " +
                    $"'{resource.Name}'. Give the user name and the password their own parameters. " +
                    $"Diagnostic: ASPIRERADIUS070.");
            }
 
            RegisterRecipeCredential(userNameParameter, resource, isProjectionSubstitution: false);
            if (string.Equals(
                _radiusTypeByResourceName[resource.Name],
                RadiusResourceTypes.RabbitMQ,
                StringComparison.Ordinal))
            {
                if (!_rabbitMqUserNames.TryGetValue(userNameParameter, out var owners))
                {
                    owners = [];
                    _rabbitMqUserNames[userNameParameter] = owners;
                }
 
                owners.Add(resource);
            }
        }
 
        await SetTypePropertyAsync(construct, "username", withConnectionString, "username").ConfigureAwait(false);
 
        if (construct.GetSchemaProperty("username") is null)
        {
            throw new RadiusBackingResourceProjectionException(
                resource,
                $"Resource '{resource.Name}' is emitted as a Radius type that requires 'username' as a schema property, " +
                $"but '{resource.Name}' exposes no 'username' connection property for Aspire to supply it, so the " +
                $"deployment would be rejected by schema validation. Expose the property on the resource's " +
                $"connection string, or map the resource to a type that does not require it. " +
                $"Diagnostic: ASPIRERADIUS076.");
        }
 
        // `guest` cannot be deployed. RabbitMQ restricts the `guest` account to loopback
        // connections, so a broker provisioned with it rejects every client running in another Pod
        // — which is every client in a Radius deployment. The type's schema says so explicitly
        // ("Avoid `guest`, which RabbitMQ restricts to loopback connections") and defaults the
        // property to `radius` for exactly this reason.
        //
        // Neither alternative can be made correct silently:
        //   - emitting `guest` provisions a broker no workload can authenticate against;
        //   - emitting `radius` instead leaves the connection string Aspire already composed saying
        //     `guest`, because a default user name arrives as literal text inside the format string
        //     with no value provider to substitute (the same gap DefaultUserName_RemainsALiteral
        //     pins), so the two would disagree.
        // So this fails the publish instead, and names the one-line fix.
        //
        // Both spellings of `guest` have to be caught. A literal user name renders as the Bicep
        // string literal `'guest'`, but a parameter-supplied one renders as a Bicep *identifier*
        // (`param queueuser`), so the rendered text says nothing about the value. Supplying a
        // parameter is exactly the remediation the message below recommends, so missing that case
        // would route users around the guard with the guard's own advice. Parameter values are
        // normally known at publish time, so resolve it and compare.
        if (construct.GetSchemaProperty("username") is { } emittedUserName &&
            (RenderBicepValue(emittedUserName) is "'guest'" ||
             await ResolvesToGuestUserNameAsync(userNameParameter).ConfigureAwait(false)))
        {
            throw CreateRabbitMqGuestUserNameException(resource);
        }
 
        // `queue` is deliberately not emitted. It is optional on the type, and Aspire's model has no
        // queue concept to map from — AddRabbitMQ declares a broker, not a queue — so any value here
        // would be invented. The UDT's own default (`jobs`) applies instead, and consumers create
        // the queues they need through the AMQP client.
    }
 
    /// <summary>
    /// Determines whether a parameter-supplied user name resolves to RabbitMQ's <c>guest</c>
    /// account, which the emitted Bicep cannot reveal because a parameter renders as an identifier
    /// rather than as its value.
    /// </summary>
    /// <remarks>
    /// A parameter whose value cannot be produced while publishing (no value configured, or a
    /// default only the deployment can materialize) is treated as not being <c>guest</c>: the guard
    /// exists to catch the value Aspire itself composed into the connection string, and failing the
    /// publish on an unknowable value would reject models that are perfectly valid.
    /// </remarks>
    private async Task<bool> ResolvesToGuestUserNameAsync(ParameterResource? userNameParameter)
    {
        if (userNameParameter is null)
        {
            return false;
        }
 
        try
        {
            var value = await userNameParameter.GetValueAsync(_cancellationToken).ConfigureAwait(false);
            return string.Equals(value, "guest", StringComparison.Ordinal);
        }
        catch (MissingParameterValueException)
        {
            // The parameter has no value configured while publishing, so `guest` cannot be ruled in
            // or out. Deliberately narrow: any other failure resolving the parameter is a real error
            // and must keep propagating rather than being swallowed by this guard.
            return false;
        }
    }
 
    internal static RadiusBackingResourceProjectionException CreateRabbitMqGuestUserNameException(IResource resource) =>
        new(
            resource,
            $"Resource '{resource.Name}' would be deployed with the user name 'guest', which RabbitMQ restricts to " +
            $"loopback connections — the deployed broker would reject every workload that connects to it. Supply an " +
            $"explicit user name, for example AddRabbitMQ(\"{resource.Name}\", userName: builder.AddParameter(\"{resource.Name}user\")), " +
            $"so the same value is both provisioned on the broker and composed into the connection string. " +
            $"Diagnostic: ASPIRERADIUS082.");
 
    /// <summary>
    /// Resolves a connection property of <paramref name="source"/> and assigns it to a property on
    /// the Radius resource, recording the result so a <c>ConfigureRadiusInfrastructure</c> callback
    /// that later renames or removes a construct the value reads from can be repaired or rejected.
    /// </summary>
    private async Task SetTypePropertyAsync(
        RadiusResourceTypeConstruct construct,
        string propertyName,
        IResourceWithConnectionString source,
        string connectionPropertyKey)
    {
        if (await TryResolveConnectionPropertyAsync(source, connectionPropertyKey).ConfigureAwait(false) is not { } resolved)
        {
            return;
        }
 
        construct.SetSchemaProperty(propertyName, resolved.Value);
 
        if (resolved.Parts.Any(static p => p.Projection is not null))
        {
            _projectedTypeProperties.Add(new ProjectedTypeProperty(
                construct,
                propertyName,
                resolved.Parts,
                RenderBicepValue(resolved.Value),
                resolved.Parts.Where(static p => p.Projection is not null)
                    .Select(static p => p.Projection!.Target)
                    .Distinct()
                    .Select(static t => (t, t.BicepIdentifier))
                    .ToList()));
        }
    }
 
    /// <summary>
    /// The names of every resource referenced by another resource in the model, taken from the
    /// <see cref="ResourceRelationshipAnnotation"/>s that <c>WithReference</c> and the
    /// <c>WithEnvironment</c> overloads record.
    /// </summary>
    /// <remarks>
    /// Reference information is needed at step 4b, before container environment values are resolved,
    /// so it cannot be derived from the resolved values themselves. Annotations are the only signal
    /// available this early, and a reference created through a <c>WithEnvironment</c> callback that
    /// builds its value inline records none. Because of that blind spot, an empty result is treated
    /// as "unknown" rather than "unused": <see cref="ApplyRecipeInputPropertyCredentialsAsync"/>
    /// fails when several databases exist and none is annotated, instead of picking one that a
    /// callback-based consumer may not be using.
    /// </remarks>
    private HashSet<string> GetReferencedResourceNames()
    {
        var referenced = new HashSet<string>(StringComparer.Ordinal);
 
        foreach (var resource in _model.Resources)
        {
            foreach (var relationship in resource.Annotations.OfType<ResourceRelationshipAnnotation>())
            {
                if (string.Equals(relationship.Type, KnownRelationshipTypes.Reference, StringComparison.OrdinalIgnoreCase))
                {
                    referenced.Add(relationship.Resource.Name);
                }
            }
        }
 
        return referenced;
    }
 
    /// <summary>
    /// Warns when a credential the AppHost supplied explicitly is discarded in favour of the value
    /// the Radius recipe generates.
    /// </summary>
    /// <remarks>
    /// A parameter Aspire generated for local run mode is meaningless at deploy time, so replacing
    /// it is invisible and correct. A parameter the AppHost author supplied is a deliberate choice,
    /// and silently ignoring it would leave them debugging a credential mismatch against a value
    /// that never reached the cluster.
    /// <para>
    /// <c>Default is GenerateParameterDefault</c> only distinguishes the two in publish mode:
    /// <c>ParameterResourceBuilderExtensions.CreateGeneratedParameter</c> rewrites <c>Default</c> to
    /// an internal user-secrets wrapper in run mode. This code only ever runs while publishing.
    /// </para>
    /// </remarks>
    private void WarnIfUserSuppliedCredentialIsReplaced(IResource resource, ParameterResource parameter, string credentialKind)
    {
        if (parameter.Default is GenerateParameterDefault)
        {
            return;
        }
 
        _logger.LogWarning(
            "The {CredentialKind} parameter '{ParameterName}' supplied for '{ResourceName}' is not used when deploying " +
            "to Radius. The recipe that provisions that resource generates its own credentials, and consumers are given " +
            "those instead. Remove the parameter, or provision the resource yourself if the value must be fixed.",
            credentialKind,
            parameter.Name,
            resource.Name);
    }
 
    /// <summary>
    /// Warns when a parameter that was substituted for a recipe-generated value is also referenced
    /// by a resource that has nothing to do with the backing resource that owns it.
    /// </summary>
    /// <remarks>
    /// The substitution rewrites the parameter <em>everywhere it appears</em>, so an unrelated
    /// <c>WithEnvironment("ADMIN_PASSWORD", sharedParameter)</c> silently receives another
    /// resource's recipe secret rather than the parameter's own value. This is checked here, where
    /// the substitution is actually applied during environment resolution, rather than by scanning
    /// <see cref="ResourceRelationshipAnnotation"/>s beforehand: a relationship-based pre-scan can't
    /// see a parameter that only shows up inside an <c>EnvironmentCallbackAnnotation</c> lambda
    /// (e.g. <c>.WithEnvironment(ctx => ctx.EnvironmentVariables["ADMIN_PASSWORD"] = shared)</c>),
    /// which records no relationship at all. Sharing between two backing resources is rejected
    /// outright by <see cref="RegisterRecipeCredential"/>; this covers the looser case, where the
    /// intent is genuinely ambiguous, so it warns instead of failing.
    /// </remarks>
    private void WarnIfUnrelatedUseOfSubstitutedParameter(ParameterResource parameter, IResource resource)
    {
        if (!_recipeCredentialOwners.TryGetValue(parameter, out var owner) ||
            ReferenceEquals(owner.Owner, resource) ||
            !_warnedUnrelatedSubstitutions.Add((resource, parameter)))
        {
            return;
        }
 
        _logger.LogWarning(
            "Resource '{ResourceName}' references parameter '{ParameterName}', which is also the credential of " +
            "'{OwnerName}'. That resource is provisioned by a Radius recipe which generates its own credential, " +
            "so the referencing resource receives the recipe's value rather than the parameter's. Use a separate " +
            "parameter if that is not intended. Diagnostic: ASPIRERADIUS070.",
            resource.Name,
            parameter.Name,
            owner.Owner.Name);
    }
 
    /// <summary>
    /// Returns the backing resource whose connection-property injection produced the environment
    /// variable <paramref name="key"/>, or <paramref name="resource"/> when the value is the
    /// consuming resource's own.
    /// </summary>
    /// <remarks>
    /// <c>WithReference(cache)</c> splats the referenced resource's connection properties straight
    /// into the consumer's environment (<c>CACHE_PASSWORD</c>, <c>CACHE_URI</c>, ...) as plain
    /// <see cref="ReferenceExpression"/>s, with nothing in the value identifying where they came
    /// from — unlike the connection string itself, which arrives wrapped in a
    /// <see cref="ConnectionStringReference"/>. Those values legitimately contain the backing
    /// resource's credential parameter and must not be reported as unrelated uses, so they are
    /// matched back to their owner here.
    /// <para>
    /// Provenance must come from the injection, not from the value: a value is something the AppHost
    /// author can construct independently. A user-authored
    /// <c>ReferenceExpression.Create($"{shared}")</c> renders to the same manifest expression as the
    /// owner's <c>password</c> connection property whenever <c>shared</c> <em>is</em> that parameter,
    /// so treating equal expressions as proof of origin would suppress
    /// <see cref="WarnIfUnrelatedUseOfSubstitutedParameter"/> for exactly the case it exists to
    /// report — a user's value silently replaced by the recipe credential. The variable's
    /// <em>name</em> is therefore the primary evidence, because the splat derives it mechanically:
    /// <c>&lt;ENCODED_CONNECTION_NAME&gt;_&lt;PROPERTY&gt;</c> (see <c>SplatConnectionProperties</c>).
    /// <paramref name="referencePrefixes"/> carries the prefixes that injection actually used for
    /// this consumer, recovered by <see cref="BuildReferencePrefixes"/>, so a name the splat could
    /// not have produced is never attributed to it.
    /// </para>
    /// <para>
    /// The value is still compared, as a second condition rather than the only one: a resource may
    /// expose a property whose name collides with a variable the author sets for their own purposes,
    /// and the recipe substitution should only be considered legitimate where the value really is
    /// the owner's. That comparison stays structural because the instance is not stable —
    /// <c>GetConnectionProperties()</c> builds a fresh <see cref="ReferenceExpression"/>, and fresh
    /// endpoint providers inside it, on every call — while the manifest expression is.
    /// </para>
    /// <para>
    /// A value that satisfies both conditions is indistinguishable from the injection by
    /// construction: the author named the variable exactly as the splat for a reference they
    /// declared would, and gave it that reference's value. Attributing it to the owner is then
    /// correct on the available evidence, and the outcome is identical either way.
    /// </para>
    /// </remarks>
    private IResource ResolveEnvValueProvenance(
        object? rawValue,
        string key,
        IResource resource,
        Dictionary<IResource, HashSet<string>> referencePrefixes)
    {
        if ((_recipeSecretSubstitutions.Count == 0 && _emptyCredentialSubstitutions.Count == 0) ||
            rawValue is not ReferenceExpression expression)
        {
            return resource;
        }
 
        var valueExpression = expression.ValueExpression;
 
        foreach (var (credentialOwner, _) in _recipeCredentialOwners.Values)
        {
            if (ReferenceEquals(credentialOwner, resource))
            {
                continue;
            }
 
            // A reference can target the backing server itself or one of its database children
            // (`AddSqlServer("sql").AddDatabase("appdb")`). The recipe credential is registered
            // against the server, but the splat used the referenced resource's own name for the
            // prefix and its own connection properties for the values — a child exposes properties
            // the server does not (`APPDB_URI`) while still embedding the server's credential — so
            // each referenced resource is matched with its own properties and then attributed to
            // the server that owns the credential.
            foreach (var (referenced, prefixes) in referencePrefixes)
            {
                if (referenced is not IResourceWithConnectionString withConnectionString ||
                    !ReferenceEquals(ResolveToParent(referenced), credentialOwner))
                {
                    continue;
                }
 
                foreach (var (propertyName, connectionProperty) in withConnectionString.GetConnectionProperties())
                {
                    if (KeyMatchesSplattedProperty(key, propertyName, prefixes) &&
                        string.Equals(connectionProperty.ValueExpression, valueExpression, StringComparison.Ordinal))
                    {
                        return credentialOwner;
                    }
                }
            }
        }
 
        return resource;
    }
 
    /// <summary>
    /// Recovers, for each resource <paramref name="resource"/> references, the environment variable
    /// prefixes that <c>WithReference</c>'s connection-property splat used for it.
    /// </summary>
    /// <remarks>
    /// Use the logical name carried by <see cref="ConnectionStringReference"/> environment values, not the
    /// projected physical alias: <c>db__primary</c> splats to <c>DB__PRIMARY_*</c> even though its
    /// portable connection-string alias is <c>ConnectionStrings__db_primary</c>.
    /// <para>
    /// Reference relationships retain the resource-name fallback when connection-string injection
    /// is suppressed via <see cref="ReferenceEnvironmentInjectionFlags"/>.
    /// </para>
    /// </remarks>
    private static Dictionary<IResource, HashSet<string>> BuildReferencePrefixes(IResource resource, Dictionary<string, object> environmentVariables)
    {
        var prefixes = new Dictionary<IResource, HashSet<string>>();
 
        if (resource.TryGetAnnotationsOfType<ResourceRelationshipAnnotation>(out var relationships))
        {
            foreach (var relationship in relationships)
            {
                if (!string.Equals(relationship.Type, KnownRelationshipTypes.Reference, StringComparison.OrdinalIgnoreCase))
                {
                    continue;
                }
 
                Add(relationship.Resource, relationship.Resource.Name);
            }
        }
 
        foreach (var reference in environmentVariables.Values.OfType<ConnectionStringReference>())
        {
            if (reference.EnvironmentVariableNames is { } names)
            {
                Add(reference.Resource, names.LogicalName);
            }
        }
 
        return prefixes;
 
        void Add(IResource target, string connectionName)
        {
            if (!prefixes.TryGetValue(target, out var names))
            {
                names = [];
                prefixes[target] = names;
            }
 
            names.Add(connectionName.Length == 0
                ? string.Empty
                : $"{EnvironmentVariableNameEncoder.Encode(connectionName).ToUpperInvariant()}_");
        }
    }
 
    /// <summary>
    /// Whether <paramref name="key"/> is a name the connection-property splat produced for
    /// <paramref name="propertyName"/> under one of <paramref name="prefixes"/>.
    /// </summary>
    private static bool KeyMatchesSplattedProperty(string key, string propertyName, HashSet<string> prefixes)
    {
        var suffix = propertyName.ToUpperInvariant();
 
        return prefixes.Any(prefix =>
            key.Length == prefix.Length + suffix.Length &&
            key.StartsWith(prefix, StringComparison.Ordinal) &&
            key.EndsWith(suffix, StringComparison.Ordinal));
    }
 
    /// <summary>
    /// Whether the model contains any resource that could consume <paramref name="resource"/> or one
    /// of its <paramref name="databaseChildren"/> without recording a reference annotation.
    /// </summary>
    /// <remarks>
    /// Used to scope the "several databases, none referenced" failure. That failure exists because a
    /// <c>WithEnvironment</c> callback composes a connection string inline and records no
    /// annotation, so an unreferenced database may still have a consumer. Only a resource that
    /// receives environment variables can be that hidden consumer, so a model containing none — a
    /// server and its databases and nothing else — can be published safely: no connection string is
    /// handed to anyone, wrong or otherwise.
    /// </remarks>
    private bool ModelHasPotentialConsumer(IResource resource, List<IResourceWithConnectionString> databaseChildren) =>
        _model.Resources.Any(candidate =>
            candidate is IResourceWithEnvironment &&
            !ReferenceEquals(candidate, resource) &&
            !databaseChildren.Any(child => ReferenceEquals(child, candidate)));
 
    /// <summary>
    /// Finds the database resources parented to <paramref name="resource"/>. These are skipped by
    /// <see cref="ClassifyResources"/> (they are represented by their parent), but the parent's
    /// recipe still needs to know which database to create.
    /// </summary>
    private List<IResourceWithConnectionString> FindDatabaseChildren(IResource resource) =>
        _model.Resources
            .OfType<IResourceWithConnectionString>()
            .Where(r => r is IResourceWithParent child && ReferenceEquals(child.Parent, resource))
            .ToList();
 
    /// <summary>
    /// The name of the database a child resource actually deploys to, which is what the recipe
    /// provisions and what a consumer's connection string names.
    /// </summary>
    /// <remarks>
    /// The Aspire resource name and the database name are independent: <c>AddDatabase("orders-a",
    /// "orders")</c> creates a child named <c>orders-a</c> that targets the database <c>orders</c>.
    /// Two such children are aliases for one physical database, so counting children would reject a
    /// model the single recipe-provisioned database fully satisfies.
    /// </remarks>
    private static string GetPhysicalDatabaseName(IResourceWithConnectionString child)
    {
        var expression = FindConnectionProperty(child, "databasename");
 
        // Unwrap pass-through wrappers for the same reason TryGetCredentialParameter does: resources
        // commonly expose a property as ReferenceExpression.Create($"{inner}") where `inner` carries
        // the value, so the literal sits a level or two down.
        while (expression is { Format: "{0}", ValueProviders: [ReferenceExpression nested] })
        {
            expression = nested;
        }
 
        // Only a wholly literal name identifies the deployed database at publish time — a database
        // name composed from a parameter is not known until deploy. Falling back to the child
        // resource name is the conservative direction: two children then compare as distinct and
        // keep the existing diagnostic rather than being silently merged into one database.
        return expression is { ValueProviders.Count: 0 }
            ? UnescapeBraces(expression.Format)
            : child.Name;
    }
 
    /// <summary>
    /// Resolves a named connection property to a Bicep value suitable for a Radius resource property,
    /// or <see langword="null"/> when the resource does not expose that property.
    /// </summary>
    /// <remarks>
    /// Goes through the same resolution the container env vars use, so a property that is a plain
    /// literal, a parameter, or a composition of both all produce the value the consumer will see.
    /// <para>
    /// Recipe <em>inputs</em> are resolved with parameter substitution disabled. A substitution
    /// rewrites an Aspire parameter to the value a recipe generates, which is the right answer for a
    /// value flowing <em>out</em> to a consumer but circular for a value flowing <em>in</em>: it
    /// would feed a resource's own output back in as its input. Because substitutions are registered
    /// as the resource loop progresses, leaving it enabled would also make the result depend on
    /// model order — a parameter shared with an earlier resource would resolve differently than one
    /// shared with a later resource.
    /// </para>
    /// <para>
    /// A fragment the publisher cannot produce is a *failure* here, not a skip. The env-var loop
    /// can drop one unresolvable variable and carry on, but a connection property feeds a schema
    /// property of the emitted Radius type, so dropping it silently would publish an artifact that
    /// is either rejected by schema validation or deployed with a value that describes nothing.
    /// <see cref="RadiusUnresolvableValueException"/> is internal and carries no diagnostic code, so
    /// it is translated here into the public exception rather than escaping <c>aspire publish</c>
    /// as an unattributed internal error.
    /// </para>
    /// </remarks>
    private async Task<(BicepValue<object> Value, List<EnvPart> Parts)?> TryResolveConnectionPropertyAsync(
        IResourceWithConnectionString resource,
        string key)
    {
        if (FindConnectionProperty(resource, key) is not { } expression)
        {
            return null;
        }
 
        var parts = new List<EnvPart>();
 
        try
        {
            await ResolveEnvPartsAsync(expression, resource, parts, resource, allowRecipeSubstitutions: false).ConfigureAwait(false);
        }
        catch (RadiusUnresolvableValueException ex)
        {
            throw new RadiusBackingResourceProjectionException(
                resource,
                $"The '{key}' connection property of Radius resource '{resource.Name}' could not be resolved at publish " +
                $"time: {ex.Message} That value is written onto the emitted Radius type, so it cannot be skipped. " +
                $"Supply the value with a parameter or a literal in the app model — the property is written by the " +
                $"publisher and cannot be assigned from a ConfigureRadiusInfrastructure callback. " +
                $"Diagnostic: ASPIRERADIUS086.",
                ex);
        }
 
        return parts.Count == 0 ? null : (new BicepValue<object>(BuildEnvBicepValue(parts).Compile()), parts);
    }
 
    /// <summary>
    /// Extracts the <see cref="ParameterResource"/> backing a named connection property (e.g.
    /// <c>password</c>) when that property is nothing but the parameter, so the publisher can reach
    /// a backing resource's credential without referencing the optional hosting package that
    /// defines the resource type.
    /// </summary>
    private static ParameterResource? TryGetCredentialParameter(IResourceWithConnectionString resource, string key)
    {
        if (FindConnectionProperty(resource, key) is not { } expression)
        {
            return null;
        }
 
        // Only a property that is *exactly* one parameter can be substituted; anything composed
        // with literals is a formatted value whose parameter cannot be swapped wholesale.
        //
        // The parameter is often wrapped in one or more pass-through ReferenceExpressions rather
        // than sitting directly in ValueProviders. PostgresServerResource, for example, exposes
        // `new("Username", ReferenceExpression.Create($"{UserNameReference}"))` where
        // `UserNameReference` is itself `ReferenceExpression.Create($"{UserNameParameter}")`, so the
        // parameter is two levels down. Unwrap those wrappers, but only while the expression adds
        // nothing of its own — a format other than "{0}" means literal text is being composed in.
        while (expression.Format == "{0}" && expression.ValueProviders is [ReferenceExpression nested])
        {
            expression = nested;
        }
 
        return expression.ValueProviders is [ParameterResource parameter] && expression.Format == "{0}"
            ? parameter
            : null;
    }
 
    private static ReferenceExpression? FindConnectionProperty(IResourceWithConnectionString resource, string key)
    {
        foreach (var property in resource.GetConnectionProperties())
        {
            if (string.Equals(property.Key, key, StringComparison.OrdinalIgnoreCase))
            {
                return property.Value;
            }
        }
 
        return null;
    }
 
    /// <summary>
    /// Projects an endpoint property of a backing resource onto the Radius recipe's own outputs.
    /// </summary>
    /// <remarks>
    /// Returns <see langword="false"/> when the endpoint's resource is not a backing resource,
    /// in which case the caller falls back to normal container service discovery. Throws when the
    /// resource *is* a backing resource but cannot be addressed — emitting a wrong value here is
    /// what https://github.com/microsoft/aspire/issues/18935 is about, so this never degrades
    /// silently.
    /// </remarks>
    private bool TryProjectBackingEndpoint(
        EndpointReference endpointReference,
        EndpointProperty property,
        List<EnvPart> parts)
    {
        var resource = ResolveToParent(endpointReference.Resource);
 
        if (!ResourceTypeMapper.IsBackingResource(resource))
        {
            return false;
        }
 
        if (!_typeInstancesByResourceName.TryGetValue(resource.Name, out var construct) ||
            !_radiusTypeByResourceName.TryGetValue(resource.Name, out var radiusType))
        {
            // The resource is a backing resource but this environment did not emit it — it belongs
            // to a different Radius environment. There is no construct to project from, and the
            // recipe outputs of another environment's deployment are not reachable from this Bicep.
            throw new RadiusBackingResourceProjectionException(
                resource,
                $"Resource '{resource.Name}' is deployed by a Radius recipe in a different environment than '{_environment.Name}', " +
                $"so its address cannot be resolved here. Deploy the consumer and '{resource.Name}' to the same Radius environment. " +
                $"Diagnostic: ASPIRERADIUS069.");
        }
 
        if (RadiusBackingConnections.GetSchema(radiusType) is not { } schema)
        {
            throw new RadiusBackingResourceProjectionException(
                resource,
                $"Resource '{resource.Name}' maps to Radius type '{radiusType}', which does not expose an address Aspire can " +
                $"project. Remove the reference, or map the resource to a Radius type that publishes host/port outputs. " +
                $"Diagnostic: ASPIRERADIUS071.");
        }
 
        ThrowIfNotPrimaryEndpoint(endpointReference, resource, radiusType);
 
        var scheme = endpointReference.EndpointAnnotation.UriScheme;
 
        ThrowIfTransportSecurityIsNotRecipeBacked(endpointReference, property, resource, radiusType);
 
        switch (property)
        {
            case EndpointProperty.Host or EndpointProperty.IPV4Host:
                parts.Add(EnvPart.FromProjection(Host(construct, schema, resource, radiusType)));
                return true;
            case EndpointProperty.Port or EndpointProperty.TargetPort:
                parts.Add(EnvPart.FromProjection(Port(construct, schema, resource, radiusType)));
                return true;
            case EndpointProperty.HostAndPort:
                parts.Add(EnvPart.FromProjection(Host(construct, schema, resource, radiusType)));
                parts.Add(EnvPart.FromLiteral(":"));
                parts.Add(EnvPart.FromProjection(Port(construct, schema, resource, radiusType)));
                return true;
            case EndpointProperty.Url:
                parts.Add(EnvPart.FromLiteral($"{scheme}://"));
                parts.Add(EnvPart.FromProjection(Host(construct, schema, resource, radiusType)));
                parts.Add(EnvPart.FromLiteral(":"));
                parts.Add(EnvPart.FromProjection(Port(construct, schema, resource, radiusType)));
                return true;
            case EndpointProperty.Scheme:
                parts.Add(EnvPart.FromLiteral(scheme));
                return true;
            case EndpointProperty.TlsEnabled:
                parts.Add(EnvPart.FromLiteral(endpointReference.EndpointAnnotation.TlsEnabled ? bool.TrueString : bool.FalseString));
                return true;
            default:
                throw new RadiusBackingResourceProjectionException(
                    resource,
                    $"The endpoint property '{property}' is not supported for Radius backing resource '{resource.Name}'. " +
                    $"Diagnostic: ASPIRERADIUS077.");
        }
 
        static ProjectedValue Host(
            RadiusResourceTypeConstruct construct,
            RadiusBackingConnections.RadiusConnectionSchema schema,
            IResource resource,
            string radiusType) =>
            schema.HostProperty is { } hostProperty
                ? new ProjectedValue(construct, hostProperty, IsSecret: false, IsNumeric: false)
                : throw new RadiusBackingResourceProjectionException(
                    resource,
                    $"Radius type '{radiusType}' used for resource '{resource.Name}' does not publish a host output, " +
                    $"so consumers cannot be given its address. Diagnostic: ASPIRERADIUS079.");
 
        static ProjectedValue Port(
            RadiusResourceTypeConstruct construct,
            RadiusBackingConnections.RadiusConnectionSchema schema,
            IResource resource,
            string radiusType) =>
            schema.PortProperty is { } portProperty
                // Radius types the port output as an int, so it needs an explicit string()
                // conversion when it lands in an env var on its own.
                ? new ProjectedValue(construct, portProperty, IsSecret: false, IsNumeric: true)
                : throw new RadiusBackingResourceProjectionException(
                    resource,
                    $"Radius type '{radiusType}' used for resource '{resource.Name}' does not publish a port output, " +
                    $"so consumers cannot be given its address. Diagnostic: ASPIRERADIUS079.");
    }
 
    /// <summary>
    /// Rejects a value that would describe the connection's transport security from the Aspire
    /// endpoint, when the recipe - not Aspire - decides what the deployed workload actually speaks.
    /// </summary>
    /// <remarks>
    /// <c>AddRedis("cache").WithTls()</c> makes the primary endpoint <c>rediss</c> with
    /// <see cref="EndpointAnnotation.TlsEnabled"/> set, which describes how the container runs
    /// locally. In publish mode the workload is provisioned by the mapped recipe instead, and no
    /// currently mapped type publishes a TLS output - <c>local-dev/rediscaches</c>, for example,
    /// starts plain Redis on 6379. Projecting the Aspire value would emit <c>rediss://</c> and
    /// <c>ssl=true</c> against a plaintext server, so the consumer fails to connect at run time with
    /// a handshake error that points at neither the recipe nor the AppHost.
    /// <para>
    /// Only the properties that carry the security decision are rejected: <c>Scheme</c>,
    /// <c>TlsEnabled</c>, and <c>Url</c>, which embeds the scheme. <c>Host</c>, <c>Port</c>, and
    /// <c>HostAndPort</c> stay projectable - the address is correct regardless of transport, and a
    /// consumer reading only those connects to exactly the right place.
    /// </para>
    /// <para>
    /// This fails the publish rather than warning because the emitted document is not usable: unlike
    /// a credential the recipe overrides, there is no value here that becomes right at deploy time.
    /// When a mapped type does publish a TLS output, this becomes a projection off that output
    /// instead of a diagnostic.
    /// </para>
    /// </remarks>
    private static void ThrowIfTransportSecurityIsNotRecipeBacked(
        EndpointReference endpointReference,
        EndpointProperty property,
        IResource resource,
        string radiusType)
    {
        if (!endpointReference.EndpointAnnotation.TlsEnabled ||
            property is not (EndpointProperty.Scheme or EndpointProperty.TlsEnabled or EndpointProperty.Url))
        {
            return;
        }
 
        throw new RadiusBackingResourceProjectionException(
            resource,
            $"Endpoint '{endpointReference.EndpointName}' of resource '{resource.Name}' is TLS-enabled, but the Radius " +
            $"type '{radiusType}' that provisions it publishes no transport-security output, so '{property}' would " +
            $"describe how '{resource.Name}' runs locally rather than how the recipe deploys it. Remove the TLS " +
            $"configuration for publishing, or provision the resource yourself if the deployed workload must use TLS. " +
            $"Diagnostic: ASPIRERADIUS081.");
    }
 
    /// <summary>
    /// Rejects a reference to a backing resource's <em>secondary</em> endpoint.
    /// </summary>
    /// <remarks>
    /// A Radius type publishes exactly one <c>host</c>/<c>port</c> pair — the recipe's data
    /// endpoint — so every endpoint of the resource would otherwise project to the same address.
    /// That is right for the primary endpoint and silently wrong for any other:
    /// <c>AddRabbitMQ(...).WithManagementPlugin()</c> declares a <c>management</c> endpoint on
    /// 15672, while the mapped <c>Applications.Messaging/rabbitMQQueues</c> recipe exposes only AMQP
    /// and does not enable the management plugin, so a consumer of that endpoint would be handed an
    /// HTTP URL pointing at the AMQP port.
    /// <para>
    /// The primary endpoint is the first one declared: <c>AddRedis</c>/<c>AddRabbitMQ</c> and the
    /// other <c>Add*</c> methods declare the data endpoint when the resource is created, and every
    /// secondary endpoint is added afterwards by a <c>With*</c> call.
    /// </para>
    /// </remarks>
    private static void ThrowIfNotPrimaryEndpoint(EndpointReference endpointReference, IResource resource, string radiusType)
    {
        var endpoints = resource.Annotations.OfType<EndpointAnnotation>().ToList();
 
        if (endpoints.Count <= 1 ||
            string.Equals(endpoints[0].Name, endpointReference.EndpointName, StringComparison.OrdinalIgnoreCase))
        {
            return;
        }
 
        throw new RadiusBackingResourceProjectionException(
            resource,
            $"Resource '{resource.Name}' is provisioned by a Radius recipe as '{radiusType}', which publishes a single " +
            $"address — that of its '{endpoints[0].Name}' endpoint. The reference to its '{endpointReference.EndpointName}' " +
            $"endpoint cannot be answered: the recipe does not deploy that endpoint, and projecting the primary address " +
            $"for it would hand the consumer a wrong one. Reference the '{endpoints[0].Name}' endpoint, or deploy " +
            $"'{resource.Name}' as a container. Diagnostic: ASPIRERADIUS077.");
    }
 
    private static Dictionary<string, RadiusResourceTypeConstruct> GetConnectionTargets(
        IResource resource,
        List<IResource> radiusResources,
        Dictionary<string, RadiusResourceTypeConstruct> typeInstancesByResourceName)
    {
        var connections = new Dictionary<string, RadiusResourceTypeConstruct>(StringComparer.Ordinal);
 
        // Find all ResourceRelationshipAnnotation with type "Reference"
        var references = resource.Annotations
            .OfType<ResourceRelationshipAnnotation>()
            .Where(r => r.Type == "Reference");
 
        foreach (var reference in references)
        {
            var referencedResource = reference.Resource;
 
            // Resolve child resources (e.g., SqlServerDatabaseResource) to parent
            if (referencedResource is IResourceWithParent childResource)
            {
                referencedResource = childResource.Parent;
            }
 
            // Only create connections for Radius resource type instances (non-compute)
            if (radiusResources.Any(p => p.Name == referencedResource.Name)
                && typeInstancesByResourceName.TryGetValue(referencedResource.Name, out var targetConstruct))
            {
                connections[referencedResource.Name] = targetConstruct;
            }
        }
 
        return connections;
    }
 
    private static RadiusContainerConstruct CreateContainerConstruct(
        string identifier, string resourceName, string image,
        RadiusApplicationConstruct appConstruct,
        RadiusEnvironmentConstruct? envConstruct,
        Dictionary<string, RadiusResourceTypeConstruct> connectionTargets,
        IReadOnlyDictionary<string, ContainerEnvVarConstruct> env,
        IReadOnlyDictionary<string, ContainerPortConstruct> ports)
    {
        var construct = new RadiusContainerConstruct(identifier, resourceName);
        construct.ContainerName = resourceName;
        construct.Image = image;
        construct.ApplicationId = BuildIdExpression(appConstruct);
        construct.EnvironmentId = BuildIdExpression(envConstruct!);
 
        if (connectionTargets.Count > 0)
        {
            foreach (var (name, targetConstruct) in connectionTargets)
            {
                var connectionConstruct = new ConnectionConstruct();
                connectionConstruct.Source = BuildIdExpression(targetConstruct);
                construct.Connections[name] = connectionConstruct;
            }
        }
 
        foreach (var (name, envVar) in env)
        {
            construct.Env[name] = envVar;
        }
 
        foreach (var (name, port) in ports)
        {
            construct.Ports[name] = port;
        }
 
        return construct;
    }
 
    /// <summary>
    /// Maps a compute resource's <see cref="EndpointAnnotation"/>s to Radius container ports,
    /// keyed by endpoint name. Uses the target (container) port when specified, otherwise the
    /// allocated/declared port. Endpoints with no resolvable port are skipped.
    /// </summary>
    private static Dictionary<string, ContainerPortConstruct> ResolvePorts(IResource resource)
    {
        var ports = new Dictionary<string, ContainerPortConstruct>(StringComparer.Ordinal);
        if (!resource.TryGetAnnotationsOfType<EndpointAnnotation>(out var endpoints))
        {
            return ports;
        }
 
        var seenPorts = new HashSet<(int ContainerPort, string Protocol)>();
        foreach (var endpoint in endpoints)
        {
            // Use the shared service-port resolver so the container port emitted here matches the
            // Service port the recipe exposes and the port the environment puts in service-discovery
            // URLs (RadiusServiceDiscovery). A null result means this endpoint contributes no port
            // (e.g. the synthetic default HTTPS endpoint), so the recipe creates no Service for it.
            if (RadiusServiceDiscovery.ResolveServicePort(resource, endpoint.Name) is not int containerPort)
            {
                continue;
            }
 
            var protocol = endpoint.Protocol == ProtocolType.Udp ? "UDP" : "TCP";
 
            // Deduplicate by (container port, protocol), matching the Kubernetes publisher's ToService
            // dedup. Multiple endpoints can resolve to the same container port (e.g. an explicit
            // portless HTTP and HTTPS endpoint on a project both default to 8080), and the recipe would
            // otherwise emit two Kubernetes Service ports with the same (port, protocol), which the
            // provider rejects. The first endpoint wins; the others still resolve to the same port in
            // their service-discovery URLs, so nothing is lost. See: https://github.com/microsoft/aspire/issues/14029
            if (!seenPorts.Add((containerPort, protocol)))
            {
                continue;
            }
 
            var port = new ContainerPortConstruct
            {
                ContainerPort = containerPort,
                Protocol = protocol,
            };
            ports[endpoint.Name] = port;
        }
 
        return ports;
    }
 
    /// <summary>
    /// Resolves a compute resource's environment variables into Radius container <c>env</c>
    /// entries. Mirrors the Kubernetes publisher: HTTPS service-discovery variables are dropped
    /// (no in-cluster TLS), endpoint references become cluster-FQDN URLs via the environment's
    /// <see cref="RadiusEnvironmentResource.GetHostAddressExpression"/>, and secret/parameter
    /// values are routed to Bicep <c>param</c>s so no literal secret is written to the artifact.
    /// </summary>
    private async Task<Dictionary<string, ContainerEnvVarConstruct>> ResolveEnvironmentAsync(
        IResource resource,
        RadiusInfrastructureOptions options,
        RadiusEnvironmentConstruct? envConstruct,
        RadiusApplicationConstruct? appConstruct)
    {
        var result = new Dictionary<string, ContainerEnvVarConstruct>(StringComparer.Ordinal);
        if (resource is not IResourceWithEnvironment)
        {
            return result;
        }
 
        var context = new EnvironmentCallbackContext(_executionContext, resource, cancellationToken: _cancellationToken)
        {
            Logger = _logger,
        };
 
        if (resource.TryGetAnnotationsOfType<EnvironmentCallbackAnnotation>(out var callbacks))
        {
            foreach (var callback in callbacks)
            {
                await callback.Callback(context).ConfigureAwait(false);
            }
        }
 
        var referencePrefixes = BuildReferencePrefixes(resource, context.EnvironmentVariables);
        ProjectPortableConnectionStringAliases(context.EnvironmentVariables);
 
        // Drop HTTPS service-discovery variables: containers in the cluster don't terminate TLS
        // (ingress/service mesh does), so an https `services__*` URL would be unreachable. This
        // matches RemoveHttpsServiceDiscoveryVariables in the Kubernetes/Docker Compose publishers.
        var httpsServiceKeys = context.EnvironmentVariables
            .Where(kvp => kvp.Value is EndpointReference epRef
                && epRef.Scheme == "https"
                && kvp.Key.StartsWith("services__", StringComparison.Ordinal))
            .Select(kvp => kvp.Key)
            .ToList();
        foreach (var key in httpsServiceKeys)
        {
            context.EnvironmentVariables.Remove(key);
        }
 
        // Created on demand: a container with no credential-bearing variable emits no secret.
        RadiusSecuritySecretConstruct? containerSecret = null;
 
        foreach (var (key, rawValue) in context.EnvironmentVariables)
        {
            var parts = new List<EnvPart>();
 
            try
            {
                await ResolveEnvPartsAsync(rawValue, resource, parts, ResolveEnvValueProvenance(rawValue, key, resource, referencePrefixes)).ConfigureAwait(false);
            }
            catch (RadiusUnresolvableValueException ex)
            {
                // Only the two conditions the publisher explicitly recognises as unavailable at
                // publish time reach here — see RadiusUnresolvableValueException. This used to be
                // `catch (InvalidOperationException)`, which also swallowed genuine publish errors:
                // whether a bug surfaced depended on the exception's type rather than on the
                // publisher having decided the value was legitimately unavailable.
                //
                // Logged at Warning, not Debug: dropping a variable the container asked for is
                // observable in the deployed app, and the previous Debug level meant it never
                // appeared in a normal publish.
                _logger.LogWarning(
                    "Environment variable '{Key}' on resource '{Resource}' was omitted from the Radius output: {Reason}",
                    key, resource.Name, ex.Message);
                continue;
            }
 
            var value = BuildEnvBicepValue(parts);
            var envVar = new ContainerEnvVarConstruct();
            RadiusSecuritySecretDataEntryConstruct? secretEntry = null;
            string? secretKey = null;
 
            if (parts.Any(IsSensitive))
            {
                // The composed expression is written into a secret and referenced instead of being
                // assigned to `value`, so the resolved credential never reaches the Deployment spec.
                // Composition still happens in Bicep, which is what preserves each part's own
                // `uriComponent()` escaping — kubelet's `$(VAR)` expansion could not have.
                containerSecret ??= CreateContainerEnvSecret(resource, options, envConstruct, appConstruct);
                secretEntry = new RadiusSecuritySecretDataEntryConstruct
                {
                    Encoding = "string",
                    Value = value,
                };
                secretKey = ToSecretKey(resource, key);
                containerSecret.Data[secretKey] = secretEntry;
                envVar.SecretName = containerSecret.SecretName;
                envVar.SecretKey = secretKey;
 
                // Tracked independently of _projectedEnvValues, which only records values that read
                // a backing resource's outputs. A value built purely from a secret parameter has no
                // projection, but its reference to the secret is just as breakable by a callback.
                _containerEnvSecretReferences.Add(new ContainerEnvSecretReference(
                    envVar,
                    containerSecret,
                    secretEntry,
                    resource.Name,
                    key,
                    secretKey,
                    RenderBicepValue(envVar.SecretName)));
            }
            else
            {
                envVar.Value = value;
            }
 
            result[key] = envVar;
 
            // Track values that name a backing resource's Bicep identifier so they can be repaired
            // (or rejected) if a ConfigureRadiusInfrastructure callback later renames or removes it.
            if (parts.Any(static p => p.Projection is not null))
            {
                _projectedEnvValues.Add(new ProjectedEnvValue(
                    envVar,
                    parts,
                    resource.Name,
                    key,
                    RenderBicepValue(secretEntry is null ? envVar.Value : secretEntry.Value),
                    parts.Where(p => p.Projection is not null)
                        .Select(p => p.Projection!.Target)
                        .Distinct()
                        .Select(t => (t, t.BicepIdentifier))
                        .ToList())
                {
                    SecretEntry = secretEntry,
                    Secret = secretEntry is null ? null : containerSecret,
                    SecretKey = secretKey,
                });
            }
        }
 
        return result;
    }
 
    private static void ProjectPortableConnectionStringAliases(Dictionary<string, object> environmentVariables)
    {
        // Snapshot the references before projecting aliases in the same dictionary.
        foreach (var reference in environmentVariables.Values.OfType<ConnectionStringReference>().Distinct().ToArray())
        {
            if (reference.EnvironmentVariableNames is not { } names ||
                string.Equals(names.OriginalName, names.PortableName, StringComparison.OrdinalIgnoreCase) ||
                !environmentVariables.ContainsKey(names.PortableName))
            {
                continue;
            }
 
            // Radius renders these values as Kubernetes container environment variables. Deploy only
            // the portable generated alias, preserving any later override of the original alias.
            if (environmentVariables.Remove(names.OriginalName, out var originalValue))
            {
                environmentVariables[names.PortableName] = originalValue;
            }
        }
    }
 
    /// <summary>
    /// Whether this part carries a credential, and so must not be written into the container's
    /// <c>value</c> block.
    /// </summary>
    /// <remarks>
    /// Routing to an <c>@secure()</c> Bicep <c>param</c> only keeps a credential out of the
    /// published <em>artifact</em>. The deployed <c>Deployment</c> spec still holds the resolved
    /// string, where it is readable by anyone who can read the Deployment or its rollout history,
    /// and it is retained in the rollout history after a rotation. Only
    /// <c>valueFrom.secretKeyRef</c> keeps it out of both.
    /// </remarks>
    private static bool IsSensitive(EnvPart part)
        => part.Parameter?.IsSecure == true || part.Projection?.IsSecret == true;
 
    /// <summary>
    /// Maps an environment-variable name to a key of the container's secret.
    /// </summary>
    /// <remarks>
    /// Kubernetes restricts <c>Secret</c> data keys to <c>[-._a-zA-Z0-9]+</c>, caps them at 253
    /// characters, and rejects <c>.</c>, <c>..</c> and any name starting with <c>..</c> — all
    /// narrower than what an environment-variable name may contain. Aspire's own names
    /// (<c>services__*</c>, <c>ConnectionStrings__*</c>, <c>OTEL_*</c>) all satisfy it, but a name
    /// supplied through <c>WithEnvironment</c> need not, and an invalid key is rejected by the API
    /// server at deploy time rather than at publish time. Reject it here instead, where the name can
    /// be attributed. <see cref="KubernetesName.IsValidSecretDataKey"/> carries the full contract,
    /// so it is reused rather than restated as a looser character-class check here.
    /// </remarks>
    private static string ToSecretKey(IResource resource, string envVarName)
    {
        if (!KubernetesName.IsValidSecretDataKey(envVarName))
        {
            throw new InvalidOperationException(
                $"Environment variable '{envVarName}' on resource '{resource.Name}' holds a credential, so it is " +
                $"published as a Kubernetes secret key, but its name is not a valid one (a key must be 1-253 " +
                $"characters of letters, digits, '-', '_' and '.', and may not be '.' or '..' or start with '..'). " +
                $"Rename the variable. Diagnostic: ASPIRERADIUS083.");
        }
 
        return envVarName;
    }
 
    /// <summary>
    /// Creates the single <c>Radius.Security/secrets</c> resource holding every credential-bearing
    /// environment value of one container.
    /// </summary>
    /// <remarks>
    /// One secret per container rather than one per variable keeps the emitted artifact
    /// proportional to the number of workloads instead of the number of variables.
    /// <para>
    /// This secret is only ever consumed by its own container, so it cannot create the
    /// <c>secret → resource → secret</c> cycle that <see cref="SecretResourceCredential"/> guards
    /// against: its values may read a backing resource's outputs, which orders it
    /// <em>after</em> that resource, and nothing orders the backing resource after it.
    /// </para>
    /// </remarks>
    private RadiusSecuritySecretConstruct CreateContainerEnvSecret(
        IResource resource,
        RadiusInfrastructureOptions options,
        RadiusEnvironmentConstruct? envConstruct,
        RadiusApplicationConstruct? appConstruct)
    {
        // The environment scope is required by the type. A container is always parented to the UDT
        // environment, so this is unreachable in practice — fail rather than emit a secret Radius
        // would reject for a missing required scope. InvalidOperationException rather than
        // RadiusBackingResourceProjectionException deliberately: that exception carries a *backing*
        // resource, and a container is a compute workload, not a backing resource.
        if (envConstruct is null)
        {
            throw new InvalidOperationException(
                $"Container '{resource.Name}' has environment variables holding credentials, which are published as a " +
                $"'{RadiusResourceTypes.SecuritySecrets}' resource, but no Radius environment was emitted to scope it to. " +
                $"Diagnostic: ASPIRERADIUS094.");
        }
 
        var secret = new RadiusSecuritySecretConstruct(
            BicepPostProcessor.SanitizeIdentifier($"{resource.Name}_env_secret"))
        {
            SecretName = BuildKubernetesSecretName($"{resource.Name}-env-secret"),
            EnvironmentId = BuildIdExpression(envConstruct),
        };
 
        if (appConstruct is not null)
        {
            secret.ApplicationId = BuildIdExpression(appConstruct);
        }
 
        options.SecuritySecrets.Add(secret);
 
        _containerEnvSecrets.Add(new ContainerEnvSecret(
            secret,
            resource.Name,
            RenderBicepValue(secret.EnvironmentId),
            secret.ApplicationId is { } applicationId ? RenderBicepValue(applicationId) : null));
 
        return secret;
    }
 
    /// <summary>
    /// Builds the <c>Radius.Security/secrets</c> resource name for a secret this publisher emits.
    /// </summary>
    /// <remarks>
    /// Lowercased because the Kubernetes secrets recipe uses the Radius resource name verbatim as
    /// the <c>core/Secret</c> <c>metadata.name</c> (<c>name: secretName</c>, where
    /// <c>secretName = context.resource.name</c>), and a Kubernetes object name must be an RFC 1123
    /// subdomain — lowercase only. The container recipe normalizes for itself
    /// (<c>var normalizedName = toLower(resourceName)</c>), so a mixed-case Aspire resource name
    /// deploys fine as a workload and only breaks on the secret; <c>AddContainer("MyApi", ...)</c>
    /// with a credential-bearing variable would otherwise emit <c>MyApi-env-secret</c> and be
    /// rejected at apply time. Aspire resource names are already restricted to ASCII letters,
    /// digits, and non-trailing hyphens starting with a letter (see <c>ModelName</c>), so
    /// lowercasing is the only transformation RFC 1123 needs here.
    /// See <see href="https://github.com/radius-project/resource-types-contrib/blob/main/Security/secrets/recipes/kubernetes/bicep/kubernetes-secrets.bicep"/>
    /// and <see href="https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names"/>.
    /// </remarks>
    private static string BuildKubernetesSecretName(string name) => name.ToLowerInvariant();
 
    /// <summary>
    /// A container environment value that reads from a backing resource's Radius construct, kept so
    /// it can be re-emitted after <c>ConfigureRadiusInfrastructure</c> callbacks run.
    /// </summary>
    private sealed record ProjectedEnvValue(
        ContainerEnvVarConstruct EnvVar,
        List<EnvPart> Parts,
        string ResourceName,
        string Key,
        string? OriginalValue,
        List<(RadiusResourceTypeConstruct Target, string OriginalIdentifier)> TargetIdentifiers)
    {
        /// <summary>The container the value belongs to, attached once the construct exists.</summary>
        public RadiusContainerConstruct? Container { get; set; }
 
        /// <summary>
        /// The secret entry holding the composed value when the variable is emitted as a
        /// <c>valueFrom.secretKeyRef</c>, in which case the container's own <c>value</c> is unset
        /// and it is this entry that has to be repaired after a rename.
        /// </summary>
        public RadiusSecuritySecretDataEntryConstruct? SecretEntry { get; set; }
 
        /// <summary>The secret owning <see cref="SecretEntry"/>.</summary>
        public RadiusSecuritySecretConstruct? Secret { get; set; }
 
        /// <summary>The key of <see cref="SecretEntry"/> within <see cref="Secret"/>'s data map.</summary>
        public string? SecretKey { get; set; }
    }
 
    /// <summary>
    /// A Radius resource property whose value reads from a backing resource's Radius construct.
    /// </summary>
    /// <remarks>
    /// Tracked separately from <see cref="ProjectedEnvValue"/> because the two live in different
    /// places: an env value hangs off a container construct and is a <c>BicepValue&lt;string&gt;</c>,
    /// while such a property hangs off a <see cref="RadiusResourceTypeConstruct"/> and is an
    /// already-compiled <c>BicepValue&lt;object&gt;</c>. Without this record a
    /// <c>ConfigureRadiusInfrastructure</c> callback that renames a construct would repair the
    /// container's env vars and silently leave those properties pointing at the old symbol.
    /// </remarks>
    private sealed record ProjectedTypeProperty(
        RadiusResourceTypeConstruct Owner,
        string Key,
        List<EnvPart> Parts,
        string? OriginalValue,
        List<(RadiusResourceTypeConstruct Target, string OriginalIdentifier)> TargetIdentifiers);
 
    /// <summary>
    /// The <c>Radius.Security/secrets</c> resource emitted to hold one container's
    /// credential-bearing environment values.
    /// </summary>
    /// <param name="Secret">The emitted secret.</param>
    /// <param name="ResourceName">The container the secret belongs to.</param>
    /// <param name="OriginalEnvironmentId">The scope reference the publisher wrote, used to detect a callback edit.</param>
    /// <param name="OriginalApplicationId">As <paramref name="OriginalEnvironmentId"/>, for the application scope.</param>
    private sealed record ContainerEnvSecret(
        RadiusSecuritySecretConstruct Secret,
        string ResourceName,
        string? OriginalEnvironmentId,
        string? OriginalApplicationId);
 
    /// <summary>
    /// One container environment variable emitted as a <c>valueFrom.secretKeyRef</c>.
    /// </summary>
    /// <remarks>
    /// Tracked separately from <see cref="ProjectedEnvValue"/>, which only records values that read
    /// a backing resource's outputs: a value composed purely from secret parameters has no
    /// projection but its reference to the secret is broken by a callback in exactly the same ways.
    /// </remarks>
    private sealed record ContainerEnvSecretReference(
        ContainerEnvVarConstruct EnvVar,
        RadiusSecuritySecretConstruct Secret,
        RadiusSecuritySecretDataEntryConstruct Entry,
        string ResourceName,
        string Key,
        string SecretKey,
        string? OriginalSecretName)
    {
        /// <summary>The container the value belongs to, attached once the construct exists.</summary>
        public RadiusContainerConstruct? Container { get; set; }
    }
 
    /// <summary>
    /// A <c>Radius.Security/secrets</c> resource emitted to carry a credential that
    /// <paramref name="Consumer"/> reads by resource ID from <paramref name="PropertyName"/>.
    /// </summary>
    /// <remarks>
    /// This secret exists solely to satisfy the consuming resource's own property. It must stay a
    /// distinct resource from any secret composed *from* that resource's outputs (e.g. a full
    /// connection string), or the deployment graph becomes <c>secret → resource → secret</c> and
    /// Radius cannot order it.
    /// </remarks>
    private sealed record SecretResourceCredential(
        RadiusSecuritySecretConstruct Secret,
        RadiusResourceTypeConstruct Consumer,
        string PropertyName,
        string SecretKey,
        RadiusSecuritySecretDataEntryConstruct Entry,
        string? OriginalEntryValue,
        string? OriginalEntryEncoding,
        string OriginalSecretIdentifier,
        string OriginalPropertyValue,
        string? OriginalEnvironmentId,
        string? OriginalApplicationId);
 
    /// <summary>
    /// Renders a Bicep value to a comparable string, so a value a callback overwrote can be told
    /// apart from the one the publisher generated. <c>ContainerEnvVarConstruct.Value</c> assigns
    /// into the existing <see cref="BicepValue{T}"/> rather than replacing it, so reference
    /// equality cannot detect an override.
    /// </summary>
    // The literal value only — null when the value is unset or carries an expression, so callers
    // that need a statically-known value are not handed the rendered expression text instead.
    private static string? RenderBicepLiteral<T>(BicepValue<T> value) =>
        value is IBicepValue { Expression: null } bicepValue
            ? bicepValue.LiteralValue?.ToString()
            : null;
 
    private static string? RenderBicepValue<T>(BicepValue<T> value) =>
        value is IBicepValue bicepValue
            ? bicepValue.Expression?.ToString() ?? bicepValue.LiteralValue?.ToString()
            : null;
 
    // An ordered fragment of a container env-var value: a literal string, a reference to a Bicep
    // parameter (used for secret/parameter values so the literal is never emitted), or a value
    // projected out of a backing resource's Radius construct (e.g. `cache.properties.host` or
    // `cache.listSecrets().password`).
    private readonly record struct EnvPart(
        string? Literal,
        ProvisioningParameter? Parameter,
        ProjectedValue? Projection,
        string? StringFormat = null)
    {
        public static EnvPart FromLiteral(string literal) => new(literal, null, null);
        public static EnvPart FromParameter(ProvisioningParameter parameter) => new(null, parameter, null);
        public static EnvPart FromProjection(ProjectedValue projection) => new(null, null, projection);
 
        /// <summary>
        /// Applies the format declared on the placeholder this part came from. A literal is escaped
        /// here and now, because its value is already known; a parameter or projection is only known
        /// at deploy time, so the escaping has to be emitted as a Bicep call instead.
        /// </summary>
        public EnvPart WithStringFormat(string stringFormat) => Literal is { } literal
            // Mirrors Aspire.Hosting's internal FormattingHelpers.FormatValue, which this assembly
            // cannot reference. Keep the two in sync if another format is ever added.
            ? this with
            {
                Literal = string.Equals(stringFormat, "uri", StringComparison.OrdinalIgnoreCase)
                    ? Uri.EscapeDataString(literal)
                    : throw new NotSupportedException(
                        $"The string format '{stringFormat}' is not supported by the Radius publisher. " +
                        $"Diagnostic: ASPIRERADIUS073."),
            }
            : this with { StringFormat = stringFormat };
 
        /// <summary>
        /// Wraps <paramref name="expression"/> in the Bicep equivalent of this part's format.
        /// </summary>
        public BicepExpression ApplyStringFormat(BicepExpression expression) => StringFormat?.ToLowerInvariant() switch
        {
            null => expression,
            // Aspire escapes "uri"-formatted values with Uri.EscapeDataString. Bicep's
            // uriComponent() is the closest available equivalent and is what ARM documents for
            // percent-encoding a value for use inside a URI.
            // https://learn.microsoft.com/azure/azure-resource-manager/bicep/bicep-functions-string#uricomponent
            "uri" => RadiusBackingConnections.UriComponent(expression),
            var unsupported => throw new NotSupportedException(
                $"The string format '{unsupported}' has no Bicep equivalent, so a value using it cannot be emitted " +
                $"for Radius. Diagnostic: ASPIRERADIUS073."),
        };
    }
 
    /// <summary>
    /// A value read off a backing resource's Radius construct.
    /// </summary>
    /// <remarks>
    /// The Bicep identifier is resolved lazily, at emit time, rather than captured as a finished
    /// expression. A <c>ConfigureRadiusInfrastructure</c> callback may rename the construct after
    /// the environment is resolved, and an eagerly-built expression would then reference a symbol
    /// that no longer exists. See <c>RebuildProjectedEnvValues</c>.
    /// </remarks>
    /// <param name="Target">The construct the value is read from.</param>
    /// <param name="Accessor">The property name, or the <c>listSecrets()</c> key when <paramref name="IsSecret"/>.</param>
    /// <param name="IsSecret">Whether the value comes from <c>listSecrets()</c> rather than <c>properties</c>.</param>
    /// <param name="IsNumeric">Whether the Radius schema types this value as a number, which needs
    /// an explicit <c>string(...)</c> conversion when it is not inside an interpolation.</param>
    private sealed record ProjectedValue(
        RadiusResourceTypeConstruct Target,
        string Accessor,
        bool IsSecret,
        bool IsNumeric)
    {
        public BicepExpression Build() => IsSecret
            ? RadiusBackingConnections.Secret(Target.BicepIdentifier, Accessor)
            : RadiusBackingConnections.Property(Target.BicepIdentifier, Accessor);
    }
 
    /// <summary>
    /// Recursively flattens an environment-variable value into ordered <see cref="EnvPart"/>s.
    /// Endpoint references resolve to cluster-FQDN URLs, parameter resources resolve to Bicep
    /// <c>param</c> references, and composite reference expressions are spliced together so a
    /// mixed literal/secret value is preserved precisely.
    /// </summary>
    /// <remarks>
    /// <paramref name="referencedResource"/> is the resource whose own value is currently being
    /// expanded, which is <paramref name="owner"/> until the recursion descends into a referenced
    /// resource's connection string. It only exists to tell a credential parameter reached through
    /// its owner's connection string (expected) from one the owner named directly (ambiguous) — see
    /// <see cref="WarnIfUnrelatedUseOfSubstitutedParameter"/>.
    /// </remarks>
    private async Task ResolveEnvPartsAsync(object? value, IResource owner, List<EnvPart> parts, IResource referencedResource, bool allowRecipeSubstitutions = true)
    {
        switch (value)
        {
            case null:
                return;
            case string s:
                parts.Add(EnvPart.FromLiteral(s));
                return;
            case bool b:
                parts.Add(EnvPart.FromLiteral(b ? "true" : "false"));
                return;
            case ParameterResource param:
                parts.Add(ResolveParameterPart(param, referencedResource, allowRecipeSubstitutions));
                return;
            case IResourceBuilder<ParameterResource> paramBuilder:
                parts.Add(ResolveParameterPart(paramBuilder.Resource, referencedResource, allowRecipeSubstitutions));
                return;
            case EndpointReference endpointReference:
                ThrowIfEndpointMissing(endpointReference, owner);
                if (!TryProjectBackingEndpoint(endpointReference, EndpointProperty.Url, parts))
                {
                    parts.Add(EnvPart.FromLiteral(ResolveEndpointUrl(endpointReference)));
                }
                return;
            case EndpointReferenceExpression endpointReferenceExpression:
                ThrowIfEndpointMissing(endpointReferenceExpression.Endpoint, owner);
                if (!TryProjectBackingEndpoint(
                        endpointReferenceExpression.Endpoint,
                        endpointReferenceExpression.Property,
                        parts))
                {
                    parts.Add(EnvPart.FromLiteral(ResolveEndpointProperty(endpointReferenceExpression)));
                }
                return;
            case ConnectionStringReference connectionStringReference:
                // The credential parameters inside a backing resource's own connection string are
                // exactly the ones the substitution is meant to rewrite, so the referenced resource
                // becomes the context here — otherwise every consumer of `.WithReference(cache)`
                // would be reported as an unrelated use of the cache's own password. A database
                // child (`AddSqlServer("sql").AddDatabase("appdb")`) composes its connection string
                // from its server's credential, which is registered against the server, so the
                // context is canonicalized to the parent — otherwise `ConnectionStrings__appdb`
                // would report the server's own password as an unrelated use.
                RecordConnectionStringConsumption(connectionStringReference.Resource, owner);
                await ResolveEnvPartsAsync(connectionStringReference.ConnectionStringExpression, owner, parts, ResolveToParent(connectionStringReference.Resource), allowRecipeSubstitutions).ConfigureAwait(false);
                return;
            case IResourceWithConnectionString resourceWithConnectionString:
                RecordConnectionStringConsumption(resourceWithConnectionString, owner);
                await ResolveEnvPartsAsync(resourceWithConnectionString.ConnectionStringExpression, owner, parts, ResolveToParent(resourceWithConnectionString), allowRecipeSubstitutions).ConfigureAwait(false);
                return;
            case ReferenceExpression referenceExpression:
                RecordConnectionStringExpressionConsumption(referenceExpression, owner);
                await ResolveReferenceExpressionPartsAsync(referenceExpression, owner, parts, referencedResource, allowRecipeSubstitutions).ConfigureAwait(false);
                return;
            case IFormattable formattable:
                parts.Add(EnvPart.FromLiteral(formattable.ToString(null, CultureInfo.InvariantCulture)));
                return;
            default:
                // Fall back to publish-mode resolution (e.g. manifest expression providers) and
                // capture whatever literal string the framework produces.
                if (value is IValueProvider valueProvider)
                {
                    var context = new ValueProviderContext { ExecutionContext = _executionContext, Caller = owner };
 
                    string? resolved;
                    try
                    {
                        resolved = await valueProvider.GetValueAsync(context, _cancellationToken).ConfigureAwait(false);
                    }
                    catch (InvalidOperationException ex) when (value is IManifestExpressionProvider manifestExpressionProvider)
                    {
                        // Only a provider that positively declares deployment-substituted semantics
                        // may be skipped. Reaching this default branch means the value is not a
                        // parameter, endpoint, connection string, or reference expression (all
                        // handled above), so an IManifestExpressionProvider here is a placeholder
                        // another deployment fills in — an Azure Bicep output is the canonical case,
                        // and it throws "...has no value..." until the deployment that produces it
                        // has run. Radius does not run those deployments, so the value genuinely
                        // cannot be known while publishing.
                        //
                        // The marker gates the skip; the failure alone never does. An arbitrary
                        // IValueProvider may use InvalidOperationException for a genuine invalid
                        // state, and silently dropping the variable with a warning would hide a real
                        // publish bug behind an exception type.
                        //
                        // The marker alone is not sufficient either: some manifest-expression
                        // providers do resolve at publish time (Aspire.Hosting.Blazor's
                        // GatewayOriginReference wraps an endpoint, for example), so pre-emptively
                        // skipping every one of them would drop values that are available.
                        throw new RadiusUnresolvableValueException(
                            owner,
                            $"'{manifestExpressionProvider.ValueExpression}' is only known after that resource's own " +
                            $"deployment, which Radius does not perform ({ex.Message})",
                            ex);
                    }
 
                    parts.Add(EnvPart.FromLiteral(resolved ?? string.Empty));
                    return;
                }
 
                parts.Add(EnvPart.FromLiteral(value.ToString() ?? string.Empty));
                return;
        }
    }
 
    /// <summary>
    /// Records that <paramref name="consumed"/>'s connection string was resolved into a value
    /// belonging to some other resource, which is the only signal available for a consumer created
    /// by a <c>WithEnvironment</c> callback that builds its value inline (no
    /// <see cref="ResourceRelationshipAnnotation"/> is recorded for one).
    /// </summary>
    /// <remarks>
    /// A resource resolving its own connection string is not consumption: the publisher does that
    /// itself while wiring credentials onto the emitted Radius type. The parent check covers the
    /// same case one level up, where a database child's expression is composed from its server's.
    /// </remarks>
    private void RecordConnectionStringConsumption(IResourceWithConnectionString consumed, IResource owner)
    {
        if (ReferenceEquals(consumed, owner) ||
            ReferenceEquals(ResolveToParent(consumed), owner))
        {
            return;
        }
 
        _resolvedConnectionStringConsumption.Add(consumed.Name);
    }
 
    /// <summary>
    /// Records consumption of a database child whose <c>ConnectionStringExpression</c> was assigned
    /// directly (<c>ctx.EnvironmentVariables["CS"] = db.Resource.ConnectionStringExpression</c>)
    /// rather than the child resource itself.
    /// </summary>
    /// <remarks>
    /// That form reaches environment resolution as a bare <see cref="ReferenceExpression"/>: the
    /// child is not a node in the value (its expression is composed from the <em>server's</em>
    /// expression plus the database name as a literal), and no
    /// <see cref="ResourceRelationshipAnnotation"/> is recorded either, so both of
    /// <see cref="WarnForDatabasesNotCreatedByTheRecipe"/>'s signals miss it and a consumer of a
    /// database the recipe never creates receives no diagnostic at all.
    /// <para>
    /// Matching on the manifest expression is sound evidence <em>here</em>, unlike in
    /// <see cref="ResolveEnvValueProvenance"/> where provenance decides whether a credential the
    /// author owns was silently replaced. <c>ASPIRERADIUS080</c> is a statement about the value:
    /// this string names a database the deployment will not contain. An author who hand-composed
    /// the identical string has the identical problem, so attributing it to the child is correct
    /// regardless of how the value was built.
    /// </para>
    /// </remarks>
    private void RecordConnectionStringExpressionConsumption(ReferenceExpression expression, IResource owner)
    {
        // Built lazily rather than in the constructor: it is derived from the emitted-type table,
        // which is only filled once resource types have been resolved. Every call site runs after
        // that, so the first lookup already sees the complete set.
        _databaseChildConnectionStringExpressions ??= BuildDatabaseChildConnectionStringExpressions();
 
        if (_databaseChildConnectionStringExpressions.Count == 0 ||
            !_databaseChildConnectionStringExpressions.TryGetValue(expression.ValueExpression, out var childName))
        {
            return;
        }
 
        // A database child resolving its own connection string is the publisher wiring the resource,
        // not a consumer being handed an unusable value.
        if (string.Equals(owner.Name, childName, StringComparison.Ordinal))
        {
            return;
        }
 
        _resolvedConnectionStringConsumption.Add(childName);
    }
 
    private Dictionary<string, string> BuildDatabaseChildConnectionStringExpressions()
    {
        var expressions = new Dictionary<string, string>(StringComparer.Ordinal);
 
        // Enumerated from the model rather than from _databasesNotCreatedByTheRecipe or
        // _recipeDatabaseSelections, because both of those are filled *during* the credential pass
        // that also triggers this map's first lookup: a resource wired before its own selection is
        // recorded would be missing from the map, and its consumers would then go unobserved. Keying
        // off the emitted-type table instead makes the result independent of resource order — it is
        // complete from step 4 onwards, before any credential is wired.
        //
        // Extra entries are harmless: both consumers of _resolvedConnectionStringConsumption filter
        // it back down to the database children of the specific resource they are reporting on.
        foreach (var child in _model.Resources.OfType<IResourceWithConnectionString>())
        {
            if (child is IResourceWithParent { Parent: { } parent } &&
                _radiusTypeByResourceName.ContainsKey(parent.Name))
            {
                expressions[child.ConnectionStringExpression.ValueExpression] = child.Name;
            }
        }
 
        return expressions;
    }
 
    /// <summary>
    /// Rejects a reference to an endpoint the target resource does not declare, before any code
    /// touches <see cref="EndpointReference.EndpointAnnotation"/> (which raises a bare
    /// <see cref="InvalidOperationException"/> that would be indistinguishable from a real error).
    /// </summary>
    private static void ThrowIfEndpointMissing(EndpointReference endpointReference, IResource owner)
    {
        if (endpointReference.Exists)
        {
            return;
        }
 
        throw new RadiusUnresolvableValueException(
            owner,
            $"the endpoint '{endpointReference.EndpointName}' is not defined on resource " +
            $"'{endpointReference.Resource.Name}'");
    }
 
    /// <summary>
    /// Splices a composite <see cref="ReferenceExpression"/> into ordered parts by interleaving
    /// its literal <see cref="ReferenceExpression.Format"/> chunks with the recursively-resolved
    /// parts of each value provider (matching the <c>{0}</c>, <c>{1}</c>, ... placeholders).
    /// </summary>
    private async Task ResolveReferenceExpressionPartsAsync(ReferenceExpression expression, IResource owner, List<EnvPart> parts, IResource referencedResource, bool allowRecipeSubstitutions = true)
    {
        // A conditional expression carries no format at all and exposes the *union* of both
        // branches' providers, so the splice below would resolve both branches — potentially
        // failing the publish on the inactive one — and then append nothing, leaving the variable
        // empty. Select the branch first, matching ReferenceExpression.GetValueAsync and
        // ExpressionResolver.EvalExpressionAsync.
        if (expression.IsConditional)
        {
            // An endpoint-shaped condition must not go through GetValueAsync: it awaits an
            // allocation publish mode never makes, so the publish blocks until cancellation rather
            // than failing. ResolveEnvPartsAsync answers endpoints at publish time. Anything else
            // (a parameter, a literal) is asked for its value directly — resolving it through
            // ResolveEnvPartsAsync would declare a Bicep `param` for a value that is consumed
            // during publish and never emitted.
            string? conditionValue;
            if (expression.Condition is EndpointReference or EndpointReferenceExpression)
            {
                var conditionParts = new List<EnvPart>();
                await ResolveEnvPartsAsync(expression.Condition, owner, conditionParts, referencedResource, allowRecipeSubstitutions).ConfigureAwait(false);
 
                // A projected part is a recipe output known only at deploy time, and the branch
                // choice is baked into the emitted document, so there is nothing to select on.
                if (conditionParts.Any(static p => p.Literal is null))
                {
                    throw new RadiusUnresolvableValueException(
                        owner,
                        "a conditional value's condition is only known after deployment, so the branch to emit " +
                        "cannot be selected while publishing. Diagnostic: ASPIRERADIUS078");
                }
 
                conditionValue = string.Concat(conditionParts.Select(static p => p.Literal));
            }
            else
            {
                // Translate only the conditions the publisher recognises as legitimately unknown
                // while publishing; everything else is a real failure and must abort the publish.
                var conditionContext = new ValueProviderContext { ExecutionContext = _executionContext, Caller = owner };
                try
                {
                    conditionValue = await expression.Condition!.GetValueAsync(conditionContext, _cancellationToken).ConfigureAwait(false);
                }
                catch (Exception ex) when (
                    // A parameter with no configured value. Used as a *value* the same parameter
                    // resolves to a `@secure()` param reference, so the publish can continue; as a
                    // condition there is no branch to select.
                    ex is MissingParameterValueException ||
                    // A placeholder another deployment fills in (an Azure Bicep output is the
                    // canonical case), which throws until that deployment has run. Radius does not
                    // run those deployments, so the value genuinely cannot be known here.
                    //
                    // The classification, not the exception type, gates the skip. Testing
                    // `Condition is IManifestExpressionProvider` directly would be nearly vacuous —
                    // `IExpressionValue` derives from that marker, so a parameter, a reference
                    // expression or a connection string all carry it — and because the outer
                    // environment loop turns a RadiusUnresolvableValueException into a warning and
                    // *drops the variable*, a configuration error, a network failure, or a bug
                    // inside a provider would silently remove an environment variable from the
                    // deployed app.
                    (ex is InvalidOperationException && IsDeploymentSubstituted(expression.Condition)))
                {
                    throw new RadiusUnresolvableValueException(
                        owner,
                        $"a conditional value's condition could not be evaluated at publish time, so the branch to " +
                        $"emit cannot be selected ({ex.Message}). Diagnostic: ASPIRERADIUS078",
                        ex);
                }
            }
 
            var branch = string.Equals(conditionValue, expression.MatchValue, StringComparison.OrdinalIgnoreCase)
                ? expression.WhenTrue!
                : expression.WhenFalse!;
 
            await ResolveReferenceExpressionPartsAsync(branch, owner, parts, referencedResource, allowRecipeSubstitutions).ConfigureAwait(false);
            return;
        }
 
        // No providers: the format string is already the literal value (after un-escaping braces).
        if (expression.ValueProviders.Count == 0)
        {
            parts.Add(EnvPart.FromLiteral(UnescapeBraces(expression.Format)));
            return;
        }
 
        // Pre-resolve each provider's parts so the placeholder splice is a simple lookup.
        var providerParts = new List<EnvPart>[expression.ValueProviders.Count];
        for (var i = 0; i < expression.ValueProviders.Count; i++)
        {
            var inner = new List<EnvPart>();
            await ResolveEnvPartsAsync(expression.ValueProviders[i], owner, inner, referencedResource, allowRecipeSubstitutions).ConfigureAwait(false);
 
            // Apply the placeholder's string format (today only "uri") to every part the provider
            // produced. Without this the emitted value contains the raw credential: Aspire's own
            // resolution escapes it via Uri.EscapeDataString, but the publisher writes a Bicep
            // expression rather than a resolved string, so the escaping has to be carried into the
            // generated Bicep instead. Indexed defensively because StringFormats is a parallel list
            // the ReferenceExpression constructors are not required to keep the same length as
            // ValueProviders — a missing entry means "no format", not a malformed expression.
            var stringFormat = i < expression.StringFormats.Count ? expression.StringFormats[i] : null;
            providerParts[i] = stringFormat is null
                ? inner
                : inner.Select(part => part.WithStringFormat(stringFormat)).ToList();
        }
 
        // Walk the format string, emitting literal text and substituting `{i}` placeholders.
        // Braces are escaped as `{{`/`}}` in composite expression formats.
        var format = expression.Format;
        var literal = new StringBuilder();
        for (var i = 0; i < format.Length; i++)
        {
            var c = format[i];
            if (c == '{')
            {
                if (i + 1 < format.Length && format[i + 1] == '{')
                {
                    literal.Append('{');
                    i++;
                    continue;
                }
 
                var close = format.IndexOf('}', i + 1);
                var indexText = format.Substring(i + 1, close - i - 1);
                var index = int.Parse(indexText, CultureInfo.InvariantCulture);
 
                if (literal.Length > 0)
                {
                    parts.Add(EnvPart.FromLiteral(literal.ToString()));
                    literal.Clear();
                }
 
                parts.AddRange(providerParts[index]);
                i = close;
                continue;
            }
 
            if (c == '}' && i + 1 < format.Length && format[i + 1] == '}')
            {
                literal.Append('}');
                i++;
                continue;
            }
 
            literal.Append(c);
        }
 
        if (literal.Length > 0)
        {
            parts.Add(EnvPart.FromLiteral(literal.ToString()));
        }
    }
 
    /// <summary>
    /// Decides whether <paramref name="value"/> resolves to a placeholder that only another
    /// deployment can fill in, and whose failure to evaluate is therefore expected while publishing
    /// rather than a bug.
    /// </summary>
    /// <remarks>
    /// This mirrors the type dispatch in <see cref="ResolveEnvPartsAsync"/>, where the
    /// <see cref="IManifestExpressionProvider"/> test lives in the <c>default</c> arm and is
    /// meaningful precisely <em>because</em> parameters, endpoints, connection strings and reference
    /// expressions were already handled by earlier cases. Testing the marker on its own would be
    /// nearly vacuous: <c>IExpressionValue</c> derives from <see cref="IManifestExpressionProvider"/>,
    /// so <see cref="ParameterResource"/>, <see cref="ReferenceExpression"/> and
    /// <see cref="ConnectionStringReference"/> all carry it, and a genuine failure inside any of them
    /// would be misread as "not deployed yet".
    /// </remarks>
    private static bool IsDeploymentSubstituted(object? value) =>
        IsDeploymentSubstituted(value, new HashSet<object>(ReferenceEqualityComparer.Instance));
 
    private static bool IsDeploymentSubstituted(object? value, HashSet<object> visited)
    {
        // Revisiting a node means the walk has cycled. Answering false fails closed: the caller
        // rethrows the original exception and the publish stops, rather than silently dropping an
        // environment variable on the strength of an incomplete traversal.
        if (value is not null && !visited.Add(value))
        {
            return false;
        }
 
        switch (value)
        {
            case null:
            case string:
            case bool:
            case ParameterResource:
            case IResourceBuilder<ParameterResource>:
            case EndpointReference:
            case EndpointReferenceExpression:
                return false;
 
            // A conditional expression's ValueProviders is the union of the two *branches* and
            // deliberately excludes the expression's own Condition, so the ValueProviders walk below
            // would miss a nested conditional whose condition is the deployment-substituted value.
            // The branches are walked too because either one may itself be conditional and hide its
            // own condition the same way.
            case ReferenceExpression { IsConditional: true } conditional:
                return IsDeploymentSubstituted(conditional.Condition, visited) ||
                       IsDeploymentSubstituted(conditional.WhenTrue, visited) ||
                       IsDeploymentSubstituted(conditional.WhenFalse, visited);
 
            case ReferenceExpression referenceExpression:
                foreach (var provider in referenceExpression.ValueProviders)
                {
                    if (IsDeploymentSubstituted(provider, visited))
                    {
                        return true;
                    }
                }
 
                return false;
 
            case ConnectionStringReference connectionStringReference:
                return IsDeploymentSubstituted(connectionStringReference.ConnectionStringExpression, visited);
 
            case IResourceWithConnectionString resourceWithConnectionString:
                return IsDeploymentSubstituted(resourceWithConnectionString.ConnectionStringExpression, visited);
 
            case IFormattable:
                return false;
 
            default:
                // A provider that positively declares manifest-expression semantics *and* is not one
                // of the eagerly-resolved shapes above is a placeholder another deployment fills in
                // — an Azure Bicep output is the canonical case, and it throws until that deployment
                // has run.
                //
                // ContainerImageReference is the one core exception: it carries the marker but
                // resolves eagerly, and throws a genuine InvalidOperationException
                // ("RemoteImageName must be set.") that names a real configuration error rather than
                // a value awaiting another deployment.
                return value is IManifestExpressionProvider and not ContainerImageReference;
        }
    }
 
    /// <summary>
    /// Records which backing resource a credential parameter belongs to, and rejects sharing that
    /// cannot resolve to a correct value.
    /// </summary>
    /// <remarks>
    /// Sharing one <see cref="ParameterResource"/> across resources is only safe when every owner
    /// takes the credential as a <em>schema property</em> on its own resource: the same parameter is
    /// then passed into
    /// each recipe, and every consumer reads back that same value. It is not safe once any owner
    /// uses the <c>listSecrets()</c> substitution, because that rewrites the parameter to one
    /// specific resource's recipe-generated secret everywhere it appears — the other resources'
    /// consumers would silently be handed the wrong credential.
    /// </remarks>
    private void RegisterRecipeCredential(ParameterResource parameter, IResource owner, bool isProjectionSubstitution)
    {
        if (_recipeCredentialOwners.TryGetValue(parameter, out var existing) &&
            !ReferenceEquals(existing.Owner, owner) &&
            (existing.IsProjectionSubstitution || isProjectionSubstitution))
        {
            throw new InvalidOperationException(
                $"Parameter '{parameter.Name}' is used as the credential of both '{existing.Owner.Name}' and '{owner.Name}', " +
                $"and at least one of them is provisioned by a Radius recipe that generates its own credential. The shared " +
                $"parameter would be rewritten to one resource's secret for both. Give each resource its own parameter. " +
                $"Diagnostic: ASPIRERADIUS070.");
        }
 
        _recipeCredentialOwners[parameter] = (owner, isProjectionSubstitution);
    }
 
    /// <summary>
    /// Re-emits every projected environment value whose target construct was renamed by a
    /// <c>ConfigureRadiusInfrastructure</c> callback, and fails when the target was removed.
    /// </summary>
    /// <remarks>
    /// Projected values reference a backing resource by Bicep identifier, exactly like the
    /// <c>.id</c> cross-references <see cref="RewireIdReferences"/> repairs, so they break the same
    /// way. Values are only rewritten when the identifier actually changed, preserving the
    /// last-write-wins contract for a callback that set an environment value itself.
    /// </remarks>
    private void RebuildProjectedEnvValues(RadiusInfrastructureOptions options)
    {
        var liveInstances = new HashSet<RadiusResourceTypeConstruct>(options.ResourceTypeInstances);
        var liveContainers = new HashSet<RadiusContainerConstruct>(options.Containers);
 
        foreach (var projected in _projectedEnvValues)
        {
            // A callback that dropped or replaced the workload, removed the variable, or set the
            // variable itself owns the result — last-write-wins. Only values still exactly as the
            // publisher generated them are ours to repair or reject.
            // A secret-backed value lives in the secret's data entry, not on the env var, so that
            // is what is compared and repaired.
            var valueHolder = projected.SecretEntry is { } entry ? entry.Value : projected.EnvVar.Value;
 
            if (projected.Container is null ||
                !liveContainers.Contains(projected.Container) ||
                !projected.Container.Env.TryGetValue(projected.Key, out var currentEnvVar) ||
                // BicepDictionary wraps each entry, so unwrap before comparing construct identity.
                !ReferenceEquals(currentEnvVar?.Value, projected.EnvVar) ||
                !string.Equals(RenderBicepValue(valueHolder), projected.OriginalValue, StringComparison.Ordinal))
            {
                continue;
            }
 
            // A callback may have replaced the secret's data entry, in which case the callback owns
            // the value and rebuilding our now-orphaned entry would change nothing that is emitted.
            if (projected.Secret is { } secret &&
                (!options.SecuritySecrets.Contains(secret) ||
                 !secret.Data.TryGetValue(projected.SecretKey!, out var liveEntry) ||
                 !ReferenceEquals(liveEntry?.Value, projected.SecretEntry)))
            {
                continue;
            }
 
            var changed = false;
 
            foreach (var (target, originalIdentifier) in projected.TargetIdentifiers)
            {
                if (!liveInstances.Contains(target))
                {
                    throw new InvalidOperationException(
                        $"Environment variable '{projected.Key}' on container '{projected.ResourceName}' reads connection " +
                        $"information from Radius resource '{target.BicepIdentifier}', but a ConfigureRadiusInfrastructure " +
                        $"callback removed or replaced that resource. Keep the resource, or set '{projected.Key}' explicitly " +
                        $"in the callback. Diagnostic: ASPIRERADIUS074.");
                }
 
                if (!string.Equals(target.BicepIdentifier, originalIdentifier, StringComparison.Ordinal))
                {
                    changed = true;
                }
            }
 
            if (changed)
            {
                var rebuilt = BuildEnvBicepValue(projected.Parts);
                if (projected.SecretEntry is { } secretEntry)
                {
                    secretEntry.Value = rebuilt;
                }
                else
                {
                    projected.EnvVar.Value = rebuilt;
                }
            }
        }
 
        RebuildProjectedTypeProperties(liveInstances);
    }
 
    /// <summary>
    /// The <see cref="RebuildProjectedEnvValues"/> counterpart for projected resource properties.
    /// </summary>
    private void RebuildProjectedTypeProperties(HashSet<RadiusResourceTypeConstruct> liveInstances)
    {
        foreach (var projected in _projectedTypeProperties)
        {
            // The construct that owns the parameter is gone, or the callback set the parameter
            // itself — last-write-wins, exactly as for container env values.
            if (!liveInstances.Contains(projected.Owner) ||
                projected.Owner.GetSchemaProperty(projected.Key) is not { } current ||
                !string.Equals(RenderBicepValue(current), projected.OriginalValue, StringComparison.Ordinal))
            {
                continue;
            }
 
            var changed = false;
 
            foreach (var (target, originalIdentifier) in projected.TargetIdentifiers)
            {
                if (!liveInstances.Contains(target))
                {
                    throw new InvalidOperationException(
                        $"Connection property '{projected.Key}' on Radius resource '{projected.Owner.BicepIdentifier}' reads " +
                        $"connection information from Radius resource '{target.BicepIdentifier}', but a " +
                        $"ConfigureRadiusInfrastructure callback removed or replaced that resource. Keep the resource: " +
                        $"'{projected.Key}' is a schema property the publisher owns and it cannot be assigned from a " +
                        $"callback. Diagnostic: ASPIRERADIUS074.");
                }
 
                if (!string.Equals(target.BicepIdentifier, originalIdentifier, StringComparison.Ordinal))
                {
                    changed = true;
                }
            }
 
            if (changed)
            {
                projected.Owner.SetSchemaProperty(
                    projected.Key,
                    new BicepValue<object>(BuildEnvBicepValue(projected.Parts).Compile()));
            }
        }
    }
 
    private static string UnescapeBraces(string format) =>
        format.Replace("{{", "{", StringComparison.Ordinal).Replace("}}", "}", StringComparison.Ordinal);
 
    // A backing resource's password is generated by its Radius recipe, not by Aspire, so the
    // Aspire parameter is replaced by the recipe's own secret accessor wherever it is referenced.
    // Everything composed from it (connection string, URI, splatted *_PASSWORD) then carries the
    // deployed value. Parameters that are not a recipe credential keep the normal `param` routing.
    private EnvPart ResolveParameterPart(ParameterResource parameter, IResource owner, bool allowRecipeSubstitutions)
    {
        if (allowRecipeSubstitutions && _recipeSecretSubstitutions.TryGetValue(parameter, out var secretProjection))
        {
            WarnIfUnrelatedUseOfSubstitutedParameter(parameter, owner);
            return EnvPart.FromProjection(secretProjection);
        }
 
        // No recipe-generated counterpart exists at all (an unauthenticated workload), so the
        // honest value is empty rather than Aspire's run-mode password. ApplyNoCredential already
        // warned about the discarded value.
        if (allowRecipeSubstitutions && _emptyCredentialSubstitutions.Contains(parameter))
        {
            WarnIfUnrelatedUseOfSubstitutedParameter(parameter, owner);
            return EnvPart.FromLiteral(string.Empty);
        }
 
        return EnvPart.FromParameter(GetOrAddEnvParameter(parameter));
    }
 
    // Allocates (or reuses) the Bicep parameter that carries this Aspire parameter's value. The
    // parameter is declared `@secure()` when the source is a secret so its value is neither printed
    // in deploy logs nor written to the artifact. The identifier→resource mapping is recorded for
    // the deploy step, which supplies the actual value via `rad deploy --parameters`.
    //
    // Identifiers come from BicepPostProcessor.SanitizeIdentifier — the same function
    // GetOrAddRecipeParameter uses — rather than the SDK's NormalizeBicepIdentifier. The two agree
    // on ordinary names but not on the Radius-specific reservations: `radius` has to become
    // `radiusenv` because a bare `param radius` collides with the `extension radius` alias. Since
    // the two allocators reuse each other's declarations, a parameter reached through one path
    // must produce the identifier the other path would have produced.
    private ProvisioningParameter GetOrAddEnvParameter(ParameterResource parameter)
    {
        if (_envParametersByName.TryGetValue(parameter.Name, out var existing))
        {
            return existing;
        }
 
        var identifier = BicepPostProcessor.SanitizeIdentifier(parameter.Name);
 
        // A recipe parameter / inline secret may already have allocated a secure `param` for this
        // same Aspire parameter — recipe-pack and secret-store emission both run before container
        // env-var resolution. Reuse that declaration (it is emitted via options.RecipeParameters)
        // so the shared value produces a single Bicep `param` and one deploy binding rather than a
        // duplicate declaration. Keyed on the exact Aspire parameter name (unique in the app model)
        // so two *distinct* parameters whose names normalize to the same identifier are NOT merged
        // here — they fall through and surface as a genuine identifier collision (ASPIRERADIUS056).
        // Not cached in _envParametersByName so it is not emitted twice.
        if (_recipeParameters.TryGetValue(parameter.Name, out var recipeParameter))
        {
            return recipeParameter;
        }
 
        var provisioningParameter = new ProvisioningParameter(identifier, typeof(string))
        {
            IsSecure = parameter.Secret,
        };
 
        _envParametersByName[parameter.Name] = provisioningParameter;
        _deployParametersByIdentifier[identifier] = parameter;
        return provisioningParameter;
    }
 
    private static BicepValue<string> BuildEnvBicepValue(List<EnvPart> parts)
    {
        if (parts.Count == 0)
        {
            return string.Empty;
        }
 
        // All-literal value: concatenate directly (also covers the common single-literal case).
        if (parts.All(static p => p.Parameter is null && p.Projection is null))
        {
            return string.Concat(parts.Select(static p => p.Literal));
        }
 
        // A single parameter with no surrounding literals maps straight to the `param` reference,
        // emitting `value: paramName` rather than an interpolated string. A formatted parameter has
        // to go through the expression path instead, so the escaping call is emitted around it.
        if (parts is [{ Literal: null, StringFormat: null, Parameter: { } soleParameter }])
        {
            return soleParameter;
        }
 
        // Likewise a lone Bicep expression is emitted bare (`value: cache.properties.host`) rather
        // than wrapped in a single-placeholder interpolation.
        if (parts is [{ Literal: null, Parameter: null, Projection: { } soleProjection } soleProjectionPart])
        {
            var expression = soleProjection.Build();
            return new BicepValue<string>(
                soleProjectionPart.ApplyStringFormat(
                    soleProjection.IsNumeric ? RadiusBackingConnections.ToStringExpression(expression) : expression));
        }
 
        // Mixed literal/parameter value: build an interpolated Bicep string ('...${param}...').
        // Literals are passed as interpolation arguments (not spliced into the format) so any '{'
        // or '}' they contain can't be misread as a placeholder.
        var format = new StringBuilder();
        var args = new object[parts.Count];
        for (var i = 0; i < parts.Count; i++)
        {
            format.Append('{').Append(i.ToString(CultureInfo.InvariantCulture)).Append('}');
            args[i] = parts[i] switch
            {
                // A formatted parameter cannot be passed as the ProvisioningParameter itself: the
                // escaping call has to wrap the identifier, so hand the interpolation an expression.
                { Parameter: { } parameter, StringFormat: not null } part =>
                    part.ApplyStringFormat(new IdentifierExpression(parameter.BicepIdentifier)),
                { Parameter: { } parameter } => parameter,
                // A formatted numeric projection (e.g. a `:uri`-formatted port) has to be converted
                // to a string before the format is applied: `uriComponent()` requires a string
                // argument, and Bicep type-checks that eagerly rather than coercing it implicitly
                // the way string interpolation does for an unformatted numeric projection.
                { Projection: { IsNumeric: true } projection, StringFormat: not null } part =>
                    part.ApplyStringFormat(RadiusBackingConnections.ToStringExpression(projection.Build())),
                { Projection: { } projection } part => part.ApplyStringFormat(projection.Build()),
                var part => part.Literal!,
            };
        }
 
        return BicepFunction.Interpolate(FormattableStringFactory.Create(format.ToString(), args));
    }
 
    /// <summary>
    /// Resolves an <see cref="EndpointReference"/> to a cluster-FQDN URL (<c>scheme://host:port</c>)
    /// using the environment's <see cref="RadiusEnvironmentResource.GetHostAddressExpression"/> so
    /// the namespace-qualified service name is used.
    /// </summary>
    private string ResolveEndpointUrl(EndpointReference endpointReference) =>
        ResolveHostExpression(((IComputeEnvironmentResource)_environment).GetEndpointPropertyExpression(endpointReference.Property(EndpointProperty.Url)));
 
    private string ResolveEndpointProperty(EndpointReferenceExpression endpointReferenceExpression) =>
        ResolveHostExpression(((IComputeEnvironmentResource)_environment).GetEndpointPropertyExpression(endpointReferenceExpression));
 
    /// <summary>
    /// Resolves a <see cref="ReferenceExpression"/> produced by the environment's endpoint
    /// helpers to a literal string. The host address is a literal cluster FQDN, so the whole
    /// expression resolves synchronously without needing the run-mode value pipeline.
    /// </summary>
    private static string ResolveHostExpression(ReferenceExpression expression) =>
        expression.GetValueAsync(CancellationToken.None).AsTask().GetAwaiter().GetResult() ?? string.Empty;
 
    /// <summary>
    /// Warns when a container image may not pull correctly without <c>imagePullPolicy</c>.
    /// The container v2 schema removes <c>imagePullPolicy</c>, so users of kind clusters or
    /// local images need to ensure images are pre-loaded and use explicit tags.
    /// </summary>
    private void WarnIfImageMayNotPull(string resourceName, string image)
    {
        if (image.EndsWith(":latest", StringComparison.Ordinal) || !image.Contains(':'))
        {
            _logger.LogWarning(
                "Resource '{ResourceName}' uses image '{Image}' which may default to 'Always' pull policy " +
                "in Kubernetes. The Radius container v2 schema no longer supports imagePullPolicy. " +
                "For kind clusters, pre-load images with 'kind load docker-image' and use explicit tags.",
                resourceName, image);
        }
 
        if (!image.Contains('/'))
        {
            _logger.LogWarning(
                "Resource '{ResourceName}' uses image '{Image}' without a registry prefix. " +
                "Ensure the image is available in the target cluster (e.g., pre-loaded via 'kind load docker-image').",
                resourceName, image);
        }
    }
 
    private void RunConfigureCallbacks(RadiusInfrastructureOptions options)
    {
        var callbacks = _environment.Annotations
            .OfType<RadiusInfrastructureConfigureAnnotation>()
            .ToArray();
 
        foreach (var callback in callbacks)
        {
            callback.Configure(options);
        }
    }
 
    // A Kubernetes Service name must be a valid RFC 1123 DNS label of at most 63 characters:
    // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names
    // The Radius recipe names the Service `{resource}-{resource}` (RadiusServiceDiscovery), so a
    // resource name longer than 31 characters overflows the limit even though Aspire itself allows
    // names up to ModelName.DefaultMaxLength (64).
    private const int MaxKubernetesServiceNameLength = 63;
 
    // Validates the final (post-callback) container set. Aspire emits service discovery
    // (`services__*` URLs and the recipe Service name/port) from the pre-callback model, so a
    // ConfigureRadiusInfrastructure callback that renames a container, changes/removes a port,
    // adds a port to a previously portless container, or replaces/drops a workload can silently
    // break cross-container calls or emit an invalid manifest. Fail fast on any detectable
    // divergence. Only literal values are validated; a non-literal (Bicep-expression) name or port
    // cannot be reconciled with the fixed literal service-discovery value, so it is rejected too.
    private static void ValidatePostCallbackContainerInvariants(
        RadiusInfrastructureOptions options,
        IReadOnlyDictionary<string, Dictionary<string, (int Port, string Protocol)>> portSnapshots)
    {
        // Index the final containers by their immutable map key (the resource name, fixed at
        // construction). Keying by the map key rather than the construct instance means a callback
        // that swapped in a new construct for the same workload is still matched to its baseline.
        var containersByMapKey = new Dictionary<string, RadiusContainerConstruct>(StringComparer.Ordinal);
        foreach (var container in options.Containers)
        {
            containersByMapKey[container.ContainerMapKey] = container;
        }
 
        foreach (var (mapKey, snapshot) in portSnapshots)
        {
            // A portless container has no Service and no `services__*` value can address it, so
            // removing it in a callback is harmless — skip the preservation check for empty
            // snapshots so the invariant does not needlessly reject valid customization callbacks.
            if (snapshot.Count == 0)
            {
                continue;
            }
 
            // The workload service discovery was emitted for must still be present under the same
            // map key. A callback that removed it — or replaced it with a differently keyed
            // container — leaves consumers pointing at a Service that is no longer produced.
            if (!containersByMapKey.TryGetValue(mapKey, out var container))
            {
                throw new InvalidOperationException(
                    $"A ConfigureRadiusInfrastructure callback removed or replaced container '{mapKey}'. Aspire " +
                    $"service discovery already emitted 'services__*' variables that address it, so dropping the " +
                    $"workload would break cross-container calls. Keep the container to keep service discovery consistent.");
            }
 
            // Only containers that had service ports pre-callback have a Service (`{name}-{name}`)
            // that `services__*` addresses, so the name/map-key equality is only required for them.
            // A portless baseline container or one added entirely by the callback has no service-
            // discovery contract — Radius permits its top-level name to differ from the map key — so
            // gating this check on a non-empty snapshot keeps the customization escape hatch open.
            ValidateContainerNameMatchesMapKey(container);
 
            foreach (var (portName, expected) in snapshot)
            {
                if (!container.Ports.TryGetValue(portName, out var portValue) || portValue.Value is not { } port)
                {
                    throw new InvalidOperationException(
                        $"A ConfigureRadiusInfrastructure callback removed port '{portName}' from container " +
                        $"'{mapKey}'. Aspire service discovery already emitted this port ({expected.Port}) into " +
                        $"consumer 'services__*' variables, so removing it would break cross-container calls. " +
                        $"Remove the port change to keep service discovery consistent.");
                }
 
                // Reject a non-literal port/protocol: service discovery is a fixed literal, so a
                // callback that swaps in a Bicep expression could evaluate to a different value at
                // deploy time, reintroducing exactly the mismatch this guard prevents. An
                // expression-backed BicepValue<int> reports a default LiteralValue of 0 (not null),
                // so a non-null Expression is the reliable "non-literal" signal, not the LiteralValue.
                var portValueBicep = (IBicepValue)port.ContainerPort;
                if (portValueBicep.Expression is not null || portValueBicep.LiteralValue is not int literalPort)
                {
                    throw new InvalidOperationException(
                        $"A ConfigureRadiusInfrastructure callback replaced port '{portName}' on container " +
                        $"'{mapKey}' with a non-literal Bicep expression. Aspire service discovery already emitted " +
                        $"the literal port {expected.Port} into consumer 'services__*' variables and cannot follow a " +
                        $"computed port, so a computed containerPort is not supported. Remove the port change.");
                }
 
                var protocolValueBicep = (IBicepValue)port.Protocol;
                if (protocolValueBicep.Expression is not null || protocolValueBicep.LiteralValue is not string literalProtocol)
                {
                    throw new InvalidOperationException(
                        $"A ConfigureRadiusInfrastructure callback replaced the protocol of port '{portName}' on " +
                        $"container '{mapKey}' with a non-literal Bicep expression. Aspire service discovery assumes " +
                        $"the literal protocol '{expected.Protocol}', so a computed protocol is not supported. Remove " +
                        $"the protocol change.");
                }
 
                if (literalPort != expected.Port || !string.Equals(literalProtocol, expected.Protocol, StringComparison.Ordinal))
                {
                    throw new InvalidOperationException(
                        $"A ConfigureRadiusInfrastructure callback changed port '{portName}' on container " +
                        $"'{mapKey}' from {expected.Port}/{expected.Protocol} to {literalPort}/{literalProtocol}. " +
                        $"Aspire service discovery already emitted {expected.Port}/{expected.Protocol} into consumer " +
                        $"'services__*' variables, so this would break cross-container calls. Remove the port change.");
                }
            }
        }
 
        // Validate the FINAL container set. A callback can add the first port to a previously
        // portless container, add a new container, or add a second endpoint. Once a container
        // declares ports the recipe creates a Service, so re-check the two things the recipe cares
        // about on the post-callback state: the Service name fits the Kubernetes limit, and the
        // container's ports are unique by (containerPort, protocol).
        foreach (var container in options.Containers)
        {
            if (container.Ports.Count == 0)
            {
                continue;
            }
 
            ValidateServiceNameWithinKubernetesLimit(container);
 
            // The pre-callback `seenPorts` dedup in ResolvePorts only covers the baseline ports. A
            // callback can add a second endpoint (e.g. `http2`) that resolves to the same
            // (containerPort, protocol) as a preserved one, which would make the recipe emit
            // duplicate Kubernetes Service ports — exactly what the baseline dedup prevents. Re-run
            // the dedup on the FINAL literal ports so a callback can't reintroduce the collision.
            var seenPorts = new HashSet<(int ContainerPort, string Protocol)>();
            foreach (var (portName, portValue) in container.Ports)
            {
                if (portValue.Value is not { } port)
                {
                    continue;
                }
 
                // Only literal ports can collide deterministically; non-literal (expression-backed)
                // ports on callback-added containers are the customization's own responsibility and
                // can't be compared here.
                if (((IBicepValue)port.ContainerPort).LiteralValue is not int literalPort ||
                    ((IBicepValue)port.Protocol).LiteralValue is not string literalProtocol)
                {
                    continue;
                }
 
                if (!seenPorts.Add((literalPort, literalProtocol)))
                {
                    throw new InvalidOperationException(
                        $"A ConfigureRadiusInfrastructure callback left container '{container.ContainerMapKey}' with " +
                        $"more than one port on {literalPort}/{literalProtocol} (for example port '{portName}'). The " +
                        $"Radius container recipe creates one Kubernetes Service port per declared port, so duplicate " +
                        $"(containerPort, protocol) pairs would emit conflicting Service ports. Remove the duplicate port.");
                }
            }
        }
 
        ValidateContainerEnvVarForms(options);
    }
 
    /// <summary>
    /// Rejects a container environment entry that carries neither exactly a <c>value</c> nor a
    /// complete <c>valueFrom.secretKeyRef</c>.
    /// </summary>
    /// <remarks>
    /// The Radius container schema models each <c>env</c> entry as one form or the other, and the
    /// Kubernetes API server rejects a container env var that carries both <c>value</c> and
    /// <c>valueFrom</c>. A <c>secretKeyRef</c> missing either half is incomplete in the same way:
    /// <c>secretName</c> without <c>key</c> names no data entry, and <c>key</c> without
    /// <c>secretName</c> names no secret.
    /// <para>
    /// The publisher only ever writes one complete form (see <c>ResolveEnvironmentAsync</c>), so
    /// every state rejected here comes from a <c>ConfigureRadiusInfrastructure</c> callback —
    /// <see cref="ContainerEnvVarConstruct.Value"/>, <see cref="ContainerEnvVarConstruct.SecretName"/>
    /// and <see cref="ContainerEnvVarConstruct.SecretKey"/> are all public and independently
    /// settable, so the type's documented mutual exclusivity cannot be enforced by construction.
    /// Rejecting it here keeps the failure attributable to the callback instead of surfacing it as
    /// a rejected manifest at <c>rad deploy</c> time.
    /// </para>
    /// <para>
    /// Assignment is detected with <see cref="RenderBicepValue"/> rather than a null check on the
    /// property: <c>DefineProperty</c> returns a non-null <see cref="BicepValue{T}"/> in an unset
    /// state, so an unassigned property is only recognisable by having neither an expression nor a
    /// literal — the same test <c>ProjectedEnvValue.OriginalValue</c> relies on.
    /// </para>
    /// </remarks>
    private static void ValidateContainerEnvVarForms(RadiusInfrastructureOptions options)
    {
        foreach (var container in options.Containers)
        {
            foreach (var (key, entry) in container.Env)
            {
                // BicepDictionary wraps each entry; a callback can leave a hole by assigning null.
                if (entry?.Value is not { } envVar)
                {
                    throw new InvalidOperationException(
                        $"A ConfigureRadiusInfrastructure callback left environment variable '{key}' on container " +
                        $"'{container.ContainerMapKey}' with neither a 'value' nor a 'valueFrom.secretKeyRef'. The " +
                        $"variable would be dropped from the deployed container. Assign either Value or the " +
                        $"SecretName/SecretKey pair. Diagnostic: ASPIRERADIUS087.");
                }
 
                var hasValue = RenderBicepValue(envVar.Value) is not null;
                var hasSecretName = RenderBicepValue(envVar.SecretName) is not null;
                var hasSecretKey = RenderBicepValue(envVar.SecretKey) is not null;
 
                // Neither form assigned. Radius accepts the empty object and the Kubernetes recipe
                // emits no `value` and no `valueFrom`, so the variable the callback added is absent
                // from the deployed container — the same silent drop the two checks below exist to
                // prevent, reached by leaving everything unset rather than by setting too much.
                if (!hasValue && !hasSecretName && !hasSecretKey)
                {
                    throw new InvalidOperationException(
                        $"A ConfigureRadiusInfrastructure callback left environment variable '{key}' on container " +
                        $"'{container.ContainerMapKey}' with neither a 'value' nor a 'valueFrom.secretKeyRef'. The " +
                        $"variable would be dropped from the deployed container. Assign either Value or the " +
                        $"SecretName/SecretKey pair. Diagnostic: ASPIRERADIUS087.");
                }
 
                if (hasValue && (hasSecretName || hasSecretKey))
                {
                    throw new InvalidOperationException(
                        $"A ConfigureRadiusInfrastructure callback left environment variable '{key}' on container " +
                        $"'{container.ContainerMapKey}' carrying both a 'value' and a 'valueFrom.secretKeyRef'. The two " +
                        $"forms are mutually exclusive and Kubernetes rejects an environment variable that sets both. " +
                        $"Assign either Value or the SecretName/SecretKey pair, not both. Diagnostic: ASPIRERADIUS087.");
                }
 
                if (hasSecretName != hasSecretKey)
                {
                    throw new InvalidOperationException(
                        $"A ConfigureRadiusInfrastructure callback left environment variable '{key}' on container " +
                        $"'{container.ContainerMapKey}' with an incomplete 'valueFrom.secretKeyRef': " +
                        $"{(hasSecretName ? "SecretName is set but SecretKey is not" : "SecretKey is set but SecretName is not")}. " +
                        $"A secret reference needs both halves to name a key of a " +
                        $"'{RadiusResourceTypes.SecuritySecrets}' resource. Assign both, or assign Value instead. " +
                        $"Diagnostic: ASPIRERADIUS087.");
                }
 
                // A complete-but-invalid reference is as broken as an incomplete one, just later:
                // the recipe copies both halves verbatim into the pod spec's `secretKeyRef`, so an
                // unrepresentable name or key is rejected by the API server when the pod is created
                // rather than by `rad deploy`. Only literals are checked — RewireContainerEnvSecrets
                // deliberately assigns the secret's own BicepValue here, and an expression cannot be
                // resolved until deploy time.
                if (!IsBicepExpression(envVar.SecretName) &&
                    RenderBicepLiteral(envVar.SecretName) is { } literalSecretName &&
                    !KubernetesName.IsDns1123Subdomain(literalSecretName))
                {
                    throw new InvalidOperationException(
                        $"A ConfigureRadiusInfrastructure callback pointed environment variable '{key}' on container " +
                        $"'{container.ContainerMapKey}' at the secret name '{literalSecretName}', which is not a valid " +
                        $"Kubernetes object name. The recipe copies it verbatim into the pod's " +
                        $"'valueFrom.secretKeyRef.name', so the pod would be rejected. A name must be a DNS-1123 " +
                        $"subdomain: 1-253 characters of lowercase letters, digits, '-' and '.', starting and ending " +
                        $"alphanumeric. Diagnostic: ASPIRERADIUS087.");
                }
 
                if (!IsBicepExpression(envVar.SecretKey) &&
                    RenderBicepLiteral(envVar.SecretKey) is { } literalSecretKey &&
                    !KubernetesName.IsValidSecretDataKey(literalSecretKey))
                {
                    throw new InvalidOperationException(
                        $"A ConfigureRadiusInfrastructure callback pointed environment variable '{key}' on container " +
                        $"'{container.ContainerMapKey}' at the secret key '{literalSecretKey}', which is not a valid " +
                        $"Kubernetes Secret data key. The recipe copies it verbatim into the pod's " +
                        $"'valueFrom.secretKeyRef.key', so the pod would be rejected. A key is limited to letters, " +
                        $"digits, '-', '_' and '.'. Diagnostic: ASPIRERADIUS087.");
                }
            }
        }
    }
 
    // Ensures a container's top-level `name:` still equals its `properties.containers` map key. The
    // default name is a literal (the resource name); a callback that changes it to a mismatched
    // literal, or replaces it with a non-literal Bicep expression we cannot compare, throws.
    //
    // NOTE: this is an *Aspire* service-discovery limitation, not a Radius v2 schema requirement.
    // Radius itself permits a container resource whose map keys (e.g. `frontend`, `sidecar`) differ
    // from the top-level name; Aspire derives `services__*` values from the original resource name,
    // so a rename would make the emitted address diverge from the deployed Service.
    private static void ValidateContainerNameMatchesMapKey(RadiusContainerConstruct container)
    {
        var name = (IBicepValue)container.ContainerName;
 
        // An expression-backed BicepValue reports a default LiteralValue (null for string), but to
        // stay consistent with the port guard we treat any non-null Expression as the non-literal
        // signal.
        if (name.Expression is not null || name.LiteralValue is not string literalName)
        {
            throw new InvalidOperationException(
                $"A ConfigureRadiusInfrastructure callback replaced container '{container.ContainerMapKey}' name " +
                $"with a non-literal Bicep expression. The Aspire Radius publisher derives service discovery from " +
                $"the original container name, so it must stay the literal resource name '{container.ContainerMapKey}' " +
                $"(this is an Aspire limitation, not a Radius schema requirement). Remove the rename to keep the " +
                $"emitted 'services__*' values addressing the deployed Service.");
        }
 
        if (!string.Equals(literalName, container.ContainerMapKey, StringComparison.Ordinal))
        {
            throw new InvalidOperationException(
                $"A ConfigureRadiusInfrastructure callback renamed container '{container.ContainerMapKey}' to " +
                $"'{literalName}'. The Aspire Radius publisher derives service discovery from the original container " +
                $"name, so renaming it makes the emitted 'services__*' values point at a Service that is no longer " +
                $"produced (this is an Aspire limitation, not a Radius schema requirement). Remove the rename to keep " +
                $"cross-container calls working.");
        }
    }
 
    private static void ValidateServiceNameWithinKubernetesLimit(RadiusContainerConstruct container)
    {
        // The recipe names the Service `${normalizedName}-${containerName}` = `{top-level name}-
        // {map key}`. For a baseline container the name-equality guard forces name == map key, so
        // this is `{name}-{name}`; for a callback-added/portless container the name may legitimately
        // differ, so compute the actual Service name from the literal top-level name when available.
        var mapKey = container.ContainerMapKey;
        var topLevelName = ((IBicepValue)container.ContainerName).LiteralValue is string literalName ? literalName : mapKey;
        var serviceName = RadiusServiceDiscovery.GetServiceName(topLevelName, mapKey);
        if (serviceName.Length > MaxKubernetesServiceNameLength)
        {
            throw new InvalidOperationException(
                $"The Radius container recipe creates a Kubernetes Service named '{serviceName}' for resource " +
                $"'{mapKey}', but that is {serviceName.Length} characters — longer than the " +
                $"{MaxKubernetesServiceNameLength}-character limit for a Kubernetes Service name (an RFC 1123 DNS " +
                $"label). Shorten the resource name to at most {(MaxKubernetesServiceNameLength - 1) / 2} characters " +
                $"so the doubled '{{name}}-{{name}}' Service name stays within the limit.");
        }
    }
 
    internal readonly record struct RecipeEntry(string RecipeKind, string RecipeLocation);
 
    // ---------------------------------------------------------------------------------------------
    // Recipe parameters (WithRecipeParameters) — environment-wide + resource-type-scoped values
    // flowed onto the shared recipe pack. ParameterResource-backed values are emitted as valueless
    // (secure when the source is secret) Bicep `param`s so no literal secret lands in the artifact.
    // ---------------------------------------------------------------------------------------------
 
    /// <summary>
    /// Computes the effective recipe parameter set for a resource type by merging the
    /// environment-wide parameters with any parameters scoped to that resource type.
    /// Resource-type-scoped values win on key collision. Returns <see langword="null"/> when no
    /// parameters apply.
    /// </summary>
    private IReadOnlyDictionary<string, object>? GetEffectiveRecipeParameters(string resourceType)
    {
        var annotation = _environment.Annotations
            .OfType<Annotations.RadiusRecipeParametersAnnotation>()
            .FirstOrDefault();
        if (annotation is null)
        {
            return null;
        }
 
        var effective = new Dictionary<string, object>(annotation.EnvironmentWide, StringComparer.Ordinal);
 
        if (annotation.ByResourceType.TryGetValue(resourceType, out var scoped))
        {
            foreach (var (key, value) in scoped)
            {
                if (effective.ContainsKey(key))
                {
                    _logger.LogDebug(
                        "Recipe parameter '{Key}' scoped to resource type '{ResourceType}' overrides the environment-wide value.",
                        key, resourceType);
                }
 
                effective[key] = value;
            }
        }
 
        return effective.Count == 0 ? null : effective;
    }
 
    /// <summary>
    /// Serializes each effective recipe parameter into <paramref name="target"/>, preserving Bicep
    /// type fidelity and emitting parameter references for bound <see cref="ParameterResource"/>
    /// values and provider references.
    /// </summary>
    private void ApplyRecipeParameters(BicepDictionary<object> target, IReadOnlyDictionary<string, object> parameters)
    {
        foreach (var (key, value) in parameters)
        {
            target[key] = ConvertRecipeParameterValue(value);
        }
    }
 
    /// <summary>
    /// Converts a single recipe parameter value to a Bicep value. Handles
    /// <see cref="ParameterResource"/> bindings (emitted as a Bicep <c>param</c> reference, never a
    /// resolved secret), provider-scope references, and literal/array/object values.
    /// </summary>
    private BicepValue<object> ConvertRecipeParameterValue(object? value) =>
        ConvertRecipeParameterValue(value, new HashSet<object>(ReferenceEqualityComparer.Instance), depth: 0);
 
    private BicepValue<object> ConvertRecipeParameterValue(object? value, HashSet<object> visited, int depth)
    {
        if (depth > MaxRecipeParameterNestingDepth)
        {
            throw new NotSupportedException(
                $"Recipe parameter values cannot be nested deeper than {MaxRecipeParameterNestingDepth} levels.");
        }
 
        switch (value)
        {
            case null:
                return new BicepValue<object>(new NullLiteralExpression());
            case BicepValue<object> bicepValue:
                return bicepValue;
            case IBicepValue alreadyBicep:
                return new BicepValue<object>(alreadyBicep);
            case BicepExpression expression:
                return new BicepValue<object>(expression);
            case IResourceBuilder<ParameterResource> parameterBuilder:
                return ParameterReference(GetOrAddRecipeParameter(parameterBuilder.Resource));
            case ParameterResource parameterResource:
                return ParameterReference(GetOrAddRecipeParameter(parameterResource));
            case RadiusProviderReference providerReference:
                return ToRecipeBicepValue(ResolveProviderReference(providerReference));
            case System.Collections.IDictionary dictionary:
                return ConvertRecipeParameterObject(dictionary, visited, depth);
            case string or int or long or bool or double or float or decimal:
                return ToRecipeBicepValue(value);
            case System.Collections.IEnumerable sequence:
                return ConvertRecipeParameterArray(sequence, visited, depth);
            default:
                return ToRecipeBicepValue(value);
        }
    }
 
    private BicepValue<object> ConvertRecipeParameterObject(
        System.Collections.IDictionary dictionary,
        HashSet<object> visited,
        int depth)
    {
        if (!visited.Add(dictionary))
        {
            throw new NotSupportedException("Recipe parameter values cannot contain cycles.");
        }
 
        try
        {
            var result = new BicepDictionary<object>();
            foreach (System.Collections.DictionaryEntry entry in dictionary)
            {
                if (entry.Key is not string key)
                {
                    throw new NotSupportedException(
                        $"Recipe parameter object keys must be strings, but found '{entry.Key?.GetType().Name ?? "null"}'.");
                }
 
                result[key] = ConvertRecipeParameterValue(entry.Value, visited, depth + 1);
            }
 
            return new BicepValue<object>(result);
        }
        finally
        {
            visited.Remove(dictionary);
        }
    }
 
    private BicepValue<object> ConvertRecipeParameterArray(
        System.Collections.IEnumerable sequence,
        HashSet<object> visited,
        int depth)
    {
        if (!visited.Add(sequence))
        {
            throw new NotSupportedException("Recipe parameter values cannot contain cycles.");
        }
 
        try
        {
            var result = new BicepList<object>();
            foreach (var element in sequence)
            {
                result.Add(ConvertRecipeParameterValue(element, visited, depth + 1));
            }
 
            return new BicepValue<object>(result);
        }
        finally
        {
            visited.Remove(sequence);
        }
    }
 
    private static BicepValue<object> ToRecipeBicepValue(object value)
    {
        return BicepPostProcessor.ToBicepValue(value) switch
        {
            BicepValue<object> bicepValue => bicepValue,
            var nestedValue => new BicepValue<object>(nestedValue)
        };
    }
 
    /// <summary>
    /// Wraps a Bicep <c>param</c> declaration as a value usable inside a recipe <c>parameters</c>
    /// object (a reference to the parameter identifier).
    /// </summary>
    private static BicepValue<object> ParameterReference(ProvisioningParameter parameter)
    {
        BicepValue<object> reference = parameter;
        return reference;
    }
 
    /// <summary>
    /// Returns (creating once) the Bicep <c>param</c> declaration for an Aspire
    /// <see cref="ParameterResource"/>. Secret parameters are declared secure so no value is
    /// written to the published artifact.
    /// </summary>
    private ProvisioningParameter GetOrAddRecipeParameter(ParameterResource parameter)
    {
        if (!_recipeParameters.TryGetValue(parameter.Name, out var provisioningParameter))
        {
            // Container env-var resolution may already have allocated a `param` for this same
            // Aspire parameter. Reuse that declaration rather than allocating a second one: both
            // spellings normalize to the same Bicep identifier, so a fresh allocation would trip
            // the identifier-collision guard below (ASPIRERADIUS056) on a model that is actually
            // valid — one secret parameter used by both `WithEnvironment` and a
            // `Radius.Security/secrets`-scoped recipe parameter. This mirrors the reuse
            // GetOrAddEnvParameter already performs in the opposite direction, so the two
            // allocators agree regardless of which one runs first. Deliberately not cached in
            // _recipeParameters: the env allocator emits it through options.Parameters, and the
            // deploy binding was already recorded in _deployParametersByIdentifier, which
            // RecordDeployParameters merges with the recipe bindings.
            if (_envParametersByName.TryGetValue(parameter.Name, out var envParameter))
            {
                return envParameter;
            }
 
            var identifier = BicepPostProcessor.SanitizeIdentifier(parameter.Name);
 
            // Two distinct parameter names can sanitize to the same Bicep identifier (e.g.
            // "my-key" and "my.key" both become "my_key"). Emitting two `param my_key`
            // declarations produces invalid Bicep, so fail with an actionable diagnostic
            // (ASPIRERADIUS028) instead.
            if (_recipeParameterIdentifiers.TryGetValue(identifier, out var existingName))
            {
                throw new InvalidOperationException(
                    $"Recipe parameters bound to Aspire parameters '{existingName}' and '{parameter.Name}' both " +
                    $"map to the Bicep identifier '{identifier}'. Rename one of the parameters so they produce " +
                    "distinct Bicep identifiers. Diagnostic: ASPIRERADIUS028.");
            }
 
            provisioningParameter = new ProvisioningParameter(identifier, typeof(string))
            {
                IsSecure = parameter.Secret,
            };
            _recipeParameters[parameter.Name] = provisioningParameter;
            _recipeParameterIdentifiers[identifier] = parameter.Name;
            // Remember the originating ParameterResource keyed by the Bicep identifier so the
            // deploy step can pass `--parameters <identifier>=<value>` for this valueless param.
            _recipeParameterBindings[identifier] = parameter;
        }
 
        return provisioningParameter;
    }
 
    /// <summary>
    /// Resolves a <see cref="RadiusProviderReference"/> to the corresponding scope value from the
    /// cloud provider configured on this environment. Throws when the referenced provider is not
    /// configured.
    /// </summary>
    private string ResolveProviderReference(RadiusProviderReference reference)
    {
        var providers = _environment.Annotations
            .OfType<Annotations.RadiusCloudProvidersAnnotation>()
            .FirstOrDefault();
 
        return reference.Field switch
        {
            RadiusProviderScopeField.Region =>
                providers?.Aws?.Region ?? throw MissingProviderReference("AWS", "WithAwsProvider"),
            RadiusProviderScopeField.AccountId =>
                providers?.Aws?.AccountId ?? throw MissingProviderReference("AWS", "WithAwsProvider"),
            RadiusProviderScopeField.SubscriptionId =>
                providers?.Azure?.SubscriptionId ?? throw MissingProviderReference("Azure", "WithAzureProvider"),
            RadiusProviderScopeField.ResourceGroup =>
                providers?.Azure?.ResourceGroup ?? throw MissingProviderReference("Azure", "WithAzureProvider"),
            _ => throw new NotSupportedException($"Unknown provider scope field '{reference.Field}'."),
        };
    }
 
    private InvalidOperationException MissingProviderReference(string cloud, string configureMethod) =>
        new($"A recipe parameter on Radius environment '{_environment.Name}' references {cloud} provider " +
            $"configuration, but no {cloud} provider is configured. Call {configureMethod}(...) on the environment.");
 
    /// <summary>
    /// Emits a non-fatal warning for each resource-type-scoped parameter set whose resource type
    /// has no recipe entry in the emitted recipe pack.
    /// </summary>
    private void WarnUnmatchedResourceTypeScopes(IEnumerable<string> emittedResourceTypes)
    {
        var annotation = _environment.Annotations
            .OfType<Annotations.RadiusRecipeParametersAnnotation>()
            .FirstOrDefault();
        if (annotation is null)
        {
            return;
        }
 
        var emitted = new HashSet<string>(emittedResourceTypes, StringComparer.Ordinal);
        foreach (var resourceType in annotation.ByResourceType.Keys)
        {
            if (!emitted.Contains(resourceType))
            {
                _logger.LogWarning(
                    "Recipe parameters were scoped to resource type '{ResourceType}' on Radius environment " +
                    "'{Environment}', but no recipe entry of that type exists in the emitted recipe pack; " +
                    "those parameters were ignored.",
                    resourceType, _environment.Name);
            }
        }
    }
 
    // ---------------------------------------------------------------------------------------------
    // Secret stores (AddRadiusSecretStore / WithSecretStore) — emitted as Applications.Core/
    // secretStores scoped to the legacy environment/application, plus recipeConfig consumers.
    // ---------------------------------------------------------------------------------------------
 
    /// <summary>
    /// Returns the Radius secret stores routed to this environment: environment-scoped stores owned
    /// by this environment, plus all application-scoped stores.
    /// </summary>
    private IEnumerable<RadiusSecretStoreResource> GetSecretStoresForScope()
    {
        return _model.Resources.OfType<RadiusSecretStoreResource>().Where(s =>
            (s.Scope == RadiusSecretStoreScope.Environment && ReferenceEquals(s.OwningEnvironment, _environment))
            || s.Scope == RadiusSecretStoreScope.Application);
    }
 
    /// <summary>
    /// Emits one <see cref="RadiusSecretStoreConstruct"/> per declared store, scoped to the legacy
    /// Applications.Core environment/application (secret stores are Applications.Core resources) and
    /// populated per mode (inline / existing / sealed).
    /// </summary>
    private Dictionary<string, RadiusSecretStoreConstruct> EmitSecretStores(
        RadiusInfrastructureOptions options,
        IReadOnlyList<RadiusSecretStoreResource> stores,
        LegacyApplicationEnvironmentConstruct? legacyEnvConstruct,
        LegacyApplicationConstruct? legacyAppConstruct)
    {
        var storeConstructs = new Dictionary<string, RadiusSecretStoreConstruct>(StringComparer.Ordinal);
 
        foreach (var store in stores)
        {
            var identifier = BicepPostProcessor.SanitizeIdentifier(store.Name);
            var construct = new RadiusSecretStoreConstruct(identifier)
            {
                StoreName = store.Name,
                StoreType = store.Type.ToRadiusTypeString(),
            };
 
            // Scope is implied by the declaring API form: application-scoped stores reference the
            // application; environment-scoped stores reference the environment.
            if (store.Scope == RadiusSecretStoreScope.Application && legacyAppConstruct is not null)
            {
                construct.ApplicationId = BuildIdExpression(legacyAppConstruct);
            }
            else if (legacyEnvConstruct is not null)
            {
                construct.EnvironmentId = BuildIdExpression(legacyEnvConstruct);
            }
 
            PopulateInlineSecretStoreData(store, construct);
            PopulateSecretReferenceData(store, construct, options);
 
            storeConstructs[store.Name] = construct;
            options.SecretStores.Add(construct);
        }
 
        ApplySecretStoreConsumers(legacyEnvConstruct, storeConstructs);
 
        return storeConstructs;
    }
 
    /// <summary>
    /// Emits the environment's <c>recipeConfig</c> from the recorded secret-store consumers
    /// (private Bicep-registry auth, Terraform Git PAT auth, and <c>envSecrets</c>), referencing
    /// each store by its <c>.id</c>.
    /// </summary>
    private void ApplySecretStoreConsumers(
        LegacyApplicationEnvironmentConstruct? legacyEnvConstruct,
        IReadOnlyDictionary<string, RadiusSecretStoreConstruct> storeConstructs)
    {
        var annotation = _environment.Annotations
            .OfType<Annotations.RadiusSecretStoresAnnotation>()
            .FirstOrDefault();
        if (legacyEnvConstruct is null || annotation is null || annotation.Consumers.Count == 0)
        {
            return;
        }
 
        var bicepAuth = new Dictionary<string, object>(StringComparer.Ordinal);
        var gitPat = new Dictionary<string, object>(StringComparer.Ordinal);
        var envSecrets = new Dictionary<string, object>(StringComparer.Ordinal);
 
        foreach (var consumer in annotation.Consumers)
        {
            var secretRef = ResolveSecretStoreReference(consumer.Store, storeConstructs);
            switch (consumer.Kind)
            {
                case RadiusSecretStoreConsumerKind.BicepRegistryAuth:
                    bicepAuth[consumer.Selector!] = new Dictionary<string, object> { ["secret"] = secretRef };
                    break;
                case RadiusSecretStoreConsumerKind.TerraformGitPat:
                    gitPat[consumer.Selector!] = new Dictionary<string, object> { ["secret"] = secretRef };
                    break;
                case RadiusSecretStoreConsumerKind.EnvSecret:
                    envSecrets[consumer.Selector!] = new Dictionary<string, object>
                    {
                        ["source"] = secretRef,
                        ["key"] = consumer.Key!,
                    };
                    break;
                default:
                    throw new InvalidOperationException(
                        $"Unknown secret-store consumer kind '{consumer.Kind}' for store '{consumer.Store.Name}'.");
            }
        }
 
        var recipeConfig = new Dictionary<string, object>(StringComparer.Ordinal);
        if (bicepAuth.Count > 0)
        {
            recipeConfig["bicep"] = new Dictionary<string, object> { ["authentication"] = bicepAuth };
        }
 
        if (gitPat.Count > 0)
        {
            recipeConfig["terraform"] = new Dictionary<string, object>
            {
                ["authentication"] = new Dictionary<string, object>
                {
                    ["git"] = new Dictionary<string, object> { ["pat"] = gitPat },
                },
            };
        }
 
        if (envSecrets.Count > 0)
        {
            recipeConfig["envSecrets"] = envSecrets;
        }
 
        if (recipeConfig.Count > 0)
        {
            legacyEnvConstruct.RecipeConfig = BicepPostProcessor.ToBicepObject(recipeConfig);
        }
    }
 
    /// <summary>
    /// Resolves the value emitted for a secret-store reference in <c>recipeConfig</c>: the store's
    /// <c>.id</c> expression.
    /// </summary>
    /// <exception cref="InvalidOperationException">
    /// The store is not emitted for this environment (<c>ASPIRERADIUS050</c>).
    /// </exception>
    private object ResolveSecretStoreReference(
        RadiusSecretStoreResource store,
        IReadOnlyDictionary<string, RadiusSecretStoreConstruct> storeConstructs)
    {
        if (storeConstructs.TryGetValue(store.Name, out var construct))
        {
            return BuildIdExpression(construct);
        }
 
        // Never fall back to the bare store name: that emits a plain string where a secret-store
        // `.id` is expected, producing a reference Radius rejects only at deploy (or, worse, that
        // silently resolves to nothing). Fail fast with an actionable diagnostic naming the
        // consuming environment and the unresolved store.
        throw new InvalidOperationException(
            $"Environment '{_environment.Name}' references secret store '{store.Name}', but that store is not " +
            "emitted for this environment. Ensure the store is declared on this environment. " +
            "Diagnostic: ASPIRERADIUS050.");
    }
 
    /// <summary>
    /// Populates a secret-store construct's <c>data</c> for the inline (Radius-created) mode: each
    /// key's value is a reference to a valueless <c>@secure()</c> Bicep <c>param</c> (reusing
    /// <see cref="GetOrAddRecipeParameter"/>), with <c>encoding</c> emitted when the author set it
    /// explicitly or the type default is not <c>raw</c>.
    /// </summary>
    private void PopulateInlineSecretStoreData(RadiusSecretStoreResource store, RadiusSecretStoreConstruct construct)
    {
        if (!store.Population.HasInlineData)
        {
            return;
        }
 
        foreach (var (key, binding) in store.Population.Data)
        {
            var parameter = GetOrAddRecipeParameter(binding.Parameter);
            var entry = new RadiusSecretStoreDataEntryConstruct
            {
                Value = new IdentifierExpression(parameter.BicepIdentifier),
            };
 
            var encoding = binding.Encoding ?? store.Type.DefaultEncoding();
            if (binding.Encoding is not null || !string.Equals(encoding, "raw", StringComparison.Ordinal))
            {
                entry.Encoding = encoding;
            }
 
            construct.Data[key] = entry;
        }
    }
 
    /// <summary>
    /// Populates a secret-store construct for the existing-secret / sealed-secret modes: emits
    /// <c>properties.resource: '&lt;namespace&gt;/&lt;name&gt;'</c> and each declared key as an
    /// empty object (<c>{}</c>). A bare <c>&lt;name&gt;</c> defaults its namespace to the owning
    /// environment's <see cref="RadiusEnvironmentResource.Namespace"/>.
    /// </summary>
    private void PopulateSecretReferenceData(
        RadiusSecretStoreResource store,
        RadiusSecretStoreConstruct construct,
        RadiusInfrastructureOptions options)
    {
        if (!store.Population.IsSecretReference)
        {
            return;
        }
 
        construct.ResourceReference = ResolveSecretResourceReference(store, options);
 
        foreach (var key in store.Population.Keys)
        {
            // An entry with no assigned properties emits as an empty object, naming a key to
            // expose from the referenced Secret without passing any value through Aspire.
            construct.Data[key] = new RadiusSecretStoreDataEntryConstruct();
        }
    }
 
    /// <summary>
    /// Resolves a secret store's <c>resource</c> reference: a fully-qualified
    /// <c>&lt;namespace&gt;/&lt;name&gt;</c> is emitted verbatim; a bare <c>&lt;name&gt;</c> is
    /// prefixed with the owning environment's namespace.
    /// </summary>
    private string ResolveSecretResourceReference(RadiusSecretStoreResource store, RadiusInfrastructureOptions options)
    {
        var population = store.Population;
        var defaultNamespace = store.OwningEnvironment?.Namespace ?? _environment.Namespace;
 
        // For a sealed store the underlying Secret's namespace/name come from the SealedSecret
        // manifest metadata (also the deploy-time materialization poll target); a missing or
        // unreadable manifest fails publish with ASPIRERADIUS044.
        if (population.HasSealedSecret)
        {
            var manifestPath = store.Population.SealedManifestPath!;
            if (!options.SealedSecretManifests.TryGetValue(store.Name, out var manifest))
            {
                manifest = SealedSecretManifest.ReadValidated(store.Name, manifestPath, defaultNamespace);
                options.SealedSecretManifests[store.Name] = manifest;
            }
 
            var metadata = manifest.Metadata;
            RadiusSecretStoreValidation.ValidateSealedSecretNamespace(store, metadata, manifest.SourcePath);
            return $"{metadata.Namespace}/{metadata.Name}";
        }
 
        var reference = population.ResourceReference!;
        if (reference.Contains('/', StringComparison.Ordinal))
        {
            return reference;
        }
 
        return $"{defaultNamespace}/{reference}";
    }
}