Kucatoo-Code · Wiki

Hosting — VM tiers and client-VM lifecycle

Operator-facing reference for the hosted edition: one fly.io micro-VM per client (stage 2 of the hosting roadmap), how the tiers map to concrete fly parameters, and the lifecycle rules for deploy, up/downgrade and termination. The customer-facing short version lives in templates/help.html (section 19, "Hosting tiers"). Design context: docs/plan/hosting-vm-tiers-and-lifecycle-2026-08-11.md.

Measured sizing facts (2026-08-11, image sizes measured 2026-08-15)

Tier table

Pricing basis: fly.io pricing page, Toronto (yyz), 2026-08-14. All fly.io prices are USD (Canadian cards are converted by the bank — quote customers in USD). Machines: shared-cpu-1x = $2.17 (256 MB) / $3.56 (512 MB) / $6.35 (1 GB) / $11.93 (2 GB) per month; extra RAM $5.58/GB/mo; volumes $0.15/GB/mo, 1–500 GB, extend-only.

Tier fly.io size RAM Volume Build All-inclusive $/mo (yyz) Fits
Micro shared-cpu-1x 256 MB 1/2/3/5 GB (default 1) slim (no torch) 2.32–2.92 cheapest BYOK chat; small registry utility apps
Basic shared-cpu-1x 512 MB 1/2/3/5 GB (default 2) slim (no torch) 3.71–4.31 BYOK chat, agents, TTS via provider APIs
Plus shared-cpu-1x 1 GB 1/2/3/5 GB (default 3) full (local embeddings) 6.50–7.10 semantic memory recall offline
Pro (later) shared-cpu-1x 2 GB 1/2/3/5 GB (default 5) full 12.08–12.68 heavy multi-agent / teams usage
Escape hatches (operator-assigned only) shared-cpu-2x / -4x 4 / 8 GB 1–5 GB full 24.02+ / 47.88+ not in the picker

Notes:

A tier is really a triple: (Docker image, memory, volume). The slim/full split is a build-time decision — pip install happens only at image build time, never on the VM (a 512 MB machine OOMs on builds; this matches fly.io's build-once/deploy-many model). 512 MB serves steady state fine; it OOMs only if torch is loaded, so torch must stay out of the slim image.

Implemented 2026-08-15: one Dockerfile with ARG BUILD (full is the default so the operator's own deploys keep today's behavior):

Rules:

Current provisioning path (routes_fly.py)

Two provision paths, chosen by env (all endpoints operator-only):

Tier-aware since 2026-08-15: provision takes tier + volume_gb (+ optional customer + subdomain), validated by hosting_tiers.resolve_tier against the catalog, the tier_config toggles and the price env. The chosen shape is persisted on the clients row and audited (provision/destroy events). With billing configured, deploy requires a client row with an active/trialing subscription for the tier's price (fail-open otherwise). GET /api/fly/apps annotates each app with its clients.db tier/memory/volume/customer.

Tier toggles (owner)

Implemented 2026-08-15: a tier_config table in clients.db (tier, enabled, price_id, memory_mb, volume_gb, image_tag, updated_at) is seeded from the hosting_tiers.py catalog on every connect, owner-only GET/PATCH /api/settings/tiers flips the switch, and the Settings page's Operator block renders the toggle list. Toggle-off is soft — running deployments on a disabled tier keep running and billing; the tier only disappears from new deploys, checkouts and upgrade targets. Toggles are audited in deployment_events (tier_toggle). The deploy picker, resolve_tier validation and the checkout price lookup all read the same table; the Stripe price ids remain env-driven (STRIPE_PRICE_ID_*).

Subscriber shared-model caps (pricing locked 2026-08-16)

Paid plans include the tier's shared DeepSeek models on the operator's key within a monthly allowance. The budget is dollar-denominated internally, so provider price increases shrink the token-equivalent, not the operator's exposure (the grandfathered-price back-door — see docs/plan/hosting-roadmap-2026-08-10.md for the unit economics, which is staff-only). Customer-facing unit = credits (1 credit = $0.01 of included usage; Flash and Pro runs debit different credit counts). subscriber_limits.py + the subscriber_usage ledger + the query gate (query_gates.py): model gating, over-cap 402 with a BYOK/upgrade hint, and a user's own key always bypasses.

Tier Shared models Credits / month
Micro none (BYOK only) 0
Basic deepseek-v4-flash 600
Plus flash + deepseek-v4-pro 1,000
Pro flash + deepseek-v4-pro 1,500

Founding prices $12 / $19 / $29 / $49 (anchors $16/$24/$39/$69; quarterly and annual billing −15%; no top-ups v1) — see the roadmap's product-tiers section.

Central pay hub (Kucatoo-Pay)

Kucatoo-Pay (FastAPI/SQLAlchemy) is the central payment app: checkout, signature-verified Stripe webhooks (7 event types, idempotent event table), Customer Portal, per-product /v1/entitlements API, migration runner, Dockerfile + fly.toml, 69 tests.

Kucatoo-Code side (wired 2026-08-15):

State: clients.db

clients rows carry the deployment shape alongside the subscription: tier, vm_size, memory_mb, volume_gb (added by _migrate() for databases created before the hosting-tiers schema — SQLite has no ADD COLUMN IF NOT EXISTS, so the migration checks PRAGMA table_info(clients) on connect and applies missing columns). set_client_deployment() updates these fields.

deployment_events is the append-only audit trail: every deploy, tier change and termination (including near-terminations) is recorded with operator identity, reason, a free-form detail (JSON old→new values, backup URL, or error) and a timestamp. Rows are never deleted — the clients row and its events remain the billing/audit record after a site is terminated (status='terminated', row kept).

Upgrade / downgrade

Implemented 2026-08-15 (POST /api/fly/change-tier, operator-only):

Termination safeguards

Implemented 2026-08-15. Termination destroys customer data; the safeguards, in order (POST /api/fly/terminate):

  1. Pre-checks — only when the subscription is cancelled/expired, or an explicit operator override with a written reason (active-subscription termination returns 409 with requires_override).
  2. Backup first, always — the client volume's /data is pulled operator-side via fly-ssh (tar czf - -C /data . streamed to a temp file — nothing is added to the client app; the VM must be running), then uploaded to the operator's Drive folder. No verified upload → no terminate (override requires the written reason).
  3. Two-step confirm — dialog states exactly what is destroyed and that it is irreversible; the app name must be typed.
  4. Soft-delete windowsuspend_app now, destroy scheduled at deletion_scheduled_at (+ TERMINATION_GRACE_DAYS, default 7); POST /api/fly/terminate-undo restores + resumes. The sweep (POST /api/fly/sweep, also lazily on /api/fly/apps) destroys apps past the window.
  5. Auditterminate_requested, pending_deletion, terminate_undone, terminated (and failures) to deployment_events with operator identity, reason, backup detail, timestamps.
  6. Post-destroy cleanup — the subdomain's Alibaba DNS record removal is recorded as dns_cleanup_pending (manual v1 — no Alibaba API client), clients.status='terminated', row kept.

Immediate destroy (/api/fly/destroy) stays available for accidental/test resources and now REFUSES apps with a client row — terminate is the only path for client sites.

routes_settings.py ships per-provider console links (_PROVIDER_LINKS) rendered on the Settings cards: "Get a key" and (where separate) "Manage plan" — the providers' own consoles, correct for BYOK (customer signs up with the provider, pastes the key into Settings). Qwen URLs are region-pinned to ap-southeast-1 (international token-plan console); when mainland endpoints land, add a mainland link pair alongside. The Settings page also shows an owner-only "Operator dashboards" block (Fly.io / Stripe / Clerk), gated on g.user_role == ROLE_OWNER.

verified against code: 2026-08-15