Boundary–Control–Entity, verified against the shipped backend (backend/app/ on origin/main, July 2026) — not the founding plan. This page + Data Model + API are the deepest layer of this spec: if you only had these three pages and the code's directory names, you could rebuild the backend's shape.
api → features → core, and features → boundaries. integrations implements boundaries and is the only place an external SDK (anthropic, stripe, google-auth, sqlalchemy's engine, …) may be imported. wiring.py is the single composition root — the only file allowed to import both a use case and a concrete integration and wire them together. core/ imports no framework and performs no I/O — no datetime.now(), no DB session, no HTTP client; time comes in only via the injected Clock. A CI lint (scripts/check_core_purity.py) fails the build if this is violated.
| Layer | Folder | Holds | May import |
|---|---|---|---|
| Entity | core/ | Pure domain: value objects, policies, pure functions (meal estimation, band-state classification, RSS aggregation, target-building, billing catalogs, nutrients, locale/market rules, calibration, backdate rules) | stdlib + other core/ modules only |
| Control | features/ | Use cases — one class per file, orchestrates core + boundaries | core/, boundaries/ (never another features/ class, never integrations/ directly) |
| Boundary | boundaries/ | Protocol interfaces — the seam to every external system | core/ types only (for method signatures) |
| Integration | integrations/ | Concrete adapters implementing a boundary; the only place an SDK/driver/ORM engine is imported; also owns the SQLAlchemy models.py (ORM rows are infrastructure, never in core/) | the SDK it wraps, boundaries/, core/ (to map rows ↔ domain types) |
| Boundary (HTTP) | api/ | Thin FastAPI routers — parse request, call one features/ class, map the result/exception to a response | features/, Pydantic wire models (defined alongside the router) |
| Composition root | wiring.py | Reads settings.py, chooses which integrations/ class implements each boundary, constructs every features/ use case with its dependencies, registers routers | everything |
.claude/skills/python-coding-standards/SKILL.md, "Naming by BCE layer")~40 shipped classes already follow one shape per layer. New code matches it — grep the layer and copy the neighbours rather than inventing a scheme.
| Layer | Pattern | Examples |
|---|---|---|
Boundary (boundaries/, a Protocol) | <Domain><RoleNoun> — domain first, role last. Role noun from a fixed vocabulary: Store (persistence), Estimator (AI→estimate), Service (external transactional system), Gateway (external session/entry), Meter, Codec, Emailer, Clock. Fall back to bare Boundary only when no crisp noun fits. | MealStore, AiEstimator, PaymentService, SessionCodec, UsageMeter, AuthBoundary |
Integration (integrations/, an adapter) | <AdapterTech><ExactBoundaryName> — provider/tech first, boundary name verbatim after. Never feature-first-then-provider. | AnthropicAiEstimator + FakeAiEstimator (both implement AiEstimator), SqlAlchemyMealStore, StripePaymentService, GoogleAuthBoundary, JwtSessionCodec, SystemClock, InMemoryDemoSessionGateway |
Use case (features/, the control) | An imperative verb phrase — not a noun, not a …Generation/…Activity/…BA suffix. A verb phrase names the process; a role noun (boundary) names the thing that does it. | LogMeal, SaveMeal, BuildDailyView, RefineMeal, SearchMealHistory, SocialSignIn, StartCheckout, GenerateDailySuggestion |
Domain (core/) | Plain nouns for value objects; configurable rules → <Domain>Policy; SQLAlchemy ORM rows (in integrations/models.py, never core/) → <Entity>Row | MealEstimate, SuggestionContext, DailyTipPolicy, MealRow, UserRow |
Avoid: vague role suffixes (Manager, Handler, Processor, Facade, bare Service outside the external-boundary sense) and operation nouns as a class role (…Generation, …Building, …Logging) — the operation is the verb-phrase use case; the object is the role-noun boundary. Prefer extending an existing boundary (e.g. add a method to AiEstimator for both meal parsing and tip/suggestion generation) over minting a new provider-named …Generator boundary.
assets/diagrams/boundary-integration-map.mmd — verified against backend/app/boundaries/ + backend/app/integrations/ (July 2026). Dashed nodes are not yet implemented.Every row is a Protocol in backend/app/boundaries/ with at least one concrete adapter in backend/app/integrations/. A Fake*/InMemory* adapter exists wherever a story needs to run in CI without a real credential.
| Boundary | Hides | Shipped integration(s) | Used by (features) |
|---|---|---|---|
AiEstimator | Anthropic Claude — meal parsing + tip/suggestion generation. Only module allowed to import the anthropic SDK. | AnthropicAiEstimator, FakeAiEstimator | LogMeal, RunAiJob, ReestimateDraft, RefineMeal, GenerateDailySuggestion, RunCalibration |
MealStore | Read-side meal persistence (owns only the meals table's queries) | SqlAlchemyMealStore | SearchMealHistory and meal read endpoints |
MealLogStore | Atomic draft-meal + AI-job creation — the one transaction that must not split | SqlAlchemyMealLogStore | LogMeal |
MealSaveStore | Atomic draft→logged save + daily_summaries recompute | SqlAlchemyMealSaveStore | SaveMeal |
MealReestimateStore | Re-estimating a caller-owned draft (capped 1×) | SqlAlchemyMealReestimateStore | ReestimateDraft |
MealRefineStore | Replacing a caller-owned logged meal's estimate + day recompute | SqlAlchemyMealRefineStore | RefineMeal (feature exists; no API route wired yet — gap) |
MealMemoryStore | Confirmed-dish reuse (meal_memory) — 0-AI-call repeat logging ≥3 confirmations | SqlAlchemyMealMemoryStore | LogMeal, SaveMeal |
AiJobStore | Async estimation worker queue (ai_jobs); patches the estimate onto the owning meal atomically | SqlAlchemyAiJobStore | RunAiJob |
ProfileStore | User profile + preferences (currently: Today-nutrient visibility) | SqlAlchemyProfileStore | profile endpoints |
DailyViewStore | Read seam assembling one local day's band inputs (daily_summaries + user_targets + Today nutrients) | not yet implemented — MOCK_MODE serves GET /daily from fixtures instead (Epic 03) | BuildDailyView |
DayContextStore | Optional per-day wellbeing context tags (daily_context_tags) | SqlAlchemyDayContextStore | day-context endpoints |
ActivityContextStore | Optional per-day movement/turnover context (activity_context) — informational only, never feeds bands/budgets/streaks | SqlAlchemyActivityContextStore | activity-context endpoints |
AuthBoundary | The social identity provider's OAuth 2.0/OIDC Authorization Code dance — build the redirect, exchange the code for identity. emealia stores no passwords. | GoogleAuthBoundary, FakeAuthBoundary | SocialSignIn |
AuthUserStore | Claim-or-create on (auth_provider, provider_subject) — the identity claim, nothing else (not profile, not meals, not usage) | SqlAlchemyAuthUserStore | SocialSignIn |
SessionCodec | Signs/reads the session-cookie token; signing key/algorithm live only in the integration | JwtSessionCodec (HS256) | /auth/* endpoints |
DemoSessionGateway | Pre-signup demo sessions, rate-limited per IP | InMemoryDemoSessionGateway | /demo-sessions endpoint |
PaymentService | Stripe — paid subscriptions only. The free trial is never enforced here (non-negotiable #3) — that's UsageMeter's job. | StripePaymentService, FakePaymentService | StartCheckout, StartCustomerPortal |
Emailer | Transactional email | not yet implemented | (not yet called by a shipped feature) |
Clock | System time — testable via fake/fixed time; local_date defines "today" per the user's IANA timezone | SystemClock | nearly every feature (backdate windows, trial reservation, daily_summaries.local_date) |
UsageMeter | Trial entitlement (check_entitlement) + per-user/global AI rate caps (check_and_reserve) — two distinct checks, two distinct failure modes | SqlAlchemyUsageMeter | LogMeal, ReestimateDraft, RefineMeal |
ConsentStore | GDPR consent grant/revoke ledger, versioned | SqlAlchemyConsentStore | BodyHealthConsent |
features/ — 13 shipped)| Class | Does |
|---|---|
LogMeal | Consent + backdate + entitlement checks, creates a draft meal + ai_jobs row atomically (via MealLogStore), returns 202 |
SaveMeal | Flips a draft to logged, recomputes daily_summaries (RSS aggregation) atomically |
ReestimateDraft | Re-runs the AI estimate on a caller-owned draft, capped at 1 re-estimate |
RefineMeal | Replaces a caller-owned logged meal's estimate, recomputing that day's summary (not yet exposed via an API route) |
RunAiJob | The async worker step: calls AiEstimator, maps + validates the response, writes it onto the job's meal |
RunCalibration | Gate G2 tooling — runs a batch of real meals through the estimator and reports the correction rate |
BuildDailyView | Assembles the no-numbers band view model for Today (reads DailyViewStore) |
GenerateDailySuggestion | Coaching: builds a bounded SuggestionContext, calls AiEstimator.suggest_daily, gated by the pure DailyTipPolicy |
SearchMealHistory | Keyword search over a user's logged meals + meal_memory |
SocialSignIn | Exchanges the provider's auth code (AuthBoundary) for an identity, claims-or-creates the user row (AuthUserStore), issues a session (SessionCodec) |
StartCheckout | Resolves the market's Stripe price id (BillingCatalogs) and opens a Stripe Checkout session |
StartCustomerPortal | Opens a Stripe customer-portal session for self-service plan management |
BodyHealthConsent | Grant/revoke the Art. 9 body-health-data consent |
No use case imports another use case — orchestration composes at the api/ or wiring.py level, not feature-to-feature. New feature modules stay ≤250 lines of logic.
These items are named in CLAUDE.md, the concept docs, or research notes, but have no code behind them yet. An AI asked to "rebuild emealia as it is" should treat everything above this section as the spec, and everything below as future scope only.
| Item | Status |
|---|---|
Access+refresh session split, a sessions table + SessionStore boundary, rotation/revocation, POST /auth/logout-all | Planned — story 7.4a/7.4b |
AppleAuthBoundary (Apple Sign-In) | Planned — story 7.1b |
| Age gate | Planned — story 7.7 |
Account settings, GET/PATCH /account, full_name/username columns on users | Planned — story 7.6/7.6b |
daily_suggestions table + SuggestionStore boundary (persisting coaching output) | Planned — story 4.5 |
| Every-other-day coaching review suggestions (4.6a–c) | Deferred for beta |
| Breakfast-adaptive daily-tip threshold (4.4d) | Proposed only, not accepted |
| Epic 18 — activity logging as first-class context beyond the current calm turnover-range note | DRAFT — explicitly "do not implement" per the implementation plan |
Emailer integration (SES or equivalent) | Boundary declared, no adapter shipped |
DailyViewStore real integration (Today is currently MOCK_MODE-only) | Epic 03 in progress |
| emealia Plus add-on layer (recipes, photo logging, advanced stats/export) | Post-launch, gated on D7 ≥ 30% |
The shipped core/daily_tip.py + DailyTipPolicy (story 4.3, GenerateDailySuggestion) gate the one daily tip on: cumulative kcal today > 35% of the rolling baseline (floor 300 kcal), time gates at 10:00 / 20:00, hard cutoff 23:00, minimum 3 logging days all-time, 1/day. CLAUDE.md's root context states a 50%-of-7-day-median threshold with only a 10:00/23:00 gate — that wording is stale; the values above (35%/300kcal, 10:00/20:00/23:00) are what the shipped policy implements. Every-other-day review suggestions are deferred for beta, not currently built.
LogMeal does not import PaymentService, StartCheckout does not import AiEstimator.MealMemoryStore only touches meal_memory, never reaches into meals) — see modularity-maintainability skill.backend/prompts/ as versioned data files, never string literals in integrations/anthropic_estimator.py. Changing a prompt = changing a data file + bumping its version; AiEstimator reads the active version.backend/config/admin.toml, loaded by admin_config.py — not secrets, deploy-overridable via ADMIN_CONFIG_PATH.