Domain Definition

The decided domain model of PackyTrace. Architecture-significant choices live in the ADRs; this page holds the domain rules and vocabulary. Codes like QAS-x and ASR-x reference Quality Attribute Scenarios and Architecturally Significant Requirements. The current web-app implementation guide describes how the frontend realizes this model today; where terms differ, this page wins.

1. Ubiquitous Language

1.1 Consumer side

Term Meaning
Consumer The person scanning products. Anonymous (pseudonymous Visitor ID only) or registered (owns a Health Profile and Fridge).
Visitor ID Stable, invisible, pseudonymous id created at the first scan. Linked — not replaced — when an account is created, so pre-account activity is preserved (ASR-3).
Scan Decoding a GS1 Digital Link and resolving it to a Product Passport — the entry point of every consumer journey.
GS1 Digital Link The URL in the QR code (https://packytrace.app/01/{GTIN}/10/{LOT}/21/{SERIAL}?17={EXPIRY}). A real link on a PackyTrace domain: any camera app opens it and lands on the Resolver.
Resolver The entry point a Digital Link routes to: parses the path into a ScannedItem and resolves the GTIN to a passport. The in-app scanner is a shortcut to the same Resolver.
ScannedItem Per-physical-item facts from the URL: { gtin, lot?, serial?, expiry?, raw }. Item-level data lives here; the passport stays per-SKU.
GTIN Global Trade Item Number — the product's global identifier (8–14 digits).
Lot / Batch Production batch identifier (GS1 AI 10).
Serial Unique item serial (GS1 AI 21).
Product Catalog Entry The stored per-GTIN record: identity, Brand attribution, Digital Label. The reliable local base for scan resolution and Verdict computation.
Resolved Passport The progressively assembled scan response: catalog entry plus independently loaded Journey and Eco sections, each Pending, Loaded or Failed.
Digital Label The regulated on-pack information (ingredients, allergens, nutrition) — a section of the catalog entry, not a separate aggregate.
Journey Ordered supply-chain trace (farm → … → retail) — the provenance section.
Eco Score Aggregate sustainability rating (0–100) derived from the five Sustainability Pillars.
Sustainability Pillars CO₂, recyclability, animal welfare, local sourcing, packaging.
Health Profile The consumer's primary goal, conditions, allergies and dietary preferences — gated by explicit consent.
Primary Goal The single outcome the consumer currently prioritizes (manage blood sugar, avoid allergens, …). Drives the Verdict's goal-fit line.
Verdict The scan-time judgement of a product against a Health Profile: Good / Careful / Avoid, with reasons and a goal-fit line. The core concept.
Alert An attention signal toward the consumer: from a Careful/Avoid Verdict at scan time, or from Fridge expiry independently of any scan.
Fridge The consumer's personal inventory of owned products, with freshness tracking.
Freshness Derived state of an owned item — fresh / expiring / expired — computed from the expiry date.
Consent Record GDPR opt-in audit record (granted flag, timestamp, method, policy version).
Degraded Passport A Resolved Passport with Failed sections, served partial rather than failing the scan (QAS-2).

1.2 Brand side

Term Meaning
Brand A food company whose products carry passports — the unit of tenancy (ADR-001). tenant and brand_id are technical terms, not domain language.
Brand User A person authenticated into exactly one Brand. No cross-brand role in the MVP.
Brand Dashboard The Brand's aggregate-only analytics view, behind the privacy wall (ASR-4).

1.3 Out of MVP scope

Reward Points, Lottery Tickets and Reviews exist only in the mock UI; they are future concepts, not part of this model.

2. Subdomains

Subdomain Type Rationale
Personalization & Verdict Core "Does this fit me" is the differentiator. Product data is commodity; the profile × product judgement is not.
Passport Assembly Supporting Composing the passport from external sources is necessary and non-trivial (ACL, degradation), but the data is commodity.
Scanning & Resolution Supporting Decoding Digital Links and routing to assembly — folded into Product Passport (§3).
Fridge / Inventory Supporting Ownership and freshness drive retention and the waste story; standard inventory mechanics.
Brand Analytics Supporting The revenue side, but technically a read model over aggregate measurement events (CQRS).
Identity & Consent Generic Auth and GDPR consent — solved problems; but consent gates the core subdomain's input.
Notifications Generic Alert delivery — pure infrastructure.
Community / Reviews, Rewards Future Out of MVP.

3. Bounded Contexts & Context Map

Five bounded contexts, each an independently deployed service (ADR-007). Scanning & Resolution, Provenance and Eco Scoring fold into Product Passport: all facets of "build the trusted record", sourced through one anticorruption layer. Notifications is infrastructure; the gateway is composition — neither is a context.

flowchart TB subgraph EXT["External sources"] direction LR OFF["Open Food Facts"] AGB["Agribalyse (ADEME)"] end ACL{{"ACL + circuit breaker"}} subgraph CONSUMER["Consumer side"] PP["<b>Product Passport</b>"] PV["★ <b>Personalization & Verdict</b>"] FR["<b>Fridge</b>"] ID["<b>Identity & Consent</b>"] AP["<b>Measurement & Anonymization</b><br/>(pipeline module)"] end WALL["PRIVACY WALL"] subgraph BRAND["Brand side"] BA["<b>Brand Analytics</b>"] end OFF --> ACL AGB --> ACL ACL --> PP PP --> PV PP --> FR ID -->|"consent gate"| PV PV -->|"re-screen"| FR PP -.->|"raw scan fact"| AP PV -.->|"raw verdict fact"| AP FR -.->|"raw save fact"| AP ID -.->|"account-linked fact"| AP AP -.->|"k-anonymous aggregate batches"| WALL WALL -.-> BA classDef core fill:#fff3bf,stroke:#e8a500,stroke-width:3px,color:#000 classDef ctx fill:#e7f0fe,stroke:#3b6fb6,color:#000 classDef ext fill:#f1f3f5,stroke:#868e96,color:#000 classDef wall fill:#ffe3e3,stroke:#c92a2a,stroke-width:2px,color:#000 classDef acl fill:#e6fcf5,stroke:#0ca678,color:#000 class PV core class PP,FR,ID,AP,BA ctx class OFF,AGB ext class WALL wall class ACL acl

Solid arrows = upstream→downstream relationships (§3.2); dashed arrows = measurement facts. Raw facts stay on the consumer side; only minimum-group-size aggregates cross the privacy wall (ASR-4).

3.1 The five contexts

Context Subdomain (§2) Owns Notes
Product Passport Passport Assembly + Scanning & Resolution Product Catalog entries, ScanRecords, passport assembly Resolves Brand attribution from the catalog; assembles response sections behind the ACL (QAS-1/2).
Personalization & Verdict the core HealthProfile, versioned screening policies, Verdict, Alerts Runs server-side: profile data never travels with scan requests. Anonymous scans skip it.
Fridge Fridge / Inventory Fridge with FridgeItems, Freshness, expiry Alerts Re-screens items when the profile changes (event-driven).
Identity & Consent Identity & Consent Visitor identities, Accounts, sessions, the ConsentRecord ledger Owns visitor→account linking; consent revocation, profile deletion and account deletion are distinct actions (§4.4).
Brand Analytics Brand Analytics Aggregate read models per Brand, Brand Dashboard Entirely behind the privacy wall; consumes only aggregate batches; every record tenant-scoped (ADR-001).

3.2 Relationships

Upstream Downstream Relationship Notes
Open Food Facts / Agribalyse Product Passport Conformist behind an Anticorruption Layer External models become our VOs (DigitalLabel, EcoScore) at the ACL; circuit breaker and Degraded Passport live at this seam (QAS-2).
Product Passport Personalization & Verdict Customer–supplier The core consumes AllergenSet / label data; its needs drive the passport's internal API.
Product Passport Fridge Customer–supplier Product identity + expiry captured at add-to-fridge time.
Identity & Consent Personalization & Verdict Customer–supplier + events Consent gate: a HealthProfile exists only while consent is granted; ConsentRevoked triggers erasure (QAS-3).
Personalization & Verdict Fridge Event collaboration HealthProfileUpdated → Fridge re-screens owned items.
Measurement pipeline Brand Analytics Aggregate batches across the privacy wall Minimum group size enforced before publishing; no per-scan or per-visitor record crosses (ASR-4).

3.3 External data sources

See ADR-003. The ACL keeps sources pluggable. The MVP uses two independent upstreams — one per passport section, each behind its own circuit breaker, so one source down ≠ both sections down (QAS-2).

Source Feeds Access (verified 2026-06) Status
Open Food Facts DigitalLabel: ingredients, allergens, nutrition (~3M products) Free, no auth; custom User-Agent; 15 reads/min/IP → the ACL adapter must cache. MVP
Agribalyse (ADEME) EcoScore pillars: LCA data, ~2,600 foods Open data; upstream of the official Green-Score. MVP
USDA FoodData Central Nutrition fallback (US-centric) Free API key, 1,000 req/h. Future
openFDA / RASFF Future SafetyNotice (recalls) openFDA free, US data; RASFF has no public API. Future

GS1 is a standard we implement, not a runtime source. Brand attribution comes from the catalog's explicit GTIN → BrandId mapping populated at tenant onboarding — never from a prefix guess or a GS1 API call (GS1 lookups are rate-capped and membership-gated, unviable at scan time).

4. Tactical Design — Aggregates, Entities, Value Objects

One aggregate cluster per context. Value objects are immutable; everything derived is computed, never stored.

4.1 Product Passport context

Aggregate root: ProductCatalogEntry — identity: GTIN (per-SKU)

Member Kind Notes
gtin identity resolvable key; maps explicitly to the owning Brand
name, volume, image, organic, certifications[] attributes catalog data
brandRef reference the owning Brand, by id (Identity context owns the Brand aggregate)
origin VO Origin { country, region, producer }
digitalLabel VO DigitalLabel { nutrition, ingredients, allergens: AllergenSet, labelVerified } AllergenSet = EU-14 taxonomy codes, the authoritative screening input; labelVerified=false when ACL ingestion met unmappable allergen data

Invariants — identified and resolvable by GTIN; carries no consumer state (freshness and ownership belong to the Fridge).

Response model: ResolvedPassport — assembled per scan, not an aggregate:

ResolvedPassport
├── catalogEntry: ProductCatalogEntry
├── verdict?: Verdict
├── journey: TabData<Journey>
└── eco: TabData<EcoScore>

TabData<T> = Pending | Loaded<T> | Failed(reasonCode)

Failed sections make degradation explicit without polluting the stored catalog entry (QAS-1/2). EcoScore.overallScore is derived from its pillars.

Slice note (J3.4): the assembled response carries verdict as a TabData<Verdict> rather than the bare optional verdict? shown above. The Passport→Personalization call degrades to a Failed section with a reason code — unauthenticated (anonymous scan), no_profile (signed in, no Health Profile yet, the onboarding CTA), or verdict_unavailable (Personalization timed out or errored) — so the absent cases stay distinguishable to the result page's verdict hero. A verdict failure never fails the scan.

Context-local VOs: ScannedItem { gtin, lot?, serial?, expiry?, raw } — produced by the Resolver, handed to the Fridge on add. ScanRecord { scanId, visitorId, consumerRef?, gtin, brandId, scannedAt } — records the scan independently of response assembly; consumerRef is nullified on account deletion.

4.2 Personalization & Verdict context ★ core

Aggregate root: HealthProfile — identity: the owning Account id (one per consumer)

Member Kind Notes
primaryGoal VO PrimaryGoal exactly one active goal; drives goalFit
conditions[] typed condition codes e.g. diabetes, celiac
allergies VO AllergenSet same vocabulary as DigitalLabel
dietaryPreferences[] typed diet codes e.g. vegan, low-sodium
createdAt immutable

Invariants

  • Exists only while Identity reports granted consent: created after the consent gate, erased when ConsentRevoked arrives (QAS-3).
  • At most one HealthProfile per Account; exactly one PrimaryGoal.
  • No consent data stored here — consent lives in Identity.

Value objects

  • Verdict { grade: Good | Careful | Avoid | Unknown, reasons: VerdictReason[], goalFit }
  • VerdictReason { type: allergen | condition | diet | unverified, matchedCode, severity }
  • Alert { severity: high | medium | low, type, reasonCode } — presentation layers turn reason codes into localized messages

Grading rules — the verdict domain service (§6) composes versioned policies (ConditionRulePolicy, DietRulePolicy, GoalFitPolicy); the result records the ruleVersion. Expiry never affects the Verdict — it is not a judgement about fit.

flowchart TD IN(["DigitalLabel × HealthProfile"]) --> DEG{"Digital Label<br/>degraded?"} DEG -- yes --> UNK["Unknown<br/><i>cannot assess — never silently Good</i>"] DEG -- no --> ALG{"Allergen-code<br/>intersection?"} ALG -- yes --> AVOID["Avoid"] ALG -- no --> POL{"Condition or diet<br/>policy conflict?"} POL -- yes --> CARE["Careful"] POL -- no --> VERIF{"labelVerified?"} VERIF -- no --> CARE2["Careful<br/><i>reason: unverified</i>"] VERIF -- yes --> GOOD["Good"] classDef bad fill:#ffe3e3,stroke:#c92a2a,color:#000 classDef warn fill:#fff3bf,stroke:#e8a500,color:#000 classDef good fill:#e6fcf5,stroke:#0ca678,color:#000 classDef unk fill:#f1f3f5,stroke:#868e96,color:#000 class AVOID bad class CARE,CARE2 warn class GOOD good class UNK unk

GoalFitPolicy independently produces the goal-fit line from PrimaryGoal — it accompanies every grade rather than changing it.

RuleSet v1 (ruleVersion 1.0.0) — the versioned thresholds the policies above encode. Nutrition is read per 100 g/ml (how the catalog stores Digital Labels); thresholds are boundary-inclusive (a value at the cutoff conflicts). No free-text rules — only typed codes and constants, bumped under a new ruleVersion when changed.

Policy Code Conflict trigger Reason severity
Allergen screening any EU-14 code code ∈ label allergens high → Avoid
ConditionRulePolicy celiac gluten ∈ allergens medium
ConditionRulePolicy diabetes sugarG ≥ 15 medium
ConditionRulePolicy hypertension saltG ≥ 1.5 medium
DietRulePolicy vegan milk or eggs ∈ allergens low
DietRulePolicy low-sugar sugarG ≥ 15 low
DietRulePolicy low-sodium saltG ≥ 1.5 low

GoalFitPolicy classifies one nutrient per goal into good | neutral | poor (at/above high → poor, at/below low → good, else neutral); goalFit is the pair { goalCode, fit }, neutral when the label is degraded or the goal code is unknown:

primaryGoal Nutrient high (poor) / low (good)
manage-blood-sugar sugarG 15 / 5
heart-health saturatedFatG 5 / 1.5
reduce-sodium saltG 1.5 / 0.3
lose-weight energyKj 1500 / 200
general-wellbeing always neutral

4.3 Fridge context

Aggregate root: Fridge — identity: the owning Account id (one per consumer); contains FridgeItem entities.

Entity: FridgeItem

Member Kind Notes
itemId identity local to the Fridge
scannedItem VO ScannedItem carries the item's lot/serial/expiry
passportSnapshot VO name/brand/image captured at add time
addedAt attribute
status see below eaten ✓ vs wasted ✗
stateDiagram-v2 [*] --> bought: ItemAddedToFridge bought --> consumed: ItemConsumed — eaten ✓ bought --> discarded: ItemDiscarded — wasted ✗ consumed --> [*] discarded --> [*] note right of bought Freshness (fresh/expiring/expired) is derived from expiry + clock — never a stored state. Expiring/expired raise Alerts, not transitions. end note

Invariants

  • Each physical item appears at most once — by serial when available, else by generated itemId; multiple items may share GTIN, lot or expiry.
  • consumed and discarded are terminal; no other transitions exist.
  • The monthly waste summary is a projection over ItemDiscarded events — the event-sourcing subject.

Slice note (J4, complete): the fridge-service stands up the event store — an append-only fridge_events stream plus a fridge_items current-state projection maintained in the same transaction (ADR-009).

  • Add (4.1): POST /api/v1/me/fridge/itemsItemAddedToFridge, fetching the trusted ScannedItem + passport snapshot from passport's internal GET /internal/v1/scans/{scanId}/item; the single-physical-item invariant is enforced in the aggregate and by a unique index on (account_id, serial).
  • Read (4.2): GET /api/v1/me/fridge returns owned items soonest-to-expire first with derived freshness — FreshnessOf(expiry, now), ≤5-day "expiring" threshold, never stored.
  • Mark used/thrown (4.4): PATCH …/items/{itemId}ItemConsumed / ItemDiscarded (terminal); the projection status flips, freeing the serial. There is no neutral removal — every removal is a used/thrown classification, so the waste count stays honest (the bare DELETE is intentionally not offered).
  • Waste summary (4.5): GET …/waste-summary folds the month's ItemConsumed/ItemDiscarded facts into plain used/wasted counts.
  • Expiry alerts (4.3): a scheduled in-process sweep applies the FreshnessPolicy to all active dated items and raises one alert per item per level (fridge_alerts, unique on (item_id, level) — no spam), emitting ItemExpiring/ItemExpired + AlertRaised to Kafka. The web app reads the in-app feed via GET …/alerts and dismisses with DELETE …/alerts/{id}; real push delivery still awaits notifications infrastructure.

Aggregate root: Account (consumer) — accountId, email, displayName, and an append-only consentLedger[] of ConsentRecord entities { kind, granted, timestamp, method, policyVersion }. Current consent = the latest record per kind; revocation appends, never edits.

Aggregate root: VisitorIdentityvisitorId, createdAt, linkedAccountId?. Created invisibly at the first scan; linking an Account preserves all earlier activity without changing the Visitor ID (ASR-3).

Aggregate root: Brandthe tenant unitbrandId, name, and BrandUser entities, each belonging to exactly one Brand.

Three erasure triggers exist, with deliberately different scopes (QAS-3); all handlers are idempotent:

flowchart LR CR["ConsentRevoked"] --> P["erase HealthProfile"] PD["explicit profile deletion"] --> P AD["AccountDeleted"] --> P AD --> F["erase Fridge"] AD --> C["disable credentials"] AD --> S["nullify consumerRef<br/>on ScanRecords"] classDef trig fill:#e7f0fe,stroke:#3b6fb6,color:#000 classDef act fill:#ffe3e3,stroke:#c92a2a,color:#000 class CR,PD,AD trig class P,F,C,S act

Profile deletion does not touch the consent ledger; revocation erases the profile but not the Account. Already-published aggregate batches are unlinkable by construction.

Slice note (J3.5): the ConsentRevoked → erase HealthProfile handler ships in the slice as a best-effort Kafka consumer — fire-and-forget, auto-commit, a failed or malformed message is logged and skipped, not retried. The handler is idempotent (a redelivered revocation erases nothing and emits no HealthProfileDeleted). Reliable delivery (outbox + retries + DLQ + audit) is brought forward in P3 before the account-deletion endpoint ships; the AccountDeleted trigger and the Fridge/Passport legs of the cascade are deferred to P3 as well.

4.5 Brand Analytics context

No domain aggregates — by design (CQRS): only read models built from privacy-safe aggregate batches, e.g. ScansPerProduct, ScanTrend, verdict distributions, % scans saved, % scanners with accounts. Every row is tenant-scoped by brand_id; no row contains a scan, visitor or consumer identity (ASR-4).

4.6 Persistence boundaries

See ADR-004. HealthProfile and Fridge are server-owned. Anonymous consumers persist no profile or inventory — only a pseudonymous VisitorIdentity and ScanRecords for measurement (ASR-3). Web-app localStorage is at most a cache for the Visitor ID and session, never the source of truth.

5. Domain Events

The canonical event catalog — these names are load-bearing: the event-sourced Fridge, the CQRS read models and the Kafka contracts in contracts/ reuse them verbatim. Raw measurement events stay on the consumer side; only BrandMetricBatchPublished crosses the privacy wall (ADR-005).

Every event uses the envelope { eventId, eventType, occurredAt, schemaVersion, correlationId, causationId, payload } (ADR-006); the table shows payloads only.

Event Published by Consumed by Payload Notes
ProductScanned Product Passport (Resolver) measurement pipeline { scanId, visitorId, consumerRef?, gtin, brandId } Raw consumer-side fact; never crosses the wall.
VerdictComputed Personalization & Verdict measurement pipeline { scanId, gtin, brandId, grade, goalCode, ruleVersion } No reasons or profile contents.
ItemAddedToFridge Fridge projections; measurement pipeline { accountId, scanId?, itemId, gtin, expiry } scanId enables scan→save conversion measurement.
VisitorLinkedToAccount Identity & Consent measurement pipeline { visitorId, accountId } Scanner→account conversion without losing pre-account activity.
BrandMetricBatchPublished ⛔→📊 measurement pipeline Brand Analytics { brandId, period, metric, dimensions, count, denominator? } Published only when the group meets the minimum size; no scan, visitor or account reference.
AlertRaised Personalization, Fridge Notifications (infra) { accountId, severity, type, reasonCode } Presentation supplies the localized message.
ConsentGranted / ConsentRevoked Identity & Consent Personalization { accountId, kind, policyVersion } Revocation erases the HealthProfile, not the Account (QAS-3).
HealthProfileDeleted Personalization & Verdict Fridge { accountId, reason } Fridge removes personalized projections.
AccountDeleted Identity & Consent Personalization, Fridge, Product Passport { accountId } Full erasure cascade (§4.4); handlers idempotent.
HealthProfileUpdated Personalization & Verdict Fridge { accountId, ruleVersion } Reference only — no profile contents; Fridge re-screens.
ItemConsumed Fridge waste projection { accountId, itemId } Stream event — eaten ✓.
ItemDiscarded Fridge waste projection { accountId, itemId } Stream event — wasted ✗; the monthly waste summary folds over these.
ItemExpiring / ItemExpired Fridge (scheduled freshness check) Fridge → AlertRaised { accountId, itemId, gtin, expiry } Clock-derived — not part of the state fold (state = Added/Consumed/Discarded only).

6. Domain Services & Repositories

6.1 Domain services

Service Context Contract Notes
VerdictService Personalization ★ (HealthProfile, DigitalLabel?, RuleSet) → Verdict Pure function composing the policies of §4.2; records ruleVersion.
ConditionRulePolicy / DietRulePolicy Personalization ★ typed profile codes + label → conflicts Versioned thresholds; no free-text rules.
GoalFitPolicy Personalization ★ (PrimaryGoal, DigitalLabel) → GoalFit Produces the goal-fit line (QAS-1).
AllergenMappingPolicy Product Passport (ACL) external tags → AllergenSet + labelVerified Runs at ingestion, not scan time; unmappable input sets labelVerified=false.
PassportAssemblyService Product Passport ScannedItem → ResolvedPassport Local catalog entry first, then progressive TabData sections behind per-source breakers (QAS-1/2).
MeasurementAggregationPipeline consumer side raw facts → BrandMetricBatchPublished Enforces minimum group size before the wall.
FreshnessPolicy Fridge (expiry, now) → Freshness Pure; the scheduled check applies it and emits ItemExpiring/ItemExpired.
ErasureProcess cross-context see §4.4 diagram Distinct triggers, idempotent handlers — the QAS-3 implementation.

6.2 Repositories

Repository Lookup Notes
ProductCatalogRepository GTIN / Brand id authoritative GTIN→Brand mapping and local label data
ScanRecordRepository Scan id / Visitor id consumer-side facts; nullable Account reference
HealthProfileRepository Account id server-owned
FridgeRepository Account id event-store-backed: appends stream events, rehydrates by folding them
AccountRepository Account id / email includes the append-only consent ledger
VisitorIdentityRepository Visitor id / linked Account id stable anonymous measurement identity
BrandRepository Brand id Brand and BrandUser; product ownership lives in Product Catalog

Brand Analytics has no repositories — its read models are the CQRS query side, populated only by the wall-crossing events above.