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_streaming→TALKcomposition. 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_streaming→FREE_CHATcomposition. 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
TALKpath as the canonical Rung-1 dialogue and flags theFREE_CHATpath where it diverges.
A. Rung-1 first-session dialogue¶
A.1 Where the Talk prompt is assembled¶
- Entry function (Mini App):
TalkConversationUseCase.execute_streaming—src/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_composed—src/infrastructure/llm/llm_service.py:880, invoked fromLLMService.talk_streaming—llm_service.py:943. - Composition spec:
TALK—src/infrastructure/llm/composition_specs.py:104. - Dynamic suffix template (on disk):
src/infrastructure/llm/prompts/talk/dynamic_template.txt, loaded atcomposition_specs.py:125. - Layered composer:
compose_prompt—src/infrastructure/llm/composed_loader.py:168. - Telegram path equivalent:
_build_talk_composed_prompt—llm_service.py:699, compositionFREE_CHAT—composition_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,unload—src/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_blockis empty unlessin_depth—talk_conversation.py:193-200; the{depth_block}slot is the last line oftalk/dynamic_template.txt:16.unloadis selected by the model itself per the "Exhaustion override" rules intalk_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), sopersonality_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_contextis 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_static — omitted 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_tools—src/infrastructure/llm/claude_client.py:411, openingself._client.messages.stream(...)atclaude_client.py:427. - Model:
claude-sonnet-4-6— passed asself._router.SONNET(llm_service.py:986), definedsrc/infrastructure/llm/router.py:4. max_tokens: default1000—claude_client.py:417;talk_streamingdoes 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_task—llm_service.py:978-981. - (Telegram
FREE_CHATpath usesself._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(TALKpartial 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_pro—src/infrastructure/db/models.py:75(migrationa7b8c9d0e1f2_add_day_planner.py:25,server_default false). - It is NOT on the
Userdomain entity (src/domain/entities/user.py:9-23has no tier field — onlygoal_loop_enabled,pod_access, etc.). - Adjacent monetization columns also exist on
UserModel:bundle_id(FKtoken_bundles),haiku_limit_override,sonnet_limit_override(models.py:88-93); tableTokenBundleModel/token_bundles(models.py:40),UserTokenBudgetModel/user_token_budgets(models.py:421). These describe a token-bundle model, parallel tois_pro. (Flagged; not resolved.) - No
tier/plan/subscriptionenum 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 everyone — src/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:34 —
where(UserModel.is_pro.is_(True)) (real filter; evening-review nudges gated to Pro).
- src/infrastructure/workers/tasks/morning_greeting.py:266 —
if 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 nois_procheck; 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) iteratesuser_repo.get_all_active()(:505).get_all_active()= every row inusers("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). Nois_pro. Enqueuessynthesize_for_userfor every active user. - Weekly synthesis (L3.5):
schedule_weekly_synthesis(src/infrastructure/workers/tasks/memory_weekly_synthesis.py:112) also iteratesget_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:505andmemory_weekly_synthesis.py:127— where a paid-only filter would replaceget_all_active().- Migration: a real tier would reuse the existing
users.is_procolumn (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 used — src/infrastructure/llm/claude_client.py:_build_system_param
(:48). For a composed_system it emits ephemeral 1h breakpoints on:
- shared_static — claude_client.py:83.
- localized_static — claude_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 beat —
src/infrastructure/workers/celery_app.py:20(beat_schedule). Entries: schedule-nightly-synthesis→nightly_synthesis.schedule_nightly_synthesis,crontab(minute="*")(every minute, fires per-user 00:30 local) —celery_app.py:89-91.schedule-weekly-memory-synthesis→memory_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; promptsnightly_synthesis_ru.txtis ~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_usage — src/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 tracker → not 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) andclaude_client.py:48(_build_system_param). - Tier-gating / batch:
nightly_synthesis.py:505,538andmemory_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)¶
D.1 Deep-link handling (/start param → Mini App)¶
- Telegram
/start:start_handler—src/interfaces/telegram/handlers/start.py:25. It does not parse a start param (nocontext.argsread); it registers the user and replies withwelcome+onboarding.name(start.py:69-70) for new users, orwelcome.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:7→https://<webapp>/app?screen=.... - Mini App side reads the deep link:
webapp/js/deep_link.js:readDeepLink(:17), reading?screen=(:20) or the Telegramstart_paramfallback fort.me/Bot?startapp=...(:31,_parseStartParam:50). - Router in the shell:
webapp/index.htmlmodule block (navigatelistener at theswitch (screen)~ index.html:157), casesgate,dashboard,onboarding,chat, etc. A received deep link dispatchesnavigatetodl.screen(index.html ~line 165). - The "open the bot" gate link (outside Telegram) hard-codes
?start=app—webapp/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 dispatchesscreen: '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), componentwebapp/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-61and 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/enwelcome/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 rawis_procolumn — so ifis_prostarts meaning "paid", those two surfaces change behaviour immediately and independently of the central handler. (Inconsistent gating source: column vssubscription_handler.) subscription_handler.is_pro()hard-returnsTrue(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.jsis about session conflict gating, not entitlement (chat.gating.test.js:1-11). Do not mistake it for tier tests. - EN promise stub:
about_areto.responseis"[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 (
TalkConversationUseCasevsFreeConversationUseCase, 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_usagevstoken_usage) and two prompt compositions (TALKvsFREE_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.