Skip to content

C# runtime recipes

These recipes use only services listed by GET ${WORKSPACE_API_BASE_URL}/api/workspace/admin/net-operation-project/developer-surface. WORKSPACE_API_BASE_URL is the configured installation’s API host, not moltaro.com; see the workspace API connection guide. Supply ids, keys, and row versions discovered from the current workspace. Typed runtime results contain Success, Data, and structured errors; do not turn a failed result into success or branch on localized text.

For a parent hierarchy, write the configured Parent as an ordinary Reference through the supported record services and use the current RowVersion. The Parent Tree View developer guide explains refresh, concurrency, and the V1 boundary around cycle validation.

For principals, inject IUserService, IRoleService, and IGroupService. They are the existing trusted direct-DB CRUD contracts. Boards and Entitlement Operations use their preferred *.Automation services, which execute the complete application flow as moltaro-system-automation and retain the original user, function run, and correlation in origin/audit metadata. Their request DTOs deliberately omit ActorUserId; do not build an actor or create Board Data manually.

The lower-level *.Runtime services are advanced trusted direct-DB APIs. Use them only when your code intentionally owns all application orchestration and side effects. The installation-local developer-surface response marks the two levels as ApplicationAutomation and TrustedDirectDb.

Import a large attachment without buffering it

Section titled “Import a large attachment without buffering it”

Inject IAttachmentService and pass the source stream directly. The stream may be non-seekable, but it must start at byte zero and produce exactly the declared size. Keep the idempotency key stable for the same logical source attachment:

var transfer = await attachments.UpsertStreamAsync(
MoltaroResourceIdentity.EntityInstance("order", orderId),
new MoltaroAttachmentStreamUpsertRequest
{
FileReferenceId = $"ventcontrol:{legacyFileId}",
FileName = fileName,
MimeType = mimeType,
DeclaredSizeBytes = sourceLength,
ContentStream = sourceStream,
IdempotencyKey = $"ventcontrol-file:{legacyFileId}:{sourceVersion}",
// Null is valid when the legacy author is unknown. A non-null id must
// identify an existing workspace user.
SourceCreatedByUserId = sourceUserId,
SourceCreatedAt = sourceCreatedAt,
Progress = progress
},
cancellationToken);
while (!transfer.IsTerminal)
{
if (transfer.State == MoltaroAttachmentTransferStateEnum.Failed
&& transfer.FailureCode == "filesystem.upload.providerUnavailable")
{
// Let the enclosing import retry reopen the source and repeat
// UpsertStreamAsync with the same idempotency key.
throw new InvalidOperationException("Managed attachment completion must be retried.");
}
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
transfer = await attachments.GetUploadAsync(
MoltaroResourceIdentity.EntityInstance("order", orderId),
transfer.UploadSessionId,
cancellationToken)
?? throw new InvalidOperationException("Upload session disappeared.");
}

Cancellation does not abort the session. Open the source again at byte zero and repeat UpsertStreamAsync with the same metadata and idempotency key. Use AbortUploadAsync only when the source operation is intentionally abandoned. If GetUploadAsync reports filesystem.upload.expired, keep the same FileReferenceId and IdempotencyKey, open a fresh stream, and repeat UpsertStreamAsync. The returned UploadSessionId is the successor physical attempt; the predecessor remains terminal. Do not retry an explicitly aborted or rejected upload under the same key. The same retry is required for non-terminal Failed/filesystem.upload.providerUnavailable; status polling does not execute the failed provider command. Run transfer, polling, abort, and download calls outside an ambient package database transaction. Do not persist provider URLs: Available returns the stable Moltaro FileReferenceId to store in ordinary file fields or application link data. Markdown inline images remain on their separate 25-item/100 MiB draft workflow.

For a backlog, open independent streams for a bounded slice and let Moltaro own the parallel runtime scopes. Do not use Parallel.ForEachAsync with one injected scoped service:

var batchItems = pendingFiles.Take(100)
.Select(file => new MoltaroAttachmentStreamUpsertBatchItem(
MoltaroResourceIdentity.EntityInstance("order", file.OrderId),
new MoltaroAttachmentStreamUpsertRequest
{
FileReferenceId = $"ventcontrol:{file.LegacyId}",
FileName = file.Name,
MimeType = file.MimeType,
DeclaredSizeBytes = file.Length,
ContentStream = file.OpenRead(),
IdempotencyKey = $"ventcontrol-file:{file.LegacyId}:{file.Version}"
}))
.ToArray();
MoltaroAttachmentTransferBatchResult result;
try
{
result = await attachments.UpsertStreamsAsync(
batchItems,
new MoltaroAttachmentTransferBatchOptions { MaximumConcurrency = 32 },
cancellationToken);
}
finally
{
foreach (var item in batchItems)
{
await item.Request.ContentStream.DisposeAsync();
}
}

The request may ask for up to 32 workers, but ordinary actors remain capped at 8 active sessions. Only host-authenticated trusted system automation receives the dedicated 32-session capacity; the reserved system actor id alone grants neither access nor additional capacity.

Dispose the caller-owned streams after the batch returns. For each successful item, poll GetUploadAsync with the returned UploadSessionId until the transfer becomes terminal. Archive the source migration task only after that transfer reaches Available. Retry failed or non-terminal provider-unavailable items according to their IsRetryable and RetryAfterSeconds values while preserving their idempotency keys.

Create or update a blocked credentialless user

Section titled “Create or update a blocked credentialless user”

Inject IUserService. A stable source user id becomes the actual persisted Moltaro user id; no external-key column is needed. UserName preserves exact Unicode, including spaces; Moltaro trims leading/trailing whitespace, applies Unicode NFC, and uses invariant uppercase only for lookup uniqueness. Control, format, line, and paragraph separator characters are rejected, as is a single word that mixes Latin, Cyrillic, and Greek letters. RoleIds is the complete desired role set and is applied literally: a role missing from an explicit list is revoked, including Admin and Configurator. Pass null to leave the current memberships untouched. Everyone is always retained, and roles marked auto-assign are added when a user is created, not re-added on update. Email = null requires EmailConfirmed = false, and no password is created unless explicitly requested.

This enqueueable Job creates the blocked service user on its first run and updates it on later runs. Its publication permission controls who may start the function.

using System.Collections.Generic;
using Moltaro.Package.NET;
using Moltaro.Package.NET.Entity.Schema;
using Moltaro.Package.NET.Functions;
namespace Moltaro.Operational.Functions;
[MoltaroFunction(
"docs.examples.reconcileBlockedPrincipal",
Key = "docs.principals.reconcile",
Name = "Reconcile blocked integration user",
Description = "Creates or reconciles one blocked credentialless service user.")]
[MoltaroApiEnqueue(
PermissionKey = "operations.principals.reconcile",
TimeoutSeconds = 900)]
public sealed class ReconcileBlockedPrincipalJob(
IUserService users) : GlobalFunction
{
private const string StableUserId = "partner-directory-user-282";
public override async Task<FunctionResult> OnRunAsync(
CancellationToken cancellationToken)
{
var current = await users.GetUserAsync(StableUserId, cancellationToken);
MoltaroUser saved;
if (current is null)
{
saved = await users.CreateUserAsync(
new MoltaroCreateUserRequest
{
Id = StableUserId,
UserName = "QA Діана ",
Email = null,
FirstName = "Partner",
LastName = "Directory",
UserType = MoltaroUserTypeEnum.Service,
EmailConfirmed = false,
IsBlocked = true,
RoleIds = [],
ExtendedData = new Dictionary<string, string>
{
["partner.source-kind"] = "directory-user"
}
},
cancellationToken);
}
else
{
saved = await users.UpdateUserAsync(
current.Id,
new MoltaroUpdateUserRequest
{
UserName = "QA Діана ",
Email = null,
FirstName = "Partner",
LastName = "Directory",
UserType = MoltaroUserTypeEnum.Service,
EmailConfirmed = false,
IsBlocked = true,
RoleIds = [],
ExtendedData = new Dictionary<string, string>
{
["partner.source-kind"] = "directory-user"
}
},
cancellationToken);
}
return FunctionResult.OkData(
new
{
saved.Id,
saved.UserName,
saved.IsBlocked
},
"Principal state applied.");
}
}

The RoleIds = [] above is a real instruction, not a placeholder: on update it means “this user holds no roles beyond Everyone” and revokes anything an administrator granted in between. Send the ids you want to keep, or use null when the integration does not own this user’s roles. UserType is nullable for the same reason — leave it null to keep the stored type.

After an unknown outcome, call GetUserAsync again and compare the returned state before deciding whether to retry. Repeating the same complete update is safe. Creating or changing an Owner is always rejected, as are incompatible WorkspaceUser/Service transitions while local passwords, external identity, or API keys exist.

An active external-identity link also makes identity-profile state read-only; blocked state, role membership, and ExtendedData remain mutable. DeleteUserAsync archives the current link and releases its external subject for a future link.

For a bulk import, load existing users first, validate the source batch, and apply each item through the same Get/Create-or-Update loop. There is no parallel preflight endpoint or synchronization DTO family.

These C# services reject invalid input by throwing ArgumentException or InvalidOperationException with a plain English message: unsafe Unicode, a mixed-script word, a user-name collision after trim + NFC + invariant uppercase normalization, and a normalized email collision are all distinct failures but they do not carry stable error codes. Do not branch on the message text. Detect the state you care about before writing — call GetUserAsync for the stable id and ListUsersAsync when you must check a name or email — and let the exception fail the function otherwise. Stable ApiError codes such as users.userName.confusable and users.email.exists belong to the workspace HTTP user API, not to IUserService. There is no separate external-name field because the exact supported value is stored directly in UserName.

For roles, make the namespaced source id the Moltaro role id:

const string roleId = "ventcontrol:legacy-role-41";
var existingRole = await roles.GetRoleAsync(roleId, cancellationToken);
var role = existingRole is null
? await roles.CreateRoleAsync(
new MoltaroCreateRoleRequest
{
Id = roleId,
Name = "Dispatch coordinators",
Description = "Imported from Ventcontrol.",
AutoAssign = false,
ExtendedData = new Dictionary<string, string>
{
["ventcontrol.source-kind"] = "legacy-role"
}
},
cancellationToken)
: await roles.UpdateRoleAsync(
roleId,
new MoltaroUpdateRoleRequest
{
Name = "Dispatch coordinators",
Description = "Imported from Ventcontrol.",
// Complete desired state: omitting AutoAssign would clear the
// role's existing auto-assign setting on every repeat.
AutoAssign = existingRole.AutoAssign,
ExtendedData = new Dictionary<string, string>
{
["ventcontrol.source-kind"] = "legacy-role"
}
},
cancellationToken);

MoltaroUpdateRoleRequest is complete desired state, so send every field you want to keep — an omitted AutoAssign clears an existing auto-assign setting. Changing Name later updates the same role. RoleId is immutable, permissions and memberships are not inferred from it or from ExtendedData. A caller-supplied principal id is at most 128 characters, starts with a letter or digit, and may otherwise contain only letters, digits, and ., _, :, -, so it always survives a URL path segment. Changing the source key means explicitly creating a different role. Deleting a non-system role releases the id, so a later create can reuse it. Reusing an id that already exists, or targeting a built-in system role id, throws InvalidOperationException from IRoleService; the stable roles.id.exists and roles.systemRole.protected codes belong to the workspace HTTP role API, not to this C# service.

Entity validation, mutation, and business events

Section titled “Entity validation, mutation, and business events”

Generate validation and mutation shapes with the entity-bound source templates. Validation returns field-keyed ValidationIssue values. A mutation changes the typed generated entity before the runtime saves it.

Automatic field-level audit is intentionally off for project-owned direct-db saves. After a meaningful custom state change, emit an explicit event:

await dbContext.AddBusinessEventAsync<Ticket>(
ticket.Id,
new MoltaroBusinessEvent(
"Ticket escalated",
Values: [new MoltaroBusinessEventValue("priority", "Priority", "High")]),
cancellationToken);

The entity must have audit enabled. Keep event subjects and values safe for display. Read the resulting history through the API, not an audit DbSet:

GET /api/workspace/entity/{entityIdOrKey}/instances/{instanceId}/changes

See Record history and audit for paging and audit settings.

Section titled “Create a related Entity graph in one atomic save”

Do not save each new record merely to obtain the id needed by the next record. Generated Entity ids are application-assigned strings. Assign all ids first, connect the full graph, then perform one governed save:

using System.Security.Cryptography;
using System.Text;
using Moltaro.Package.NET.ModuleRuntime.Runtime;
await using var transaction =
await dbContext.Database.BeginTransactionAsync(cancellationToken);
await dbContext.AcquireMoltaroDynamicSchemaReadLockAsync(cancellationToken);
var beneficiaryId = StableEntityId(submissionId, "beneficiary");
var vulnerabilityId = StableEntityId(submissionId, "vulnerability");
var caseId = StableEntityId(submissionId, "case");
var assistanceId = StableEntityId(submissionId, "assistance");
var beneficiary = new Beneficiary
{
Id = beneficiaryId,
// Map beneficiary fields.
};
var vulnerability = new BeneficiaryVulnerability
{
Id = vulnerabilityId,
BeneficiaryId = beneficiaryId,
// Map vulnerability fields.
};
var beneficiaryCase = new Case
{
Id = caseId,
BeneficiaryId = beneficiaryId,
// Map case fields.
};
var assistance = new BeneficiaryAssistance
{
Id = assistanceId,
BeneficiaryId = beneficiaryId,
CaseId = caseId,
// Map assistance fields.
};
dbContext.AddRange(
beneficiary,
vulnerability,
beneficiaryCase,
assistance);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
static string StableEntityId(string submissionId, string recordKind)
{
var source = Encoding.UTF8.GetBytes($"kobo:{submissionId}:{recordKind}");
return Convert.ToHexString(SHA256.HashData(source)).ToLowerInvariant();
}

The class and Reference-property names above are illustrative; use the exact types from the installation’s generated contract. For a retryable import, prefer ids derived from stable source identity or keep a durable mapping from source ids to Moltaro ids.

The shared dynamic-schema lock must be the first database operation in a caller-owned generated-context transaction. It keeps the generated EF model stable for the complete unit of work and preserves lock ordering with package schema apply. ExecuteInTransactionIfNotExistsAsync acquires it automatically.

The first Entity save in an explicit transaction fixes the complete Entity identity lock batch. A later save may revisit a subset, but cannot introduce a new identity; Moltaro rejects that sequence with moltaro.preCommit.newEntityIdentityAfterLockBatchUnsupported before acquiring another lock. The fixed, sorted batch prevents crossed-lock deadlocks between concurrent transactions. This rule applies to all generated-context Entity writes, not only to records governed by Boards.

Do not work around the rule with raw SQL, ExecuteUpdate, or ExecuteDelete. Those paths bypass the pre-commit, audit, presentation, and runtime side-effect contract. If all records cannot be staged in advance, split the workflow into separate transactions and explicitly accept the loss of whole-workflow atomicity.

When the function contract needs to return a controlled domain result for a governed-save rejection, catch the public SDK type from its exact namespace and retain every stable provider code:

using System.Linq;
using Moltaro.Package.NET.Functions;
using Moltaro.Package.NET.ModuleRuntime.Runtime;
try
{
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch (MoltaroPreCommitSaveRejectedException exception)
{
return FunctionResult.OkData(new
{
Accepted = false,
Errors = exception.Errors.Select(error => new
{
error.Code,
error.Target,
error.Metadata,
}),
});
}

If the function does not catch the exception, its run ends as ValidationFailure with general FailureReasonCode = moltaro.preCommit.rejected. The ordered Errors[].Code values are the specific reasons; there is no single exception-level reason because several providers can reject the same save.

Boolean transition: add an Entity to a Board

Section titled “Boolean transition: add an Entity to a Board”

Inject IBoardAutomationCommandService into an entity trigger. The trigger runs after the record has committed, so the board application transaction can read it. This example fires only on the false to true transition:

if (previous?.ReadyForBoard != true && current.ReadyForBoard == true)
{
var admitted = await boards.AddItemAsync(
new AddBoardAutomationItemRequest(
OperationKey: current.RowVersion,
BoardIdOrKey: "support_triage",
Target: BoardRuntimeTargetRef.EntityDefinition(
WorkspaceEntityIds.ServiceTicket,
current.Id),
Subject: current.Title,
DueDate: DateOnly.FromDateTime(DateTime.UtcNow.AddDays(2))),
cancellationToken);
admitted.ThrowIfFailed();
}

AddItemAsync runs the same application flow as adding the item through the Runtime API: it validates the target and repeat policy, executes the Board Security gates, creates Board Data, and performs audit/business events, resource-event/outbox, and attention work. Its required UUID OperationKey identifies one logical admission. This transition recipe uses the committed Entity RowVersion, so a retry of the same Trigger delivery reuses the key; use a distinct deterministic UUID for each admission when one delivery can add more than one item to the same board target definition. Exact replay returns the original item, while a changed payload with the same key fails with an idempotency conflict. Use FindOpenItemAsync before deciding to start a new process pass. Enabled Board entry Constraints are evaluated before commit; a rejected admission leaves the target, Board Data, item, audit, and outbox unchanged and returns every ordered failure in admitted.Errors. ThrowIfFailed() carries the first platform code and its trusted safe message into the terminal run/job diagnostics. Do not replace a failed facade result with InvalidOperationException, which loses that structured reason.

Read the current item immediately before a mutation and send its RowVersion:

var currentItem = await boardQueries.GetItemAsync(
"support_triage", boardItemId, cancellationToken);
if (!currentItem.Success || currentItem.Data is null)
{
return FunctionResult.Failed("Board item was not found.");
}
var request = new MoveBoardAutomationItemRequest(
"support_triage",
boardItemId,
doneStatusId,
RowVersion: currentItem.Data.RowVersion);
var validation = await boardQueries.ValidateMoveAsync(request, cancellationToken);
if (!validation.Success || validation.Data?.Allowed != true)
{
return FunctionResult.Failed(string.Join(",",
validation.Data?.Errors.Select(error => error.Code)
?? validation.Errors.Select(error => error.Code)));
}
var moved = await boards.MoveItemAsync(request, cancellationToken);
if (!moved.Success || moved.Data is null)
{
return FunctionResult.Failed(
string.Join(",", moved.Errors.Select(error => error.Code)));
}

On a stale row version, read the item again and decide whether the desired state is already present. Never turn an optimistic-concurrency error into an unconditional overwrite.

Move validation and execution share transition permission, Business Invariant, and Board Constraint planning. validation.Data.CapturedAtUtc and ordered ConstraintDecisions explain the captured preview, while Errors preserves each stable code, safe message, target, and metadata. Execution rebuilds the plan under its item lock, so a preview is not a reservation.

Board moves use optimistic concurrency through RowVersion; MoveBoardAutomationItemRequest takes no idempotency key. A durable idempotency receipt exists for Entitlement commands, not for Board moves — if a move outcome is unknown, re-read the item with GetItemAsync and compare its status before retrying. (Its optional fourth argument is a BoardRuntimePositionRequest? Position for ordering within one board, status, and cycle scope, not a key.) With a position, pass only PreviousBoardItemId to append after the current last item, only NextBoardItemId to prepend before the current first item, or both IDs for two currently adjacent items. A stale, foreign, or non-adjacent boundary returns a failed runtime result with error code moltaroBoards.runtime.validation.positionInvalid; re-read the current ordering before deciding whether to retry.

Entitlement grant, consume, access, and history

Section titled “Entitlement grant, consume, access, and history”

Inject IEntitlementAutomationCommandService and IEntitlementAutomationQueryService. No actor or correlation plumbing is accepted: the facade obtains it from the current function invocation.

var grantRequest = new GrantEntitlementAutomationRequest(
PlanId: planId,
OwnerEntityInstanceId: ownerEntityInstanceId,
IdempotencyKey: $"order:{orderId}:grant",
BeneficiaryEntityInstanceIds: [beneficiaryId],
CoveredResources:
[
new GrantEntitlementResourceReference(resourceDefinitionId, resourceId)
]);
var preview = await entitlementQueries.PreviewGrantAsync(grantRequest, cancellationToken);
if (!preview.Success)
{
return FunctionResult.Failed(
string.Join(",", preview.Errors.Select(error => error.Code)));
}
var granted = await entitlements.GrantAsync(grantRequest, cancellationToken);
if (!granted.Success || granted.Data is null)
{
return FunctionResult.Failed(
string.Join(",", granted.Errors.Select(error => error.Code)));
}
var limits = await entitlementQueries.ListLimitsAsync(
granted.Data.ResourceId,
cancellationToken);
var quantityLimit = limits.Data?.Single(limit =>
limit.LimitFamily == EntitlementLimitFamilyEnum.ConsumableQuantity);
var consumed = await entitlements.ConsumeAsync(
new ConsumeEntitlementAutomationRequest(
EntitlementId: granted.Data.ResourceId,
IdempotencyKey: $"visit:{visitId}:consume",
EntitlementLimitId: quantityLimit?.Id,
Quantity: 1,
RowVersion: quantityLimit?.RowVersion),
cancellationToken);
var access = await entitlementQueries.EvaluateAccessAsync(
new EntitlementAccessEvaluationRequest(beneficiaryId, resourceId),
cancellationToken);
var history = await entitlementQueries.ListHistoryAsync(
new EntitlementHistoryRequest(beneficiaryId),
cancellationToken);

Always provide a durable idempotency key for grant, renewal, lifecycle, consume, reverse, adjust, and renewal-operation commands. ListLimitsAsync returns the concrete limit-window RowVersion used by quantity concurrency; the entitlement command result’s RowVersion is the entitlement token and is not interchangeable with a limit token. The history result contains ledger snapshots, and ListRenewalOperationsAsync returns the renewal workflow state; never reconstruct balances or workflow state by duplicating the module’s rules.

This is an advanced, uncommon integration. Moltaro’s full Runtime Board UI currently focuses on EntityInstance targets and does not provide the complete interactive workflow for Entitlement targets. For ordinary operational work, put the related Entity record (case, request, customer asset, and so on) on the Board and keep the Entitlement id in business data or automation correlation.

If an API/C# integration deliberately needs the typed Entitlement target, configure an Entitlement target definition on the Board first, then use the same preferred Board facade:

// Persist this UUID before the first call and reuse it only for an exact retry.
var operationKey = Guid.NewGuid();
var boardItem = await boards.AddItemAsync(
new AddBoardAutomationItemRequest(
OperationKey: operationKey,
BoardIdOrKey: "renewal_review",
Target: BoardRuntimeTargetRef.Entitlement(
entitlementModelId,
entitlementId),
Subject: $"Review entitlement {entitlementId}"),
cancellationToken);

The application service performs typed target validation and the normal Board side effects. An invalid model/target definition produces typed error codes; it does not silently create a generic board card. Do not make this advanced target the default recipe for an agent unless the user explicitly requests it.

Transactions, correlation, retries, and follow-up work

Section titled “Transactions, correlation, retries, and follow-up work”
  • The caller’s function entry is authorized, then module writes are recorded as moltaro-system-automation. The original user is preserved in origin metadata rather than used as the module actor.
  • Each automation call runs and commits in a separate application scope. It never shares the transaction of an injected GeneratedWorkspaceContext or MoltaroDbContext. Prefer an after-commit trigger when the module operation depends on an entity save.
  • Correlate the Function operations run, module audit/ledger rows, and resource events with FunctionContext.CorrelationId; origin also stores FunctionRunId, function id/key, and version.
  • A successful return is terminal for the main application transaction. Created distinguishes Board item creation, and RowVersion is the returned resource’s concurrency token. Board admission exact retries must preserve AddBoardAutomationItemRequest.OperationKey; other ordinary Board mutations have no automation receipt. Entitlement command results additionally use Replayed for their native durable idempotency. For Entitlement quantity commands, use the selected limit token from ListLimitsAsync, not the entitlement token.
  • FollowUpOperationIds lists only actually queued operations. Poll returned function job ids with GET /api/workspace/functions/jobs/{jobId} until a terminal state. Durable outbox delivery can continue after the main transaction; inspect the related audit/resource activity when verifying its effects.
  • Application facades are supported only in Action, TriggerHandler, Command, Job, and HttpEndpoint contracts. Validation and BeforeSaveMutation return moltaro.automation.executionPhase.unsupported.

For synchronous composition, move shared code into a constructor-injected C# service and call it directly. Use IFunctionQueue.EnqueueAsync only to invoke a published global Job; it returns a job id that requires polling at the endpoint above.

Inject ICurrencyRatesRuntime. It never performs a network request. It reads module settings, enabled/credential-ready provider state, and locally cached sets, then applies provider priority and same/direct/inverse/cross resolution with historical fallback.

var settings = await currencyRates.GetSettingsAsync(cancellationToken);
var providers = await currencyRates.ListProvidersAsync(cancellationToken);
var rate = await currencyRates.GetRateAsync(
new CurrencyRateLookupRequest("EUR", "PLN", DateOnly.FromDateTime(invoiceDate)),
cancellationToken);
if (rate is null)
{
return FunctionResult.Failed("No cached exchange rate is available.");
}

Inspect EffectiveDate, IsFallback, RateType, and Provenance before using a historical result in a financial decision. Refresh is a host operation and is deliberately outside project code.