본문으로 건너뛰기

Recon Service

Status

Active

Date

2026-04-28

Owners

  • Platform Backend

Last Verified Commit

56362a7a

Runtime

API + standalone worker

Purpose

recon_service owns SMS automation and reconciliation behavior migrated from the legacy middle_server shooter and Pushbullet block.

Primary Entry Points

Internal owner routes:

  • /internal/recon/pushbullet/*
  • /internal/recon/shooter/*
  • /internal/recon/stats/*

Those internal routes are protected by the per-caller internal auth contract: callers send X-Caller-Service plus the matching INTERNAL_SERVICE_TOKEN_<CALLER> token. The legacy shared INTERNAL_SERVICE_TOKEN is a Stage A/B fallback only and is not intended for direct browser or frontend use.

External compatibility for bo/admin is served through admin_service, not by calling recon_service directly.

Dependencies

  • PostgreSQL
  • Redis
  • wallet_service
  • admin_service
  • internal service token
  • optional OpenRouter config
  • optional Telegram bot token

Background Work

recon_worker runs the domain loops for:

  • Pushbullet listener supervision
  • missing SMS sync
  • SMS ID sync
  • phone whitelist validation
  • template parsing
  • order matching
  • Telegram polling

Owned Data

  • shooter_pushbullet
  • shooter_sms
  • shooter_phone
  • shooter_template_recharge
  • shooter_device

Events

Emits:

  • no Redis stream contract is currently documented for recon

Consumes:

  • no Redis stream contract is currently required

Cross-service coordination currently happens through internal HTTP calls to:

  • wallet_service
  • admin_service

Health

  • API /health checks DB and Redis
  • recon_worker readiness depends on freshness-based checks for every critical loop

Key Env Vars

  • DATABASE_URL
  • REDIS_URL
  • WALLET_SERVICE_URL
  • ADMIN_SERVICE_URL
  • MULTI_BRAND_ENFORCEMENT — required; one of off / observe / enforce. Production target is enforce post-Phase-16. Drives multi_brand_enforcement_mode{service="recon_service"} gauge and the cross-brand reject decision in brand_check.py (recon_cross_brand_rejected_total).
  • BRAND_SIGNING_KEYrequired in production; HMAC-SHA256 secret used to sign X-Brand-Signature on outbound brand-scoped wallet writes from recon-driven approvals.
  • INTERNAL_SERVICE_TOKEN_RECONrequired in production; per-caller token presented to wallet when recon calls it.
  • PER_CALLER_TOKEN_REQUIREDon is the Phase 16 target; activates the legacy-token hard-reject (T4-D-I2) on inbound /internal/recon/* calls.
  • INTERNAL_SERVICE_TOKEN — legacy single-shared-token; deprecated. Phase 16 release gate requires the bare variant to be absent.
  • OPENROUTER_API_KEY
  • OPENROUTER_MODEL
  • TELEGRAM_BOT_TOKEN

Compatibility Notes

The previous back-office compatibility surface (admin_service/app/api/routes/legacy_recon.py) was removed by ADR-009 together with the rest of the admin_service staff-coupled legacy files. Recon back-office routes that depended on legacy admin auth, status/msg/data envelopes, and Authorization header refresh are no longer served. Replacements are out of scope of ADR-009.

Multi-Brand Constraints

Per ADR-009:

  • the shooter_* table family is split: operator-infrastructure rows (shooter_device, shooter_pushbullet, shooter_phone, shooter_template_recharge) remain brand-global because they describe operator-owned hardware, API tokens, whitelists, and parsing rules that are not per-brand; only shooter_sms and any player- or deposit-referencing recon review-state rows carry brand_id
  • shooter_sms.brand_id propagation contract:
    • On INSERT (Pushbullet listener / SMS-ID sync / Telegram bot ingests a raw SMS), the row is created with brand_id NULL because the parser cannot derive brand from raw message text. Migration 0037 (T9-E) relaxed the column to nullable so the ingest path stops IntegrityError-ing — the previous tightening from 0024c was too strict for tables that receive their brand correlation post-INSERT.
    • At match time (the order-matching loop pairs an inbound SMS with a player_deposit), shooter_sms.brand_id is rewritten from player_deposit.brand_id. The matched deposit is the authoritative brand source for the SMS row. The match-time UPDATE includes a fail-loud check that the column is non-NULL after the write — see recon_store.py::confirm_sms_match for the assertion that surfaces a logic bug if the brand fails to land.
    • Rows that pre-date player_deposit creation (legacy SMS captured before any deposit existed) carry the default brand from the 0024b backfill pass and are never re-stamped retroactively. A post-flip cross-brand match attempt against such a row (e.g. an SMS bound to default matching a brand2 deposit) increments recon_cross_brand_rejected_total{command="sms_match",mode} and is hard-rejected in enforce mode; operators must reconcile or abandon those rows manually.
    • shooter_sms.brand_id is never parsed from message text, even when the text contains a brand-identifying token. Brand attribution flows only through the matched deposit so a forged SMS cannot mis-route to a brand it should not touch.
  • approvals still flow through wallet_service; the per-brand wallet validation behavior (observe proceeds with request brand, enforce rejects) is gated by MULTI_BRAND_ENFORCEMENT in wallet_service

Tests

  • cd servers_v2/recon_service && uv run pytest
  • key suites:
    • tests/test_recon_routes.py
    • tests/test_worker_health.py