Skip to content

Design Session 03: Item Module API Changes for Image Upload

The System must support Item Images from either external URLs or files uploaded to S3.

Currently the System only supports external URLs in the Item entity. The improvement requires:

  1. Item Updates can provide an external URL or a key that resolves to an S3 Object, or both.
  2. An Item, when queries or read does not show the difference between an external URL and an S3 Object but simply provides the URL that can be used to retrieve the image directly. In the case of S3 objects, the URL is resolved natively by the S3 service or a CDN attached to it.
  3. CSV imports should support both external URLs and S3 keys, failing if the S3 keys do not resolve to valid pre-uploaded objects.
  4. Updates to existing items and CSV uploads should only require the uuid part of the object key to be provided. The system needs to resolve the full key and synthesize the image URL based on the provided uuid, the tenant id (from ApplicationContext) and by trying to resolve the object from S3 trying the allowed extensions.
  • The Core machinery of the ItemDataAuthority (an EditableDataAuthority) should be kept as closely as possible to the vainilla implementation of the base class.
  • The ItemInput should have an additional imageKey field of type UUID that is not required.
  • Translation from ItemInput to Item should take care of the resolution of the effective image URL:
    • If no imageKey or imageUrl is provided, the resulting Item should have imageUrl set to null.
    • If only imageKey is provided, the resulting Item should have imageUrl set to the resolved URL if it exists or result in an error otherwise.
    • If only imageUrl is provided, the resulting Item should have imageUrl set to the provided URL.
    • If both imageKey and imageUrl are provided, the resulting Item should have imageUrl set to the resolved imageKey URL. If imageKey does not resolve to a valid S3 object, the operation fails with an error — no fallback to imageUrl. (Clarified per CQ-7.)
  • The Translation from previous point, could be done as a purely functional method in the ItemService (no updates performed) or as an additional update method in the service (with side effect of updating the Item transactionally). In any case, isolating the resolution logic in a separate method is required. TBD whether an additional update method is required or the sequence of translation and update is done in the Endpoint Logic plus the CSV importer.

OQ-1: imageUrl vs imageKey — field transition strategy. The current Item entity has imageUrl: URL? storing a full URL (set via CSV import or direct API input). The new design introduces imageKey (an S3 object key). What is the intended relationship?

  • Replace: Rename imageUrlimageKey at all layers? Breaks backward compatibility for clients sending full URLs.
  • Coexist: Keep imageUrl for external URLs, add imageKey for managed S3 assets? Read path prefers imageKey over imageUrl when both are set.
  • Absorb: Keep imageUrl as the API-facing field but accept both full URLs and S3 keys, resolving internally?

Affects persistence layer, API model, CSV proto, and the read path.

JMP See guidance above.

OQ-2: Read path URL resolution. When the client reads an Item entity, what does it get back for the image?

  • A raw S3 key that the SPA resolves using a known base URL?
  • A fully resolved URL the SPA can use directly in <img src="...">?
  • Both (key for updates, resolved URL for display)?

Interacts with DQ-006’s three-phase CDN rollout — if the entity stores a key rather than a URL, the base URL can change across phases without data migration.

JMP See guidance above. Fully resolved URL.

OQ-3: Upload endpoint path convention. DS-02 specifies POST /v1/item/<itemId>/image/upload-url, but the current API uses a doubled module/entity pattern: /v1/item/item/{entity-id}. Should the upload endpoint follow this convention: /v1/item/item/{entity-id}/image/upload-url?

JMP Keep the double module/entity pattern. Need to update the decision log.

OQ-4: Draft items. The Item Module has draft operations (create/update/promote). Should the image upload workflow work with draft items? Can a user upload an image to a draft that hasn’t been promoted yet?

JMP Yes, the image upload workflow is decoupled from item editing. Sequence diagrams should be enhanced to show how upload interacts with draft items.

JMP Guidance: In the current diagrams, the Item update is shown as a single step. In reality, before the scenario starts, the Item may have been put in a draft state. The update is done to the draft and then the draft is published. The image upload is done at any time before the publish step that validates the inputs, including the existence of the image object based on the resolved URL.

OQ-5: Bulk operations and imageKey. Bulk add/update endpoints exist. While bulk image upload is out of scope, should bulk entity operations accept imageKey in their payloads (with the same HEAD validation)? Or is imageKey restricted to single-entity operations initially?

JMP See guidance above.

OQ-6: Scope of DS-03 deliverables. What should this session produce? Candidates:

  • Detailed Ktor route specs for the new upload endpoint
  • ItemInput / Item model changes (field additions, type changes)
  • Persistence migration plan (image_url column changes or new image_key column)
  • Integration design showing how the HEAD check fits into ItemService.update()
  • Read-path URL resolution design
  • All of the above as a complete “Item Module changes specification”

JMP

  • Updated Sequence Diagrams with details of editing with draft lifecycle.
  • Sequence Diagrams for the CreateItem scenario.
  • Sequence Diagrams for the CSV import scenario.
  • Ktor Route Specs, preferably using the Endpoint DSL in common-module/.../ServiceEndpointDsl.kt or pseudo-code close to it.
  • Detailed scenarios for Object Key validation during Item Update service calls (both from API and CSV import).
  • Detailed Requirements for the S3 Access abstraction, ideally expressed as a documented Kotlin Interface.

OQ-7: Object eviction. The decision log lists “Object Eviction Requirements and scenarios” as a pending open point. Should DS-03 address this, or is it deferred to a separate session?

JMP Deferred to a separate session.

Decisions from Design Session 01 and Design Session 02 that constrain this session:

  • DQ-001 — Key format: ${tenantId}/${owning-module}/${entity-type}/${property-name}/${asset-uuid}.${extension}
  • DQ-003 — Shared library in common-module (not Lambda)
  • DQ-005Option A with mandatory HEAD check: three-step workflow (request presigned POST, upload to S3, entity update with HEAD validation). Entity update returns 422 if S3 object not found.
  • DQ-007 — New S3BucketAccess abstraction in common-module

From Design Session 02 — Requirements relevant to this session:

  • E-1: New endpoint POST /v1/item/<itemId>/image/upload-url
  • E-2: Existing PUT /v1/item/<itemId> extended with imageKey + HEAD validation
  • M-1 through M-8: Pre-checks, HEAD validation, persistence, configuration

Reference: OpenAPI spec | Implementation: /operations/src/main/kotlin/cards/arda/operations/reference/item/

MethodPathOperationNotes
POST/v1/item/itemCreate itemBody: ItemInput. Query: effective-as-of?, mutation-mode?
GET/v1/item/item/{entity-id}Get item by IDQuery: as-of?
PUT/v1/item/item/{entity-id}Update itemBody: ItemInput. Query: effective-as-of?, mutation-mode?
DELETE/v1/item/item/{entity-id}Delete itemCascades to child ItemSupply entities
GET/v1/item/itemList/page itemsQuery: as-of?, pagination
GET/v1/item/item/rid/{record-id}Get by record IDBitemporal record lookup
MethodPathOperation
POST/v1/item/item/bulkBulk add
PUT/v1/item/item/bulkBulk update
GET/v1/item/item/bulkBulk get
MethodPathOperation
POST/v1/item/item/draftCreate draft
GET/v1/item/item/{eId}/draftGet draft
PUT/v1/item/item/{eId}/draftUpdate draft
DELETE/v1/item/item/{eId}/draftDelete draft
POST/v1/item/item/{eId}/draft/promotePromote draft

Supply Sub-Resource (/v1/item/item/{parent-item-id}/supply)

Section titled “Supply Sub-Resource (/v1/item/item/{parent-item-id}/supply)”
MethodPathOperationNotes
POST.../supplyCreate supplyBody: ItemSupplyInput
PUT.../supply/{item-supply-id}Update supplyBody: ItemSupplyInput
DELETE.../supply/{item-supply-id}Delete supply
GET.../supplyList supplies
MethodPathReturns
GET/v1/item/item/lookup-items[{name, eId}]
GET/v1/item/item/lookup-suppliers[string]
GET/v1/item/item/lookup-types[string]
GET/v1/item/item/lookup-subtypes[string]
GET/v1/item/item/lookup-usecases[string]
GET/v1/item/item/lookup-units[string]
GET/v1/item/item/lookup-facilities[string]
GET/v1/item/item/lookup-departments[string]
GET/v1/item/item/lookup-locations[string]
GET/v1/item/item/lookup-sublocations[string]

All take as-of (required), name (search term), limit? (default 10).

MethodPathOperation
POST/v1/item/upload-job/upload-urlGet presigned PUT URL
POST/v1/item/upload-job/{job-id}Trigger processing
GET/v1/item/upload-job/{job-id}Poll job status
MethodPathOperation
POST/v1/item/item/{eId}/print-labelPrint labels
POST/v1/item/item/{eId}/print-breadcrumbPrint breadcrumbs
HeaderRequiredDescription
X-Tenant-IdYesTenant scoping (UUID)
X-AuthorYesAuthor identifier (string)
X-Request-IdNoRequest correlation ID
FieldTypeNullableNotes
nameStringNoRequired, natural identifier
descriptionStringYes
imageUrlString (parsed to URL)YesCurrent image field — full URL
classification{type, subType?}YesItemClassification.Value
useCaseStringYes
glCodeStringYes
locator{facility, department?, location?, subLocation?}YesPhysicalLocator.Value
internalSKUStringYes
minQuantity{amount, unit}YesQuantity.Value
notesStringYes
cardNotesDefaultStringYes
taxableBooleanYes
primarySupplyItemSupplyReference.ValueYesEmbedded supply reference
secondarySupplyItemSupplyReference.ValueYesEmbedded supply reference
defaultSupplyStringYesSupplier name
defaultSupplyEIdUUIDYesSoft FK to supply entity
cardSizeCardSize enumYesSMALL, MEDIUM, LARGE, X_LARGE
labelSizeLabelSize enumYesSame values
breadcrumbSizeBreadcrumbSize enumYesSame values
itemColorItemColor enumYesBLUE, GREEN, YELLOW, ORANGE, RED, PINK, PURPLE, GRAY

Same fields as ItemInput plus eId: UUID. The domain entity stores imageUrl as a URL?. This is the field that the new imageKey mechanism will replace/augment.

The imageUrl column is stored as image_url (URL type, nullable) in the ITEM table via ScopedTable. See /operations/src/main/kotlin/cards/arda/operations/reference/item/persistence/ItemPersistence.kt.

  1. mutation-mode query parameter controls supply reference behavior on create/update:

    • LAX — trust input, no cross-entity checks (CSV import)
    • STRICT — verify references exist and match
    • PROPAGATE — cascade: auto-create/update ItemSupply + BusinessAffiliate (default for API)
  2. ItemEndpoint.Impl extends EditableDataAuthorityEndpoint, which provides the standard CRUD + draft + bulk routes. Custom routes (upload, supply, lookup, printing) are added in buildRoot().

  3. CSV upload is wired in Module.kt via CsvUploadRoutes, using CsvS3BucketDirectAccess for presigned PUT URLs and JobService for tracking. The upload routes are outside the ItemEndpoint class, added directly to the Ktor module.

  4. ItemService.update() enriches the update payload, runs PersistencePreparer.forUpdate, and propagates supply changes. The HEAD check for imageKey will need to integrate into this flow.

  5. Module wiring (Module.kt line 126): the CSV bucket is resolved from extras["uploadBucketArn"]. The new http-assets bucket will need a similar configuration entry.


Additional Materials:

  • Explore the Current SPA logic for Item Creation and Editing in the arda-frontend-app repository.

JMP: Create PlantUML sequence diagram showing SPA, BFF and Backend as participants for:

  1. Item Creation Scenario
  2. Item Draft Creation and Update Scenario
  3. Item Draft Publishing.

CLD-AI: Based on code exploration of arda-frontend-app, operations, and api-test, here are the three current interaction patterns. Internal service details (supply enrichment, persistence preparation) are abstracted away to focus on the workflow structure relevant to the upload design.

Key implementation facts discovered:

  • Draft is a prerequisite for update. EditableDataAuthorityService.updater() checks draftStore.current() and fails with "Cannot update {eId} without a draft" if no draft exists.
  • GET /draft is get-or-create. It creates a draft from the current published state if none exists, or returns the existing draft.
  • Publish = standard PUT. There is no separate promote endpoint. The PUT on /v1/item/item/{eId} checks for a draft, closes it, and updates the universe. The SPA treats this as “Save.”
  • Draft update body differs from item update. PUT on /draft uses a wrapped body { entityId, value: {...}, metadata, author }. PUT on the item uses flat ItemInput.

PlantUML diagram

PlantUML diagram

Current: Item Publish (Draft Promotion via Update)

Section titled “Current: Item Publish (Draft Promotion via Update)”

PlantUML diagram

Key observations for the upload design:

  1. ItemInput.toItem(eId) is the integration point. Currently it converts imageUrl: String? to URL? via URI(it).toURL(). The new imageKey: UUID? resolution logic fits naturally into this translation step — or into a separate resolver method called from the same place.

  2. Draft stores working state without validation. This is exactly the semantics needed for imageKey: the user can save a draft with an imageKey UUID, and validation is deferred to publish time.

  3. The SPA controls sequencing. Upload, draft save, and publish are all separate SPA-initiated calls. The SPA can upload an image, store the imageKey locally, include it in draft saves, and send it in the final publish PUT.


JMP: Propose a PlantUML sequence diagram similar to those in design-session-02.md for Item Creation.

  • Image Upload must precede the API creation call
  • API creation call verifies object exists in S3.

General Idea: SPA collects all information from the user, if the user wants to upload an image as part of the item, the SPA will upload it to the S3 bucket and set the imageKey field in the ItemInput payload. When the SPA calls the API, the image object should already exist in S3.

CLD-AI:

PlantUML diagram

Design notes:

  1. Upload endpoint has no entity-id. Unlike DS-02’s original spec (POST /v1/item/item/{itemId}/image/upload-url), this endpoint does not require an entity-id because during creation the item doesn’t exist yet. The S3 key doesn’t include entity-id either (per DQ-001). See Actions.

  2. Response includes imageKey: UUID. The upload endpoint returns the UUID separately so the client doesn’t need to parse the full objectKey. The client uses imageKey in the subsequent creation call.

  3. Extension probing. The backend probes up to 3 extensions (jpg, png, webp) via HEAD calls. This adds 1—3 S3 API calls per resolution. For single-item operations this is negligible latency (~5ms each).

  4. Resolution at create time. The resolveImageKey method is called during ItemInput to Item translation, before the item is persisted. If resolution fails, the entire create operation fails with 422.

  5. Persisted value is imageUrl, not imageKey. The Item entity stores the resolved imageUrl: URL (e.g., {base-url}/{tenantId}/operations/item/image/{uuid}.png). The imageKey: UUID is transient — it exists only in ItemInput. No persistence schema change is needed.


Step 2: Detailed Scenarios to Integrate Draft Lifecycle with Image Uploads

Section titled “Step 2: Detailed Scenarios to Integrate Draft Lifecycle with Image Uploads”

JMP: Propose a PlantUML sequence diagram similar to those in design-session-02.md for updates with Draft lifecycle.

Note General Idea in previous step, similar logic for the SPA should apply here.

Conditions:

  • Draft Creation (GET /v1/item/item/{eId}/draft) is independent of Uploads.
  • Draft Updates do not verify imageKey.
  • Draft Publish (Item Update) verifies imageKey.

CLD-AI:

PlantUML diagram

Design notes:

  1. Four distinct phases. Draft lifecycle and image upload are decoupled: phases 1 and 3 are standard draft operations; phase 2 (upload) can happen at any time before phase 4 (publish).

  2. Draft stores imageKey without validation. When the user saves a draft (phase 3), imageKey: UUID is stored in the draft’s value object alongside other item fields. The backend does not resolve or validate it — the draft is a working scratch pad.

  3. Validation deferred to publish. The HEAD check and URL resolution happen only in phase 4, during the PUT /v1/item/item/{eId} processing. This ensures that at the moment the item is published, the referenced S3 object is confirmed to exist.

  4. Resuming a saved draft. If the user saves a draft with imageKey and returns later, the SPA retrieves the draft (which includes imageKey in value) and can display the image using a locally-constructed preview URL. On publish, the backend re-validates via HEAD.

  5. Update with only imageUrl (no imageKey). If the user provides an external URL instead of uploading, the flow skips phase 2 entirely. The PUT payload includes imageUrl without imageKey, and the standard translation applies the URL directly.

  6. resolveImageKey is the same method used in Step 1 (item creation) and here (item update/publish). It is called during ItemInput to Item translation, which happens in both the create and update code paths.


JMP: Identify questions to resolve before a design (scenario) can be proposed.

Exploration only: Ask Questions to frame the problem.

CLD-AI: The following questions need to be resolved before a CSV import scenario can be diagrammed:

CSV-Q1: Column representation. The current CSV proto has string image_url. How should S3 keys be specified in CSV rows?

  • (a) Add a new string image_key column that accepts UUIDs. Rows can populate either image_url or image_key but not both (same semantics as the API).
  • (b) Reuse the existing image_url column. The backend detects whether the value is a UUID (resolve from S3) or a full URL (use directly). Heuristic: try parsing as UUID; if it succeeds, treat as S3 key; otherwise, treat as URL.
  • (c) Reuse image_url with a prefix convention. E.g., s3:{uuid} for S3 keys, plain URL otherwise.

Recommendation: (a) — a separate column avoids ambiguity and matches the API model (imageKey and imageUrl are separate fields on ItemInput).

CSV-Q2: Pre-upload workflow. CSV import implies all referenced images must be uploaded to S3 BEFORE the CSV is processed. What is the expected workflow?

  • User uploads N images via the presigned POST endpoint (one per image), collecting N UUIDs.
  • User constructs the CSV with image_key values referencing those UUIDs.
  • User uploads and processes the CSV.
  • During processing, each row’s image_key is resolved and validated via HEAD.

Is this the intended sequence? Or is there a bulk image upload mechanism anticipated? (Bulk image upload is out of scope per the project description, but the CSV workflow depends on images already being in S3.)

CSV-Q3: Performance at scale. If a CSV has 1,000 rows with image_key values, the backend performs up to 3,000 HEAD calls (3 extension probes per row). Considerations:

  • S3 HEAD requests are ~5ms each, so 3,000 calls is ~15 seconds sequentially. Parallelization could reduce this significantly.
  • Alternative: use ListObjectsV2 with prefix {tenantId}/operations/item/image/{uuid} — returns all matching keys in one call. More efficient for batch resolution but different API.
  • Should the CSV processing path use a different (batch-optimized) resolution strategy than the single-item API path?

CSV-Q4: Error handling granularity. The existing CSV import reports per-row success/failure (via RowResult). If a row’s image_key doesn’t resolve:

  • Should the row fail with a BuildError (same as malformed image_url today)?
  • Should the entire batch be rejected?
  • Should the error message indicate which extensions were tried?

Recommendation: Per-row failure with a descriptive error message (consistent with existing CSV validation patterns).

CSV-Q5: Mutual exclusivity. If a CSV row provides both image_url and image_key:

  • (a) Same rules as the API: imageKey takes precedence, fail if it doesn’t resolve (regardless of imageUrl value).
  • (b) Reject the row outright (provide one or the other, not both).
  • (c) Different rules: CSV is a batch tool, allow fallback to imageUrl.

Recommendation: (a) — same semantics as the API for consistency. The resolveImageKey method is shared between API and CSV paths.


Items requiring changes to prior documents. Do not modify the referenced documents during this session — capture amendments here for later application.

#Document / DecisionChange Required
A-1DS-02, E-1Remove {itemId} from upload endpoint path. New path: POST /v1/item/item/image/upload-url (no entity-id). Rationale: item may not exist during creation. S3 key does not include entity-id (DQ-001).
A-2DS-02, L-2 / L-6PresignedPostResult should include imageKey: UUID as a separate field alongside objectKey: String, so the client does not need to parse the key string.
A-3DS-02, Resulting DesignUpdate guidance bullet for “both provided” case: if imageKey is provided and does not resolve, the operation fails — no fallback to imageUrl. (Per CQ-7 resolution.)
A-4Decision LogUpdate DQ-005 to reflect corrected upload endpoint path (no entity-id) and the imageKey: UUID response field.
A-5DS-03, OQ-3Confirmed: upload endpoint follows the /v1/item/item/ prefix convention. Path: POST /v1/item/item/image/upload-url.

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