Finalize is the module’s key trust transition: it turns “the client says an object was uploaded” into “a business module may bind and download this asset.” The current implementation compares size, MIME, and a hash, but the authority of MIME and hash depends on the provider. Finalized is therefore not synonymous with security-verified content.
1. Request contract
POST /api/files/019c64e67fc87db8b7ca748f5442f91a/finalize HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9Content-Type: application/json
{ "contentHash": "72f7d0f8c6d6c0b51f5f3e4fa76c76bc9f70a10fb149c010eb2f2e330df8f123", "size": 48231, "contentType": "application/pdf"}The generated endpoint maps the route FileId into the command. The message uses Update authorization and the FileHandshake timeout policy. That bounds the API request; it does not add retry, idempotency, or storage rollback.
2. Current execution path
The object was written before this path starts. A database transaction may roll back asset state and the event record, but it cannot automatically roll back the object bytes.
3. Metadata authority matrix
The handler’s actual merge rule is:
// Client values begin as fallback values.var verifiedHash = request.ContentHash;var verifiedSize = request.Size;var verifiedContentType = request.ContentType;
var metadata = await storage.GetMetadataAsync(asset.StorageKey, cancellationToken);
// Length always comes from the provider; nullable facts fall back to the client.verifiedSize = metadata.ContentLength;verifiedContentType = metadata.ContentType ?? request.ContentType;verifiedHash = metadata.Sha256 ?? request.ContentHash;var verifiedETag = metadata.ETag;| Provider | Size | ContentType | SHA-256 | ETag | Effective risk |
|---|---|---|---|---|---|
| Local | file-system length | null | null | null | Client supplies type/hash; HTTP presign is unavailable |
| S3-compatible | HEAD ContentLength | HEAD ContentType | x-amz-meta-sha256 only | HEAD ETag | Uploader can supply user metadata; absent SHA falls back |
| Connector bridge | stream Length | null | null | null | Type and hash fully fall back to the client |
| Unavailable | unsupported | unavailable | unavailable | unavailable | Handler returns stable failure |
ETag is not SHA-256, especially for multipart objects. The S3 adapter correctly avoids that equivalence.
4. Aggregate validation
FileAsset.ValidateObservedMetadata performs these checks in order:
- observed hash and content type must be non-empty;
- hash is compared only when the existing ContentHash is non-empty; PendingUpload begins empty;
- observed size must exactly equal the size declared during session creation;
- content type uses case-insensitive string equality;
- state, ContentHash, ETag, and FinalizedAt change only after all checks pass.
This prevents a failed comparison from partially advancing the aggregate. It does not prove that the observed hash was computed by a trusted party.
5. State-transition detail
Aggregate Finalize advances PendingUpload twice: it marks Uploaded, then Finalized. Calling FileAssetState.Finalize() directly on PendingUpload returns File.NotYetUploaded. That difference between the public state type and aggregate contract needs explicit tests.
6. Duplicate requests and concurrency
A second finalize returns File.AlreadyFinalized; it does not replay the first result. The message has no idempotency key, version column, or explicit optimistic-concurrency token.
var response = await api.PostAsJsonAsync( $"/api/files/{fileId}/finalize", new { contentHash, size, contentType }, cancellationToken);
if (response.IsSuccessStatusCode) return await response.Content.ReadFromJsonAsync<FileAssetSummaryDto>(cancellationToken);
var problem = await response.Content.ReadFromJsonAsync<ProblemDetailsDto>(cancellationToken);
// AlreadyFinalized says database state is complete. Read the authorized summary// if the client must recover the first result; do not blindly repeat the mutation.if (problem?.Code == "File.AlreadyFinalized") return await fileAssets.GetAuthorizedSummaryAsync(fileId, cancellationToken);
throw new FileFinalizeException(problem?.Code ?? "File.Unknown");If two requests read Pending concurrently, correctness depends on repository/transaction conflict behavior. Current state tests are sequential; there is no real-database concurrent-finalize contract.
7. Error semantics
| Error | Trigger | State advances? |
|---|---|---|
File.NotFound / repository error | No visible asset in the tenant | No |
File.MetadataVerificationUnavailable | Provider cannot return metadata | No |
File.ObjectNotFound | Provider reports missing object | No |
File.ObservedMetadataRequired | Resolved hash/type is blank | No |
File.HashMismatch | Existing and observed hashes differ | No |
File.SizeMismatch | Object length differs from declaration | No |
File.ContentTypeMismatch | Object type differs from declaration | No |
File.AlreadyFinalized | Duplicate finalize | Remains Finalized |
File.AlreadyDeleted | Finalize after deletion | Remains Deleted |
The shared Result mapper creates Problem Details. Consumers should branch on the stable Code, never localized message text.
8. The real UploadExpiresAt boundary
Create persists clock.UtcNow.AddMinutes(30), but Finalize never reads UploadExpiresAt. An asset may finalize after its database session expiry if the bytes were written or the storage URL remains valid.
public static class FileErrors{ public static readonly Error UploadSessionExpired = Error.Conflict( FileErrorCodes.UploadSessionExpired, "The upload session has expired; create a new session.");}
// This is a recommended domain gate, not current source behavior.if (asset.UploadExpiresAt is not null && clock.UtcNow >= asset.UploadExpiresAt){ return Result.Failure<FileAssetSummary>(FileErrors.UploadSessionExpired);}The object-storage expiry and database value also need one source of truth. The connector provider cannot currently accept a request-level TTL.
9. Target scanning and isolation flow
The current state machine has no Quarantined, Scanning, or Rejected state. An asynchronous scanner cannot coexist safely with early Finalized state because business modules may then download unreviewed bytes.
10. Events and transactions
After repository.SaveAsync, the handler calls fileFinalizedPublisher.PublishAsync(asset.ToSummary()). With a correctly configured CAP Outbox, the database write and event record participate in the transaction pipeline. The external object does not.
| Failure point | Database | Object | Event | Recovery |
|---|---|---|---|---|
| Metadata read fails | Pending | May exist | None | Repair storage and retry |
| Aggregate rejects metadata | Pending | Exists but mismatched | None | Quarantine/delete object |
| Save fails | Rolled back | Exists | None | Idempotent retry or orphan cleanup |
| Publish fails in transaction | Rolled back | Exists | None | Outbox/command retry |
| Process dies after commit | Finalized | Exists | Outbox-dependent | Outbox redelivery |
11. Target regression test
[Fact]public async Task Finalize_should_not_trust_client_hash_when_provider_has_no_sha256(){ // Return trusted length/type but deliberately omit a provider-computed SHA-256. storage.GetMetadataAsync(asset.StorageKey, Arg.Any<CancellationToken>()) .Returns(new FileObjectMetadata( asset.StorageKey, ContentLength: asset.Size, ContentType: asset.ContentType, ETag: "etag-1", Sha256: null, LastModified: clock.UtcNow));
var result = await handler.Handle( new FinalizeUploadCommand(asset.FileId, "client-controlled", asset.Size, asset.ContentType), CancellationToken.None);
// Target behavior: fail closed instead of falling back to the request hash. result.IsFailure.ShouldBeTrue(); result.Error.Code.ShouldBe(FileErrorCodes.MetadataVerificationUnavailable);}This test describes a production target. Current code accepts the client hash, so the contract and implementation must change first.
12. Source and verification
# Run current state-machine and handler tests.dotnet test tests/BitzOrcas.Unit.Tests \ --filter FullyQualifiedName~FileAssetFinalizeTestsdotnet test tests/BitzOrcas.Application.Tests \ --filter FullyQualifiedName~FinalizeUpload
# Prove hash fallback and the unconsumed expiry field.rg -n "metadata.Sha256 \?\?|UploadExpiresAt" src/Platform/Files -g '*.cs'
# Search for scanning and quarantine states; expect no current hit.rg -n "Malware|Virus|Quarantine|Scanning|Rejected" src/Platform/Files -g '*.cs'