Areto MVP — State Reconnaissance Report

Mode: read-only. No source/prompt/schema/config changed. Generated 2026-06-13. Scope: state of the system as it is, with path:line evidence. No recommendations.

Note on parallel abstractions (flagged, not resolved): there are two conversational use cases that both assemble a "free conversation" prompt: - TalkConversationUseCase (src/application/use_cases/talk_conversation.py:60) → llm_service.talk_streamingTALK composition. Wired to the Mini App (src/interfaces/api/v1/chat.py:337) for all three surfaces (free, talk, analyze). - FreeConversationUseCase (src/application/use_cases/free_conversation.py:44) → llm_service.free_chat_streamingFREE_CHAT composition. Wired to the Telegram bot text path (src/interfaces/telegram/bot.py:234, src/interfaces/telegram/handlers/message_router.py:371).

Both are live. The Mini App is the primary surface; the report treats the TALK path as the canonical Rung-1 dialogue and flags the FREE_CHAT path where it diverges.


A. Rung-1 first-session dialogue

A.1 Where the Talk prompt is assembled

  • Entry function (Mini App): TalkConversationUseCase.execute_streamingsrc/application/use_cases/talk_conversation.py:76.
  • It calls self._llm.talk_streaming(...)talk_conversation.py:248.
  • Final string-assembly site: LLMService._build_unified_talk_composedsrc/infrastructure/llm/llm_service.py:880, invoked from LLMService.talk_streamingllm_service.py:943.
  • Composition spec: TALKsrc/infrastructure/llm/composition_specs.py:104.
  • Dynamic suffix template (on disk): src/infrastructure/llm/prompts/talk/dynamic_template.txt, loaded at composition_specs.py:125.
  • Layered composer: compose_promptsrc/infrastructure/llm/composed_loader.py:168.
  • Telegram path equivalent: _build_talk_composed_promptllm_service.py:699, composition FREE_CHATcomposition_specs.py:58.

Static (cached) layers of TALK (composition_specs.py:106-122): shared = role_free_chat, talk_posture, capabilities, memory_continuity, epistemic_calibration, date_discipline, soft_deferral, anti_sycophancy, adaptive_fragment_policy; localized = tone_examples_{lang}, voice_anchors_{lang}, persona_boundary_{lang}, respond_directive_{lang}.

A.2 Posture system and the brand-new user's default

  • The posture doctrine lives in the static prompt block src/infrastructure/llm/prompts/shared/talk_posture.txt:1 ("You are a mirror first… Going deeper is available, but only by invitation").
  • There are three registers: mirror, depth, unloadsrc/application/services/talk_tools.py:23 (REGISTER_VALUES).
  • mirror is the default: it is asserted in the static prompt, not selected by code. The only register the code toggles is depth, via depth_block:
  • depth_invited(message) keyword detector — talk_conversation.py:55 (regex at :48).
  • Sticky depth (once invited, stays for session) — talk_conversation.py:188-191.
  • depth_block is empty unless in_depthtalk_conversation.py:193-200; the {depth_block} slot is the last line of talk/dynamic_template.txt:16.
  • unload is selected by the model itself per the "Exhaustion override" rules in talk_posture.txt:32-46; no code path forces it.

Brand-new user's first message → mirror. A cold first turn carries no depth keyword (regex talk_conversation.py:48), so in_depth is False (talk_conversation.py:191), depth_block stays "" (:193), and the static "mirror first" block governs. There is no first-session branch (see A.5).

A.3 Cold first turn (zero stored memory): does the machinery still run?

For a cold first turn (no memory, no profile), the following still execute:

  • Personality lens — attempted unconditionally (talk_conversation.py:122-136). Returns empty for a thin/absent map (load_active_lens), so personality_lens="".
  • RAG / memory-packet retrieval — runs unless gated.
  • B3 low-content gate (should_skip_retrieval) can skip it for filler turns (talk_conversation.py:144-152); a substantive first message is not skipped.
  • memory_retriever.assemble_full_context is called (talk_conversation.py:166). With zero stored memory it returns an effectively empty packet → relevant_context_block="".
  • Profile summary — loaded best-effort (talk_conversation.py:110-116); empty for a new user.
  • Crisis pre-gate — always runs (_evaluate_crisis, talk_conversation.py:223, :419). On "none" it adds nothing.
  • About-intent short-circuit — runs only when crisis == "none" (talk_conversation.py:228-245); can bypass Sonnet entirely (see A.5 / D / X2).

Voice Principles block (voice_anchors_{lang}) IS injected on a cold turn — it is a static localized_partial of TALK (composition_specs.py:119), so it is present every turn regardless of stored memory.

Cold-turn prompt skeleton actually sent (system param built at claude_client.py:78-100, three+ cache blocks): 1. shared_static (9 partials above) — cached. 2. localized_static (4 partials incl. voice_anchors) — cached. 3. lens_staticomitted on cold turn (empty; block only added when non-empty, claude_client.py:94-98). 4. dynamic = talk/dynamic_template.txt filled: temporal_context_block (present, computed live llm_service.py:918), session_recap_block ("" on first turn, llm_service.py:921), user_profile_block"—", adaptive_fragment_block (AF, may be "—"), task_tool_hint_block → likely "—", relevant_context_block"—" (llm_service.py:934), depth_block"". Messages array = the single user turn (llm_service.py:939-940).

Token-size estimate of the cold prompt: [NOT FOUND] — no token counter is invoked on this path; not measured here. The static layers are large (e.g. talk_posture.txt ~70 lines, plus 8 other shared + 4 localized partials), so the cold prompt is dominated by the static (cacheable) system blocks, not the near-empty dynamic suffix. Exact count not computed (read-only).

A.4 The Anthropic dialogue call

  • Wrapper: ClaudeClient.complete_streaming_with_toolssrc/infrastructure/llm/claude_client.py:411, opening self._client.messages.stream(...) at claude_client.py:427.
  • Model: claude-sonnet-4-6 — passed as self._router.SONNET (llm_service.py:986), defined src/infrastructure/llm/router.py:4.
  • max_tokens: default 1000claude_client.py:417; talk_streaming does not override it.
  • Streaming: yes (messages.stream, claude_client.py:427; the use case yields chunks, talk_conversation.py:261-263).
  • Tools always attached: record_analysis + create_taskllm_service.py:978-981.
  • (Telegram FREE_CHAT path uses self._router.get_model("free_chat") → also Sonnet, router.py:24 / llm_service.py:858.)

A.5 First-session / onboarding-specific prompt branch?

No onboarding-specific prompt branch in the Talk turn. The first turn is assembled identically to any later turn — same TALK composition, same registers. The only first-turn-specific behaviours are non-prompt short-circuits: - About-intent canned answer once per session (talk_conversation.py:235-245), serving the static i18n string about_areto.response (not an LLM turn). - Cold-turn emptiness is data-driven (empty profile/packet/recap), not a branch.

Onboarding proper (name capture etc.) is a separate Telegram flow (src/interfaces/telegram/handlers/start.py:69-73, onboarding.py), not part of the Talk prompt.

Change surface (do not modify)

  • src/infrastructure/llm/prompts/shared/talk_posture.txt (posture calibration).
  • src/infrastructure/llm/prompts/talk/dynamic_template.txt (slot order/labels).
  • src/infrastructure/llm/prompts/shared/role_free_chat.txt, capabilities.txt, localized/voice_anchors_{lang}.txt, localized/respond_directive_{lang}.txt.
  • src/infrastructure/llm/composition_specs.py:104 (TALK partial list).
  • src/application/use_cases/talk_conversation.py:76 (retrieval/depth/crisis ordering).
  • src/infrastructure/llm/llm_service.py:880 / :943 (assembly + model/max_tokens).

B. Tier / entitlement gating

B.1 How tier is represented

  • Single boolean column users.is_prosrc/infrastructure/db/models.py:75 (migration a7b8c9d0e1f2_add_day_planner.py:25, server_default false).
  • It is NOT on the User domain entity (src/domain/entities/user.py:9-23 has no tier field — only goal_loop_enabled, pod_access, etc.).
  • Adjacent monetization columns also exist on UserModel: bundle_id (FK token_bundles), haiku_limit_override, sonnet_limit_override (models.py:88-93); table TokenBundleModel/token_bundles (models.py:40), UserTokenBudgetModel/user_token_budgets (models.py:421). These describe a token-bundle model, parallel to is_pro. (Flagged; not resolved.)
  • No tier/plan/subscription enum column anywhere. No Stars subscription state column (see B.5).

B.2 Where entitlement is enforced today

Central choke point exists but is a no-op: SubscriptionHandler.is_pro() returns True for everyonesrc/application/services/subscription_handler.py:17-19 (docstring: "MVP: returns True for everyone until a real subscription system lands", :4). require_pro raises only if is_pro is False (:21), which never happens.

Enforcement sites found: - Planner (Pro-gated via the handler): - src/interfaces/telegram/handlers/planner.py:106 - src/interfaces/telegram/handlers/planner_intent.py:37 - src/interfaces/telegram/handlers/planner_integrations.py:58, :157 - src/interfaces/api/v1/planner.py:67 (_require_pro dep, used :195,210,240,263,281,297,348,363,398,435) - src/interfaces/api/v1/planner_review.py:54 (_require_pro, :80,91) - All route through subscription_handler.is_pro → currently always allow. - Direct is_pro column reads (bypass the handler): - src/infrastructure/workers/tasks/evening_review.py:34where(UserModel.is_pro.is_(True)) (real filter; evening-review nudges gated to Pro). - src/infrastructure/workers/tasks/morning_greeting.py:266if getattr(user, "is_pro", False). - Admin/reporting: admin/users.py:305,328,463, admin/monetization.py:64 (where(UserModel.is_pro == True)).

So enforcement is scattered: a central handler that no-ops, plus two background tasks that read the column directly (and therefore would actually gate if the column were set). The conversational/memory machinery does not call either.

B.3 Is cross-session memory / nightly L3 / weekly synthesis gated by tier?

None of the three is gated by tier. All users (free included) get the full machinery.

  • Cross-session memory retrieval (Talk): talk_conversation.py:166 (assemble_full_context) has no is_pro check; the only gate is the B3 low-content/length gate (talk_conversation.py:144-152) and budget caps — content-based, not tier-based.
  • Nightly L3 synthesis: scheduler schedule_nightly_synthesis (src/infrastructure/workers/tasks/nightly_synthesis.py:477) iterates user_repo.get_all_active() (:505). get_all_active() = every row in users ("active = exists in the users table", src/infrastructure/db/repositories/user_repository_impl.py:354-360). The only filters are local-time == 00:30 (:514) and "day not already synthesised" (:520-537). No is_pro. Enqueues synthesize_for_user for every active user.
  • Weekly synthesis (L3.5): schedule_weekly_synthesis (src/infrastructure/workers/tasks/memory_weekly_synthesis.py:112) also iterates get_all_active() (:127); only filter is Sunday 02:30 local (:136). No tier.

(There is a content guard, not a tier guard: NightlySynthesizer.run_for_user returns None when there are no micro-summaries for the day — src/application/services/nightly_synthesizer.py:147-150 — so users who never talked that day are skipped. Model used: task_type="analysis" → Sonnet, nightly_synthesizer.py:198.)

B.4 Founders-grant mechanism

[VERIFY] — no dedicated "founder" representation found. The only granting mechanism is the manually-settable users.is_pro column (models.py:75; admin can flip it via admin/users.py). A founders grant would today be "set is_pro = true". Grep terms tried: founder, grant, comp, lifetime (no founder/grant-specific code).

B.5 Telegram Stars → tier sync

[NOT FOUND]. No Stars/payment ingestion. Grep terms tried (whole src): stars, XTR, successful_payment, successful_payment, pre_checkout, invoice, payment. None present. SubscriptionHandler docstring confirms "until a real subscription system lands" (subscription_handler.py:4). The only monetization surface is the read-only admin dashboard admin/monetization.py. There is a design doc docs/internal/billing-checkout-design.md (not code).

Change surface (do not modify)

  • src/application/services/subscription_handler.py:17 — the single function that currently grants Pro to all; the obvious choke point for a real gate.
  • src/application/use_cases/talk_conversation.py:144-176 — where a "Free = Rung 1 only" gate would have to suppress memory retrieval / lens / depth.
  • src/infrastructure/workers/tasks/nightly_synthesis.py:505 and memory_weekly_synthesis.py:127 — where a paid-only filter would replace get_all_active().
  • Migration: a real tier would reuse the existing users.is_pro column (models.py:75) — no new migration strictly required for a boolean Free/Paid gate. A richer tier enum would need a migration. (Flag only; none written.)

C. Cost drivers & levers

C.1 Prompt caching

Yes, cache_control is usedsrc/infrastructure/llm/claude_client.py:_build_system_param (:48). For a composed_system it emits ephemeral 1h breakpoints on: - shared_staticclaude_client.py:83. - localized_staticclaude_client.py:88. - lens_static (only when non-empty) — claude_client.py:97. - dynamic block is uncached (last block, no cache_control, :99).

There is also a cached_prefix two-block mode for non-talk paths (e.g. reflection) — claude_client.py:63-75.

So on the Talk path the large static system prompt incl. Voice Principles IS cached (shared+localized breakpoints). What is not cached: the per-turn dynamic suffix (profile/AF/recap/RAG packet/temporal), and on a cold first turn the cache is necessarily a write/miss (first occurrence). Cache hit/miss is logged at claude_client.py:104 (_log_cache_usage, fields cache_creation_input_tokens, cache_read_input_tokens).

Auxiliary single-shot Haiku/Sonnet calls (crisis classifier, about-intent, recap, reranker, openers — see C.4) pass plain system= strings → no cache_control (_build_system_param returns the bare string, claude_client.py:101).

C.2 Nightly L3 + weekly synthesis: scheduler, task, user filter

  • Scheduler: Celery beatsrc/infrastructure/workers/celery_app.py:20 (beat_schedule). Entries:
  • schedule-nightly-synthesisnightly_synthesis.schedule_nightly_synthesis, crontab(minute="*") (every minute, fires per-user 00:30 local) — celery_app.py:89-91.
  • schedule-weekly-memory-synthesismemory_weekly_synthesis.schedule_weekly_synthesis, crontab(minute="*") (per-user Sun 02:30 local) — celery_app.py:97-99.
  • Task functions: synthesize_for_user (nightly_synthesis.py:27), synthesize_weekly_for_user (memory_weekly_synthesis.py).
  • Actual user filter: ALL active users (get_all_active(), nightly_synthesis.py:505, memory_weekly_synthesis.py:127) — see B.3. No active-only/paid-only narrowing beyond "row exists" and local-time + already-done checks.
  • Model: Sonnet (task_type="analysis", nightly_synthesizer.py:198).
  • Per-user token size: [NOT FOUND] (not instrumented at the task; prompts nightly_synthesis_ru.txt is ~20 KB so the input is large, but exact tokens not computed here).

C.3 Batch API

No. [NOT FOUND] — Message Batches API is not used for any async job. Grep terms tried (whole src): message_batches, messages.batches, .batches., Batch. Nightly/weekly synthesis dispatch one synchronous messages call per user via synthesize_for_user.delay(...) (nightly_synthesis.py:538) → Celery fan-out, not the Batches API.

C.4 Usage logging — can cost-per-session / cost-per-user-month be aggregated?

Partially yes, with known undercount.

Source table: AiTokenUsageModel / ai_token_usagesrc/infrastructure/db/models.py:580. Columns include user_id, session_id (:593), use_case, input_tokens, output_tokens, cached_tokens (:600), cost_input/cost_output/cost_total (:605-607), raw_usage JSONB (:610), created_at (:612). - cost-per-session: aggregatable via session_id (indexed, :593). - cost-per-user-per-month: aggregatable via user_id + created_at (both indexed).

Write path: ClaudeClient._record_usage (claude_client.py:212) → usage_tracker.record_async (:221), called from streaming (claude_client.py:329) and non-streaming (:246,284). Normalization: AnthropicUsageAdapter (src/infrastructure/llm/usage_adapters.py:34), which stores cache_read_input_tokens into cached_tokens and puts both cache_creation_input_tokens and cache_read_input_tokens into raw_usage (usage_adapters.py:34-39).

Known gaps / what's missing: - Two columns conflate caching: there is no top-level cache_write column — cached_tokens holds cache-read only (usage_adapters.py:34); cache-write lives only inside raw_usage JSONB. - Recording requires (a) the ClaudeClient was built with a usage_tracker, and (b) current_user_uuid is set (claude_client.py:218). current_user_uuid is set on the Mini App talk routes (chat.py:339,405,436). But many auxiliary calls use a bare ClaudeClient() with no trackernot recorded: talk_recap.py:143, crisis_classifier.py:346, about_intent.py:91, memory_retriever.py:893 (reranker), opener_generator.py:166, free_conversation.py:823, collect_reflection.py:301. (Matches the documented undercount.) - A second, separate legacy table TokenUsageModel/token_usage (models.py:405, Telegram-id keyed, no cost columns) and rolling totals user_token_budgets (models.py:421) exist in parallel. (Parallel abstraction — flagged, not resolved.) - Cost columns are populated only if the adapter/cost-calculator priced the call; cost_calculator.py exists (src/application/services/cost_calculator.py) but cache tokens are not separately priced (per [VERIFY] — not traced line-by-line here).

Change surface (do not modify)

  • Caching: the bare-ClaudeClient() aux call sites above (would need a cached prefix + tracker) and claude_client.py:48 (_build_system_param).
  • Tier-gating / batch: nightly_synthesis.py:505,538 and memory_weekly_synthesis.py:127 (filter + dispatch shape).
  • Cost aggregation: usage_adapters.py:34-39 (cache-write column), usage_tracker.py:54 (record), usage_repository_impl.py:30.

D. Entry & promise (cold path)

  • Telegram /start: start_handlersrc/interfaces/telegram/handlers/start.py:25. It does not parse a start param (no context.args read); it registers the user and replies with welcome + onboarding.name (start.py:69-70) for new users, or welcome.back (:79) for returning. It does not itself open the Mini App.
  • Mini App URL construction: build_miniapp_url(screen, **params)src/interfaces/telegram/miniapp_links.py:7https://<webapp>/app?screen=....
  • Mini App side reads the deep link: webapp/js/deep_link.js:readDeepLink (:17), reading ?screen= (:20) or the Telegram start_param fallback for t.me/Bot?startapp=... (:31, _parseStartParam :50).
  • Router in the shell: webapp/index.html module block (navigate listener at the switch (screen) ~ index.html:157), cases gate, dashboard, onboarding, chat, etc. A received deep link dispatches navigate to dl.screen (index.html ~line 165).
  • The "open the bot" gate link (outside Telegram) hard-codes ?start=appwebapp/js/screens/gate.js (botLink = https://t.me/${botUsername}?start=app).

D.2 First screen of the Mini App (cold user) + where entry strings live

  • Outside Telegram → gate screen (webapp/js/screens/gate.js:renderGate), copy hardcoded in JS ("Areto works inside Telegram", "Open in Telegram").
  • Inside Telegram, with no deep link → default navigation is to dashboard (index.html dispatches screen: 'dashboard', e.g. index.html ~line 147).
  • On dashboard, a first-run user (tour flag) gets the welcome screen overlay: dashboard.js:330 (shouldShowTour(user)) → showWelcomeScreen(...) (dashboard.js:332), component webapp/js/components/welcome-screen.js:15.
  • Promise copy on the welcome screen is hardcoded RU in JS (not i18n): headline "Зеркало, не дневник" and sub "Areto не хранит записи — он отражает твои паттерны…" — webapp/js/components/welcome-screen.js:53-54, plus the bullets :58-61 and voice flow :64+. (This violates the project i18n rule; existing — flagged, not fixed.)

D.3 All user-facing "promise" strings (what Areto is), per language

i18n (src/i18n/locales/): - about_areto.response - ru.json:378 — full promise: "Areto — зеркало, не дневник… отражает твои паттерны… разбор дня… одно главное наблюдение и конкретное микро-действие… становится видимым паттерном…". (Served by the about-intent short-circuit, talk_conversation.py:240.) - en.json:378"[TODO: EN translation pending]" (stub; EN users get the placeholder — see X2). - welcome — ru.json:2 / en.json:2 ("companion for reflection and growth"). - welcome.open_app — ru.json:3 ("Открыть Areto"). - onboarding.welcome — ru.json:5 / en.json:5. - onboarding.welcome_complete — ru.json:20 / en.json:20 ("I'll get to know you better with every conversation").

Hardcoded (not i18n): - webapp/js/components/welcome-screen.js:53-61 (RU promise headline/sub/bullets + tagline "aretḗ — добродетель" :36). - webapp/js/screens/gate.js (EN gate copy).

D.4 Onboarding tour before the first session?

Yes — a feature tour gated by the show_feature_tour user flag. - Trigger: dashboard.js:330 shouldShowTour(user) → welcome overlay then startFeatureTour() (dashboard.js:334). - Components: webapp/js/components/welcome-screen.js (intro overlay) and webapp/js/components/feature-tour.js (startFeatureTour). A separate chat tour exists (webapp/js/components/chat-tour.js:104). - Flag source: UserProfile.show_feature_tour (src/domain/entities/user.py:54, default True) and show_chat_tour_free/_reflection/_analyze (:55-57); dismissal posts /api/settings/tour-completed (dashboard.js:340). - Step count: [VERIFY] — driven by selector-matched steps inside feature-tour.js (no fixed count read here; startFeatureTour is a no-op if selectors don't match, per feature-tour.js:4).

Change surface (do not modify)

  • src/i18n/locales/ru.json:378, en.json:378 (about_areto.response), ru/en welcome / onboarding.welcome* keys.
  • webapp/js/components/welcome-screen.js:53-61 (hardcoded promise).
  • webapp/js/screens/dashboard.js:330-352 (first-screen entry / tour trigger).
  • webapp/js/screens/gate.js (out-of-Telegram entry copy).

X1. Activation measurability

X1.1 Any event/analytics tracking?

No general product-analytics/event pipeline. [NOT FOUND] for screen_events, track_event, analytics, record_event. What exists are domain event systems, not behavioural analytics: - src/application/cognitive_quiz/events.py (quiz domain events). - EventService (src/application/services/event_service.py:30) = calendar events, not analytics. - reengagement_event_repository_impl.py, activity_event_repository_impl.py = feature-specific event rows. - src/infrastructure/observability/l4_telemetry.py (l4_metrics.increment, used at talk_conversation.py:130) is an internal metrics counter (e.g. personality_lens.injected/skipped), surface-tagged but not a per-user activation funnel.

X1.2 Does record_analysis fire only in depth (so it misses cold mirror turns)?

Not confirmed — the premise is contradicted by the code. record_analysis is always offered every turn, in any register, including mirror — llm_service.py:978-981 adds the tool unconditionally. The register field is a model-reported value (enum mirror|depth|unload, talk_tools.py:23,67-75), not gated by the code's depth_block. The tool's own instructions tell the model to call it on a settled observation and never on unload (talk_tools.py:38, talk_tools.py:10) — i.e. it can fire in mirror.

However, first-turn capture still produces no persisted signal, for a different reason: a record_analysis call is treated as a proposal, stored as pending (talk_conversation.py:285-298), and only persisted on the next turn's assent (_resolve_pending, talk_conversation.py:488-521; persist_record_analysis, talk_analysis_persistence.py:42). A single cold first turn therefore cannot create a persisted observation — it can at most log talk.record_analysis.proposed (talk_conversation.py:292). So the existing instrument will NOT durably capture cold first-session value, but because of assent-gating + no analytics table, not because of depth-gating.

X1.3 Closest existing signal to "first session produced a concrete observation"

No purpose-built signal exists. Closest candidates, all weak: - Log line talk.record_analysis.proposed (talk_conversation.py:292) — proposal, not committed, log-only (no table/event). - Persisted L3 analysis memory item after assent (talk_analysis_persistence.py:109 talk.record_analysis.persisted) — requires a second turn, so not a first-session signal. - l4_metrics counters (talk_conversation.py:130) — lens injection, not value. A new activation signal would be needed (not designed here).


X2. Landmines

X2.1 Code assuming the context packet / memory is always present

The Talk path is largely defensive — empty packets are normal: - relevant_context_block defaults to "" and is set inside try/except (talk_conversation.py:154-181); the dynamic template coerces empties to "—" (llm_service.py:934). Skipping retrieval for free users would not crash here. - personality_lens/profile_summary are best-effort, default empty (talk_conversation.py:110-136). - lens_static block is only added when non-empty (claude_client.py:94-98).

Caveat to verify if memory is hard-disabled per tier: - memory_retriever.assemble_full_context (talk_conversation.py:166) is called with several kwargs (include_lens, dedup_against, token_budget); short-circuiting it must keep returning the (text, retrieval) tuple shape it unpacks at :166. [VERIFY] — internal assumptions of assemble_full_context about non-empty inputs not traced line-by-line. - The background memory pipeline log_conversation_turn (talk_conversation.py:324-334) and update_recap (:338-341) run after every turn for everyone; gating memory by tier without also gating these would leave free users still feeding L1/L2 extraction.

X2.2 Other places where tier-gating the expensive machinery breaks an assumption

  • Synthesis is unconditional over get_all_active() (B.3). Two background tasks (evening_review.py:34, morning_greeting.py:266) already branch on the raw is_pro column — so if is_pro starts meaning "paid", those two surfaces change behaviour immediately and independently of the central handler. (Inconsistent gating source: column vs subscription_handler.)
  • subscription_handler.is_pro() hard-returns True (subscription_handler.py:17): any new gate added through the handler is inert until that line changes, while gates reading the column directly are already live — a split-brain risk.
  • Naming collision: webapp/js/screens/chat.gating.test.js is about session conflict gating, not entitlement (chat.gating.test.js:1-11). Do not mistake it for tier tests.
  • EN promise stub: about_areto.response is "[TODO: EN translation pending]" (en.json:378); the about-intent short-circuit (talk_conversation.py:240) will serve that placeholder verbatim to EN users — any "reframe the promise" work must fill EN or it ships a TODO.
  • Two live conversation paths (TalkConversationUseCase vs FreeConversationUseCase, see header): a Rung-1 or gating change applied to one surface (Mini App) will not affect the other (Telegram text) unless both are touched.
  • Two usage tables (ai_token_usage vs token_usage) and two prompt compositions (TALK vs FREE_CHAT): cost/prompt changes must be made in the right one.

Unknowns (every [NOT FOUND] / [VERIFY])

[NOT FOUND] - A.3 — cold-prompt total input token size: no token counter on the path; grep count_tokens, token (no measurement site on talk assembly). Not computed (read-only). - B.5 — Telegram Stars / payment → tier sync: grep stars, XTR, successful_payment, pre_checkout, invoice, payment (whole src). None. - C.3 — Message Batches API: grep message_batches, messages.batches, .batches., Batch (whole src). None. - C.2 — per-user nightly synthesis token size: not instrumented at the task. - X1.1 — general product analytics/event funnel: grep screen_events, track_event, analytics, record_event. Only domain/calendar/metrics systems found.

[VERIFY] - B.4 — founders-grant mechanism: no founder/grant code; inferred to be "set users.is_pro = true" via admin. Grep founder, grant, lifetime, comp. - C.4 — whether cache tokens are priced into cost_* columns: cost_calculator.py exists but not traced line-by-line. - D.4 — feature-tour step count: driven by selector-matched steps in feature-tour.js; no fixed count read. - X2.1 — assemble_full_context internal assumptions if memory is hard-disabled per tier: not traced line-by-line. - (Whole report) — the main Talk-path LLMService's ClaudeClient is assumed to carry a usage_tracker (so the Sonnet talk call records), since current_user_uuid is set on the route; the construction site of the injected talk ClaudeClient (vs the bare aux ClaudeClient() calls) was not opened. The aux untracked calls in C.4 ARE confirmed.