# Gas & Wallet Economics

## What this skill covers
How token economics work on Tawa — pod pricing, the deploy gate, gas metering, and wallet management.

## Token Basics

- 1 gas token = $0.01 USD
- New accounts receive 50,000 free intro tokens
- Tokens pay for two things: **hosting** (pod uptime) and **transactions** (API calls)
- Token spend order: intro tokens first, then purchased, then earned

## Pod Tiers and Pricing

| Tier | CPU | Memory | Gas/Hour | Gas/Month | USD/Month |
|------|-----|--------|----------|-----------|-----------|
| nano | 0.25 | 256Mi | 5 | 3,600 | $36 |
| small | 0.5 | 512Mi | 10 | 7,200 | $72 |
| medium | 1 | 1Gi | 23 | 16,560 | $165.60 |
| large | 2 | 2Gi | 42 | 30,240 | $302.40 |
| xlarge | 4 | 4Gi | 83 | 59,760 | $597.60 |

Set pod tier in catalog-info.yaml:
```yaml
metadata:
  annotations:
    insureco.io/pod-tier: "nano"    # Default if not specified
```

**The tier is enforced as real container resources.** On deploy the builder sets
the pod's Kubernetes `resources.limits` to the tier's CPU/Memory above. This means
the size you pick is the size you get — the tier's gas rate and its actual
CPU/Memory ceiling are the same knob. Bumping the tier raises both the cost **and**
the pod's real ceiling; lowering it does the reverse. An unknown tier falls back
to `nano` with a build warning. The namespace `LimitRange` max is `4 CPU / 4Gi`
(xlarge), so no tier is capped below its declared size.

**Requests are lower than limits, and CPU and memory are treated differently.**
Memory requests are 50% of the limit. CPU requests are **10%** of the limit
(builder ≥ 2026-09-07; they were 50% before).

| Tier | CPU request | CPU limit | Memory request | Memory limit |
|------|-------------|-----------|----------------|--------------|
| nano | 25m | 250m | 128Mi | 256Mi |
| small | 50m | 500m | 256Mi | 512Mi |
| medium | 100m | 1 | 512Mi | 1Gi |
| large | 200m | 2 | 1Gi | 2Gi |
| xlarge | 400m | 4 | 2Gi | 4Gi |

Why the split: memory is not compressible, so what a pod needs must be reserved
for it, and measured fleet usage sits close to the memory requests. CPU is
compressible: the request only decides where a pod is scheduled and its share if
a node is ever saturated, while the limit still decides how far it can burst. At
50% CPU requests the cluster ran **91% reserved and 9% used**, deploys sat
Pending on `Insufficient cpu` beside eight nearly idle nodes, and the autoscaler
added machines to hold reservations nothing consumed. Nothing about a pod's
behaviour changes under the lower request until a node is genuinely CPU-bound.

The new requests apply on each service's **next deploy**; a running pod keeps the
values it was deployed with. A pod that is actually slow or throttled is still
under-tiered — the fix is the same as before: raise `pod-tier` and redeploy.

A service can move its **requests** off these defaults without changing tier, limits
or gas: `insureco.io/memory-request` and `insureco.io/cpu-request` (floor 64Mi / 10m,
ceiling the tier's limit, invalid values warn and fall back). Example: iec-test runs
xlarge for its 4Gi ceiling with `memory-request: "512Mi"`. See
[catalog-info](catalog-info.md#memory-request--cpu-request--resource-requests-below-the-tier-default).

> A pod stuck slow/CPU-throttled is usually under-tiered — raise `pod-tier` and
> redeploy. Before this enforcement (builder < 0.6.x) every pod got a flat
> `500m/512Mi` regardless of tier; if a service was silently relying on that,
> pick the tier that matches its real need (`small` = the old default).

## Deploy Gate

Before every build, the builder checks your wallet has enough gas to cover 3 months of hosting:

```
Required reserve = hostingGasPerHour × 24 × 30 × 3
```

For a nano pod: 5 × 24 × 30 × 3 = 10,800 tokens ($108)

If you also declare `spec.storage`, the gate adds **3 months of storage gas** on top:

```
Required reserve = (hostingGasPerHour × 24 × 30 × 3) + (storageGasPerMonth × 3)
```

If your wallet balance is below the reserve, the deploy is blocked with:
> "Deploy gate: insufficient gas reserve"

The deploy gate runs two more hard-fail checks before the reserve check:

- **Org allowlist** — `spec.owner` must be in the approved-orgs list (`ALLOWED_DEPLOY_ORGS`, default `insureco`).
- **Owner tamper guard** — `spec.owner` must match the registered service owner, so a deploy can't charge another org's wallet.

### Platform Services Skip the Gate
These services bypass the deploy gate: iec-wallet, iec-janus, tawa-web, koko, bio-id, iec-test.

### Gate is Fail-Open
If the wallet service is unreachable, deploys proceed anyway (resilience over strictness).

## Gas Metering via Janus

Janus tracks two types of gas events:

| Type | Charged For | Example |
|------|-------------|---------|
| **hosting** | Pod uptime per hour | Running my-api on nano tier |
| **transaction** | API calls through Koko | GET /api/users (5 gas) |

Transaction gas is only charged on successful (2xx) responses.

## Only Janus moves money (locked)

> **Locked platform rule — decided 2026-09-24.** Not a recommendation, and not open to a
> per-service exception.

**Janus is the only thing that debits or credits a wallet.** A service declares what its
routes cost in `catalog-info.yaml` `spec.routes`; Janus charges it. The only other code allowed
to move money is the platform's own billing jobs:

| Mover | What it charges | Where |
|---|---|---|
| Janus request metering | per-call route gas (public gateway and `/i/`) | `iec-janus` gas middleware |
| Janus hosting worker | pod-tier and storage gas, hourly | `iec-janus` `src/queues/hosting-worker.ts` |
| iec-cron | 5 gas per schedule fire | `iec-cron` `src/scheduler/manager.ts` (`GAS_PER_RUN = 5`) |

Everything else — every app, plugin, worker and agent — moves money only by making a call
through Janus.

> **Correction (2026-10-04):** `/i/` was **never metered before this change** (iec-janus#10).
> A global free-infrastructure marker on the `/i` path short-circuited the `/i/` gas check, so no
> `/i/` call ever debited a wallet or wrote a `gas_events` row. The earlier statements here that
> `/i/` "charges a flat 1 gas" were wrong in practice. `/i/` now charges the **target route's
> declared `gas`** and honours its `charge`; the rest of this section describes that behaviour.

### What you do

1. **Price your routes** in `spec.routes`: `gas` (tokens per successful call, `0` = free) and
   `charge` (who pays: `service`, the default, or `callerOrg`).
2. **Call other services through Janus `/i/`** using the injected `{SERVICE}_URL`:
   - Forward the end user's token as `X-Forward-User` when **the user's org** should pay
     (pass-through billing).
   - Send only your own service token when **your org** should pay.
3. **Handle `401`** (`proxy_forward_user_required`) as "this route bills the user's org and you
   did not forward their token", **`402`** (`proxy_insufficient_gas`) as "the payer is out of
   gas", not as a bug, and
   **`503`** (`proxy_gas_unavailable`) as "Janus could not meter this call" — it fails closed and
   forwards nothing. A platform-owned service client with no `X-Forward-User` is billed to the
   InsurEco system wallet; a first-time caller gets a wallet created and then the usual order
   applies (promo, purchased, 402). See *Who pays* in [Service-to-Service Communication](/conventions/service-communication).

### What you never do

- **Never call iec-wallet to debit or credit** — not `/debit`, not `/credit`, not
  `admin-credit`, not wallet creation with `initialTokens`, whatever the SDK allows.
- **Never meter gas yourself** — no counters, no "charge after the fact", no batching usage into
  a later wallet call.
- **Never hold wallet credentials** — no wallet API key, no internal key, no admin token in
  `tawa config`. If a service has one, that is a bug to remove, not a secret to rotate.
- **Never price a call in code.** The price lives in the catalog, where Janus reads it.

Reading your own balance or ledger for display is fine. Moving money is not.

### How a layered call chain is paid

Each hop is priced by the route it lands on, and paid by whoever that route and the call say
pays. Janus charges each hop separately.

```
user ──► A  POST /api/a/quote   (gas: 5, charge: callerOrg)   user's org pays 5
          │
          ├─► /i/b/api/rate      X-Forward-User: <user token>
          │     B route: gas 3, charge: callerOrg              user's org pays 3
          │
          └─► /i/c/api/log       service token only
                C route: gas 1, charge: service                A's org pays 1
```

- **User-paid cost:** a hop whose target route is `charge: callerOrg`, called with the user's
  token forwarded. The user's org pays every such hop, so the user's total is the sum along
  the chain.
- **Service-paid cost:** a hop called without `X-Forward-User`, or to a `charge: service`
  route. The **calling** service's org pays. That is how a service absorbs a cost it chooses
  not to pass on (freemium, internal tooling, its own cron work).
- **Price lookup.** Janus prices an `/i/` call by the route the target actually receives: the
  path after `/i/{service}`, query string dropped, matched against that service's routes in
  Koko, method-aware. A literal path match wins; otherwise a route registered with `stripPrefix`
  is matched with its prefix removed. There are no gateway fallbacks and no default price.
- **A route that does not declare `gas`, an undeclared path, or `gas: 0` costs nothing** on
  `/i/`. No wallet is touched; Janus records the call in `gas_events` with `charged: false`
  and a reason (`zero_price`, `undeclared_route`). An ambiguous `stripPrefix` match (several
  candidate routes with different prices) is treated as undeclared and logged.
- **`charge: callerOrg` on a priced route requires `X-Forward-User`.** Without it the call is
  refused `401 proxy_forward_user_required` and nothing is forwarded. A free route never needs
  the header. With `charge: service`, the calling service pays even if a user token is sent;
  with no `charge` declared, a valid `X-Forward-User` org pays, else the calling service.
- **A valid `X-Forward-User` bills the user's org even when the caller is a platform-owned
  service.** Platform service → platform-owned target with no user token is free, recorded
  with the shadow price and `charged: false`, reason `platform_internal`.
- **Fail closed.** If route prices cannot be loaded from Koko (boot window, outage), a
  third-party call gets `503 proxy_gas_unavailable` (recorded `route_price_unavailable`);
  platform → platform calls stay free.
- **Relay advice.** Email and SMS over `/i/relay/send` are free until relay declares `gas` on
  its route. Declare it there; no caller change is needed.

### Who pays: the payer table (4 Oct 2026)

Janus decides the payer for every priced call before forwarding it. Checked in this order:

| Payer (recorded as) | When | Wallet | If it has no gas |
|---|---|---|---|
| **System** (`system`) | The route is system-paid: `charge: system` on a platform-owned route, or listed in the `gas.systemPaidRoutes` setting. Wins over everything else the caller sends | `wallet-insureco` | Never refused, no balance check. The debit is settled afterwards |
| **Bill org** (`bill_org`) | A platform-owned service client sent `X-Bill-Org: <orgSlug>` (a cron callback billing an agency) | that org's wallet, which must already exist | `402`; an unknown org is `422` |
| **Forwarded user** (`forwarded_user`) | Valid `X-Forward-User` and the route is `charge: callerOrg` or declares no `charge` | the user's org | `402` |
| **Calling service** (`calling_service`) | No forwarded org, or `charge: service` | the calling service's org | `402` |
| Free | `gas: 0`, undeclared route, or platform service to platform-owned target with no forwarded org (`platform_internal`) | none | n/a |

**System-paid routes.** `charge: system` is honoured only when the route's owner is the platform org
(`PLATFORM_ORG_SLUG`); a third party declaring it gets no effect, so no one else can bill InsurEco.
`gas.systemPaidRoutes` is an owner-scoped runtime setting, a list of `{ service, pathPrefix }`, whose
default is `[{ service: "septor", pathPrefix: "/v1/events/emit" }]`. On a system-paid call the caller,
`X-Forward-User` and `X-Bill-Org` are all ignored, no agency wallet is touched, and the debit is a real one
(not the free `platform_internal` path). It settles through the retrying queue; if the final attempt fails,
Janus records `charged: false` with reason `system_wallet_debit_failed` and logs an error, and the call has
already been served. Janus's and iec-wallet's own audit emits stay unmetered.

> **Audit-event gas (decision, 4 Oct 2026, Derek).** The InsurEco system wallet pays the gas for Septor
> audit events, not the service or org that emitted them, so a service is never billed for the audit trail
> of its own compliance events. At about 13,000 emits a day at 5 gas, that is roughly 65,000 gas a day from
> `wallet-insureco`. **This is not live yet.** Services reach Septor directly over cluster DNS
> (`SEPTOR_URL`), which bypasses Janus, so the system-paid rule has nothing to bill until a builder change
> routes `SEPTOR_URL` through Janus. Until then audit emits are unmetered, as before.

**`X-Bill-Org`** (see [Service Communication](/reference/service-communication)) is for platform-owned
callers only: a non-platform caller sending it gets `403 bill_org_not_permitted`. Sending it together with
`X-Forward-User` is `400 payer_ambiguous`. `X-Bill-Org: insureco` (the platform org) behaves as if it were absent.

### Daily gas check

Once a day, after 01:00 UTC, Janus checks the previous UTC day and alerts when priced traffic stops being
charged. It exists because of the 22 Sep failure, when traffic moved onto unmetered `/i/` and no
`gas_events` existed to notice. It runs in-process (a 15-minute tick, a Redis lock so only one replica runs it)
and only when `GAS_METERING_ENABLED=true`.

| Alert | Means |
|---|---|
| `PRICED_TRAFFIC_NOT_CHARGED` | Billable priced calls, none charged |
| `CHARGES_STOPPED` / `CHARGES_DROPPED` | Charged calls fell to zero, or below 20% of the trailing 7-day median |
| `LEDGER_MISSING` / `LEDGER_DIVERGENCE` | Janus's charged count disagrees with iec-wallet's distinct debit count |
| `SETTLEMENT_FAILURES` | Settlement jobs exhausted their retries |

Results go out as Septor `gas.daily_check.completed` (always) and `gas.daily_check.alert` (on alert or a
failed check), an error log and Prometheus gauges. No email is sent. Platform admins read it at
`GET /admin/gas/daily-check` (`stale: true` if yesterday's check is overdue), `/history`, and can
re-run a day with `POST /admin/gas/daily-check/run`. Tuning is by `GAS_DAILY_CHECK_*` env vars on Janus.

### Known platform gaps (verified 2026-10-04)

The rule is locked; the enforcement is nearly finished. Two gaps remain in how Janus charges a
catalog's declared prices:

| Gap | What happens today | Issue |
|---|---|---|
| `charge` is not carried by the builder | The builder does not send `charge` to Koko ([iec-builder#182](https://git.insureco.io/insureco/iec-builder/issues/182)), and Koko's route schema has no `charge` field ([iec-koko#38](https://git.insureco.io/insureco/iec-koko/issues/38)). Janus reads `charge` when a route carries one, but none does in production yet: on the public gateway Janus bills the caller's org, and on `/i/` a valid `X-Forward-User` org pays, else the calling service. `charge: callerOrg` and `charge: system` on a route have no effect until these land; system-paid routes work today only through the `gas.systemPaidRoutes` setting. | builder#182, koko#38 |
| Septor emits bypass Janus | Services call Septor directly (`SEPTOR_URL` is a cluster URL), so the system-paid audit-emit rule has nothing to bill. A builder change to route `SEPTOR_URL` through Janus is pending. | builder (pending) |

> **Correction (2026-10-04):** three gaps were removed from this table.
> - **`/i/` ignores route prices** ([iec-janus#10](https://git.insureco.io/insureco/iec-janus/issues/10)):
>   fixed, and the old row was wrong about what it described. `/i/` was **never metered**: a
>   global free-infrastructure marker short-circuited its gas check, so it neither charged a flat
>   1 gas nor anything else, and no `/i/` call produced a `gas_events` row. It now charges the
>   target route's declared `gas`.
> - **New routes need a Janus restart** ([iec-janus#11](https://git.insureco.io/insureco/iec-janus/issues/11))
>   and **gateway routes forward `/`** ([iec-janus#12](https://git.insureco.io/insureco/iec-janus/issues/12)):
>   fixed by iec-janus PR #27, deployed 4 Oct 2026. Routes now dispatch per request from the live
>   Koko cache, so a newly registered route works without a restart, and non-`/api` routes
>   forward their full path (septor's `/v1/events/emit` reaches the pod as itself).

None of these is a reason to meter or debit in your own code. Declare the price and payer you
want; Janus charges the price today, and the payer once `charge` is carried. Wallet-side
enforcement (debit/credit restricted to Janus and the platform billing jobs) is tracked in
[iec-wallet#12](https://git.insureco.io/insureco/iec-wallet/issues/12).

## Wallet Operations

```bash
# Check your balance
tawa wallet

# Buy more tokens
tawa wallet buy 10000

# View gas spending
tawa gas
```

## Key Facts
- Start with nano tier — it's enough for most services and keeps gas costs low
- The 50,000 intro tokens cover ~13 months of a nano pod
- Deploy gate checks happen BEFORE the Docker build (saves time on failures)
- Gas events are scoped by org — each org sees only their own usage
- Pod tier is the single biggest factor in your monthly cost
- **Only Janus moves money (locked).** Price routes in `spec.routes`; never debit, credit or meter in your own code
- Route params are `{name}` or `:name`, and the request method picks the route (and price) within a service; see the catalog reference
- Janus checks yesterday's charges every day and alerts when priced traffic goes uncharged
