# Budgets (v1)

> Every Chatticus deployment ships with its own budget tracking, alerts, notifications, and configurable limits. This is how spend is metered without double counting, and what a deployment must carry to be self-contained.

Canonical: https://chattic.us/wiki/budgets/

Every Chatticus deployment ships with its own budget tracking, alerts,
notifications, and configurable limits. This is how spend is metered
without double counting, and what a deployment must carry to be
self-contained.

## The invariant

> **Tokens are always counted. Dollars are counted once, by whoever
> issues the invoice.**

Two meters. Every cost belongs to exactly one, and the meter is chosen by
**billing path**, never by category.

| Meter | Covers | Mechanism | Separated per deployment by |
| --- | --- | --- | --- |
| AWS | Everything on the AWS invoice, model inference included | AWS Budgets and Cost Explorer | The AWS account |
| Vendor | Model spend AWS cannot see | Our ledger in DynamoDB | A per-deployment vendor project |

"How much are we spending on AI" is a **report**, not a meter. It is
assembled from both. Making it a meter is what double counts anything
billed through AWS.

## Why Bedrock is the whole problem

Bedrock is model inference on the AWS invoice. It is the only case where
the two axes cross, and exactly where a naive design counts twice: the
token meter records it because it is a model call, and AWS Budgets
records it because Amazon billed for it.

The rule follows from the invariant. **Bedrock belongs to the AWS meter.**
The ledger still writes a row for a Bedrock turn, because tokens are
always counted and we need them for rate limiting and apportionment. That
row carries `billed_via: "aws"` and `cost_usd: null`.

One nullable field and one enum keep the books straight, and both must
exist **before** Bedrock lands. Adding them afterwards means reconciling
a period where the answer was wrong and nobody could tell.

## One account per deployment

Each deployment gets its own AWS account. The account boundary does the
heavy lifting that tags would otherwise do badly: a client's AWS spend is
that account's spend, full stop. No apportionment, no tag hygiene
standing between us and a correct number, and no risk that one client's
runaway cost hides inside another's baseline.

Staging and production are the exception to the one-account-per-environment
picture: they share the production account. That account has exactly one
`ChatticusBudgets` stack and one monthly limit, which is the combined limit of
staging and production together; the limit and alerts do not split by
environment. Per-environment spend is read from the `chatticus:environment`
cost tag, which every stack of the two environments carries. The budget stack
already existed in the production account before staging was added, so it keeps
its `production` tag, and only development and production deploy it.

That boundary covers the AWS meter completely. **It does nothing for the
vendor meter.** OpenAI does not know what an AWS account is. Vendor spend
is separated only by giving each deployment its own vendor project and
key, and a deployment that shares a key with another one has vendor spend
that cannot be attributed to either.

`infra/lib/thin-turn-stack.ts` reads the OpenAI key from
`/chatticus/{environment}/thin-turn/openai-api-key`, a per-deployment
parameter the human seeds before live turns. Each deployment gets its own
OpenAI vendor project and key so vendor spend is attributable.

## Vendor model prices

The ledger prices each model call from a versioned `VendorPriceBook`. Both
production compositions (the turn executor and the front door, whose voice understanding
and model gateway observations are priced from it too) use
`defaultVendorPriceBook()` from `conversation/src/ledger/vendor-prices.ts`,
a table of OpenAI list prices in USD per million tokens. The book's id is
`openai@2026-10-13+pi-ai-1.0.2`, and every ledger row records the id of the
book that priced it (`price_book_id`).

| Model | Input | Cache read | Cache write | Output |
| --- | --- | --- | --- | --- |
| `gpt-6-luna` (bot turns, low reasoning) | 0.10 | 0.01 | 0.125 | 0.50 |
| `gpt-6-luna`, context above 272000 tokens | 0.20 | 0.02 | 0.25 | 0.75 |
| `gpt-5-nano` (voice understanding) | 0.05 | 0.005 | 0 | 0.40 |
| `gpt-5-mini` | 0.25 | 0.025 | 0 | 2.00 |

The figures are copied from the `openai` catalog shipped inside
`@earendil-works/pi-ai` 1.0.2 (`dist/providers/data/openai.json`, read
2026-10-13), the catalog the Pi harness prices with. They replace the earlier
LiteLLM figures (the base input and output rates are the same). They have not
been compared with OpenAI's own price page; a vitest guard pins the table to
the pi-ai catalog, so an upgrade that changes a figure fails until the table
and the book id are updated.

A call is priced by its own model: uncached input at the input rate, cache
reads and writes at their cache rates, output (reasoning included) at the
output rate, all at the tier the call's context size (uncached input plus
cache reads plus cache writes) selects. A turn that switches model prices each
call at its own model. A model whose source lists no cache rate would price its
cached tokens at the input rate and the row's `priced_with` would carry
`uncached_fallback`. Service tiers (flex, priority) are not modelled; Chatticus
requests none.

A model with no entry is never priced as zero. A turn on such a model fails
before the model is called, with the reason "No price is tabled for model ...".
A spend record of such a model (for example a voice understanding call) keeps
its tokens, carries `unpriced_calls` greater than zero and has no `cost_usd`,
which means unknown. A typical turn (about 4,700 input and 220 output tokens on
`gpt-6-luna`) costs about $0.00058 when none of the input is cached.

### Where the ledger row of a turn comes from

A turn's ledger row is not counted; it is a projection of the turn's cost records. Each model call of a
turn is one keyed record (`chatticus.cost`, key `entry-<entryId>`) in the durable Pi session of the bot
and channel, derived from the assistant entry that answered the call and priced from the book above (see
`docs/COST_ACCOUNTING.md`, section 3). The owner derives the records and then writes the turn's ledger
row, and its turn index item, from the sum of those records, as absolute totals rather than additions.
It does this when a turn concludes or fails, when it is handed on (yield) or parked on the computer, and
when an owner resumes a turn. The same calls therefore give the same row from a Lambda owner or from the
computer owner, a retry or takeover writes the same figures again, and the model gateway writes nothing
to the ledger; it only logs the usage it saw. The old per-turn counters
(`ledger_input_recorded`, `ledger_output_recorded`) no longer exist.

The ledger row keeps its shape (`input_tokens` counts cached tokens too, `cost_usd` is the sum of the
calls' own dollars, `price_book_id`, `priced_with`, `models`, `unpriced_calls`, plus `record_count`, the
number of call records it was written from; a write with fewer records than the row already holds is
refused). The row at `{tenant}#cost#{yyyy-mm}` / `turn#{turnId}` is the turn index item: the same totals
with `bot_id`, `channel_id`, `prompt_author_id`, `prompt_message_seq`, `message_seq`, `owner_kind` and a
per-model breakdown. Until the rollup moves to the turn index (Kanbus `chatticus-0d265c`, T7) the daily
rollup still sums the ledger rows, so budget alerts and the computer-work meter are unchanged.

### Every other model call, and calls the session never saw

Three more kinds of spend reach the ledger, all as projections of cost records (`docs/COST_ACCOUNTING.md`, section 3.9), never as
additions:

* **Compaction.** The summarization Pi runs to shrink a long conversation is a model call with no assistant entry. Its tokens are
  recovered from the session's own usage ledger and recorded as `compaction` cost records, so they are in the turn's row and totals.
* **Voice understanding.** The call that repairs a spoken line is a `voice_understanding` cost record, priced from the same book
  (cache reads included) and projected to the ledger row `voice:<uuid>` and a cost index item `voice#<uuid>` that names the
  member and the channel and no bot. If the record cannot be written the front door logs `voice_cost_not_recorded` and the line
  still goes through.
* **Calls only the gateway saw.** The model gateway keeps an observation of each call a container made (`{tenant}#cost#observed`,
  35 days). The daily rollup reconciles them first: a call whose response id is on the turn's session row is the same call and adds
  nothing; a call nothing in the session accounts for after two hours (the vendor answered, the session never committed) becomes a
  ledger row `turn#gateway:<id>` with `source: gateway`, marked `estimated` when the stream was interrupted. A gateway row whose
  call a session row lists is skipped in the sum, so no call is counted twice.

### What the rollup does with calls it cannot price

A ledger row with unpriced calls has no `cost_usd` (unknown, never zero). The daily rollup no longer drops such rows silently: it
adds their `unpriced_calls` to the rollup row (`unpriced_calls`, beside `vendor_cost_usd`, whose dollars leave those calls out),
logs `vendor_cost_unpriced_calls` for the day, and carries the month-to-date count in the threshold alert payload
(`unpriced_calls`). Read `vendor_cost_usd` as "known dollars" whenever `unpriced_calls` is positive.

Dollars in the ledger feed the daily rollup's `vendor_cost_usd` and
`combined_report_usd`, and so the month-to-date meter that the per-organization
computer-work ceiling reads.

## Configurable limits

Limits are deployment configuration, not constants. A `ChatticusBudgets`
construct lives in a dedicated `ChatticusBudgets` CDK stack (not
`ChatticusSnapshots`). Deploy it with `infra/deploy-chatticus-budgets.sh`
after setting both `CHATTICUS_BUDGETS_*` env vars. The construct takes a
monthly limit, alert thresholds, and notification targets, with defaults
that suit a small deployment and an override per environment.

**Cutover:** AWS budget names are unique per account
(`chatticus-monthly-aws`). An account that already has the budget on
`ChatticusSnapshots` must deploy snapshots first (which deletes the old
CFN-managed budget), then immediately deploy `ChatticusBudgets` to
recreate the same name. See `infra/README.md`.

A new account has no history to derive a limit from, and that is a
feature. Start low enough that any spend is signal and raise it
deliberately, rather than starting high and learning what normal was
after an invoice. Historical spend from a previously shared account is
not a baseline for a fresh one and should not be used as one.

## Cost allocation tags

With one account per deployment, tags stop being what separates clients
and become what breaks a single client's spend down internally.

| Tag | Values | On |
| --- | --- | --- |
| `chatticus:application` | `Chatticus` | Every stack and every computer task |
| `chatticus:environment` | development, staging, production; `shared` for stacks that are not per-environment | Every stack and every computer task |
| `chatticus:installation` | The installation's readable name, for example `Anthus AI Solutions` | Every stack and every computer task, when configured |
| `chatticus:component` | thin-turn, web, auth, computer, snapshots, dns, budgets, deploy, integration-test | Every stack (a computer task is `computer`) |
| `chatticus:tenant` | An organization's `tenant_id` | Computer tasks, at run time |

The **installation** is one deployment of Chatticus, named by the operator
(`CHATTICUS_INSTALLATION_NAME`, a GitHub Actions variable in CI). It is not an
organization: one installation serves many organizations, and only computer
tasks carry an organization, in `chatticus:tenant`. `lib/tagging.ts` holds the
stack registry and refuses to synthesize a stack it has not classified, so a
new stack cannot ship untagged. The installation name is optional; deploying
without it warns and **removes** the tag from an already-tagged stack, so keep
the variable set for every deploy path, including local scripts.

The tenant tag on a computer task is for reporting in a management account.
The spend ceiling does not depend on it: see "Platform-hosted organizations:
priced from recorded runtime". Shared infrastructure is reported as overhead,
not apportioned by guesswork.

### Activating the tags (person-step)

A cost allocation tag does not appear in Cost Explorer until it is activated,
and **activation is not retroactive**, so it belongs in the deployment
runbook rather than in someone's memory.

1. Deploy so the tags exist on billed resources. A new key can take about a
   day to show up as activatable.
2. In the Billing console (Cost allocation tags), or with
   `aws ce update-cost-allocation-tags-status`, activate `chatticus:tenant`
   and `chatticus:environment` at minimum, and `chatticus:application`,
   `chatticus:installation` and `chatticus:component` for reporting. In an AWS
   Organization only the management account can do this.
3. The rollup no longer reads these tags for any organization, so activation
   is optional and serves reporting only.

`ChatticusSnapshots` and `ChatticusComputers` are deliberately not
per-environment, so they carry `chatticus:environment=shared`.

## Where a turn's tokens and cost are written

Every request's token counts and cost must be recorded somewhere we can
find, aggregate, and alarm on. Neither Bedrock nor any external API will
hand us a per-organization breakdown, so this is ours to keep regardless
of vendor.

**Both, with different jobs.**

| | DynamoDB row | Structured log line |
| --- | --- | --- |
| Job | System of record | Observability |
| Read by | The daily rollup, billing, per-organization attribution | A human asking why Tuesday spiked |
| Lifetime | As long as the account exists | The log group's retention |
| Authoritative | Yes | No |

The log line is derived from the row, so the two cannot disagree. The
rule to hold: **DynamoDB is what you bill from, logs are what you debug
from.**

Not every row is a turn. Model calls made outside a turn, such as the voice
understand-the-user step (`docs/VOICE.md`), are recorded under their own id
with a `voice:` prefix in the row's `turn_id` field. Rollups sum every row
per organization; anything that joins ledger rows to turns must skip those
ids.

### Logging this is effectively free

The cost worry is misplaced, and the numbers say so plainly. At household
scale, on the order of a thousand turns a day and a few hundred bytes a
record, the ledger produces roughly **twelve megabytes a month**. Log
ingestion is free to five gigabytes and fifty cents a gigabyte after,
storage is three cents a gigabyte-month, and a Logs Insights query is
charged on the data it scans. A hundredfold increase in traffic still
lands inside the free tier.

The Lambda already writes to CloudWatch. Adding a structured JSON line
costs the bytes and nothing else.

### The real cost trap is metrics, not logs

CloudWatch treats **every unique combination of dimensions as a separate
custom metric**, at thirty cents per metric per month. A metric filter or
an embedded-metric-format record dimensioned by organization and by model
does not produce one metric; it produces one per pair. A hundred
organizations across three models is three hundred metrics, ninety
dollars a month, which for a small deployment exceeds the infrastructure
it is measuring.

So: **alerts come from the daily rollup reading DynamoDB, not from
log-derived metrics.** Keep custom metrics to a small fixed set, such as
total spend per environment. Never dimension a metric by organization.

### Why not logs alone

Tempting, because Logs Insights is good and the ingestion is free. Three
reasons it cannot be the only copy.

- **Retention is a cliff, not a slope.** Insights can only query what is
  still retained, so a year-over-year question is unanswerable the day
  after retention expires.
- **An invoice needs an authoritative number.** In v3 this data decides
  what an organization is charged. Scanning logs is not a defensible
  basis for a bill, and a log group is not an audit trail.
- **Scanning is O(data) forever.** A monthly rollup re-scans the whole
  period every run, where a DynamoDB query reads one partition.

All Chatticus CDK log groups use **30-day** retention (`CHATTICUS_LOG_RETENTION`
in `infra/lib/log-retention.ts`), including WebStack BucketDeployment and
development auto-delete custom-resource handlers. DynamoDB is the system of record;
CloudWatch logs are a debug window only, long enough for incident lookback and
short enough to bound storage cost. Retention is set explicitly on every log group
in CDK so nothing relies on the never-expire default.

### Record shape

Log the ledger event as **JSON**, not the `key=value` style used
elsewhere in the codebase. Insights parses both, but `stats sum()` over a
JSON field is direct where the other needs a parse expression, and this
is the one log line written specifically to be aggregated later.

## Combining the two meters

No always-on aggregator; that reintroduces the idle floor the whole
architecture avoids. EventBridge Scheduler already wakes a Lambda for
turn deadlines, and a daily budget rollup is the same shape: a routine
wake-up, not something in the token path.

Once a day a function reads Cost Explorer for the AWS side and the ledger
for the vendor side, writes one rollup row per day per organization and
per environment, and publishes to SNS when a threshold is crossed. AWS
Budgets fires its own native alerts to the same topic, and the rollup
records that an alert fired, so a human sees one sequence rather than two
disconnected emails whose relationship they must reconstruct.

A brand-new account reports nothing for roughly a day while Cost Explorer
populates. Say so in the runbook, or the first quiet day reads as a
broken alarm.

## Customer-account organizations: read the whole account

An organization that runs in its own AWS account (cross-account setup) is not
attributed by tags. The account boundary already separates its spend, so the
daily rollup assumes that organization's `ChatticusOrganizationComputerRole`
and reads the account's total `UnblendedCost` for the day. The role therefore
needs `ce:GetCostAndUsage` (in `infra/customer-role.yml`, and required by
self-setup), and the rollup Lambda needs `sts:AssumeRole` on that role name.

Two prerequisites sit on the customer side and are person-steps:

- Cost Explorer must be enabled in the customer account. Enabling it for the
  first time is a console action and the data takes about a day to appear.
- The customer must run the current published template, which carries the
  permission. A role without it fails self-setup.

A read that fails (assume-role denied, permission missing, Cost Explorer not
enabled) writes the day as `ce_status=error` with no dollar figure, never as
zero. `error` and `pending` both make the meter unavailable, and an
organization with a ceiling and an unavailable meter has new computer work
refused. Days Cost Explorer has not populated stay `pending`.

## The default ceiling

Every organization starts with a monthly ceiling of 50.00 USD, whether it was
created in the app or seeded by the operator. Self-setup replaces it with the
ceiling the owner chooses, and an owner can raise it when work pauses.
Organizations created before this rule hold no ceiling; give them the default
once with
`node conversation/bin/migrate-organization-spend-ceiling.ts --environment ENV`
(a dry run; add `--execute` to write, `--tenant T` to limit it to one
organization). It writes only the ceiling, and only while the organization
still holds none, so it never changes a ceiling an owner set. An enabled
organization whose month already cost 50.00 or more is paused the moment it
gets the ceiling, so read the month-to-date rollup before running `--execute`.

## Platform-hosted organizations: priced from recorded runtime

An organization hosted in the deployment account (setup path `anthus-managed`,
the default) has no account of its own to read, and Cost Explorer cannot split
the deployment account's bill by organization: that would need `chatticus:tenant`
as an active cost allocation tag, and only the AWS Organizations management
account can activate one, so a member-account deployment can never read it. The
rollup therefore prices what the control plane already recorded.

- The owner supervisor routes write one durable item per owner run, under
  `{tenant}#computer_runtime`, sort key `generation#{owner generation}`. The
  bootstrap exchange opens it (`started_at`), and every accepted poll moves
  `last_seen_at` forward. The item outlives the owner and the computer's owner
  fields.
- The daily rollup sums, for the rollup day, the seconds of each run that fall
  inside that UTC day, at 0.009875 USD per hour (0.25 vCPU at 0.03238 plus 0.5 GB
  at 0.00356, the Fargate Linux/ARM64 on-demand rates for us-east-1, taken from
  the AWS Price List API on 2026-09-11). A run is billed for at least 60 seconds,
  as Fargate bills. The constant is `COMPUTER_TASK_USD_PER_HOUR` in
  `conversation/src/budget/computer-runtime.ts`; change it when the rates or
  the task size in `infra/lib/computer-stack.ts` change.
- A day the computer never ran is a measured zero with `ce_status=ok`. Runtime
  records that cannot be read write `ce_status=error` with no figure. The meter
  never reads an unmeasured organization as zero: it counts such a day at the
  day's model spend plus the most the computer can cost in a day (24 hours at
  the rate above, `MOST_A_HOSTED_COMPUTER_COSTS_IN_ONE_DAY_USD`), and pauses
  computer work as "cannot be read" only when that count reaches the ceiling.
  An unread hosted day is not read again on later runs, because runtime that was
  never recorded would read as a measured zero.
- An organization in its own AWS account has no such bound. A day its account
  could not be read, or whose data is still loading, makes the meter unknown and
  pauses computer work for an organization with a ceiling. Each rollup run reads
  every such earlier day of the month again, so one failed read does not hold
  the pause for the rest of the month.
- Every organization-day a run could not read is logged as `spend_unreadable`
  and published as one `spend_unreadable` message on the budgets topic.

This is an approximation of the organization's computer, and it says so:

- Snapshot storage (S3), the shared stack costs and data transfer are not in it.
  Snapshot size is not recorded; at 0.023 USD per GB-month it is cents for a
  workspace of a few GB.
- The seconds between the task starting and the bootstrap, and between the last
  accepted poll and the task ending, are not counted, except through the
  one-minute minimum. An owner that is killed leaves its last poll as its end.
- The meter reads days that have a rollup row. The rollup runs at 06:00 UTC for
  the previous day, so today's usage reaches the ceiling decision the next
  morning.
- At the current task size an owner can run at most about 7.35 USD in a 31-day
  month, so a ceiling above that never trips on this meter alone.

Customer-account organizations are not touched by this meter; they keep the
whole-account read above.

## Per-organization attribution

Tagged AWS resources attribute cleanly. Vendor spend attributes cleanly,
because the ledger is written per turn and a turn belongs to an
organization.

**Bedrock does not.** A Bedrock invocation does not carry our tenant tag,
so Cost Explorer can total it and cannot split it. Two ways out, in
preference order:

1. Application inference profiles, which can carry tags. Confirm they
   support cost allocation before building on it.
2. Apportion the Bedrock total across organizations by the token counts
   already in the ledger. Approximate, and honest about being so.

Per-organization cost is what billing needs in v3. An attribution gap
found then costs far more than one designed around now.

## Still open

- Who receives a deployment's budget notifications, and who holds the
  payment method. Member accounts sit under one AWS Organization, so
  consolidated billing means Anthus sees every deployment's spend and can
  set budgets across member accounts from the management account, with
  Cost Explorer grouping by linked account. Whether the customer also
  receives their own deployment's alerts is a product decision, not an
  infrastructure one.
- Whether each deployment gets its own vendor account or a project inside
  ours. Separate projects are enough for attribution; separate accounts
  also separate liability and rate limits.

## Not in scope

- Charging anyone. The boundary billing needs is built here; the invoice
  is not.
- Per-user budgets inside a tenant. The deployment is the unit of
  spend and the tenant is the unit of internal chargeback.
- Reserved capacity, savings plans, or any commitment purchase.
