Domain Definition¶
1. Ubiquitous Language¶
1.1 Consumer side¶
- A Consumer is anonymous or registered. An anonymous Consumer has only a stable, pseudonymous Visitor ID. Registration links that ID to an Account without replacing it, preserving earlier activity (ASR-3).
- A Scan resolves a GS1 Digital Link, such as https://packytrace.com/01/{GTIN}/10/{LOT}/21/{SERIAL}?17={EXPIRY}. The Resolver parses it whether it came from a camera or the in-app scanner.
- A Scanned Item is the physical-item data in that link: { gtin, lot?, serial?, expiry?, raw }. GTIN identifies the product (8–14 digits), Lot or Batch identifies production (GS1 AI 10), and Serial identifies one item (AI 21).
- A Product Catalog Entry is the per-GTIN identity, Brand attribution and Digital Label. The label contains regulated ingredients, allergens and nutrition data.
- A Resolved Passport combines the catalog entry with Verdict, Journey, Eco and Recipes sections. Each section is Pending, Loaded or Failed; a passport with a failed section is a Degraded Passport, not a failed scan (QAS-2).
- A Journey is the ordered supply-chain trace for {gtin, lot}. Recipes are optional external suggestions matched by product keyword.
- The Eco Score is either the average of producer-supplied Sustainability Pillars (CO₂, recyclability, animal welfare, local sourcing and packaging) on a 0–100 scale, or the community Eco-Score grade.
- A Health Profile holds one Primary Goal, conditions, allergies and dietary preferences. It exists only with explicit consent. The Primary Goal is the outcome the Consumer currently prioritizes.
- A Verdict is the scan-time assessment of a product against a Health Profile: Good, Careful, Avoid or Unknown, with reason codes and a goal-fit line.
- A Fridge is the registered Consumer's owned inventory. Freshness (fresh, expiring, expired) is derived from expiry and the current time.
- A Shopping List is the registered Consumer's intended purchases. Each Shopping List Item references a validated GTIN, stores a product snapshot and quantity, and moves from pending to bought or removed. Free-text items are not supported.
- An Alert asks for Consumer attention. Expiry checks raise Alerts; a scan Verdict is shown inline and is not stored as an Alert.
- A Consent Record is an immutable audit entry containing consent kind, grant state, timestamp, method and policy version.
1.2 Brand side¶
- A Brand is a food company, the unit of tenancy. tenant and brand_id are technical names for this concept.
- A Brand User belongs to exactly one Brand in the MVP.
- A Brand Dashboard shows only privacy-safe aggregate analytics (ASR-4).
Reward Points, Lottery Tickets and Reviews appear only in mock UI and are outside the MVP domain.
2. Subdomains¶
- Core: Personalization & Verdict. The profile × product judgement is the product's differentiator.
- Supporting: Passport Assembly; Scanning & Resolution; Fridge / Inventory; Shopping List / Provisioning; Measurement & Anonymization; Brand Analytics.
- Generic: Identity and Consent; Notifications.
- Future: Community / Reviews and Rewards.
Scanning, provenance, Eco presentation and Recipes are facets of building a trusted passport, so they belong to Product Passport rather than separate bounded contexts.
3. Bounded Contexts & Context Map¶
Seven bounded contexts map to seven independently deployed services. The API Gateway is a composition edge, not a bounded context; Notifications is planned infrastructure.
Solid arrows show synchronous supply; dashed arrows show measurement facts. Raw facts remain on the Consumer side. Only minimum-group-size aggregates cross the privacy wall (ASR-4).
3.1 The seven contexts¶
- Product Passport (passport-service) resolves scans, owns scan records, community-cache entries and producer-fed in-memory projections, and assembles passports. Producer systems remain authoritative for producer product data.
- Personalization & Verdict (personalization-service) owns Health Profiles, versioned rule policies and Verdict computation.
- Fridge (fridge-service) owns inventory, freshness and expiry Alerts.
- Shopping List (shopping-list-service) owns product-linked purchase intent.
- Identity & Consent (identity-service) owns visitors, Accounts, Brands and consent history. Keycloak owns authentication and credentials.
- Measurement & Anonymization (measurement-pipeline) owns aggregation windows and the minimum-group-size privacy rule.
- Brand Analytics (brand-analytics-service) owns tenant-scoped aggregate read models.
3.2 Relationships¶
Producer providers are customer-supplier relationships behind the product-source anticorruption layer (ACL). Open Food Facts and recipe APIs are conformist sources behind the same ACL. Product Passport exposes an open-host service to Personalization, Fridge and Shopping List; Identity exposes one to the consent gate. Shared schemas in contracts/ are the published language.
Fridge and Shopping List have no direct backend dependency. The SPA prompts the Consumer and calls both services through the Gateway, leaving the contexts independently deployable.
3.3 External data sources¶
- Producer providers: authoritative catalog entries, Eco Pillars and Journeys, synchronized as snapshots outside the scan path.
- Open Food Facts: cached Digital Labels and community Eco-Scores for unknown GTINs.
- Recipe APIs: optional per-scan suggestions, isolated by their own timeout.
Each adapter can degrade only its own passport section (QAS-2). The Open Food Facts path also uses a circuit breaker and cache. Agribalyse, USDA FoodData Central, openFDA and RASFF are future sources.
GS1 is a standard, not a runtime source. Brand attribution comes from the explicit GTIN -> BrandId mapping in the producer catalog, never a prefix guess or scan-time GS1 call.
3.4 Decided, not yet built¶
- Fridge does not yet consume HealthProfileUpdated to re-screen owned items.
- Measurement does not yet aggregate Fridge, Shopping List or visitor-link facts. Personalization does consume Identity's consent facts for profile erasure.
- AccountDeleted exists as a contract, but its publisher and erasure consumers do not.
- ConsentRevoked profile erasure is currently best-effort. Reliable outbox, retry, DLQ and audit handling must precede account-deletion implementation.
- AlertRaised has no notification consumer; the web app polls the Fridge alert feed.
4. Tactical Design, Aggregates, Entities, Value Objects¶
Value objects are immutable. Derived values are computed rather than persisted. An aggregate owns only state that belongs to its bounded context.
4.1 Product Passport context¶
ProductCatalogEntry is the aggregate root, identified by GTIN. It owns product identity (name, volume, organic status and certifications), Brand reference, origin, Digital Label, labelVerified, and optional producer Eco Pillars or community Eco-Score. Producer entries come from the in-memory provider snapshot; community entries use a separate persistent cache and cannot overwrite producer authority. Journeys resolve by {gtin, lot} when a lot is present; seeded demos may use a community fallback.
Scan-time models are context-local:
- ScannedItem { gtin, lot?, serial?, expiry?, raw } preserves physical-item data.
- ScanRecord { scanId, visitorId, consumerRef?, gtin, brandId, scannedAt } records the scan independently of passport assembly. Account deletion nullifies consumerRef.
- ResolvedPassport combines ScannedItem, the catalog entry and the Verdict, Journey, Eco and Recipes sections. It is a response model, not an aggregate.
Each optional section is modeled as Pending, Loaded(value) or Failed(reasonCode). The current synchronous scan response resolves every section to Loaded or Failed; Pending is retained in the model for contract compatibility, not as a client-polling state. Verdict failure codes remain distinct: unauthenticated, no_profile and verdict_unavailable. No section failure fails the whole scan. Eco is derived from the available producer Pillars or community grade; Recipes come from the recipe ACL.
The catalog contains no Consumer, ownership or freshness state.
4.2 Personalization & Verdict context ★ core¶
HealthProfile is the aggregate root, identified by Account ID. There is at most one per Account, with exactly one Primary Goal plus normalized conditions, allergies and dietary preferences. It can exist only while Identity reports granted health-profile consent; consent data itself remains in Identity (QAS-3).
VerdictService is the pure function (HealthProfile, DigitalLabel?, RuleSet) -> Verdict. The first matching grade rule wins:
- Missing Digital Label -> Unknown.
- Profile allergen found in the label -> Avoid.
- Condition or diet conflict -> Careful.
- Otherwise-unverified label -> Careful.
- Otherwise -> Good.
ConditionRulePolicy and DietRulePolicy supply the conflict rules. GoalFitPolicy independently returns good, neutral or poor; it never changes the grade. Unknown or unsupported inputs produce neutral, not a guess. The Verdict stores the matching reason codes and RuleSet version. Verdict, VerdictReason and GoalFit are computed per scan and are not persisted.
Rules use only typed allergens and nutrition available from the Digital Label. Policy thresholds and mappings live in the versioned RuleSet, not hardcoded branches.
4.3 Fridge context¶
Fridge is the aggregate root, identified by Account ID. It contains FridgeItem entities with a ScannedItem, an add-time PassportSnapshot (name, Brand and image), timestamps and status.
- A physical item is active at most once: by serial when present, otherwise by generated item ID. Multiple items may share GTIN, lot or expiry.
- consumed and discarded are terminal. There is no neutral removal because every removal contributes to used-versus-wasted measurement.
- FreshnessPolicy derives fresh, expiring (five days or less) or expired at read time. An expiry sweep raises at most one Alert per item and freshness level.
- The monthly waste projection folds both ItemConsumed and ItemDiscarded facts.
The Fridge is event-sourced as described in Section 4.8.
4.4 Identity & Consent context¶
- Account is an aggregate root with Account ID, email, display name and an append-only consent ledger. Current consent is the latest record for each kind; revocation appends a record rather than editing history.
- VisitorIdentity is an aggregate root with Visitor ID, creation time and optional linked Account ID. Linking preserves the original Visitor ID (ASR-3).
- Brand is an aggregate root with Brand ID, name and Brand User entities. Each Brand User belongs to exactly one Brand.
The erasure trigger determines the scope; every handler is idempotent:
Explicit profile deletion leaves consent unchanged. Consent revocation erases only the Health Profile, not the Account. Account deletion is specified to perform the full cascade once implemented. Published aggregate batches remain because they contain no account, visitor or scan identifier. Current delivery limitations are in Section 3.4.
4.5 Brand Analytics context¶
Measurement folds raw Consumer-side facts into minimum-group-size metric batches. Only those batches enter Brand Analytics.
Brand Analytics intentionally has no domain aggregate: it is the CQRS query side with read models such as scans per product, scan trend, Verdict distribution, scan-to-save and scanner-to-account conversion. Every row requires brand_id; no row contains scan, Visitor or Consumer identity (ASR-4).
4.6 Persistence boundaries¶
Health Profile, Fridge and Shopping List are server-owned. Anonymous Consumers persist no profile, inventory or list, only a pseudonymous Visitor Identity and Scan Records (ASR-3). Browser storage may cache the Visitor ID and session, but is never the source of truth.
Shopping List is not health data: it is not consent-gated and is erased by AccountDeleted, never by ConsentRevoked.
4.7 Shopping List context¶
ShoppingList is the aggregate root, identified by Account ID. It contains ShoppingListItem entities with item ID, GTIN, Brand ID, quantity, source, add-time PassportSnapshot (name, Brand and image), timestamp and status.
- Every item uses a resolvable GTIN and a snapshot captured at add time; quantity is between 1 and 1000.
- One pending item exists per GTIN. Adding it again increases quantity instead of creating another line.
- bought and removed are terminal.
- The list is server-owned, registered-only and not consent-gated.
ShoppingListItemQuantityChanged is a local stream fact used for replay, not a Kafka event. The three lifecycle events in the event catalog are published.
The Fridge ↔ Shopping List loop is client-orchestrated. The SPA turns the Consumer's choice into separate calls through the Gateway; neither service writes the other's schema. A list purchase can use the Fridge add-by-GTIN path.
4.8 Event-sourcing vocabulary (Fridge & Shopping List)¶
Fridge and Shopping List are event-sourced. During normal aggregate operation their append-only Event Store is the source of truth (fridge_events and shopping_list_events). A Stream is one aggregate's ordered Facts; Replay is the pure operation that folds them into current state; Rehydrate is the complete operation that loads and replays a Stream before deciding a command. The name follows the event-sourcing convention: to hydrate an in-memory object is to fill it with stored data, and these aggregates are filled again from their Facts for each command rather than being persisted as objects. A Projection is a rebuildable read model (fridge_items and shopping_list_items).
A command rehydrates state, checks invariants, appends a Fact and updates the projection in one transaction. Reads use the projection. Only the published events in the event catalog leave a service; local facts such as ShoppingListItemQuantityChanged do not.
5. Domain Events¶
The JSON Schemas in contracts/ are authoritative for published payload structure. This section defines event semantics and the private event-store vocabulary; it must not be used to override a schema. Every published event uses { eventId, eventType, occurredAt, schemaVersion, correlationId, causationId, payload }. Payloads carry references and reason codes, never domain entities or localized text.
Raw facts remain on the Consumer side. BrandMetricBatchPublished is the only event that crosses the privacy wall, after the configured minimum group size is reached.
5.1 Product Passport and Personalization¶
- ProductScanned { scanId, visitorId, consumerRef?, gtin, brandId } -> Measurement.
- VerdictComputed { scanId, gtin, brandId, grade, goalCode, ruleVersion } -> Measurement. It contains neither Verdict reasons nor Health Profile contents.
- HealthProfileUpdated { accountId, ruleVersion } and HealthProfileDeleted { accountId, reason } -> downstream re-screening or cleanup; consumers are planned.
5.2 Fridge¶
- ItemAddedToFridge { accountId, scanId, itemId, gtin, expiry? } -> Measurement (planned).
- ItemConsumed { accountId, itemId } and ItemDiscarded { accountId, itemId } -> local waste projection and Measurement (planned).
- ItemExpiring { accountId, itemId, gtin, expiry } and ItemExpired { accountId, itemId, gtin, expiry } record freshness thresholds.
- AlertRaised { accountId, severity, type, reasonCode } -> Notifications (planned).
5.3 Shopping List¶
- ItemAddedToShoppingList { accountId, itemId, gtin, brandId, source }.
- ShoppingListItemBought { accountId, itemId }.
- ShoppingListItemRemoved { accountId, itemId }.
All three are published; their Measurement consumer is planned. Quantity changes remain local stream facts.
5.4 Identity, Consent and Measurement¶
- ConsentGranted { accountId, kind, policyVersion } and ConsentRevoked { accountId, kind, policyVersion } -> Personalization.
- VisitorLinkedToAccount { visitorId, accountId } -> Measurement (planned).
- AccountDeleted { accountId } -> erasure cascade (contract only).
- BrandMetricBatchPublished { brandId, period, metric, dimensions, count, denominator } -> Brand Analytics. This is the only wall-crossing event.
As verified on 2026-08-01, Product, Verdict, Fridge, Shopping List, Visitor-link, consent and Health Profile facts are published. Measurement consumes only Product and Verdict facts and publishes their aggregate metric batches; other consumers retain the status noted above. Recheck this implementation snapshot when event consumers change.