# Subscription & Token Consumption — Cross-Repo Contracts

Epic: [TW-247](https://admedia-jira.atlassian.net/browse/TW-247) · Ticket: [TW-268](https://admedia-jira.atlassian.net/browse/TW-268)

Producer: `api.lucos.com`. Consumers: `Lucos-IDE`, `local-daemon`, `lucos-cloud-client`, `adpilot-rag-service.com`, `lucos.com`.

This file is the single source of truth for the entitlements payload, trusted headers, usage
event schema, and error codes. Change it here first, then in the consumer repos.

---

## 1. Core model

A **seat** is the billing unit. Every authenticated user resolves to exactly one active seat.

### Plans (v1)

| Plan | `plan_code` | Price | Token usage | Models | Indexed repos |
|---|---|---|---|---|---|
| Free | `free` | $0 | Limited (metered $ ceiling) | Basic GPT only | 30 |
| Pro | `pro` | $9.99/mo | **Unlimited** | All 15 | Unlimited |

Both are personal seats. Team is deferred.

**Unlimited** is expressed as `includedCreditsUsd: null` on the plan and the period.
Spend is still metered and recorded for an unlimited seat — it is simply never degraded
for running out, so the usage data needed to price the plan keeps accumulating. Only a
lapsed subscription (`past_due` / `canceled`) degrades an unlimited seat.

Credit amounts are operator-configurable via `scripts/set-plan-credits.js`.

Each seat has one **usage period** at a time (normally one month). The period holds:

```
includedCreditsUsd  – what the plan grants
bonusCreditsUsd     – manual grants from support
spentCreditsUsd     – burned so far
```

Remaining = `includedCreditsUsd + bonusCreditsUsd - spentCreditsUsd`, floored at 0.

Unused credits **do not roll over**. A new period starts with a fresh `spentCreditsUsd` of 0.

Only **agent/chat LLM calls** are billable. Embeddings, indexing, retrieval, and Tab
completions are free and must never emit usage events.

---

## 1a. Rollout switch

Before TW-247 the plan gates were disabled in production (`return next()` above dead
code). Turning them on is a **live behaviour change**: Free seats become capped at 30
indexed repos and restricted to the weak model the moment it ships.

`BILLING_ENFORCEMENT_ENABLED` controls only the blocking 403s. It defaults to **off**.

| | Enforcement off (default) | Enforcement on |
|---|---|---|
| Seats + periods created | yes | yes |
| Entitlements API | yes | yes |
| Usage metered, credits debited | yes | yes |
| Trusted headers forwarded | yes | yes |
| Soft degrade at 100% | yes | yes |
| 403 `MODEL_NOT_ALLOWED` | no — logged as `model_gate_would_block` | yes |
| 403 `REPO_LIMIT_EXCEEDED` | no — logged as `repo_limit_would_block` | yes |

Ship with it off, watch the `*_would_block` logs to see who *would* have been cut off,
fix the plan data, then set it to `true`. No redeploy is needed to change plan limits —
only to flip this switch.

---

## 2. Degrade modes

Degrade is driven purely by percentage of credits spent.

| Mode | Trigger | Behaviour |
|---|---|---|
| `none` | spent < 80% | Everything normal. |
| `warn` | 80% ≤ spent < 100% | Everything still works. IDE shows a warning banner. |
| `degraded` | spent ≥ 100% | Agent tool loop disabled. Ask-only. Forced onto the plan's `degradeModel`. |

`degraded` is a **soft** state. The IDE must stay usable. Never hard-block a chat turn purely
because credits ran out — downgrade the experience instead.

A seat whose subscription status is `past_due` or `canceled` is also treated as `degraded`,
regardless of credit balance.

---

## 3. Entitlements payload

`GET /api/v1/entitlements` — requires `Authorization: Bearer <jwt>` or the `token` cookie.

```jsonc
{
  "success": true,
  "seatId": "665f1c2a9b3e4d0012a7c891",
  "userId": "665f1c2a9b3e4d0012a7c123",
  "orgId": null,                      // null for personal seats
  "seatType": "personal",             // "personal" | "team"

  "planCode": "pro",                  // "free" | "pro"
  "planName": "Pro",
  "subscriptionStatus": "active",     // active | trialing | past_due | canceled

  "credits": {
    "unlimited": false,           // true on Pro: includedUsd/remainingUsd are null
    "includedUsd": 20,
    "bonusUsd": 0,
    "spentUsd": 4.72,
    "remainingUsd": 15.28,
    "percentUsed": 23.6           // always 0 when unlimited
  },

  "period": {
    "start": "2026-07-01T00:00:00.000Z",
    "end":   "2026-08-01T00:00:00.000Z"
  },

  "degrade": {
    "mode": "none",                   // "none" | "warn" | "degraded"
    "agentToolsEnabled": true,
    "forcedModel": null               // model id to force when mode === "degraded"
  },

  "models": {
    "allowed": ["gpt-4o-mini", "gpt-4o", "gpt-5"],
    "default": "gpt-4o",
    "degrade": "gpt-4o-mini"          // used when mode === "degraded"
  },

  "limits": {
    "maxIndexedRepos": null,          // null means unlimited
    "indexedRepoCount": 12
  },

  "features": {},                     // free-form plan feature flags

  "links": {
    "upgrade": "https://lucos.com/manage-plan",
    "billing": "https://lucos.com/billing"
  }
}
```

### Client caching rules

- Cache for **60 seconds**. Do not hammer this endpoint per keystroke.
- Refetch immediately on any `401`, `403`, or when a degrade signal arrives from the gateway.
- Refetch after returning from checkout ([TW-266](https://admedia-jira.atlassian.net/browse/TW-266), [TW-267](https://admedia-jira.atlassian.net/browse/TW-267)).
- Never persist this payload to disk. The daemon in particular must keep it in memory only.

---

## 4. Trusted headers

Set by `api.lucos.com` when proxying to `adpilot-rag-service.com` and `lucos-cloud-client`.
Upstream services **must** treat these as authoritative and must **never** accept them from a
public caller.

| Header | Example | Notes |
|---|---|---|
| `x-user-id` | `665f1c…c123` | Already sent today. |
| `x-user-email` | `dev@lucos.com` | Already sent today. |
| `x-org-id` | `665f1c…c456` | Absent for personal seats. |
| `x-org-role` | `owner` | Already sent today. |
| `x-team-ids` | `id1,id2` | Already sent today. |
| `x-repo-ids` | `id1,id2` | Team-only members. Already sent today. |
| `x-plan-code` | `pro` | Already sent today. |
| **`x-seat-id`** | `665f1c…c891` | **New.** Required on usage events. |
| **`x-degrade-mode`** | `none` | **New.** `none` \| `warn` \| `degraded`. |
| **`x-allowed-models`** | `gpt-4o-mini,gpt-4o` | **New.** Comma separated. |
| **`x-forced-model`** | `gpt-4o-mini` | **New.** Only when degrade mode is `degraded`. |
| **`x-max-indexed-repos`** | `30` or `unlimited` | **New.** Sent to `lucos-cloud-client`. `unlimited` is explicit — an absent header must never be read as "no cap". |
| `x-request-id` | `req_…` | Already sent today. Correlates usage events. |

Existing `x-allowed-model-families` / `x-default-model-family` are superseded by
`x-allowed-models`. Keep sending both until every consumer has migrated, then remove them.

### RAG obligations

- If `x-degrade-mode: degraded` → run Ask-only. No tool loop. Use `x-forced-model`.
- Never select a model absent from `x-allowed-models`, even as a fallback.
- Never silently substitute a model. If the requested model is unavailable, error.

---

## 5. Usage event schema

Producer: `adpilot-rag-service.com`, once per completed LLM call.
Consumer: `POST /internal/v1/usage-events` on `api.lucos.com` (internal auth).

```jsonc
{
  "eventId": "evt_01J8Z…",        // REQUIRED. Unique per call. The idempotency key.
  "seatId": "665f1c…c891",        // from x-seat-id
  "userId": "665f1c…c123",
  "orgId": null,
  "requestId": "req_…",           // from x-request-id — one user turn
  "callId": "call_3",             // nth LLM call within that turn
  "model": "gpt-4o",
  "tokens": {
    "input": 12045,
    "output": 812,
    "cacheRead": 8000,
    "cacheWrite": 0
  },
  "status": "ok",                 // "ok" | "partial" | "error"
  "occurredAt": "2026-07-30T09:12:44.120Z"
}
```

Rules:

- **One event per LLM call**, not per user turn. A tool loop with 6 calls emits 6 events.
- `eventId` must be unique and stable. Re-sending the same `eventId` must never double-charge.
- Emit even when `status` is `partial` or `error`, **if tokens were consumed**. A cancelled
  turn still costs money.
- Batch accepted: `POST` an array of up to 50 events.
- Never emit for embeddings, indexing, retrieval, web search, or MCP calls.

### Costing

```
cost = (input      / 1e6 × inputPerMTokUsd)
     + (output     / 1e6 × outputPerMTokUsd)
     + (cacheRead  / 1e6 × cacheReadPerMTokUsd)
     + (cacheWrite / 1e6 × cacheWritePerMTokUsd)
cost = cost × markupMultiplier
```

Rates come from the `ModelRate` collection and are editable without a deploy. An event for a
model with no rate card entry is stored with `costUsd: 0` and flagged `unratedModel: true`, so
nothing is lost — but nothing is silently charged at a guessed price either.

---

## 6. Error codes

All errors use the existing `HttpError` shape:

```jsonc
{
  "success": false,
  "message": "Model not allowed on your current plan",
  "code": "MODEL_NOT_ALLOWED",
  // … code-specific fields below
}
```

| Code | HTTP | Extra fields | Raised by |
|---|---|---|---|
| `MODEL_NOT_ALLOWED` | 403 | `requestedModel`, `planCode`, `allowedModels` | gateway, daemon |
| `CREDITS_EXHAUSTED` | 402 | `planCode`, `spentUsd`, `includedUsd`, `periodEnd` | gateway |
| `REPO_LIMIT_EXCEEDED` | 403 | `planCode`, `maxIndexedRepos`, `currentCount`, `requestedCount?` | gateway, cloud-client |
| `SEAT_NOT_FOUND` | 403 | `userId` | gateway |
| `SUBSCRIPTION_INACTIVE` | 403 | `subscriptionStatus` | gateway |

Every one of these must carry enough context for the IDE to render an upgrade CTA without a
second round trip. That is the whole point of the extra fields.

`CREDITS_EXHAUSTED` is reserved for cases where a call genuinely cannot proceed. The normal
over-quota path is **soft degrade**, not this error.

### Migration note

`local-daemon` currently emits `MODEL_FAMILY_NOT_ALLOWED`, `AGENT_TASKS_DISABLED`, and
`AGENT_TURN_QUOTA_EXCEEDED` ([internal/auth/entitlements.go](../../../local-daemon/internal/auth/entitlements.go)).
Map them as:

| Old | New |
|---|---|
| `MODEL_FAMILY_NOT_ALLOWED` | `MODEL_NOT_ALLOWED` |
| `AGENT_TURN_QUOTA_EXCEEDED` | *(delete — replaced by degrade mode)* |
| `AGENT_TASKS_DISABLED` | *(delete — replaced by `degrade.agentToolsEnabled`)* |

---

## 7. Billing provider

Lucos is **payment-gateway agnostic**. No gateway name appears outside
`src/services/billing/adapters/`.

Plans store neutral references:

```jsonc
{
  "billingProvider": "manual",     // "manual" | "sticky"
  "externalPriceRef": null,        // provider's price/offer identifier
  "providerConfig": {}             // provider-specific ids, opaque to the rest of the app
}
```

For sticky.io, `providerConfig` holds `campaignId`, `offerId`, `productId`, `billingModelId`,
`gatewayId`. These are **data, not code** — changing them is a database update, not a deploy.

`manual` is a fully working provider that activates plans without taking payment. It exists so
Phase 0 and Phase 1 are testable before any gateway decision lands, and so support can grant
plans. It is the default.

### Placeholder safety

Sticky.io campaigns `1`–`10` are **live** campaigns belonging to other products
(`api.search.com`). Placeholder ids must be `0` or `null`, never a small integer. Any attempt
to charge through a provider whose `providerConfig` is still incomplete must throw before the
outbound call is made.

---

## 8. Ownership

| Repo | Owns | Must not do |
|---|---|---|
| `api.lucos.com` | Plans, seats, entitlements, rate card, ledger, gates, provider adapters | IDE UX |
| `Lucos-IDE` | Usage panel, warnings, degrade UX, model picker, deep links | Credit math |
| `local-daemon` | Preflight checks, forwarding structured codes to IDE | Any billing state on disk |
| `lucos-cloud-client` | Free repo cap, propagating trusted headers | Token metering |
| `adpilot-rag-service.com` | Emitting usage events, honouring degrade | Seat assignment, payments |
| `lucos.com` | Checkout, plan management, invoices, usage history | Credit math |
