Skip to content

Uow aggregate endpoint - #19919

Draft
vxkc wants to merge 10 commits into
mainfrom
uow-aggregate-endpoint
Draft

Uow aggregate endpoint#19919
vxkc wants to merge 10 commits into
mainfrom
uow-aggregate-endpoint

Conversation

@vxkc

@vxkc vxkc commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Related to #18110.

Description

Verification

  • Related issues are connected (if applicable)
  • Your code builds clean without any errors or warnings
  • Manual testing done (required)
  • Relevant automated test added (if you find this hard, leave it and we'll help out)

vxkc added 9 commits August 10, 2026 13:36
Thread instance, process-state, and blob version metadata from Storage responses into unit-of-work
state and conditional write preconditions, and commit staged changes through the aggregate
instance-mutation endpoint. Carry aggregate mutation and conflict handling through API controllers,
workflow and process execution, PDF, Fiks, EFormidling, and test plumbing. InstanceClient and
DataClient directly implement the internal metadata and mutation views.

Resolve one authentication method per aggregate save, preserving each call-site default when no data
method contributes and a lone method unchanged. Union distinct ServiceOwner additional scopes in
ordinal order, and reject CurrentUser/ServiceOwner or unsafe Custom mixtures before Storage I/O while
allowing Delegate.Equals-equivalent Custom providers. Track StorageVersionMetadata as an immutable
authoritative snapshot without lock, merge, wrapper, or restore escape hatches; replace it after
successful aggregate saves and authoritative replay rebuilds.

Keep workflows persisted by the prior callback protocol resumable: accept the exact main-era
discriminatorless process-state payload on MutateProcessState and the legacy SaveProcessStateToStorage
key, retain bounded support for the registered legacy discriminator, derive lock/unlock payloads only
for main-era null payloads, reject malformed non-null command payloads permanently, and hydrate
versionless callback states with an exact ServiceOwner metadata read before the controller-owned
single aggregate save.

Evict stale-by-version data caches after aggregate saves so a subsequent read never serves content the
save invalidated.

Replace the app instance lock with durable process status. Storage carries an idle/processing status
inside the instance process object, and every aggregate mutation states the status it expects.
AcquireProcessingStatus is command index 0 of every chain-initiating workflow and stages expected idle
to new processing before any other side effect; engine-owned dependent workflows inherit processing.
CommitProcessState normally clears back to idle, but keeps processing when the sequence appends
ExecuteServiceTask so the target service task is durable before its side effects run. Process-end
cleanup folds into the terminal CommitProcessState aggregate, replacing DeleteDataElementsIfConfigured
and DeleteInstanceIfConfigured. Each chain is fenced on one authoritative snapshot, a lost acquire is
classified as its own failure kind, user-facing mutations answer 409 instance-processing while an
instance is processing, and GETs stay readable through a transition. InstanceLocker, IInstanceLock,
InstanceLockClient and its telemetry, the Altinn-Storage-Lock-Token plumbing, and the lock token on
AppWorkflowContext, AppCallbackPayload, and the engine wire contract are removed.

A service task releases processing ownership only where it genuinely pauses: when the pipeline's
Finally concludes without auto-advancing, which includes the null a legacy implementation can still
return. A completed stage keeps it, because the pipeline has further engine steps to run, and a
deferral keeps it because a deferral is stateless — staging the transition would make the callback
fail on the controller's no-data-changes guard, and a parked chain is still mid-transition.

Require StepId on the callback payload. The engine's step id is what this revision sends Storage as
the aggregate mutation's idempotency key, and it was deliberately optional only to tolerate an engine
that predates the field; while both sides are pre-release, an absent id should fail the callback
rather than default to Guid.Empty, which would be one key shared by every step of every instance.

Rename DataElement.ContentEtag to BlobVersionId so the app models the raw blob version id Storage
carries on the wire rather than a quoted HTTP etag.

Vendor the Storage.Interface source under src/_vendor so the backend can compile against the approved
blob-version-id and process-status contracts before a corresponding Altinn.Platform.Storage.Interface
package is released, and block packing the app packages while that vendored copy exists. Delete the
directory and restore the released package reference once the package ships; see its README for the
exact steps. LocalTest support and LocalTest parity land separately, so the LocalTest-backed
integration tests are expected red and are not run here.

The independent parts of this work — the service-task execution reference time, the Fiks Arkiv
major-version simplification, the data-accessor threading for PDF and eFormidling, and the LF and
Docker-context hygiene — are below this revision in the stack. The retry-stable idempotency key
those parts once carried is the workflow engine's own StepId, which main now supplies on every
callback.
Copy the Storage.Interface sources from altinn-storage change lnlnxrxumkorxorksormotltoxtvkqwv, which
types the process status as a ProcessStatus enum instead of a string, and adjust the app to it. The
wire spelling is unchanged, so this is a compile-time change only: the enum still serializes as "idle"
and "processing", and the OpenAPI documents process.status as that closed string enum.

The copy is a full sync of Models and Enums rather than a targeted patch, so it also drops the vendored
InstanceMutationRequest members upstream no longer has -- newProcessStatus and the update-operation
deleteStatus. Neither reaches the wire: the app builds its own StorageInstanceMutationRequest, and a
test already pins that newProcessStatus is never sent.

TransitionProcessStatus loses its argument range guards, since the enum parameters make an
out-of-range status unrepresentable, and their test goes with them. The blocking-status flow through
ProcessStatusHelper, ProcessChangeResult and ProcessStatusProblemResult is typed the same way; the
problem's machine-readable processStatus extension still serializes to the wire spelling, while its
human-readable detail now names the enum member.

FakeWorkflowEngineClient had a duplicated createdAt local and StartAt member that made
Altinn.App.Api.Tests uncompilable; removed so the test project can build.
… review

Copy the Storage.Interface sources from altinn-storage change rmoxyzkoxxrsnlpyvnzmvtwkwmpozkzr, which
acts on review of the process-status enum, and follow it in the app.

ProcessStatus moved to Interface.Enums, so the using follows it wherever the app names the enum. Both
serializers now reject the numeric form of the status, which the app never sent, so nothing changes on
the wire.

Storage's 409 body carries the wire spelling again rather than the enum member name, and the app's own
status messages follow it: the instance-processing problem detail, the unit-of-work guard, and the
Storage fake that mirrors the platform's conflict response. The problem's machine-readable
processStatus extension was already the wire spelling, so detail and extension agree again, and its
assertion pins that spelling rather than round-tripping it through the converter that produced it.

Correcting the parent: it says the enum parameters make an out-of-range status unrepresentable, which
is the same mistake the storage side had to walk back -- (ProcessStatus)99 is representable, and the
argument range guards TransitionProcessStatus lost were what kept one out of serialization. The risk
is small enough to accept, since the method is internal and every caller passes a literal, but the
reason given for dropping the guards was wrong.
feat: commit complete confirmations through the aggregate mutation endpoint

The eFormidling task's Delivered branch confirmed completion with a direct POST .../complete -- the last
Storage write left outside the unit of work. Every write bumps the instance version, so that call moved
Storage past the version the callback's own workflow-owned save was fencing against: 412, then
InstanceDataStaleException, then a non-retryable 422, and the process never left Task_Send_EFormidling.
Staging the confirmation puts it in the same version-fenced mutation as the shipment status, which is
what the callback saves; ProcessNext_EFormidlingTask_WaitsForDelivery_AndSendsTheShipmentOnce reaches
EndEvent_1 again.

Staging a confirmation the instance already carries is a no-op. Storage records the confirmation for the
calling organisation, which for an app is the instance's own org, and it is idempotent in outcome but not
version-neutral: asking again would bump the instance version for a write that changes nothing, and
invalidate every fence held elsewhere with it. Deciding that here is safe because it only ever suppresses
a request -- a confirmation added after this snapshot was captured is still sent, and Storage settles it.

LocalTest mirrors Storage's operation, since apps must behave the same locally: the same InstanceComplete
escalation, the same organisation-scoped stakeholder, only the added confirmation on the instance update,
and the same division of labour -- its controller does not filter on the snapshot, and its repository
skips a stakeholder that already has a confirmation while holding the instance lock, where
mergeinstanceupdate does in production storage. LocalTest has no controller-test harness, so the
controller wiring is covered by Storage's tests for the same logic and by the integration test end to
end; the repository's skip has a test of its own.

A staged confirmation refuses to commit under anything but service owner authentication. Storage records
it for the calling organisation, and a data type demanding another authentication method resolves the
whole aggregate to that method -- which would confirm as the wrong stakeholder, or as none at all. Like
the other workflow-owned staged mutations, it is also rejected on a user-facing save.

EFormidlingServiceTask holds no IInstanceClient any more: nothing in it writes to Storage directly, and
the delivery tests assert the staged confirmation instead of a client call.
IDE0005: AcquireProcessingStatus and ProcessStatusProblemResult resolve ProcessStatus from
Interface.Enums and reference nothing in Interface.Models; DataController is the other way round --
its only remaining "ProcessStatus" is a log message template.

CS8600: VersionPreconditionHelper.TryParse returns (VersionPreconditions, ActionResult?), and
LocalTest's ProcessController deconstructed the error into a non-nullable ActionResult at both call
sites. It is the only storage controller the project's nullable context reaches; the eleven
identical deconstructions in the other controllers sit in "#nullable disable" files and warn about
nothing, so they are left as they are.
LocalTest stamped LastChanged/LastChangedBy on every data element update, which is what
applyinstancemutation did before it learned to stamp content updates only. Gate it on the same signal
production uses: /currentBlobVersion in the property list is what becomes the SQL's newblobversion, and
only a content update carries it.

Without this an app behaves differently locally than in the cloud in a way that is easy to miss and hard
to explain: ending a task would rewrite every locked element's LastChangedBy to the app's own
ServiceOwner identity, where real Storage leaves the content author in place.
…ion is in flight

The test asserted type "instance-processing" plus a processStatus extension and failed with
KeyNotFoundException on type. Neither is on the response. ProcessEngine.ProcessNext evaluates
GetCurrentTaskWorkflowState before the blocking-status check, and an in-flight transition leaves an
active collection head, so the request returns the Retrying conflict -- 409, title "Task is still being
processed.", processNextState "retrying" -- and never reaches CreateProcessStatusBlockedResult.

That ordering is deliberate rather than something to patch here. ProcessEngineTest pins both sides:
Next_blocks_when_current_task_workflow_is_retrying with an active head and a processing status, and
Next_blocks_non_idle_process_status_after_workflow_recovery_check without one. It is also the ordering
the frontend depends on -- useProcessNext branches on processNextState, swallowing "retrying" to show
the advancing screen and treating "resumeRequired" as a terminal failure. Hoisting the status guard
would collapse both into instance-processing, because a terminally failed workflow also leaves the
status non-idle, and the user would get an error page where the transition is merely still running.

instance-processing is therefore unobservable on this endpoint while a workflow is live; its surface is
the data and action endpoints, where DataElementAccessChecker, ActionsController, InstancesController
and PaymentController all emit it. Its absence is asserted so the next reader does not restore the
expectation.

No coverage is lost: the durable processing status is still asserted on the GET above the competing
call, and taskEndInvocations still proves the second transition never ran. Renamed because
"WhileAuthoritativeStatusIsProcessing" named a guard this endpoint cannot reach.
…hots

Every write mints a fresh blob version -- LocalTest through BlobVersionId.Encode(Guid.NewGuid()),
production through Azure -- and nothing scrubbed it, so it reached the snapshot verbatim twice: as
BlobVersionId and inside the versioned blobStoragePath. AutoVerify(includeBuildServer: false) rewrote
those files silently on every local run while CI, where it is off, failed them. InstanceStringScrubber
now replaces the value alongside the data element id.

Two verifications reused a scrubber built from the instantiation instance for a snapshot taken after a
patch had minted a new version, so the value never matched and the scrub was a no-op: BasicAppTests'
PatchFormData (16 files) and WorkflowEngineFailureTests' InstanceAfterFailure. Both now build the
scrubber from the instance in the response they verify. The failure mode is silent -- the snapshot just
keeps a volatile value -- so a shape-based fallback, matching the token wherever it appears rather than
depending on which instance the scrubber came from, would make this class of mistake impossible at the
cost of the per-element index.

The re-baselined content also carries what the stack changed and the snapshots had never been
regenerated for. Process.Status is on the wire (73 files). ReadStatus is the Unread default that Verify
omits rather than UpdatedSinceLastReview (13). A data element's post-save Size and LastChanged now reach
the response for the write that made them instead of the creation-time values (16 and 34), from the
unit-of-work stale-cache eviction. LastChangedBy moves the other way, from the app's own ServiceOwner
identity back to the party that actually wrote the content, which is the content-only stamp gate in
applyinstancemutation and its LocalTest mirror -- the localtest image had to be rebuilt from the
checkout before these snapshots could see it.

Generated from a full suite run (70 passed, 1 skipped, 0 failed) and confirmed stable: a repeat run over
BasicAppTests, WorkflowEngineFailureTests and CustomScopesTests rewrote nothing, and no raw
22-character blob token remains in any snapshot in the project.
@vxkc
vxkc requested a review from a team as a code owner August 11, 2026 15:18
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 60ec39e9-39a7-4d0c-a30d-ea20aa455125

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the skip-releasenotes Issues that do not make sense to list in our release notes label Aug 11, 2026
@vxkc
vxkc marked this pull request as draft August 11, 2026 15:19
@vxkc
vxkc removed the request for review from a team August 11, 2026 15:19
@vxkc
vxkc deployed to runtime_at_ring2 August 11, 2026 18:53 — with GitHub Actions Active
@vxkc
vxkc deployed to runtime_at_ring2 August 12, 2026 10:18 — with GitHub Actions Active
…on plan

Storage only grants ignoreLock to a metadata-only update that unlocks, so an update
that re-locks an already locked data element is rejected and fails the whole
aggregate with 409 "Data element ... is locked and cannot be updated or deleted".

Every signing flow produced exactly that request: storage's sign endpoint creates
the signature document already locked, and LockTaskData then stages a lock for
every data type bound to the task, including the signature data type. The
resulting lock-only update was a no-op that could only fail. The legacy data lock
endpoint short-circuits an already locked element, so this only became reachable
when lock mutation moved into the aggregate save.

The unit of work now drops a lock-only update whose target value already matches
the instance snapshot. The workflow-owned save carries both version preconditions
and any lock change bumps the instance version, so a snapshot that disagrees with
storage fails the save on the precondition instead of silently skipping a lock
that was still needed. When nothing else is staged the plan is empty and the save
is skipped without a storage round trip.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip-releasenotes Issues that do not make sense to list in our release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant