All Posts
SaaS Platform

A Reusable Container Is a State Machine, Not a SKU

PPWR's Article 8 reuse mandate asks something most tracking systems can't answer: how many times has this exact container been reused. That's a state-machine question, not a labeling one.

11 min read

On 12 August 2026, the EU's Packaging and Packaging Waste Regulation became generally applicable, and its reuse-system obligations turned a marketing claim into something an operator has to prove. Most systems that track reusable packaging record a container as a row with a status column: in stock, out, damaged. That model answers where a container is right now. It cannot answer how many times this exact unit has been reused, which is the number PPWR now asks for. The fix is not a new column. It is treating the container's lifecycle as an event-sourced state machine, and the regulator's own guidance shows why the shortcut everyone else gets does not apply here.

Why this matters now

Regulation (EU) 2025/40, the Packaging and Packaging Waste Regulation, entered general application on 12 August 2026. Article 8's reuse-system obligations and the traceability requirements in Articles 15 and 18 are now live, and every operator marketing packaging as reusable owes an EU Declaration of Conformity under Annex VIII plus a per-Member-State EPR registration.

On 4 August the Commission published an updated PPWR FAQ that clarified enforcement and traceability: market surveillance authorities are told to warn and give operators a chance to correct before sanctioning, stock produced before 12 August does not need relabelling, and traceability can run at batch level for most packaging — a yogurt cup with several parts needs an identifier on only one of them, and adhesive tape or plastic bags can be marked by batch rather than by unit.

Almost everyone read that FAQ as relief, and for single-use and standard packaging it is. For a reuse system it is the one place the relief runs out. A reuse claim is a per-unit historical fact, not a per-batch inventory count. Batch marking can tell an auditor which production run a bag came from. It cannot tell them that container 4471 has completed eleven wash-and-return cycles across three Member States, which is exactly the fact Article 8 exists to make provable.

A container is not a SKU

The failure mode is the same one that shows up anywhere a system models history as a mutable value. A container row gets a status column and a times_reused counter. An issue scan sets status to in_use. A return scan increments the counter and flips status back. It works, it is fast to build, and it answers every question the product team asked before Article 8 existed. It cannot answer who returned it, where, whether the same scan got recorded twice after a network retry, or what the count was as of a date three months ago — and a warning-and-correct enforcement process assumes you can show your work, not just report a number.

The alternative treats every scan as an event appended to a ledger, and derives current state and cycle count by folding that ledger rather than storing them independently. It is the same move as durable webhook ingestion: the risk is not that events arrive, it is that they arrive more than once, and the system has to be indifferent to that. Here is the shape.

// reuse-cycle-ledger.ts -- modelling a reusable container's lifecycle as an
// event-sourced state machine instead of a mutable `status` column.
//
// The mistake this avoids:
//   UPDATE containers SET status = 'in_use', times_reused = times_reused + 1 WHERE id = ?
// PPWR's Article 8 reuse-system obligations ask for a lifecycle you can prove,
// not a count you can report. A mutated counter answers "how many" and nothing
// about "when, where, by whom" -- which is what a Declaration of Conformity and
// a market-surveillance request both actually want.

type ContainerState =
  | 'issued'
  | 'in_use'
  | 'return_pending'
  | 'inspected'
  | 'reissued'
  | 'retired'

/** One physical scan or lifecycle action. Append-only -- never mutated or deleted. */
interface ReuseEvent {
  eventId: string // idempotency key -- a duplicate scan with the same id is a no-op
  containerId: string // persistent identifier on the unit itself, survives cleaning/reissue
  type: 'issue' | 'return_scan' | 'inspect_pass' | 'inspect_fail' | 'retire'
  occurredAt: string // ISO 8601 -- when the physical event happened
  recordedAt: string // when the event reached the ledger (retries can lag this)
  memberState: string // ISO 3166-1 alpha-2, for the EPR rollup below
  actorId: string // scanning device or operator, for the audit trail a warning-first
  // regulator will ask to see
}

interface ContainerSnapshot {
  containerId: string
  state: ContainerState
  cycleCount: number // completed issue -> return -> reissue loops
  lastEvent: ReuseEvent | null
  history: ReuseEvent[]
}

/**
 * The ledger is the source of truth. State and cycle count are always derived
 * by folding events, never stored independently -- so "what was true on 30
 * June" is the same function with a shorter event list, not a second table.
 */
export function fold(events: ReuseEvent[], asOf?: string): ContainerSnapshot {
  const seen = new Set<string>()
  let state: ContainerState = 'issued'
  let cycleCount = 0
  let lastEvent: ReuseEvent | null = null
  const history: ReuseEvent[] = []

  const ordered = [...events].sort((a, b) => a.occurredAt.localeCompare(b.occurredAt))

  for (const event of ordered) {
    if (asOf && event.occurredAt > asOf) continue
    if (seen.has(event.eventId)) continue // duplicate scan -- idempotent no-op, not a second cycle
    seen.add(event.eventId)

    switch (event.type) {
      case 'issue':
        state = 'issued'
        break
      case 'return_scan':
        state = 'return_pending'
        break
      case 'inspect_pass':
        state = 'inspected'
        cycleCount += 1 // a completed loop, counted once, from the ledger -- never a column increment
        break
      case 'inspect_fail':
      case 'retire':
        state = 'retired'
        break
    }
    lastEvent = event
    history.push(event)
  }

  return { containerId: events[0]?.containerId ?? '', state, cycleCount, lastEvent, history }
}

/**
 * Per-Member-State reuse rate for an EPR filing -- the query a status column
 * cannot answer honestly for a past period, because it only ever knows "now."
 */
export function eprRollup(
  events: ReuseEvent[],
  memberState: string,
  periodStart: string,
  periodEnd: string,
): { memberState: string; completedCycles: number; containersActive: number } {
  const inPeriod = events.filter(
    (e) =>
      e.memberState === memberState &&
      e.occurredAt >= periodStart &&
      e.occurredAt <= periodEnd,
  )

  const byContainer = new Map<string, ReuseEvent[]>()
  for (const e of inPeriod) {
    byContainer.set(e.containerId, [...(byContainer.get(e.containerId) ?? []), e])
  }

  let completedCycles = 0
  for (const containerEvents of byContainer.values()) {
    completedCycles += containerEvents.filter((e) => e.type === 'inspect_pass').length
  }

  return { memberState, completedCycles, containersActive: byContainer.size }
}

The function that matters is fold, and what matters about it is that state and cycleCount never exist independently of the event list — asking what was true on a given date is the same function called with an asOf cutoff, not a separate snapshot table that can drift from the ledger. The seen set is the whole idempotency story: a QR scan that gets recorded twice, because a phone retried a flaky upload, is a no-op the second time rather than a phantom extra cycle inflating a number a regulator will eventually check against physical stock. eprRollup exists because an EPR filing is a claim about a past period in one jurisdiction, and a status column has no memory of what changed within a window that already closed.

This costs more than a counter column, and it is worth saying plainly what it costs: every read that needs current state now folds a list instead of selecting a row, the event table grows without bound unless you snapshot it periodically, and a new engineer takes longer to find where a number comes from. If your packaging is single-use, or your jurisdiction never asks for a per-unit history, this is over-engineering and a status column is the right answer. Reusable packaging under PPWR is not that category, because the reuse claim itself is the product.

What a founder or CTO does with this

The audit is not "do we have a database of containers." It is whether you can answer, for one specific unit, as of one specific date, how many completed cycles it has and who touched it — because that is the shape of both a Declaration of Conformity and a customer's own supplier due-diligence request ahead of their February 2027 refill obligations. If producing that answer means a support engineer running an ad hoc join across three tables and hoping nothing was double-counted, the gap will surface during a warning period or a due-diligence review, not on your schedule.

The five-point audit

  1. Every physical unit has a persistent identifier. Not a SKU shared across a batch — an identifier that survives cleaning and reissue, because the reuse count belongs to the object, not the product line.
  2. Every transition is an appended event with an idempotency key. Not a column update. A retried scan has to be provably harmless, not merely unlikely to happen.
  3. State and cycle count are derived, never stored as the source of truth. If the fold function and the counter column can disagree, the counter column is already lying to someone.
  4. You can answer as of a past date, not only right now. An EPR filing and a Declaration of Conformity both describe a period. "Currently 11 cycles" does not tell an auditor what was true when the filing period closed.
  5. The Declaration of Conformity is generated from the ledger, not maintained by hand. A spreadsheet someone updates when they remember is the same failure mode as the counter column, one layer up the stack.

My perspective

I was the senior full-stack and cloud engineer, and a board member, at Hey Circle, a circular-economy logistics platform that tracks reusable packaging through QR-coded issue and return cycles across multiple stakeholders. PPWR did not exist in its current form while I was building that system, so this is not a confession about a mistake we made — it is the lesson the regulation now makes unavoidable for anyone building in this space today.

If I were setting the engineering roadmap for a reusable-packaging platform right now, event-sourcing the container lifecycle would move from "the more elegant architecture" to "the only one that survives an audit." A counter column tells you a number. It does not tell you how the number was produced, and a warning-first enforcement process — which is what the Commission's own August FAQ describes — is built entirely around an operator's ability to show their work when asked, not just to state a total. The teams that get this right generally aren't the ones with the fanciest tracking dashboard. They're the ones who can rebuild a container's entire history from raw events and get the same answer twice.

Recommended action this quarter

Run the five-point audit above against your own system this week, on one real container, and trace whether the current-cycle number your dashboard shows is derived from an event trail or from a value someone incremented. If it's the latter, that's your gap, and it's cheaper to close before a customer's supplier due-diligence questionnaire or a market surveillance request finds it for you. If you're operating or building on top of a reuse or deposit-return system in the EU and want a second opinion on whether your data model can take that question, that's the kind of fractional CTO and architecture review I take on as part of the work I do.

Check your reuse ledger before someone else asks

If your platform tracks anything physical — containers, devices, inventory — and "how many times has this happened, and can you prove it" isn't a query you can run today, that's fractional CTO work. Book a time and we'll find where the history got flattened.

#Reusable Packaging#Event Sourcing#PPWR#Circular Economy#State Machines