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.
Choose the supported service
Section titled “Choose the supported service”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}/changesSee Record history and audit for paging and audit settings.
Create a related Entity graph in one atomic save
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.
Validate and move a Board item
Section titled “Validate and move a Board item”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.
Add an Entitlement as a Board target
Section titled “Add an Entitlement as a Board target”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
GeneratedWorkspaceContextorMoltaroDbContext. 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 storesFunctionRunId, function id/key, and version. - A successful return is terminal for the main application transaction.
Createddistinguishes Board item creation, andRowVersionis the returned resource’s concurrency token. Board admission exact retries must preserveAddBoardAutomationItemRequest.OperationKey; other ordinary Board mutations have no automation receipt. Entitlement command results additionally useReplayedfor their native durable idempotency. For Entitlement quantity commands, use the selected limit token fromListLimitsAsync, not the entitlement token. FollowUpOperationIdslists only actually queued operations. Poll returned function job ids withGET /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.
Currency Rates: local cached lookup
Section titled “Currency Rates: local cached lookup”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.