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”Summary
Section titled “Summary”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.
Context
Section titled “Context”- 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)
In Scope
Section titled “In Scope”- New classes in
common-module:AssetKeyGenerator,CdnUrlResolver,S3AssetService - Refactor
CsvS3BucketDirectAccessto use sharedS3AssetServicecapabilities - New
ImageUploadEndpointinoperations ItemServicemodification for imageUrl validation with TD-15 grandfathering- CloudFormation, Helm, and Module.kt infrastructure wiring
- Unit and integration tests for all new and modified code
Out of Scope
Section titled “Out of Scope”- 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)
General Instructions
Section titled “General Instructions”- Follow Kotlin Coding Standards
(skill:
kotlin-coding):Result<T>returns,AppErrorhierarchy, 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 buildruns thecleantarget followed by thebuildtarget (skill:build-core-modules). - Use
git -C <path>instead ofcd. Use absolute paths for tool calls. - Do not commit
settings.gradle.ktschanges (GradleincludeBuild). 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.
Task Breakdown
Section titled “Task Breakdown”| # | Task | Phase | Depends On | Repository | Acceptance Criteria |
|---|---|---|---|---|---|
| T-1 | Baseline verification | 0 | — | both | All existing tests pass |
| T-2 | AssetKeyGenerator + tests | 2a | T-1 | common-module | AssetKeyGeneratorTest passes |
| T-3 | CdnUrlResolver + tests | 2a | T-1 | common-module | CdnUrlResolverTest passes |
| T-4 | S3AssetService + tests | 2a | T-2, T-3 | common-module | S3AssetServiceTest passes (MockAWS) |
| T-5 | Refactor CsvS3BucketDirectAccess | 2a | T-4 | common-module | Existing CSV tests pass, make clean build |
| T-6 | CloudFormation + Helm + Module wiring | 2b | T-4 | operations | Config loads, Helm lint passes |
| T-7 | ImageUploadEndpoint + tests | 2b | T-4, T-6 | operations | ImageUploadEndpointTest passes |
| T-8 | ItemValidator imageUrl validation + tests | 2b | T-4, T-6 | operations | Validation tests pass (CDN, non-CDN, null, grandfather) |
| T-9 | Build verification + CHANGELOG | Release | T-5, T-7, T-8 | both | make clean build both repos, CHANGELOGs updated |
Phase 0: Baseline Verification
Section titled “Phase 0: Baseline Verification”T-1: Verify Existing Tests
Section titled “T-1: Verify Existing Tests”Before any code changes, establish a known-good baseline.
common-module (worktree: projects/image-upload-backend-worktrees/common-module):
make -C <worktree-path> clean buildVerify all tests pass, especially CsvS3DirectAccessTest and S3BucketAccessTest.
operations (worktree: projects/image-upload-backend-worktrees/operations):
make -C <worktree-path> clean buildVerify all tests pass, especially ItemEndpointTest, ItemServiceValidationTest,
and ItemInputModelTest.
Record test counts and coverage baselines.
STOP — Baseline checkpoint
Section titled “STOP — Baseline checkpoint”Do not proceed if any existing tests fail. Report failures before continuing.
Phase 2a: common-module Library
Section titled “Phase 2a: common-module Library”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/.
T-2: AssetKeyGenerator + Tests
Section titled “T-2: AssetKeyGenerator + Tests”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
featureNamespaceas URL path segment compatible. Fails fast withIllegalArgumentExceptionif invalid. generate(): RetrievestenantIdfromApplicationContext. UUID viaUUID.randomUUID(). Extension fromextensionFor(). Key format:${tenantId}/${featureNamespace}/${uuid}.${ext}.extensionFor(): Maps content type to extension per TD-16. ReturnsResult.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.
T-3: CdnUrlResolver + Tests
Section titled “T-3: CdnUrlResolver + Tests”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): Constructshttps://${cdnHost}/${objectKey}.validate(url):- Retrieve tenant ID from
ApplicationContext(TD-20). - Parse URL. Fail if malformed.
- Check host matches
cdnHost. Fail if not. - Check path starts with
/${tenantId}/. Fail if cross-tenant. - Check path matches
/<tenantId:UUID>/<namespace>/<uuid>.<ext>pattern. Fail if unexpected format. - Return parsed URL on success.
Returns
Result.failure(AppError.ArgumentValidation)for all failures with descriptive message.
- Retrieve tenant ID from
Tests (CdnUrlResolverTest.kt):
resolve()produces correct URL from object keyvalidate()accepts valid CDN URL for correct tenant (via mockApplicationContext)validate()rejects URL with wrong hostvalidate()rejects URL with different tenant prefixvalidate()rejects URL with unexpected path formatvalidate()rejects malformed URLvalidate()handles null-equivalent (empty string) gracefully
Acceptance: CdnUrlResolverTest passes. REQ-BE-003, REQ-BE-007, REQ-BE-008.
T-4: S3AssetService + Tests
Section titled “T-4: S3AssetService + Tests”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 inCsvS3BucketDirectAccess(TD-17, TD-18).createPresignedPost():- Assume presigning role via STS
AssumeRole(usingpresignRoleArn). - Build presigned POST with policy conditions per TD-16.
- Return upload URL and form fields.
Returns
Result.failure(AppError.ExternalService)on AWS SDK errors.
- Assume presigning role via STS
headObject():- Issue HEAD request to
bucket/objectKey. - If
requiredMetadatais non-empty, validate each key-value pair against the object’s metadata. - Return object metadata on success.
Returns
Result.failure(AppError.ExternalService)if object not found. ReturnsResult.failure(AppError.IncompatibleState)if metadata mismatch.
- Issue HEAD request to
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. WhenheadObject()returnsIncompatibleState(metadata mismatch) and the caller intends a 400 response, the caller must catch and re-wrap asArgumentValidation.
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
uploadUrlandformFields - POST form fields include all 7 policy condition keys
headObject()succeeds for existing objectheadObject()fails for missing objectheadObject()with metadata validation succeeds when metadata matchesheadObject()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.
T-5: Refactor CsvS3BucketDirectAccess
Section titled “T-5: Refactor CsvS3BucketDirectAccess”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:
- Add
S3AssetServiceas a constructor parameter toCsvS3BucketDirectAccess.Impl. - Remove local
S3Presignerinstance — delegatepresignedPutUrl()toS3AssetService.presignedPutUrl(). - Remove local
getHeadObject()implementation — delegate toS3AssetService.headObject(). - Remove local
getMetadata()implementation — delegate toS3AssetService.headObject()withrequiredMetadata. - Retain:
S3AsyncClientfor GET operations (needed for streaming decompression), CSV key construction (fileKey()), compression handling, row/batch flow parsing. - Update companion object factory methods to accept
S3AssetServiceas a parameter (TD-24). The factory signature changes —S3AssetServiceis injected, not created internally. This avoids initialization issues (CSV flow has nopresignRoleArn). Existing tests must be updated to supply anS3AssetServiceinstance (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.
STOP — Phase 2a Gate
Section titled “STOP — Phase 2a Gate”Verify before proceeding to Phase 2b:
AssetKeyGeneratorTestpassesCdnUrlResolverTestpassesS3AssetServiceTestpasses- Existing CSV tests pass (no regressions)
common-modulebuilds:make clean build
Do not proceed to Phase 2b until all Phase 2a tests pass.
Phase 2b: operations Endpoints
Section titled “Phase 2b: operations Endpoints”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"T-6b: Helm ConfigMap (configmap.yaml)
Section titled “T-6b: Helm ConfigMap (configmap.yaml)”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.
T-6c: Module.kt Wiring
Section titled “T-6c: Module.kt Wiring”In Application.itemModule(), after the existing CsvS3BucketDirectAccess
creation (line ~133):
-
Load image config from extras:
val imageAssetBucketName = cfg.requireExtra("imageAssetBucketName")val imagePresignRoleArn = cfg.requireExtra("imagePresignRoleArn")val imageCdnDomain = cfg.requireExtra("imageCdnDomain") -
Create image services:
val assetKeyGenerator = AssetKeyGenerator("images")val cdnUrlResolver = CdnUrlResolver(imageCdnDomain)val s3AssetService = S3AssetService(bucket = imageAssetBucketName,region = Region.of(awsConfig.region),presignRoleArn = imagePresignRoleArn) -
Pass to
ItemService.Implconstructor (add parameters). -
Create and register
ImageUploadEndpointalongsideItemEndpoint.
Acceptance: Module loads without error. Helm lint passes. REQ-BE-017, REQ-BE-018, REQ-BE-019.
T-7: ImageUploadEndpoint + Tests
Section titled “T-7: ImageUploadEndpoint + Tests”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):
@Serializabledata class ImageUploadRequest( val contentType: String, val contentLength: Long)Response model:
@Serializabledata class ImageUploadResponse( val uploadUrl: String, val formFields: Map<String, String>, val objectKey: String, val cdnUrl: String)Endpoint logic:
- Extract author from request context.
- Validate
contentTypestarts withimage/. Return 400 if not. - Call
itemService.generateImageUploadCredentials(contentType, contentLength, author). - Return
ImageUploadResponsefrom service result.
Service method (new on ItemService):
suspend fun generateImageUploadCredentials( contentType: String, contentLength: Long, author: String): Result<ImageUploadResponse>The service method orchestrates:
- Call
AssetKeyGenerator.generate(contentType)(tenant fromApplicationContext, TD-20). - Construct Arda-Key:
operations/item/imageUrl/${assetKey.uuid}.${assetKey.extension}. - Build metadata map:
tenant-id,author,arda-key,server-side-encryption. - Call
S3AssetService.createPresignedPost(objectKey, contentType, contentLength, metadata). - Construct CDN URL via
CdnUrlResolver.resolve(objectKey). - 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
formFieldscontains expected policy condition keys - Response
cdnUrlmatches 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.
STOP — Phase 2b Gate
Section titled “STOP — Phase 2b Gate”Verify before proceeding to release tasks:
ImageUploadEndpointTestpasses- imageUrl validation tests pass
- All existing Item tests pass (no regressions)
operationsbuilds:make clean build
Release Phase
Section titled “Release Phase”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:
- common-module:
make clean build— all tests pass, coverage meets targets. - 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.
Worktree Strategy
Section titled “Worktree Strategy”Single directory per repository — no intra-repo worktrees needed. Each phase works on a different repository, and Phase 2b depends on Phase 2a completion.
| Directory | Branch | Repository | Tasks |
|---|---|---|---|
projects/image-upload-backend-worktrees/common-module | jmpicnic/item-image-upload-backend | common-module | T-1 (partial), T-2, T-3, T-4, T-5 |
projects/image-upload-backend-worktrees/operations | jmpicnic/item-image-upload-backend | operations | T-1 (partial), T-6, T-7, T-8, T-9 (partial) |
projects/image-upload-backend-worktrees/documentation | jmpicnic/item-image-upload-backend | documentation | Planning 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.
Risks and Mitigations
Section titled “Risks and Mitigations”| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| AWS SDK v2 presigned POST API not available in 2.34.3 | Low | High | Check SDK version. If missing, construct policy manually or upgrade SDK. Record as decision. |
| CSV refactoring breaks existing behavior | Medium | High | Run existing tests after every change. Factory methods preserve existing API (TD-18). |
| LocalStack does not support presigned POST validation | Medium | Medium | Test form field presence; full policy validation deferred to Phase 2c live verification. |
ItemService modification conflicts with other in-flight changes | Low | Medium | Work on feature branch; rebase before PR. |
| Image infrastructure exports not yet deployed | Low | Low | LocalStack for tests; Helm lint with placeholder values. |
Open Questions and Decisions
Section titled “Open Questions and Decisions”| # | Question | Options | Recommendation | Decision |
|---|---|---|---|---|
| OQ-1 | What 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-2 | Should 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-3 | Where 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-4 | What 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
Copyright: © Arda Systems 2025-2026, All rights reserved