Skip to content

Specification: Backend Services for Item Image Upload

Author: Claude Code for jmpicnic | Date: 2026-03-31 | Status: Draft

Specification: Backend Services for Item Image Upload

Section titled “Specification: Backend Services for Item Image Upload”

Implement the Kotlin backend services for image upload across two repositories: common-module (shared S3 library with CSV refactoring) and operations (REST endpoint, entity validation, infrastructure wiring). This specification is the driving document for the implementing engineer.

  • Goal: goal.md — scope, constraints, deliverables, success criteria
  • Analysis: analysis.md — gap analysis, extractable patterns, priorities
  • Requirements: requirements.md — REQ-BE-001 through REQ-BE-022
  • Verification: verification.md — traceability matrix
  • Backend Specification: backend-specification.md — interface contracts, module design
  • Decision Log: decision-log.md — TD-01 through TD-21
  • Project type: Modification
  • Worktrees: projects/image-upload-backend-worktrees/{common-module,operations,documentation}
  • Branches: jmpicnic/item-image-upload-backend (all repos)

  1. New classes in common-module: AssetKeyGenerator, CdnUrlResolver, S3AssetService
  2. Refactor CsvS3BucketDirectAccess to use shared S3AssetService capabilities
  3. New ImageUploadEndpoint in operations
  4. ItemService modification for imageUrl validation with TD-15 grandfathering
  5. CloudFormation, Helm, and Module.kt infrastructure wiring
  6. Unit and integration tests for all new and modified code
  • BFF routes and cookie signing (Phase 3)
  • SPA components (Phase 4)
  • API tests in api-test (Phase 5)
  • Phase 2c live verification (deployment activity — REQ-BE-NFR-001 P95 latency is verified there, not in this specification)
  • S4 (View Image) — no backend involvement; handled entirely by CDN and SPA
  • Protobuf-related CSV refactoring in operations
  • S3 orphaned upload cleanup (NFR-012)

  • Follow Kotlin Coding Standards (skill: kotlin-coding): Result<T> returns, AppError hierarchy, suspend functions, Exposed conventions.
  • Follow Backend Testing (skills: unit-tests-backend, unit-tests-infra): Kotest StringSpec, MockAWS for S3, Harness pattern for endpoint tests.
  • Build commands: make clean build runs the clean target followed by the build target (skill: build-core-modules).
  • Use git -C <path> instead of cd. Use absolute paths for tool calls.
  • Do not commit settings.gradle.kts changes (Gradle includeBuild). See Multi-Repo Development for composite build setup.
  • Record design decisions in the project decision log continuing from TD-21.
  • Each task produces its tests before the next task starts.

#TaskPhaseDepends OnRepositoryAcceptance Criteria
T-1Baseline verification0bothAll existing tests pass
T-2AssetKeyGenerator + tests2aT-1common-moduleAssetKeyGeneratorTest passes
T-3CdnUrlResolver + tests2aT-1common-moduleCdnUrlResolverTest passes
T-4S3AssetService + tests2aT-2, T-3common-moduleS3AssetServiceTest passes (MockAWS)
T-5Refactor CsvS3BucketDirectAccess2aT-4common-moduleExisting CSV tests pass, make clean build
T-6CloudFormation + Helm + Module wiring2bT-4operationsConfig loads, Helm lint passes
T-7ImageUploadEndpoint + tests2bT-4, T-6operationsImageUploadEndpointTest passes
T-8ItemValidator imageUrl validation + tests2bT-4, T-6operationsValidation tests pass (CDN, non-CDN, null, grandfather)
T-9Build verification + CHANGELOGReleaseT-5, T-7, T-8bothmake clean build both repos, CHANGELOGs updated

Before any code changes, establish a known-good baseline.

common-module (worktree: projects/image-upload-backend-worktrees/common-module):

Terminal window
make -C <worktree-path> clean build

Verify all tests pass, especially CsvS3DirectAccessTest and S3BucketAccessTest.

operations (worktree: projects/image-upload-backend-worktrees/operations):

Terminal window
make -C <worktree-path> clean build

Verify all tests pass, especially ItemEndpointTest, ItemServiceValidationTest, and ItemInputModelTest.

Record test counts and coverage baselines.

Do not proceed if any existing tests fail. Report failures before continuing.


All files in package cards.arda.common.lib.infra.storage. Source root: lib/src/main/kotlin/cards/arda/common/lib/infra/storage/. Test root: lib/src/test/kotlin/cards/arda/common/lib/infra/storage/.

File: AssetKeyGenerator.kt (new)

Constructs tenant-scoped S3 keys. Configurable feature namespace validated at construction time (TD-19). Tenant ID retrieved from ApplicationContext coroutine context (TD-20).

Interface:

class AssetKeyGenerator(
private val featureNamespace: String // e.g., "images"
) {
init {
// Fail fast: validate featureNamespace is a valid URL path segment
require(/* URL path segment compatible */) { "..." }
}
suspend fun generate(contentType: String): Result<AssetKey>
fun extensionFor(contentType: String): Result<String>
}
data class AssetKey(
val objectKey: String, // <tenantId>/<namespace>/<uuid>.<ext>
val uuid: UUID, // generated UUID
val extension: String // jpg, png, webp, heic, heif
)

Behavior:

  • Constructor validates featureNamespace as URL path segment compatible. Fails fast with IllegalArgumentException if invalid.
  • generate(): Retrieves tenantId from ApplicationContext. UUID via UUID.randomUUID(). Extension from extensionFor(). Key format: ${tenantId}/${featureNamespace}/${uuid}.${ext}.
  • extensionFor(): Maps content type to extension per TD-16. Returns Result.failure(AppError.ArgumentValidation) for unsupported types.

Tests (AssetKeyGeneratorTest.kt):

  • Construction with valid namespace succeeds
  • Construction with invalid namespace (e.g., empty, contains /, special chars) fails
  • Key matches format <tenantId>/<namespace>/<uuid>.<ext>
  • Tenant ID is the first path segment
  • UUID is unique across calls
  • Each supported content type maps to correct extension
  • Unsupported content type returns failure

Acceptance: AssetKeyGeneratorTest passes. REQ-BE-002.

File: CdnUrlResolver.kt (new)

Constructs and validates CDN URLs. Tenant ID retrieved from ApplicationContext (TD-20) for cross-tenant validation.

Interface:

class CdnUrlResolver(private val cdnHost: String) {
fun resolve(objectKey: String): URL
suspend fun validate(url: String): Result<URL>
}

Behavior:

  • resolve(objectKey): Constructs https://${cdnHost}/${objectKey}.
  • validate(url):
    1. Retrieve tenant ID from ApplicationContext (TD-20).
    2. Parse URL. Fail if malformed.
    3. Check host matches cdnHost. Fail if not.
    4. Check path starts with /${tenantId}/. Fail if cross-tenant.
    5. Check path matches /<tenantId:UUID>/<namespace>/<uuid>.<ext> pattern. Fail if unexpected format.
    6. Return parsed URL on success. Returns Result.failure(AppError.ArgumentValidation) for all failures with descriptive message.

Tests (CdnUrlResolverTest.kt):

  • resolve() produces correct URL from object key
  • validate() accepts valid CDN URL for correct tenant (via mock ApplicationContext)
  • validate() rejects URL with wrong host
  • validate() rejects URL with different tenant prefix
  • validate() rejects URL with unexpected path format
  • validate() rejects malformed URL
  • validate() handles null-equivalent (empty string) gracefully

Acceptance: CdnUrlResolverTest passes. REQ-BE-003, REQ-BE-007, REQ-BE-008.

File: S3AssetService.kt (new)

Core S3 management class supporting both presigned PUT and presigned POST, HEAD verification, and metadata validation (TD-17). Manages role assumption internally: default credentials for PUT, assumed presigning role for POST (TD-17). This is the heaviest new class — it interacts with the AWS SDK.

Interface:

class S3AssetService(
private val bucket: String,
private val region: Region,
private val presignRoleArn: String,
private val credentialsProvider: AwsCredentialsProvider = DefaultCredentialsProvider.builder().build(),
private val signatureDuration: Duration = Duration.ofMinutes(15),
private val maxFileSize: Long = 10 * 1024 * 1024 // 10 MB default, configurable
) {
fun presignedPutUrl(
objectKey: String,
contentType: String,
metadata: Map<String, String> = emptyMap()
): Result<URL>
suspend fun createPresignedPost(
objectKey: String,
contentType: String,
contentLength: Long,
metadata: Map<String, String>
): Result<PresignedPostResult>
suspend fun headObject(
objectKey: String,
requiredMetadata: Map<String, String> = emptyMap()
): Result<Map<String, String>>
}
data class PresignedPostResult(
val uploadUrl: String,
val formFields: Map<String, String>
)

Behavior:

  • presignedPutUrl(): Generates presigned PUT URL using default credentials. Content type and metadata set as signed headers. This replaces the presigning logic currently in CsvS3BucketDirectAccess (TD-17, TD-18).
  • createPresignedPost():
    1. Assume presigning role via STS AssumeRole (using presignRoleArn).
    2. Build presigned POST with policy conditions per TD-16.
    3. Return upload URL and form fields. Returns Result.failure(AppError.ExternalService) on AWS SDK errors.
  • headObject():
    1. Issue HEAD request to bucket/objectKey.
    2. If requiredMetadata is non-empty, validate each key-value pair against the object’s metadata.
    3. Return object metadata on success. Returns Result.failure(AppError.ExternalService) if object not found. Returns Result.failure(AppError.IncompatibleState) if metadata mismatch.

Design notes:

  • Role assumption is internal (TD-17): default creds for PUT, assumed role for POST. Future IAM consolidation is isolated to this class.
  • Max file size is configurable via constructor (OQ-4 resolved).
  • AppError mapping (existing in common-module HttpResponses.kt): AppError.ExternalService → 500, AppError.IncompatibleState → 500, AppError.ArgumentValidation → 400. When headObject() returns IncompatibleState (metadata mismatch) and the caller intends a 400 response, the caller must catch and re-wrap as ArgumentValidation.

Pre-implementation verification: Before coding, verify that S3Presigner supports presigned POST in the AWS SDK version pinned by common-module’s libs.versions.toml (currently 2.34.3). Check for presignPostObject() or equivalent. If unavailable, construct the POST policy document manually and sign it. Record API availability as a decision.

Tests (S3AssetServiceTest.kt):

  • Presigned PUT returns valid URL with expected path and signed headers
  • Presigned POST returns non-empty uploadUrl and formFields
  • POST form fields include all 7 policy condition keys
  • headObject() succeeds for existing object
  • headObject() fails for missing object
  • headObject() with metadata validation succeeds when metadata matches
  • headObject() with metadata validation fails when metadata mismatches
  • Test infrastructure: MockAWS with S3 and STS services enabled. Verify LocalStack supports presigned POST; if not, test form field presence only and defer full policy validation to Phase 2c live verification.

Acceptance: S3AssetServiceTest passes. REQ-BE-001, REQ-BE-005, REQ-BE-006, REQ-BE-009, REQ-BE-020.

File: CsvS3ObjectDirectService.kt (modify)

Aggressive delegation (TD-18): CsvS3BucketDirectAccess receives S3AssetService as a constructor dependency and delegates all S3 primitives. Only CSV-specific code remains local.

Changes:

  1. Add S3AssetService as a constructor parameter to CsvS3BucketDirectAccess.Impl.
  2. Remove local S3Presigner instance — delegate presignedPutUrl() to S3AssetService.presignedPutUrl().
  3. Remove local getHeadObject() implementation — delegate to S3AssetService.headObject().
  4. Remove local getMetadata() implementation — delegate to S3AssetService.headObject() with requiredMetadata.
  5. Retain: S3AsyncClient for GET operations (needed for streaming decompression), CSV key construction (fileKey()), compression handling, row/batch flow parsing.
  6. Update companion object factory methods to accept S3AssetService as a parameter (TD-24). The factory signature changes — S3AssetService is injected, not created internally. This avoids initialization issues (CSV flow has no presignRoleArn). Existing tests must be updated to supply an S3AssetService instance (constructed with MockAWS credentials).

Key risk: The getMetadata() interface in CsvS3BucketDirectAccess returns Result<Map<String, String>> with separate required and validate sets. Ensure S3AssetService.headObject() supports this pattern or adapt the delegation.

Constraint: Existing CsvS3DirectAccessTest and S3BucketAccessTest must pass without modification. Run full test suite after changes.

Acceptance: All existing CSV tests pass. make clean build succeeds. REQ-BE-020, REQ-BE-021, REQ-BE-022.

Verify before proceeding to Phase 2b:

  1. AssetKeyGeneratorTest passes
  2. CdnUrlResolverTest passes
  3. S3AssetServiceTest passes
  4. Existing CSV tests pass (no regressions)
  5. common-module builds: make clean build

Do not proceed to Phase 2b until all Phase 2a tests pass.


Source root: src/main/kotlin/cards/arda/operations/reference/item/. Test root: src/test/kotlin/cards/arda/operations/reference/item/.

Prerequisite: Phase 2a classes available via Gradle includeBuild in settings.gradle.kts. See Multi-Repo Development for composite build mechanics.

includeBuild("<absolute-path-to>/projects/image-upload-backend-worktrees/common-module")

Do not commit this change. The includeBuild directive causes Gradle to resolve cards.arda.common:lib from the local source tree instead of GitHub Packages. When common-module APIs change, operations sees them immediately without publishing.

T-6: CloudFormation + Helm + Module Wiring

Section titled “T-6: CloudFormation + Helm + Module Wiring”

Three files modified in sequence. No new Kotlin code — infrastructure plumbing.

T-6a: CloudFormation (pre-install.cfn.yml)

Section titled “T-6a: CloudFormation (pre-install.cfn.yml)”

Add IAM policies for image infrastructure, following the existing CSV upload pattern.

New S3 access policy (add to ServiceAccountRole policies):

- PolicyName: ImageS3AccessPolicy
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- s3:GetObject
- s3:PutObject
Resource:
- Fn::Sub:
- "${BucketArn}/*"
- BucketArn:
Fn::ImportValue:
Fn::Sub: "${Infrastructure}-${Purpose}-API-ImageAssetBucketArn"
- Effect: Allow
Action:
- s3:ListBucket
Resource:
- Fn::ImportValue:
Fn::Sub: "${Infrastructure}-${Purpose}-API-ImageAssetBucketArn"

New AssumeRole policy (add to existing AllowAssumeRoleBPolicy or as separate policy):

- PolicyName: AllowAssumeImagePresignRole
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- sts:AssumeRole
- sts:TagSession
Resource:
- Fn::ImportValue:
Fn::Sub: "${Infrastructure}-${Purpose}-API-ImagePresignRoleArn"

Add image infrastructure values:

system.reference.item.extras.imageAssetBucketArn={{ .Values.system.reference.item.extras.imageAssetBucketArn | required "imageAssetBucketArn" }}
system.reference.item.extras.imageAssetBucketName={{ .Values.system.reference.item.extras.imageAssetBucketName | required "imageAssetBucketName" }}
system.reference.item.extras.imagePresignRoleArn={{ .Values.system.reference.item.extras.imagePresignRoleArn | required "imagePresignRoleArn" }}
system.reference.item.extras.imageCdnDomain={{ .Values.system.reference.item.extras.imageCdnDomain | required "imageCdnDomain" }}

Also update build.gradle.kts Helm lint values with placeholder ARNs.

In Application.itemModule(), after the existing CsvS3BucketDirectAccess creation (line ~133):

  1. Load image config from extras:

    val imageAssetBucketName = cfg.requireExtra("imageAssetBucketName")
    val imagePresignRoleArn = cfg.requireExtra("imagePresignRoleArn")
    val imageCdnDomain = cfg.requireExtra("imageCdnDomain")
  2. Create image services:

    val assetKeyGenerator = AssetKeyGenerator("images")
    val cdnUrlResolver = CdnUrlResolver(imageCdnDomain)
    val s3AssetService = S3AssetService(
    bucket = imageAssetBucketName,
    region = Region.of(awsConfig.region),
    presignRoleArn = imagePresignRoleArn
    )
  3. Pass to ItemService.Impl constructor (add parameters).

  4. Create and register ImageUploadEndpoint alongside ItemEndpoint.

Acceptance: Module loads without error. Helm lint passes. REQ-BE-017, REQ-BE-018, REQ-BE-019.

File: ImageUploadEndpoint.kt (new, in api/rest/)

The endpoint must not call AssetKeyGenerator, CdnUrlResolver, or S3AssetService directly. The endpoint delegates to ItemService, which orchestrates all business logic. This follows the existing pattern where endpoints call service methods and services coordinate domain operations.

Request model (in Model.kt or separate file):

@Serializable
data class ImageUploadRequest(
val contentType: String,
val contentLength: Long
)

Response model:

@Serializable
data class ImageUploadResponse(
val uploadUrl: String,
val formFields: Map<String, String>,
val objectKey: String,
val cdnUrl: String
)

Endpoint logic:

  1. Extract author from request context.
  2. Validate contentType starts with image/. Return 400 if not.
  3. Call itemService.generateImageUploadCredentials(contentType, contentLength, author).
  4. Return ImageUploadResponse from service result.

Service method (new on ItemService):

suspend fun generateImageUploadCredentials(
contentType: String,
contentLength: Long,
author: String
): Result<ImageUploadResponse>

The service method orchestrates:

  1. Call AssetKeyGenerator.generate(contentType) (tenant from ApplicationContext, TD-20).
  2. Construct Arda-Key: operations/item/imageUrl/${assetKey.uuid}.${assetKey.extension}.
  3. Build metadata map: tenant-id, author, arda-key, server-side-encryption.
  4. Call S3AssetService.createPresignedPost(objectKey, contentType, contentLength, metadata).
  5. Construct CDN URL via CdnUrlResolver.resolve(objectKey).
  6. Return ImageUploadResponse.

Routing: Register in ItemEndpoint.buildRoot():

forResource(resourceName) {
imageUploadRoutes() // POST /<itemEId>/image-upload-url
printingRoutes()
supplyRoutes()
}

Tests (ImageUploadEndpointTest.kt):

  • Happy path: valid request returns 200 with all 4 response fields
  • Auth failure: missing credentials returns 401
  • Invalid content type (e.g., text/plain) returns 400
  • Missing body fields returns 400
  • Response formFields contains expected policy condition keys
  • Response cdnUrl matches expected CDN host pattern
  • Multiple calls produce distinct object keys (idempotency)

Acceptance: ImageUploadEndpointTest passes. REQ-BE-001, REQ-BE-004, REQ-BE-006, REQ-BE-013, REQ-BE-014, REQ-BE-015, REQ-BE-016.

T-8: ItemValidator imageUrl Validation + Tests

Section titled “T-8: ItemValidator imageUrl Validation + Tests”

File: ItemValidator.kt (modify, in persistence/)

Pre-implementation step (TD-25): Verify whether any other validator in the codebase takes constructor dependencies. Search for ScopingValidator subclasses that are class (not object). If ItemValidator is the first to take dependencies, document the pattern for future validators.

Add imageUrl validation in ItemValidator.validateForUpdate() and validateForCreate() (TD-23). Convert ItemValidator from object to class with CdnUrlResolver and S3AssetService as constructor parameters. Instantiate in Module.kt and pass to ItemUniverse. The validator already receives all required context — no extra DB trips needed.

Current signature (from ScopingValidator):

override suspend fun validateForUpdate(
ctx: ApplicationContext, // tenant via ctx.scope.tenant (TD-20)
payload: Item, // new value
metadata: ItemMetadata,
asOf: TimeCoordinates,
author: String,
idempotency: Idempotency,
previous: BitemporalEntity<Item, ItemMetadata>? // persisted value
): DBIO<Unit>

Validation logic (new private function within ItemValidator):

private suspend fun validateImageUrl(
ctx: ApplicationContext,
payload: Item,
previous: BitemporalEntity<Item, ItemMetadata>?
): Result<Unit> {
val imageUrl = (payload as? Item.Entity)?.imageUrl ?: return Result.success(Unit)
val existingImageUrl = (previous?.payload as? Item.Entity)?.imageUrl
// TD-15: skip validation if unchanged
if (imageUrl == existingImageUrl) return Result.success(Unit)
// TD-20: tenantId from ApplicationContext
val tenantId = (ctx.scope as? ServiceScope.Tenant)?.tenant
?: return Result.failure(AppError.ContextValidation("Tenant scope required"))
// CDN pattern + tenant validation (BE-FR-003)
return cdnUrlResolver.validate(imageUrl.toString())
.flatMap { _ ->
// Extract object key from CDN URL path
val objectKey = imageUrl.path.removePrefix("/")
// HEAD verification + tenant metadata validation (BE-FR-004)
s3AssetService.headObject(objectKey, mapOf("tenant-id" to tenantId.toString()))
}
// Re-wrap IncompatibleState (→500) as ArgumentValidation (→400)
.recoverCatching { error ->
when (error) {
is AppError.IncompatibleState -> throw AppError.ArgumentValidation(
"imageUrl", error.message ?: "Image metadata validation failed"
)
else -> throw error
}
}.map { }
}

Integration into validateForUpdate():

override suspend fun validateForUpdate(...): DBIO<Unit> =
super.validateForUpdate(ctx, payload, metadata, asOf, author, idempotency, previous)
.also { validateImageUrl(ctx, payload, previous).getOrThrow() }

Integration into validateForCreate():

override suspend fun validateForCreate(...): DBIO<Unit> =
super.validateForCreate(ctx, payload, metadata, asOf, author)
.also { validateImageUrl(ctx, payload, null).getOrThrow() }

Dependency injection: ItemValidator currently is a stateless object. It will need access to CdnUrlResolver and S3AssetService. Options: (a) convert to a class instantiated in Module.kt, or (b) pass dependencies via the ItemUniverse which already holds the validator reference. The implementer should choose the approach that best fits the existing DI pattern and record as a decision.

Implementation note: AppError.IncompatibleState from headObject() maps to HTTP 500 in the common-module error handler (HttpResponses.kt). Since imageUrl validation failures are client errors, the validator catches IncompatibleState and re-wraps as AppError.ArgumentValidation (→ 400). AppError.ExternalService (object not found) is similarly a client error in this context and should be re-wrapped.

Tests (extend ItemServiceValidationTest.kt or new file):

  • Update with valid CDN URL + existing S3 object → succeeds
  • Update with non-CDN URL → fails with 400
  • Update with CDN URL for wrong tenant → fails with 400
  • Update with CDN URL but missing S3 object → fails with 400
  • Update with imageUrl: null → succeeds (clears image)
  • Update with unchanged non-CDN URL → succeeds (TD-15 grandfathering)
  • Create with valid CDN URL + existing S3 object → succeeds
  • Create with non-CDN URL → fails with 400
  • Create with CDN URL but missing S3 object → fails with 400

Acceptance: All validation tests pass. REQ-BE-007, REQ-BE-008, REQ-BE-009, REQ-BE-010, REQ-BE-011.

Verify before proceeding to release tasks:

  1. ImageUploadEndpointTest passes
  2. imageUrl validation tests pass
  3. All existing Item tests pass (no regressions)
  4. operations builds: make clean build

T-9: Build Verification, CHANGELOG + Release Sequencing

Section titled “T-9: Build Verification, CHANGELOG + Release Sequencing”

Per Release Lifecycle (skill: release-lifecycle), multi-repo releases must follow upstream-first ordering.

Build verification:

  1. common-module: make clean build — all tests pass, coverage meets targets.
  2. operations: make clean build — all tests pass, coverage meets targets.

Release sequencing (order matters): 3. Update CHANGELOG.md in common-module. 4. Create common-module PR, merge, and tag new version. 5. Update operations/gradle/libs.versions.toml — bump arda-common-version to the new common-module version. 6. Revert operations/settings.gradle.kts — remove includeBuild directive. 7. Verify operations builds with the published common-module artifact (not the local composite build). 8. Update CHANGELOG.md in operations. 9. Create operations PR, merge. 10. Verify no uncommitted changes outside the intended scope.


Single directory per repository — no intra-repo worktrees needed. Each phase works on a different repository, and Phase 2b depends on Phase 2a completion.

DirectoryBranchRepositoryTasks
projects/image-upload-backend-worktrees/common-modulejmpicnic/item-image-upload-backendcommon-moduleT-1 (partial), T-2, T-3, T-4, T-5
projects/image-upload-backend-worktrees/operationsjmpicnic/item-image-upload-backendoperationsT-1 (partial), T-6, T-7, T-8, T-9 (partial)
projects/image-upload-backend-worktrees/documentationjmpicnic/item-image-upload-backenddocumentationPlanning documents, decision log updates

Cross-repo access during Phase 2b: Gradle includeBuild provides operations access to common-module classes without publishing. The includeBuild path must be absolute and must not be committed.


RiskLikelihoodImpactMitigation
AWS SDK v2 presigned POST API not available in 2.34.3LowHighCheck SDK version. If missing, construct policy manually or upgrade SDK. Record as decision.
CSV refactoring breaks existing behaviorMediumHighRun existing tests after every change. Factory methods preserve existing API (TD-18).
LocalStack does not support presigned POST validationMediumMediumTest form field presence; full policy validation deferred to Phase 2c live verification.
ItemService modification conflicts with other in-flight changesLowMediumWork on feature branch; rebase before PR.
Image infrastructure exports not yet deployedLowLowLocalStack for tests; Helm lint with placeholder values.

#QuestionOptionsRecommendationDecision
OQ-1What level of CSV refactoring?(a) Minimal: standalone. (b) Moderate: delegate HEAD/metadata. (c) Aggressive: delegate all S3 primitives.Resolved → (c) Aggressive (TD-18). Promotes cohesive abstractions. S3 interaction primitives belong in one class.
OQ-2Should ImageUploadEndpoint be a standalone endpoint class or integrated into ItemEndpoint?(a) Standalone class. (b) Routes within ItemEndpoint.buildRoot().Resolved → (b) (TD-22). Integrated routes for OpenAPI spec, client routing, and existing ItemService injection.
OQ-3Where is imageUrl validation implemented and how does it access the previous value?(a) ItemService.update() with universe.getAsOf(). (b) Parameter from endpoint. (c) ItemValidator.validateForUpdate().Resolved → (c) (TD-23). ItemValidator.validateForUpdate() already receives previous: BitemporalEntity and ctx: ApplicationContext. No extra DB trips.
OQ-4What is the maximum file size for presigned POST policy?(a) 10 MB. (b) 5 MB. (c) Configurable via module extras.Resolved → (c) Configurable, defaulting to 10 MB. Set in S3AssetService constructor.

Copyright: (c) Arda Systems 2025-2026, All rights reserved