Skip to content

.NET SDK for C# business logic

The Moltaro .NET SDK is the programming model for C# business logic in the workspace Net Operation Project: a function is a C# class that derives from an SDK base class and declares identity, bindings, and publication with attributes. Authoring happens in the Constructor area under Automation & logic > Development, or entirely through the Configuration API. AI agents should use the API-first authoring flow; downloading the NET Project ZIP is optional for a human using a local IDE.

Use the curated .NET reference together with the installation’s GET .../net-operation-project/developer-surface catalog. The catalog, not arbitrary public CLR visibility, defines supported assemblies and injectable services.

Package and Net Operation Project assemblies execute in collectible runtime epochs. Keep work inside the invocation: use ordinary awaited async calls, and use Moltaro schedules or IFunctionQueue when work must run later. Check and Build reject detached, started, discarded, or unobserved stored tasks, threads and ThreadPool work (MOLTARO040), long-lived timers or callbacks (MOLTARO041), subscriptions to host events (MOLTARO042), and explicit default-context/dynamic-load or GCHandle escapes (MOLTARO044). CancellationToken.Register and UnsafeRegister are supported only when the returned registration itself is disposed by using. A local method-group delegate to either registration API is allowed only when every invocation is disposed this way and the delegate does not escape. Process-global ActivitySource.AddActivityListener registration and access to DiagnosticListener.AllListeners are rejected. Creating, subscribing to, or deriving from DiagnosticListener, and deriving from EventListener, are also rejected because their process-global registration cannot be made fail-closed in package code. Call MeterListener.Start only on a receiver declared by using var or a using declaration. A method-group delegate to Start must remain local and be invoked directly. A stored task must be directly awaited or returned by the very next statement; for parallel work, store a combined Task.WhenAll(...) task and await it next.

MOLTARO043 warns on every user-authored non-const static/static-readonly field. Review it as epoch-owned state: it must not escape to host state or retain callbacks, host services, collectible CLR types, delegates, or invocation values. Generated code and constants are excluded. Task.Delay and ordinary async/await are supported. An already active artifact is not stopped by a newly introduced analyzer rule, but its next Check or Build must pass the current contract.

Reflective host-event subscriptions, AppDomain.SetData, AppContext.SetData, process-principal replacement, named/thread-local data slots, and writes to external static fields or properties are lifetime escapes and are rejected as well. Process-global Encoding.RegisterProvider registration is also rejected. The same applies to TypeDescriptor.AddProvider, AddProviderTransparent, AddAttributes, AddEditorTable, and CreateAssociation. The same rule rejects process-global Console.SetIn/SetOut/SetError replacement and all Trace.Listeners collection access because those APIs can retain package-owned readers, writers, or listeners after the runtime epoch. Taking a method-group delegate to a forbidden process-global API is rejected too. Structured result helpers for command and function data, typed HTTP JSON, and data sources use the current runtime epoch’s serializer context automatically. Existing installed artifacts that explicitly pass process-singleton JSON options to those helpers remain runtime-safe because the epoch context replaces them; their next Check/Build rejects that reference with MOLTARO044, so omit the option. Direct JsonSerializer calls must pass a freshly constructed inline options instance or explicit typed JSON metadata; options obtained through parameters, fields, properties, or locals are rejected because their ownership cannot be proven. A JsonSerializer method group is allowed only when its selected overload requires JsonTypeInfo or JsonSerializerContext. Do not put serializer options, converters, runtime types, exceptions, or result objects into static or other process-global storage. Dynamic invocation is rejected because Check/Build cannot verify that the runtime-selected target respects these lifetime rules. Inline Activator.CreateInstance<T>() and CreateInstance(typeof(T)) remain available for ordinary non-listener types. Type-erased activation and Activator.CreateInstance method groups are rejected because the constructed lifetime cannot be verified.

Parent assignment and reparenting use the same supported record write services and optimistic concurrency as any Reference. See the Parent Tree View developer guide.

Reference Eligibility is likewise configured on Entity Definition fields or through the Configuration API, not authored from public Net Operation Project schema code. The configuration workflow supports validation, impact preflight, activation, Form/Table pre-filtering, and authoritative save enforcement. See the Reference Eligibility guide.

Hierarchy Selector configuration is available from Entity Definition Form and Table-filter authoring and from the Configuration API. It supports fixed Reference paths and recursive indexed self-References while storing only the terminal Reference value. See the Hierarchy Selector guide.

Entity UI layout remains workspace configuration rather than Net Operation Project source. Configure responsive Card item width in the Entity Definition Surface library or through the Configuration API, as described in Card item layout. Workspace C# consumes the generated entity contract but does not author or override these UI spans.

Entity Manual Order is also platform-owned configuration. Net Operation Project source does not declare the capability, choose or write its protected rank storage, or receive a direct Manual Order move service. Generated entity writes continue through the normal Moltaro save pipeline. When Manual Order is active, that pipeline automatically gives each newly inserted active record a canonical tail position in the same transaction, so ordinary generated-context creates do not need special ordering code. Positional moves remain an authenticated application/UI operation with the same authorization and concurrency boundary as other Entity actions.

For workspace-specific algorithms that need their own ordered keys, the SDK also exposes the policy-free Moltaro.Package.NET.ModuleRuntime.Ordering.LexicographicRankKeyGenerator. It returns typed generation statuses and evenly spaced sequences. Using that primitive does not grant access to Moltaro’s protected Manual Order rank, and it does not provide a Manual Order move endpoint or service.

For calendar lifecycle, effective-dated Site and Worker assignments, schedule exceptions, and effective-schedule reads, prefer IWorkScheduleAutomationCommandService and IWorkScheduleAutomationQueryService. The generated workspace contract registers both interfaces, and developer-surface marks them ApplicationAutomation and Preferred: true.

These calls run complete Work Schedule application orchestration in a separate host-owned scope. Requests cannot supply an actor or authority tier. Every command uses a stable caller-owned OperationId; calendar mutations additionally use the current RowVersion, while assignment and exception mutations use the last returned ChainVersion. Preserve an operation id only for an exact retry after an unknown outcome.

The lower-level services in Moltaro.Package.NET.WorkSchedule.Runtime remain available as advanced TrustedDirectDb, Preferred: false contracts for code that explicitly owns authorization, transaction, audit, and side effects. Read the Work Schedule C# guide before choosing that boundary.

The generated workspace contract reproduces each Entity Definition’s explicit PostgreSQL search targets through the SDK entity builder:

  • SearchField(...) declares a direct field or a path through Reference, Inverse Reference, or Table fields to a String, Text, Address, or File terminal;
  • SearchSystem(EntitySearchSystemTargetEnum, ...) declares Display name, Number, Comments, or Attachments at the root or after a relation path.

These declarations are generated build input, not user-owned Net Operation Project source. Configure search targets in the Entity Definition Search tab or through the Configuration API search contract; the next generated contract then gives Check, Build, and server-side language services the same schema metadata.

Generated String and Text properties whose Entity Definition field is labeled with the Markdown property label still expose the original Markdown source as their typed property value. Moltaro separately maintains a display-safe plain-text projection for search, presentation, projections, and audit previews. Net Operation Project code should write Markdown through the generated context and its normal SaveChanges pipeline; do not issue raw SQL against the source or its internal companion column. The save is rejected when the source exceeds the 512 KiB UTF-8 limit, and source plus projection are committed together.

A tracked direct-database update through MoltaroDbContext.SaveChanges is authoritative for every modified field. The save removes stored declarative mutation-effect ownership only from those fields and preserves ownership of unchanged fields. It does not execute configured mutation effects; those run only through the application/API Entity save pipeline.

Markdown inline images use opaque moltaro-attachment://<FileReferenceId> source tokens. Package code must not construct raw download URLs or treat a token as an access grant. The public Net Operation Project programming model does not expose draft-session adoption; use product record forms for destination-scoped image authoring.

All new attachment uploads enter PendingInspection. The descriptor exposes AvailabilityState, detected MIME, image dimensions, content generation, and the checksum/generation covered by successful inspection. Only Available content whose inspected generation and checksum still match can be downloaded through IAttachmentService. MoltaroAttachmentDownloadResult.Content is now a non-null verified Stream; dispose it after reading. Moltaro verifies the immutable body into a bounded spool before exposing any bytes, and the result does not expose BlobUri. Attachment downloads are intentionally rejected inside caller-owned database transactions so a checksum mismatch can persist its security invalidation independently. Code compiled against the previous buffered result must be updated before moving to this SDK contract.

For large Managed Cloud attachments, use UploadStreamAsync or UpsertStreamAsync with an exact declared size and a stable idempotency key. Call GetUploadCapabilitiesAsync before opening or downloading the source. It returns the effective storage type, maximum file size, multipart availability, part policy, checksum policy, actor-local active-session/reserved-byte usage and limits, any saturation retry delay, the 24-hour inactivity and 48-hour absolute session lease, and authoritative extension-aware FileTypeRules plus FileTypePolicyVersion and FileTypePolicyHash without exposing storage credentials. A rule accepts a file only when its longest matching extension and normalized declared MIME type are present together. AllowedMimeTypes is a deprecated, conservative compatibility projection and must not be used to make new upload decisions. It deliberately omits application/octet-stream; a pre-rule runtime may reject DWG/FBD after a rollback instead of allowing ambiguous bytes for every extension. A pre-session policy rejection throws MoltaroSdkException; inspect its stable FailureCode (also available as ReasonCode) instead of parsing the safe bounded message. When FailureCode is filesystem.upload.activeLimitReached, the exception has IsRetryable = true, exposes RetryAfterSeconds, and includes safe active count/limit and reserved-byte usage/limit metadata. Keep the idempotency key unchanged and retry only after the advertised delay. filesystem.file.type.notAllowed metadata includes the normalized extension, declared MIME, policy version, and policy hash so a caller can correlate the decision with the exact preflight snapshot across API and Worker processes. The rule check is not antivirus or malware inspection and cannot detect an executable file renamed to an allowed ambiguous format. Workspace administrators may deliberately configure executable extensions and own the consequences of that policy. The source may be non-seekable; Moltaro reads it in bounded parallel windows of 16 MiB parts up to the advertised MaximumParallelParts and does not expose S3 endpoints, buckets, object keys, credentials, or presigned URLs. Interactive cancellation preserves the resumable session. Host cancellation of a trusted background function aborts that physical attempt and releases capacity; an exact retry with the same logical idempotency key creates a successor. Retry the complete stream with the same key, poll GetUploadAsync, and call AbortUploadAsync only when the upload should be discarded. Uploaded means the provider accepted the body, Verifying means background verification/publication is running, and only Available returns the published reference and descriptor ids. The idempotency key and requested file-reference id identify one logical upload; UploadSessionId identifies its current physical attempt. If the attempt expired, repeat the same stream call with a fresh stream and unchanged actor, owner, metadata, key, and file-reference id. Moltaro returns one new successor session after rechecking current policy and concurrency. Querying the old session still returns terminal filesystem.upload.expired. A background attempt aborted by host cancellation may restart; explicitly aborted ordinary uploads and rejected uploads remain terminal. An already published result is replayed without uploading again. Failed with filesystem.upload.providerUnavailable is non-terminal; reopen the source and repeat the same stream call with the same idempotency key because polling does not retry a provider command. Start, status, abort, and download operations must run outside an ambient package database transaction because they can perform external storage I/O. The legacy direct-database Database/Local upload and upsert methods remain available and enforce the same workspace FileTypeRules before accepting bytes.

For a migration backlog, use UpsertStreamsAsync rather than running concurrent calls through one scoped service instance. A batch accepts at most 100 items; MaximumConcurrency is 1 through 32 and defaults to 4, while current actor upload capacity may reduce the effective concurrency. Ordinary actors remain capped at 8 active sessions; only host-established trusted system automation receives the dedicated 32-session capacity. Each item owns an independent runtime scope and transaction, results stay in input order, and one item failure does not cancel successful siblings. Each failed result carries its stable code and retry metadata. If a terminal item failure occurs after session creation, the runtime aborts the physical attempt and releases its reservation before returning the result; retryable failures retain their resumable attempt. Provide a separate caller-owned readable stream for every item and invoke the batch outside an ambient package transaction. A successful item has entered the transfer lifecycle; poll its returned upload-session id to Available before archiving the source migration task.

Trusted scheduled code may use either stream method when the historical author is unknown. UploadStreamAsync has no source-author input; for UpsertStreamAsync, leave SourceCreatedByUserId null. Moltaro preserves null on the published descriptor, file reference, and relations while keeping the runtime-owned moltaro-system-automation identity as the authorization and audit actor. A non-null source creator must resolve to an existing workspace user and never grants access. Direct-db upload/upsert follows the same unknown creator rule for the reserved system actor; ordinary direct-db calls require a real actor and attribute an omitted creator to that actor.

Use IAttachmentService.ReassignAsync to move one published attachment between EntityInstance owners without changing its FileReferenceId or copying its body. The request carries a stable OperationId, exact source and target owner identities, and the row version returned by the attachment list. A committed retry returns AlreadyApplied; the descriptor, provider object, bytes, checksum, upload attribution, and relations stay unchanged. Moltaro rejects the operation when access or Business Invariants deny detach/attach, the reference is still used by a File or nested Table<File> field, its inspected content is not currently downloadable, an upload/replacement or hold is active, or the target already contains the normalized filename. Use the stable filesystem.reassign.* failure code rather than parsing the safe message. When a configured Business Invariant provides its own stable rejection code, Moltaro preserves that code so the caller can identify the exact policy that blocked the reassignment; filesystem.reassign.invariantRejected is the fallback when the invariant provides no code. Runtime functions require the Moltaro host gateway and never call their own HTTP API or receive provider locators. A direct-db host must configure a canonical workspace name and a real Business Invariant guard, and must call reassignment outside any ambient package transaction.

This large-file contract does not change Markdown inline images. Markdown draft uploads remain image-only, at most 25 items and 100 MiB in aggregate, and use opaque moltaro-attachment:// tokens only after the existing draft workflow. Do not place a presigned URL in Markdown source.

Use ICommentService.UpsertAsync for an import or restore that must preserve a comment id, source creation time, source author, and relations. It cannot move an existing comment to another owner. If the source author is omitted in a host-authenticated CRON or Trigger function, Moltaro records moltaro-system-automation; an explicit source author must be an existing workspace user. The reserved actor id by itself is not trusted provenance.

CRON and Trigger functions can use ReassignAsync to atomically move one active comment between existing EntityInstance or BoardItem owners. The comment id, text, parent, source attribution/time, and relations remain unchanged. Supply a stable operation id and the current comment row version. Replaying that exact request returns AlreadyApplied without duplicate writes or events. User-action functions and external direct-db hosts cannot use this operation.

var current = await comments.GetAsync(commentId, cancellationToken)
?? throw new InvalidOperationException("The source comment no longer exists.");
var moved = await comments.ReassignAsync(
new MoltaroCommentReassignRequest
{
OperationId = operationId,
CommentId = current.Id,
SourceOwner = MoltaroResourceIdentity.BoardItem(sourceBoardId, sourceItemId),
TargetOwner = MoltaroResourceIdentity.BoardItem(targetBoardId, targetItemId),
ExpectedRowVersion = current.RowVersion
},
cancellationToken);

Keep the operation id only for the exact retry. On stale input, re-read and start a new operation. Handle MoltaroSdkException.ReasonCode using MoltaroCommentErrorCodes; do not parse error messages.

Principal CRUD and application automation services

Section titled “Principal CRUD and application automation services”

For workspace C# that changes principals, use the existing principal CRUD services. Boards and Entitlement Operations retain their curated application-level services:

  • IUserService, IRoleService, and IGroupService;
  • IBoardAutomationCommandService and IBoardAutomationQueryService;
  • IEntitlementAutomationCommandService and IEntitlementAutomationQueryService.

Principal mutations create their own transaction, serialize with authoritative Entity authorization through the workspace access-state lock, and atomically commit the principal rows, cache invalidation version, and applicable user-index outbox row. They are not supported inside an already active package transaction.

Entity application automation uses IEntityAutomationCommandService. Its exact 15 commands are CreateAsync, UpdateAsync, ArchiveAsync, RestoreAsync, DeleteAsync, MergeAsync, ImportAsync, BulkUpdateAsync, ReplaceResponsibilitiesAsync, ReplaceTagsAsync, CreateCommentAsync, UpdateCommentAsync, DeleteCommentAsync, UploadAttachmentAsync, and DeleteAttachmentAsync. Every request carries an OperationKey; exact replay returns the stored authorized result, while reusing the key for different input returns an idempotency conflict. Import and bulk update accept at most 100 items per captured transaction.

CreateAsync takes the five-member EntityCreateCommandRequest: a non-empty UUID OperationKey, the matching EntityDefinitionKey, a Values field map, the latest ExpectedDefinitionRowVersion, and a non-null ResponsibilityCandidates list. Use Array.Empty<ResponsibilityCandidatePayload>() when the caller proposes no StageOnCreate assignments. Preserve the operation key only for an exact retry; re-read the Entity Definition before rebuilding a request after a schema concurrency conflict.

Code written for the earlier create shape must replace Fields with Values and replace string MutationIdempotencyKey values with caller-generated UUID OperationKey values. Rebuild against the current SDK rather than maintaining an adapter for the removed request shape.

Each method returns SecurityMutationCommandResult<TResult>. Success values are the documented authorized-detail, safe identity/receipt, responsibility, tag, comment, attachment, import, or bulk result unions. Current Entity View and field visibility are rechecked before replay output, so a retry cannot reveal a record or field that is no longer readable. This facade is host-enforced and always requires the current Entity Security configuration and dependency guards. Its host-authenticated system actor bypasses end-user permission statements but not configuration existence, lifecycle invariants, concurrency, or idempotency; the reserved actor id without trusted provenance grants no access. It is distinct from trusted direct-database services, which must never be substituted behind an application-facade call.

For record lifecycle automation, inject IEntityAutomationCommandService and pass EntityIdentityCommandRequest to ArchiveAsync or RestoreAsync:

var request = new EntityIdentityCommandRequest(
operationKey,
"service_ticket",
ticketId,
currentRowVersion,
"Closed by retention policy");
var result = await entities.ArchiveAsync(request, cancellationToken);
if (!result.IsSuccess)
{
var codes = result.Errors.Select(error => error.Code).ToArray();
return FunctionResult.Failed(string.Join(", ", codes));
}
var receipt = (EntityLifecycleMutationReceipt)result.Result!;
var nextRowVersion = receipt.Data.RowVersion;
var replayed = receipt.IsIdempotentReplay;

Preserve operationKey only for an exact retry and use the record’s current RowVersion. moltaro.instances.notFound is the safe missing-or-not-visible failure, moltaro.instances.conflict means the row version is stale, and moltaro.instances.archive.alreadyArchived / moltaro.instances.restore.notArchived report lifecycle-state conflicts. Success always returns an EntityLifecycleMutationReceipt; its identity-only Data contains the new committed RowVersion without disclosing Entity field values. The receipt exposes IsIdempotentReplay, and a replay does not emit another audit row or resource event. The effective actor is always moltaro-system-automation. Scheduled Jobs may run without an original user; when an interactive user exists, that identity and the function, run, correlation, and Schedule/Trigger/Function origin remain audit attribution.

Board Statements are workspace configuration authored through the Boards Configuration API, not a new C# evaluator or Net Operation Project service. Their public Boards package schema exists for trusted direct-database module integration, while supported workspace logic should use the documented configuration endpoints and server-owned DSL authoring profile.

Board automation facade mutations enforce enabled transition, entry, exit, and destination status-invariant Constraints through the same application transaction as the Runtime API. BoardAutomationMoveValidationResult exposes ordered Errors, CapturedAtUtc, and ConstraintDecisions in addition to compatibility ErrorCodes; callers must handle every returned error and must not parse the workspace-authored message. The advanced direct-database Boards package also exposes IBoardConstraintRuntimeEvaluator and AddMoltaroBoardsConstraintRuntimeEvaluator<TEvaluator>() for trusted hosts. This public custom-evaluator contract handles Board events only. It cannot opt a standalone host into proposed-state StatusInvariant evaluation, which remains fail-closed outside Moltaro’s host-provided integration. Its default event evaluator fails closed if an enabled Binding applies without a registered evaluator. Destination pickers should call IBoardRuntimeCommandService.ValidateMoveOptionsAsync; it evaluates candidates for one item and actor as one request-local batch. Custom high-volume evaluators should override EvaluateBatchAsync. Authoritative mutations call EvaluateAuthoritativeBatchAsync only after acquiring governance locks. An evaluator backed by a configuration or compiled-plan cache must override that method and reload the applicable configuration inside that call; it may delegate to EvaluateBatchAsync only when its evaluation source is uncached and already authoritative. Custom definition providers must implement the remaining set-based Board definition lookups. BoardRuntimeActor carries only the user id; roles, groups, responsibilities, Statements, and exact transition permissions come from the host’s captured SQL authorization scope.

Direct package move validation also returns ordered transition LinkPrerequisites with current and missing counts. A trusted host can set MoveBoardRuntimeItemRequest.PrerequisiteLinks to existing Board item ids; the package derives link kind/direction from the transition, applies ordinary link permissions and invariants, and commits any new link with the move. Inline target creation remains an application/Runtime API concern rather than a direct-package contract.

When code opens a generated-context transaction directly, call AcquireMoltaroDynamicSchemaReadLockAsync immediately after BeginTransactionAsync and before any query, row lock, or mutation. Use the synchronous pair for synchronous transactions. The lock prevents a NetPackage schema update from changing the meaning of the generated EF model during the unit of work. ExecuteInTransactionIfNotExistsAsync takes this lock automatically before it invokes package code.

Normal generated-context SaveChanges and SaveChangesAsync also enforce status invariants when an existing Entity is the Target or Board Data of a governing Board Item. Moltaro evaluates the complete tracked proposed root and owned-table state, including owned Address and Money changes, before persistence. Single Select values use one option key and Multi-Select values use an array of option keys. The generated workspace contract maps a sanitized C# enum member back to the actual workspace option key through a platform-owned EF converter; for example, member InProgress can represent key in_progress. Board DSL and Runtime API values use the workspace option key, not the sanitized member name. Generated String fields carrying the TimeZone semantic role retain that role in direct-database audit evidence. Normal generated-context SaveChanges and SaveChangesAsync canonicalize IANA aliases and reject values outside the platform catalog before persistence, including owned table rows. Raw SQL bypasses this protection and is not a supported mutation path. File values use stable string ids. Money uses an Amount/CurrencyCode object: a missing Amount makes the whole value canonical null, while an omitted, null, empty, or whitespace CurrencyCode uses the field’s normalized uppercase BaseCurrencyCode. An Address with no meaningful text or coordinate component is canonical null. Persisted and proposed values use the same carriers as the Runtime API. A rejection rolls back the save and throws MoltaroPreCommitSaveRejectedException; its Errors collection contains the safe, ordered failures from every registered provider, including all governing active Boards. Catch that typed exception when function code needs to translate a rejected save into its own result.

The exception is public from Moltaro.Package.NET.ModuleRuntime.Runtime. The following fragment is intended for an OnRunAsync method that returns FunctionResult:

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

Branch on Errors[].Code, not Message. A run that does not catch the exception receives the general run-level failure reason moltaro.preCommit.rejected; the provider-specific reasons remain the ordered codes in Errors because one save may be rejected for more than one reason. BoardConstraintSaveRejectedException is a separate exception type used only by direct Boards runtime commands and protected direct Board mutations; code that handles both boundaries must catch both types explicitly. Multiple tracked saves inside one explicit transaction retain the latest proposed Entity state from earlier successful saves. The first Entity mutation lock batch fixes the transaction’s complete Entity identity set, including a Board-command batch or a save later rejected by validation. Every later batch must be a subset. A new identity fails before lock acquisition with moltaro.preCommit.newEntityIdentityAfterLockBatchUnsupported; stage all records in a cross-Entity unit of work and save them in one call. This lock-batch rule applies to every generated-context Entity transaction, even when none of the records is used by a Board. The complete sorted identity set is fixed before locks are acquired so concurrent transactions cannot each hold one Entity lock while waiting for the other.

Manual Order adds the same fail-closed transaction rule for its separate lock. If an Entity mutation lock has already been acquired, a later save cannot first introduce a create, archive, restore, or delete that needs the Entity’s Manual Order lock. The SDK throws MoltaroPreCommitSaveRejectedException before lock acquisition with code moltaro.preCommit.manualOrderAfterEntityLockUnsupported. Stage that lifecycle operation in the first save, or roll back/complete the current work and retry it in a new transaction. Catch the typed exception and inspect Errors; do not parse its message.

New generated Entity ids are application-assigned strings. For an atomic multi-record import, assign every id first, connect References/foreign keys in memory, add the complete graph to the context, and call SaveChangesAsync once. A dependent record does not need an intermediate save merely to discover its parent’s id. Prefer source-stable ids or a durable source-id mapping when the import must be safely repeatable. See Create a related Entity graph in one atomic save.

An ordinary single-savepoint rollback restores that snapshot: Entity state saved before the savepoint remains visible, while state saved after it is discarded. If nested savepoint history is ambiguous to the runtime, every later Entity save fails closed with moltaro.preCommit.transactionOverlayInvalidated; roll back the transaction and retry the complete unit of work in a new transaction. MoltaroDbContext.SaveChanges[Async] creates that savepoint before the OnBeforeSaveChanges[Async] hook. Database work performed by the hook, runtime preflight, the Entity write, mutation-effect ownership release, audit, and runtime outbox changes therefore share the same rollback boundary. An owned table row’s OwnerEntityInstanceId is immutable after insertion. Do not parse exception text or expose evaluated field values.

The same save boundary validates newly assigned File fields on Entity roots and table rows. The id must resolve to a non-deleted file reference and descriptor whose content is Available or LegacyUninspected. Pending, rejected, soft-deleted, and nonexistent references are rejected through MoltaroPreCommitSaveRejectedException with the safe code filesystem.file.content.unavailable. An unchanged existing value does not block an unrelated edit.

ExecuteUpdate[Async] and ExecuteDelete[Async] over a MoltaroEntity or one of its owned table-row types produce analyzer error MOLTARO039: EF bulk operations bypass interceptors and therefore cannot participate in pre-commit validation, audit, presentation, or runtime side effects. Direct SQL that bypasses the generated MoltaroDbContext is unsupported.

Do not add, edit, or delete BoardItem rows through a generated context. Tracked direct writes are rejected at save time; ExecuteUpdate[Async] and ExecuteDelete[Async] over BoardItem produce analyzer error MOLTAROBOARD001. Use IBoardRuntimeCommandService so locking, Constraints, history, audit, and outbox behavior stay atomic. Raw SQL against Board runtime tables is outside the supported Package SDK contract.

Inside one explicit package transaction, call a Board runtime command before staging or saving any Entity or owned-table mutation. A direct Boards runtime service call throws BoardConstraintSaveRejectedException, and a Net Operation Project Board automation facade returns an error, with moltaroBoards.runtime.entityMutationBeforeBoardCommandUnsupported when this ordering is violated. The facade performs this check before opening its separate application scope, so it cannot wait on locks held by the calling transaction. Internal Moltaro orchestration may use configuration-prelocked paths that are not part of the Package SDK contract.

Board configuration is admin-owned. Tracked direct writes to Board, Statement, Constraint, Binding, Status, Transition, Target Definition, and related configuration rows are rejected at save time. Bulk writes over those types produce MOLTAROBOARD002. Use the Board Configuration API so advisory locking, DSL validation, active-board health checks, and cache invalidation are part of the same transaction.

Historical Board import and reconciliation do not replay transition, entry, or exit Constraints against the current actor or clock. Only the resulting status invariant governs that path, and it is enforced before commit.

Principal CRUD accepts caller-supplied stable string ids. Use an external source id as MoltaroCreateUserRequest.Id, MoltaroCreateRoleRequest.Id, or MoltaroCreateGroupRequest.Id, call the matching Get*Async, then choose Create*Async or Update*Async. These ids are the persisted Moltaro ids, not separate external keys. There is no separate synchronization facade, upsert result family, or preflight service.

The exact Unicode UserName is stored without trimming or rewriting; lookup uniqueness uses trim + Unicode NFC + invariant uppercase. User create/update can apply profile, type, email confirmation, blocked state, complete role membership, and metadata in one operation. MoltaroUpdateUserRequest.RoleIds is applied literally — an explicit list revokes every role missing from it, including Admin and Configurator, while null preserves the current memberships and Everyone is always retained. UserType is nullable and null keeps the stored type. Credential activation remains explicit. Role and group create/update use the same stable-id CRUD pattern, and built-in system role ids cannot be created, renamed, or deleted.

These services signal failure with ordinary .NET exceptions — ArgumentException for malformed input and InvalidOperationException for a rejected operation such as a duplicate id, a protected role, or an Owner change. They carry English messages and no stable code, so check state with the matching Get*Async before writing instead of parsing exception text. The stable ApiError codes documented for users and roles belong to the workspace HTTP API.

ITagService.ReplaceAsync uses the current execution actor for newly added tags. Scheduled automation is therefore attributed to moltaro-system-automation without creating a synthetic workspace user. Set SourceCreatedByUserId only when an import or restore preserves an existing workspace user as the historical author. An unknown source user throws MoltaroSdkException with the stable MoltaroTagErrorCodes.SourceCreatorNotFound reason code; function runs persist that value as FailureReasonCode and do not expose database constraint details. Any other effective actor must identify a real workspace user or the operation fails with MoltaroTagErrorCodes.ActorNotFound. Concurrent replacements for the same resource are serialized, retries are idempotent, and tag changes emit TagAdded, TagUpdated, or TagDeleted resource events in the same transaction. Moltaro uses those events to retain function origin metadata, update entity activity, and produce Board item audit/outbox side effects. Scheduled automation is authorized through the platform’s owner/admin system scope only while the runtime-owned tag mutation guard is active and the function invocation carries platform-issued trusted-system provenance. Actor strings from HTTP calls or persisted resource events cannot create that provenance. The actor id is reserved from workspace-user creation and by a database constraint, so supplying the string alone does not grant access. Upgrades reject a legacy user row with that id until the conflicting user is renamed or removed. OriginalUserId is attribution and does not grant access. Direct-db tag events retain original-user and correlation metadata as a non-function Moltaro.Tags source. MoltaroTag.CreatedByUserId can be null when reading legacy rows created before author attribution was required.

Entity and Board tag flags are deliberately separate. An EntityInstance owner uses EntityDefinition.TagsEnabled; a BoardItem owner uses Board.TagsEnabled. Ordinary access failures expose stable MoltaroTagErrorCodes for disabled Entity tags, disabled Board tags, terminal Board items that require historical reconciliation, access denial, unknown owners, and unsupported owner kinds. The workspace Tags HTTP API returns the same safe code and owner metadata in correlated Problem Details.

While a user has a current external-identity link, its identity-profile state remains provider-owned; blocked state, role membership, and ExtendedData remain mutable. DeleteUserAsync archives the current external-identity link before soft-deleting the user so that the external subject can be linked again.

User, role, and responsibility-group DTOs expose ExtendedData for small non-secret integration metadata. Keys should be source-namespaced, for example ventcontrol.source-id. A null request value preserves existing metadata; an empty dictionary clears it. The supported limit is 32 string entries, key length 128, and value length 2048. moltaro.* and moltaro:* keys are reserved. Extended data is read back by list/get calls, is not a permission grant, and must not contain credentials, tokens, secrets, or full source payloads.

The Board and Entitlement application services execute complete Moltaro application orchestration in a separate scope as moltaro-system-automation, while preserving original-user, function-run, and correlation metadata. Command DTOs do not accept ActorUserId. Entitlement command DTOs require a stable IdempotencyKey. AddBoardAutomationItemRequest requires a caller-owned UUID OperationKey: preserve it for an exact retry, which returns the original item without another write; changed-payload reuse conflicts. Other ordinary Boards commands mirror Runtime API concurrency and repeat semantics without a separate receipt. Rebuild existing consumers and pass the UUID as the first AddBoardAutomationItemRequest constructor argument; the older constructor without an operation key is no longer available. Historical Board migration is the exception: ImportHistoricalItemsAsync uses durable, case-sensitive per-Board source keys and GetHistoricalImportAsync resolves unknown outcomes. Batches contain 1–500 items, commit per item, return ordered partial results, and require the latest ExpectedRowVersion only when reconciling changed state. Exact replay returns Unchanged without additional writes. Reconciliation updates the existing Board Data record; its creation-time DataRecordId cannot be replaced. The separate ReconcileHistoricalItemDataAsync operation repairs only Subject/Description/Part values on terminal, non-removed, source-identified historical items. It requires exactly one BoardItemId or case-sensitive SourceKey locator plus ExpectedRowVersion; changed stale patches roll back, while an exact replay is write-free and returns the current token. Omitting a BoardHistoricalTextPatch preserves that text value and wrapping null clears it. The operation preserves Board lifecycle, target, history, and timestamps; ordinary closed-item UpdateDataAsync remains forbidden. A pre-existing Entity Definition Board item uses the explicit GetHistoricalRecoveryStateAsync then RecoverHistoricalItemAsync flow with the returned exact item id, row version, and recommended adoption or post-removal mode. Self-contained, Entitlement, and ambiguous multi-pass targets are rejected by recovery. These services are supported only in Action, TriggerHandler, Command, Job, and HttpEndpoint functions. Validation and BeforeSaveMutation receive the stable moltaro.automation.executionPhase.unsupported error.

ReconcileHistoricalItemTagsAsync is the terminal-only tag counterpart. Its 1-500 item batch commits per owner and returns ordered partial results with structured MoltaroRuntimeError code, field, target, and metadata. Every item uses exactly one Board item id or historical source key and a row-version guard. Add creates only missing names. ReplaceManagedSet changes only the explicit ManagedTagNames set, preserving all manual/unrelated tags; an empty desired tag list clears that managed set. Exact replay is write-free; lifecycle, Board Data, completion/history, and item row version are unchanged. Normal tag resource events carry the system actor and function/run/correlation origin into Board audit and outbox processing.

Ordinary SDK tag mutation rejection throws MoltaroSdkException with the same stable owner-specific ReasonCode/FailureCode, optional Field, and safe Metadata used by the HTTP and automation contracts. Callers should branch on the code and metadata, never on the diagnostic message.

The *.Runtime module services remain advanced trusted direct-DB APIs. They are useful when package-style code deliberately owns all application side effects, but they are not the default for triggers or jobs. See the C# runtime recipes and read ServiceKind/Preferred in the installation’s developer-surface response.

Advanced direct-DB code that uses ExecuteInTransactionIfNotExistsAsync receives one atomic boundary in both hosting modes. The helper opens and owns a transaction when none exists and acquires the shared dynamic-schema lock before invoking the action. When the caller already owns a transaction, it acquires the same lock before creating a nested savepoint; an exception or a result rejected by shouldCommit restores both persisted changes and the EF tracked state to that savepoint. The caller may then decide whether to commit or roll back its outer transaction.

Moltaro.Package.NET.Functions.ISecretService is available through constructor injection in Job, Command, Action, TriggerHandler, Validation, BeforeSaveMutation, and HttpEndpoint functions:

public sealed class SendToPartner(ISecretService secrets) : GlobalFunction
{
public override Task<FunctionResult> OnRunAsync(CancellationToken cancellationToken)
{
var token = secrets.GetRequired("integrations.partner.api-token");
// Use token only for the outbound call. Do not log or return it.
return Task.FromResult(FunctionResult.Ok("Partner call completed."));
}
}

GetRequired throws SecretUnavailableException; inspect Code against SecretUnavailableCodes.Missing, Disabled, and Retired. TryGet returns false with a null value for those states. Keys are case-insensitive, but null, empty, whitespace-padded, or null-character-containing keys throw ArgumentException.

The runtime captures one immutable snapshot before it creates the function. Rotation affects the next invocation, not repeated lookups in the current one. The service is a trusted-code boundary: any published trusted C# that knows a key can resolve it. Never place a value in arguments, results, DevLog, diagnostics, audit data, exports, or exception messages.

The canonical Managed secrets guide covers provisioning, lifecycle, audit, stable codes, safe token handling, agent rules, and a compile-checked Job example.

  • ValidationFunction<TEntity> — rejects an entity save with validation issues. Bound to save operations (create, update, archive, restore, delete) with [EntitySaveBinding(...)]. See entity-scoped logic.
  • MutationFunction<TEntity> — changes a record before create/update save, or runs after an operation when bound as a trigger with [EntityTrigger(...)]; covered on the same page.
  • GlobalFunction / GlobalFunction<TArgs> — reusable logic without an entity binding. See global logic.
  • CommandFunction<TResult> / CommandFunction<TArgs, TResult> — synchronous calls through the authenticated commands API. See commands and API functions.
  • GlobalActionFunction<TInput>, EntityInstanceActionFunction<TEntity, TInput>, EntitySelectionActionFunction<TEntity, TInput>, and EntityCollectionActionFunction<TEntity, TInput> — user-triggered UI actions with typed input. See actions.
  • HttpEndpointFunction / HttpEndpointFunction<TBody> — inbound HTTP endpoints served under /api/workspace/inbound/{endpointKey}, declared with [MoltaroHttpEndpoint]. See HTTP endpoints.

TEntity is the workspace entity class the function works with; argument, input, and result types are JSON-serializable records you define.

For generated workspace entities, a Date and time field is a nullable System.DateTimeOffset property backed by one PostgreSQL timestamp(6) with time zone column. SDK SaveChanges normalizes values to UTC and truncates precision beyond microseconds. The original numeric offset is not a persisted attribute of the field.

The SDK exposes the supported object-level Business Invariant contract under Moltaro.Package.NET.BusinessInvariants. It defines the generic BusinessInvariant<TEvaluationData> authoring base, the no-data BusinessInvariant base, canonical resource and actor inputs, operation-key constants, denial results, and the explicit IBusinessInvariantValidator contract for function code.

Build analysis validates stable keys, typed targets, exact/category operation scopes and exceptions, declared denial messages, parameter schema, supported dependencies, and pure Grant code, including side effects reached through source-defined helper methods. Package and Net Operation Project publication produce equivalent deterministic descriptors and resolve embedded default text without creating configuration.

Discovery alone does not enable an invariant; administrators own target configuration. Enabled compatible targets shape current object-operation decisions and automatically guard admitted Entity mutation owners. Function execution is not intercepted automatically. Inject IBusinessInvariantValidator, validate the exact existing resource and function.{BusinessFunction.Key} with empty parameters, and stop before side effects when the ordinary ValidationResult is invalid.

When an owning runtime adapter returns an invariant denial, its public API error keeps the exact operation key in Target and the stable invariant source key in Metadata.SourceKey.

Use the published constants instead of private operation strings. Board cycle assignment and rollover both use BoardItemObjectOperationKeys.AssignCycle; rollover validates every affected open item before moving any of them.

Every function class carries [MoltaroFunction(id)]. The id is the durable function id; an optional Key overrides the callable key (by default the id is the key). Name and Description are shown to administrators and configurators — set Description on any published function so its purpose is clear in the Function Catalog. EnabledByDefault controls whether the function starts enabled when it is first applied.

Publishing commands and enqueueable functions

Section titled “Publishing commands and enqueueable functions”

[MoltaroCommand] publishes a command function at POST /api/workspace/commands/{functionKey}. [MoltaroApiEnqueue] publishes a Job-contract global function for asynchronous enqueue at POST /api/workspace/functions/{functionKey}/enqueue; callers with access can also observe and cancel the resulting queued job.

Both attributes are fail-closed: exactly one of PermissionKey or AllowAnyAuthenticatedUser = true must be configured; a publication without an access declaration is rejected at build and apply time, and anonymous invocation is never supported. A permission key is a lowercase dotted identifier such as acme.commands.recalculate — the segment after the last dot becomes the catalog code, the leading segments the catalog group, so administrators can assign it to roles.

MoltaroCommand.TimeoutSeconds optionally overrides the synchronous timeout, from 1 through 86400 seconds. When unset, the runtime action timeout setting applies — and the effective timeout never exceeds that installation setting.

MoltaroApiEnqueue.TimeoutSeconds optionally declares the worker timeout for the queued job, also from 1 through 86400 seconds. Moltaro captures the value when the API request creates the job, so every retry uses the same timeout even if the publication or runtime defaults change. When unset, the runtime Job timeout setting applies.

[MoltaroFunction("recalculate-credit", Name = "Recalculate credit",
Description = "Recomputes the customer credit snapshot.")]
[MoltaroCommand(PermissionKey = "acme.commands.recalculate",
TimeoutSeconds = 120)]
public sealed class RecalculateCredit : CommandFunction<RecalculateResult>
{
public override async Task<CommandResult<RecalculateResult>> OnRunAsync(
CancellationToken cancellationToken)
{
// ... business logic ...
return CommandResult<RecalculateResult>.Ok(result);
}
}

A message passed to CommandResult.Failed(...) is returned to the caller as user-facing text: keep it actionable and free of secrets or internal diagnostics. Use CommandResult.ValidationFailed(...) to reject an invocation with field-level validation issues instead of a generic failure.

For choosing between C# and declarative logic, project layout, and the build and apply workflow, continue with C# business logic.