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.
1. Overview
Section titled “1. Overview”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. Information model
Section titled “2. Information model”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.
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.
2.2 ItemReference (operations)
Section titled “2.2 ItemReference (operations)”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.
2.3 ItemReferenceComponent (operations)
Section titled “2.3 ItemReferenceComponent (operations)”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):
| Column | Type | Null | Notes |
|---|---|---|---|
item_reference_retired | boolean | NOT NULL, default false | routing hint; also forces legacy cards onto the believed-live path |
item_reference_provenance_updated_by | varchar | nullable | both-or-neither with _updated_at |
item_reference_provenance_updated_at | bigint | nullable | epoch ms |
2.4 Migration
Section titled “2.4 Migration”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.
3. Service behavior
Section titled “3. Service behavior”3.1 Propagation (Flow 1)
Section titled “3.1 Propagation (Flow 1)”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.
3.2 Two-path resolution (Flow 2)
Section titled “3.2 Two-path resolution (Flow 2)”detailsFor(cardId), detailsFor(recordId), and listWithDetails resolve each card’s item by the cached retired flag:
retired == false→itemService.getAsOf(item.eId, asOf, includeRetired = true). Include-retired is the safety net for the observer-lag window (and for the legacy default).retired == true→itemService.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.retiredval effectiveProvenance = resolved?.let { Provenance.Value(it.author, it.asOf.recorded) } ?: card.item.provenanceThis 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.
3.4 Cache self-heal
Section titled “3.4 Cache self-heal”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.
3.5 List hardening
Section titled “3.5 List hardening”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).
4. API contract
Section titled “4. API contract”No new or changed endpoints, no auth change. The card’s item reference gains, additively:
retired: Booleanprovenance: { 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.
ProvenancerecordsupdatedBy(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-followcommon-modulechange plus operations adoption.
5. Print payloads
Section titled “5. Print payloads”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 byKanbanCardPrinter.renderfrom the coalesceditem.ItemPrintInfo(Label + Breadcrumb templates):is_retired: Boolean,last_updated_by: String,last_updated_at: String. Populated byItemPrinter.renderfrom 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
ItemPrintingServiceresolution (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:listRecordsresolves each item by its (frozen) record id, which returns the row regardless ofretiredstate. A retired item’s Label/Breadcrumb already resolves, so C7 is only theItemPrintermarking — noItemPrintingService/listRecordschange was made.
6. Implementation scope (commit map)
Section titled “6. Implementation scope (commit map)”| # | Repo | Change |
|---|---|---|
| C1 | common-module | Provenance value object + provenanceComponent; list-path includeRetired on Query/universe if not already exposed |
| C2 | operations | ItemReference (retired + provenance); nest in ItemReferenceComponent; Flyway migration (both tables) |
| C3 | operations | Uniform propagation: deletedItem + aligned updatedItem |
| C4 | operations | Single-card two-path + coalesce + best-effort heal |
| C5 | operations | List path: partition + coalesce + null hardening |
| C6 | operations | Card print fields + KanbanCardPrinter.render + template |
| C7 | operations | Item Label/Breadcrumb print fields + ItemPrinter.render (no listRecords change — see §5 as-built correction) |
| C8 | operations | CHANGELOG |
| D1 | documentation | Roadmap finalization + kanban/item docs |
C1 (common-module) lands first (composite build).
7. Out of scope
Section titled “7. Out of scope”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.
References
Section titled “References”- 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
Copyright: © Arda Systems 2025-2026, All rights reserved