Skip to content

Specification: Kanban Cards with Deleted Items — Phase 1 (backend)

Implementation specification for Phase 1. Backend-only. Defines the information-model changes, service behavior, API/print contract, and the commit decomposition. Acceptance lives in verification.md.

Kanban cards hold an ItemReference to their Item. When the Item is deleted, the read/render paths re-resolve it excluding retired records, get nothing, and fail with AppError.IncompatibleState → HTTP 500. Item deletion is a soft delete (the bitemporal store retains the full payload behind a retired = true row), so the data — including the name — survives.

Phase 1 makes the card’s ItemReference an observer-synchronized denormalized cache of two things: the item’s retired state and a Provenance stamp (who/when last updated). Reads resolve the item by a two-path rule and treat the resolved record as the source of truth, so correctness never depends on the cache being warm. Both print payloads carry the deletion marker.

2.1 Provenance value object (common-module)

Section titled “2.1 Provenance value object (common-module)”

A reusable who/when stamp, modeled as a value object with a persistence component, mirroring Money / Quantity / Duration.

cards.arda.common.lib.domain.general
sealed interface Provenance {
val updatedBy: String
val updatedAt: Long // epoch ms
data class Value(
override val updatedBy: String,
override val updatedAt: Long,
) : Provenance
}

A provenanceComponent factory + ProvenanceComponent : Component.Sub map it to two prefixed columns (<prefix>_updated_by, <prefix>_updated_at), reconstructing via build.requiring(updatedBy, updatedAt) (all-or-nothing: both columns null ⇒ null; mixed ⇒ error). This follows the exact pattern of ItemSupplyReferenceComponent’s nested unitCost/orderQuantity/averageLeadTime.

reference/item/business/Item.kt
sealed interface ItemReference : EntityPayload {
val eId: EntityId
val rId: UUID?
val name: String?
val retired: Boolean // NEW — default false
val provenance: Provenance? // NEW — null only until first synced
data class Value(
override val eId: EntityId,
override val rId: UUID? = null,
override val name: String?,
override val retired: Boolean = false,
override val provenance: Provenance? = null,
) : ItemReference
}

Defaults keep the ~50 existing construction sites and the serialized contract non-breaking.

Nest the provenance component and add the retired scalar, alongside the existing entity_id / item_name / record_id:

val retired = tbl.bool(forName("retired")).default(false)
val provenance = provenanceComponent<TBL, R, Provenance.Value?>(forName("provenance"))

fill / getComponentValue / setComponentValue delegate to provenance exactly as ItemSupplyReferenceComponent delegates to orderQuantity/unitCost. Resulting columns (compounded prefix):

ColumnTypeNullNotes
item_reference_retiredbooleanNOT NULL, default falserouting hint; also forces legacy cards onto the believed-live path
item_reference_provenance_updated_byvarcharnullableboth-or-neither with _updated_at
item_reference_provenance_updated_atbigintnullableepoch ms

A Flyway migration per affected module adds the three columns to both kanban_card and order_line (the component is shared):

ALTER TABLE <table>
ADD COLUMN item_reference_retired boolean NOT NULL DEFAULT false,
ADD COLUMN item_reference_provenance_updated_by varchar(255) NULL,
ADD COLUMN item_reference_provenance_updated_at bigint NULL;

No data backfill (the Item and KanbanCard databases are separate; backfill is unnecessary — see §3.3). order_line values stay default/null and are unused by procurement.

The existing in-memory ItemListener (kanban ServiceImpl) observes Item Update/Delete events (published post-commit). Implement the no-op deletedItem and align updatedItem so both set the same reference fields — only retired differs:

val syncedRef = card.item.copy(
rId = itemRd.rId,
name = itemRd.payload.name,
retired = isDelete,
provenance = Provenance.Value(itemRd.author, itemRd.asOf.recorded),
)

Async, post-commit, best-effort (a failed listener is logged at WARN, not retried) — so the cache is eventually-consistent.

PlantUML diagram

detailsFor(cardId), detailsFor(recordId), and listWithDetails resolve each card’s item by the cached retired flag:

  • retired == falseitemService.getAsOf(item.eId, asOf, includeRetired = true). Include-retired is the safety net for the observer-lag window (and for the legacy default).
  • retired == trueitemService.getRecord(item.rId). Direct primary-key fetch of the frozen tombstone; asOf-independent.

3.3 Resolved record is the source of truth (coalesce)

Section titled “3.3 Resolved record is the source of truth (coalesce)”

composeDetails (and the printers) derive the response’s retired + provenance from the resolved item record (EntityRecord.retired, .author, .asOf), using the reference cache only as a fallback:

val effectiveRetired = resolved?.retired ?: card.item.retired
val effectiveProvenance = resolved?.let { Provenance.Value(it.author, it.asOf.recorded) }
?: card.item.provenance

This is what makes the feature correct for the pre-existing backlog with no backfill: a legacy deleted-item card has retired = false (default) → believed-live path → includeRetired = true resolves the retired tombstone → the response is marked correctly from the record. The denormalized cache is purely a routing hint + a convenience for non-resolving consumers.

PlantUML diagram

On the single-card detail read, when the resolved record shows the cached retired/provenance is stale, persist the corrected reference back to the card. The heal is effective-dated at max(card.asOf.effective, resolvedItem.asOf.effective) so it is never dated before the item change it reflects — otherwise a card unchanged since creation would retroactively mark the item retired in history. The write is best-effort and conflict-tolerant: swallow AppError.ConflictingState (optimistic-version races) so a heal never fails the read. Kanban card update is not draft-gated, so this is a plain versioned update. Do not heal inline in listWithDetails (it would turn a page read into N writes and pin the hot path to the writer under the AWS JDBC wrapper); list reads coalesce only. Residual cards (never read and item never updated again) are invisible and heal on their eventual first read.

listWithDetails partitions each chunk: believed-live eIds through the include-retired batch (listEntities with the new flag), retired entries through a batch fetch-by-rId. A genuinely-absent item (true corruption, not a normal deletion) is skipped/marked rather than failing the whole page (collectAll).

No new or changed endpoints, no auth change. The card’s item reference gains, additively:

  • retired: Boolean
  • provenance: { updatedBy: String, updatedAt: Long } | null

KanbanCardDetails gains no top-level fields; consumers read card.item.retired / card.item.provenance. The 500 (IncompatibleState) is removed for the deleted-item case on the detail/list/print routes (retained only for genuine internal inconsistency).

Follow-up. Provenance records updatedBy (the display author). Recording the immutable subject (oidcSub) instead of — or alongside — the display name is a tracked follow-up (PDEV-820), to be done as a fast-follow common-module change plus operations adoption.

Both are flat presentation projections (no nested objects, no embedded domain types):

  • KanbanCardPrintInfo (card template): item_retired: Boolean, item_last_updated_by: String, item_last_updated_at: String. Populated by KanbanCardPrinter.render from the coalesced item.
  • ItemPrintInfo (Label + Breadcrumb templates): is_retired: Boolean, last_updated_by: String, last_updated_at: String. Populated by ItemPrinter.render from the item record (itemRd.retired/author/asOf).

The two *_last_updated_at fields are rendered as an ISO-8601 date in UTC (e.g. 2026-06-10) via a single shared formatProvenanceDate helper, so both printers format identically and the eventual localization has one place to parametrize.

As-built correction. The phase plan assumed ItemPrintingService resolution (universe.listRecords) would need an include-retired flag, else a retired item’s Label/Breadcrumb would fail “Some items not found for printing”. Implementation showed this is unnecessary: listRecords resolves each item by its (frozen) record id, which returns the row regardless of retired state. A retired item’s Label/Breadcrumb already resolves, so C7 is only the ItemPrinter marking — no ItemPrintingService / listRecords change was made.

#RepoChange
C1common-moduleProvenance value object + provenanceComponent; list-path includeRetired on Query/universe if not already exposed
C2operationsItemReference (retired + provenance); nest in ItemReferenceComponent; Flyway migration (both tables)
C3operationsUniform propagation: deletedItem + aligned updatedItem
C4operationsSingle-card two-path + coalesce + best-effort heal
C5operationsList path: partition + coalesce + null hardening
C6operationsCard print fields + KanbanCardPrinter.render + template
C7operationsItem Label/Breadcrumb print fields + ItemPrinter.render (no listRecords change — see §5 as-built correction)
C8operationsCHANGELOG
D1documentationRoadmap finalization + kanban/item docs

C1 (common-module) lands first (composite build).

Front-end/UI; procurement/order-read behavior; preventing new orders for a deleted item; deletion-reason capture, deleted-items discovery, pre-delete impact warning, receiving guidance.

  • Goal · Verification
  • Code: reference/item/business/Item.kt, reference/item/domain/persistence/ItemReferenceComponent.kt, resources/kanban/service/ServiceImpl.kt, reference/item/service/ItemPrinter.kt / ItemPrintingService.kt
  • Linear: PDEV-547 (parent), PDEV-808 (this phase)

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