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.
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
verdictas aTabData<Verdict>rather than the bare optionalverdict?shown above. The Passport→Personalization call degrades to aFailedsection with a reason code —unauthenticated(anonymous scan),no_profile(signed in, no Health Profile yet, the onboarding CTA), orverdict_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
ConsentRevokedarrives (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.
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 ✗ |
Invariants
- Each physical item appears at most once — by serial when available, else by
generated
itemId; multiple items may share GTIN, lot or expiry. consumedanddiscardedare terminal; no other transitions exist.- The monthly waste summary is a projection over
ItemDiscardedevents — the event-sourcing subject.
Slice note (J4, complete): the
fridge-servicestands up the event store — an append-onlyfridge_eventsstream plus afridge_itemscurrent-state projection maintained in the same transaction (ADR-009).
- Add (4.1):
POST /api/v1/me/fridge/items→ItemAddedToFridge, fetching the trustedScannedItem+ passport snapshot from passport's internalGET /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/fridgereturns 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 bareDELETEis intentionally not offered).- Waste summary (4.5):
GET …/waste-summaryfolds the month'sItemConsumed/ItemDiscardedfacts 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), emittingItemExpiring/ItemExpired+AlertRaisedto Kafka. The web app reads the in-app feed viaGET …/alertsand dismisses withDELETE …/alerts/{id}; real push delivery still awaits notifications infrastructure.
4.4 Identity & Consent context¶
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: VisitorIdentity — visitorId, createdAt, linkedAccountId?.
Created invisibly at the first scan; linking an Account preserves all earlier activity
without changing the Visitor ID (ASR-3).
Aggregate root: Brand — the tenant unit — brandId, name, and BrandUser
entities, each belonging to exactly one Brand.
Three erasure triggers exist, with deliberately different scopes (QAS-3); all handlers are idempotent:
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 HealthProfilehandler 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 noHealthProfileDeleted). Reliable delivery (outbox + retries + DLQ + audit) is brought forward in P3 before the account-deletion endpoint ships; theAccountDeletedtrigger 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.