Skip to content

Costs dash: what a deployed group costs per day ​

GET /costs is an Access-gated, read-only page (plus a JSON twin) showing what a named group of deployed pieces costs per day on every meter Cloudflare bills a Workers deployment on, priced at list from a snapshot of the providers' own billing datasets — taken daily by the bot's own loop, or on request with costs snapshot, never in a page load — and what share of the account's whole Cloudflare spend that is, so a deployer can tell this software's cost from everything else on their account. The page says which snapshot it shows, how old it is and when the next one is due. The first group is switchboard — the bot Worker + its container, the resident/sandbox/memory Workers and their containers — but the config is a map of groups so the same page serves other apps on the account later. LLM spend is layered in from the Anthropic Admin API when an Admin key and a per-group workspace are configured.

  • Code: src/core/costs.ts (parseCostsConfig, CLOUDFLARE_PRICES, DAYS_PER_MONTH, containerCostUsd, doDurationCostUsd, doRequestsCostUsd, doRowsCostUsd, storageDayCostUsd, workersCostUsd, workflowsCostUsd, workflowStepsFromEvents, r2OperationClass, r2OperationsCostUsd, attributionOf, buildCostReport, resolveRange, CloudflareGraphqlUsageSource, AnthropicCostReportSource, NullLlmCostSource, COSTS_SNAPSHOT_EVERY_HOURS); src/core/modelPricing.ts (ANTHROPIC_PRICES, anthropicPriceOf, anthropicTokensCostUsd, modelIdOf, llmUsdOfUsage, PricedModelUsage — the list prices every token arithmetic is priced at, and a usage priced per model); src/core/costsSnapshot.ts (takeCostsSnapshot, reportFromSnapshot, byReportFromSnapshot, CostsSnapshotter — the take, the arithmetic over it, the refresh loop, the status and its subscribers); src/core/costsSnapshotStore.ts (CostsSnapshot, isCostsSnapshot, InMemoryCostsSnapshotStore, WorkerCostsSnapshotStore, buildCostsSnapshotStore); src/core/stateWorkerRef.ts (stateWorkerOf, StateWorkerBlocks, SecretReader — which *.worker block names the state Worker, shared with the delivery snapshot store); src/core/costsService.ts (CostsService, createCostsService, costsFromConfig, NullCostsService, NoCostsSnapshotError, viewerRunUserIds); src/core/commands/costs.ts (costs.by, costs.snapshot); src/core/snapshotAge.ts (snapshotAgeText, untilText — shared with the delivery page); src/channels/costsView.ts (parseCostsRoute, CostsView, CostsByView, DIMENSION_OF_VIEW, createCostsViewHandler — serves the shared web shell with a CostsSeed, see live-view.md's Rendering paragraph; serveCostsStatus, canSnapshot — the status feed and the button's admission, item 8b); the page rendering in web/: web/green/pages/Costs.vue (mobile layout included), web/src/components/costs/CostChart.vue, web/src/components/costs/CostsByDimension.vue, web/src/lib/costs.ts (snapshotLineOf, parseStatusFrame among the view model); src/core/costsBy.ts (CostDimension, COST_DIMENSIONS, coverageFrom, buildCostsByReport — the by-dimension arithmetic over the usage cells); src/index.ts (wires the service from costs: config + env through costsFromConfig, starts the refresh loop, gates /costs* behind Access next to /runs* and /residents*); deploy/cloudflare-memory/worker.ts (CostsSnapshotDO, POST /costs/snapshot/get|put); deploy/cloudflare/worker.ts (forwards CF_ANALYTICS_TOKEN / ANTHROPIC_ADMIN_KEY into the container env).
  • Tests: src/core/costs.test.ts, src/core/modelPricing.test.ts, src/core/costsSnapshot.test.ts, src/core/costsSnapshotStore.test.ts, src/core/costsService.test.ts, src/core/commands/costs.test.ts, src/core/costsBy.test.ts, src/channels/costsView.test.ts (routing + seed), deploy/cloudflare-memory/costs.test.ts (the Durable Object, in workerd), web/src/pages/costs.test.ts and web/src/lib/costs.test.ts (rendering), web/green/pages/personalReports.test.ts.
  • Code: src/core/shipSpend.ts groups finished child runs from snapshot usage; web/src/components/costs/ShipSpendChart.vue draws the selected-range total and admitted cap.
  • Tests: src/core/shipSpend.test.ts proves unit grouping, recorded cost, and unknown dollars.
  • Docs: Code map, Capacity and sizing, access-gate.md, resident-repos.md (sibling Access-gated dash).

Configuration ​

yaml
costs:
  cloudflareAccountId: <32-hex>
  cloudflareAccountName: acme-infra            # optional: printed beside the account's share; else the id's first hex
  cloudflareTokenEnv: CF_ANALYTICS_TOKEN      # default
  anthropicAdminKeyEnv: ANTHROPIC_ADMIN_KEY   # legacy (one release): moved to providers.anthropic.invoiceKeyEnv (item 4d)
  snapshot:
    everyHours: 24                            # default; 1–168. How often the bot reads the billing sources (item 6)
    alertChannel: slack:C0123ABCD             # optional: told after three failed takes in a row, and when one lands again
  groups:
    switchboard:
      label: Switchboard
      workers: [switchboard, switchboard-resident, switchboard-sandbox, switchboard-memory]   # the attribution root
      containerApps: { <application id>: bot, <application id>: resident, <application id>: sandbox }
      durableObjectNamespaces: { <namespace id>: bot DO }   # optional labels — a namespace hosted by a listed Worker is attributed anyway
      r2Buckets: { <bucket name>: label }                    # optional — a bucket named <worker>-cache (the template's name) is attributed anyway
      anthropicWorkspaceId: wrkspc_…          # optional
  prices:                                     # optional: USD per million tokens for a model the Anthropic list does not know, or to override the list (item 4b)
    openai/gpt-5: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }
SecretScopeEffect when absent
CF_ANALYTICS_TOKENCloudflare API token, Account → Account Analytics: Read only, scoped to the one account/costs answers 503 (feature off)
ANTHROPIC_ADMIN_KEYAnthropic Admin API key (sk-ant-admin…)LLM column absent; page says "LLM spend not configured" (never $0)

Container application ids: GET /accounts/{id}/containers/applications; DO namespace ids: GET /accounts/{id}/workers/durable_objects/namespaces. A group's LLM spend is the cost report filtered to anthropicWorkspaceId; the bot's API key must live in that workspace for the attribution to be right (the org default workspace reports as null and is never attributed to a group).

Behavior ​

  1. Pricing model is Cloudflare's. vCPU bills on active seconds (cpuTimeSec); memory and disk bill on the provisioned size for every awake second (allocatedMemory / allocatedDisk byte-seconds). Durable Objects bill duration ($12.50 per million GB-s, metered as 128 MB × active wall-clock seconds) plus requests ($0.15/M). Duration comes from durableObjectsPeriodicGroups.sum.duration, which is already the billable GB-s per namespace — not from summed request wall time (durableObjectsInvocationsAdaptiveGroups.wallTime), which double-counts overlapping long requests (SSE streams, exec) and has been observed ~2× above billed (bot: 179k s summed vs 86k s active on one day). The other meters a Workers deployment is billed on are priced too, from their own datasets: SQLite rows read ($0.001/M) and written ($1.00/M) from the same durableObjectsPeriodicGroups rows; SQLite storage ($0.20/GB-month) from durableObjectsSqlStorageGroups; Worker requests ($0.30/M) and CPU time ($0.02 per million CPU-ms; the dataset reports microseconds) from workersInvocationsAdaptive; R2 storage ($0.015/GB-month) from r2StorageAdaptiveGroups (payload + metadata) and operations from r2OperationsAdaptiveGroups — class A $4.50/M (an action that mutates or lists: Put*, Create*, Complete*, Upload*, List*, Copy*), class B $0.36/M (Get*, Head*, Usage*), deletes and aborts free, an action not met before classed by its verb so it is priced rather than dropped; Workflows from workflowsAdaptiveGroups — steps ($0.80 per 100,000: the step endings, STEP_SUCCESS and STEP_FAILURE, summed per Workflow per day; ATTEMPT_* are the retries inside a step and WORKFLOW_* the instance's own lifecycle, neither a step on the bill) and persisted state ($0.20/GB-month on the day's peak bytes; no analytics dataset reports them, so the GraphQL source answers 0 there and the meter prices what it can see), while a Workflow's requests and CPU time bill at Workers rates onto the hosting Worker's own row and are never re-metered. A GB-month rate is prorated per day over the mean month (DAYS_PER_MONTH = 365.25 ÷ 12) on the day's peak bytes. Constants in CLOUDFLARE_PRICES (Cloudflare list prices; re-check against the pricing pages when they move); the page states them. Gross — plan fees and included allowances are not subtracted. Not priced: Workers Logs volume (no analytics dataset exposes it) and Access (free at this seat count); the page says so.
  2. Attribution is by Worker script, and the account is the denominator. A group names its Workers; everything Cloudflare bills hangs off a script, so attributionOf derives the rest: a Durable Object namespace is attributed to the Worker that hosts it (the durableObjectsInvocationsAdaptiveGroups join of namespaceId → scriptName, any day in range), labelled by a durableObjectNamespaces entry when there is one and by its Worker's name otherwise — a listed namespace is attributed even when the range shows it no invocation; an R2 bucket is attributed when it carries exactly the name the deploy templates give a Worker's bucket (<worker>-cache, the resident template's {{script}}-cache) or is listed in r2Buckets — a prefix match would let a stranger's switchboard-2-tfstate inflate switchboard's figure on a shared account; a Workflow when it carries exactly the name the resident template gives its refresh Workflow (<worker>-refresh) — the Workflows dataset names no script, so the name is the only join, and the Workflow's steps and state land in the hosting Worker's group; Worker requests and CPU by scriptName; container applications only by the configured containerApps map (no dataset ties an application to its Worker). A resource that appears after the config was written is therefore counted, not silently dropped, as long as it belongs to a named Worker. Every row in the account — attributed or not — is priced the same way into account.cloudUsd, so the page can say what share of the account's Cloudflare spend this group is; the report carries the attribution it used (Workers, namespaces, buckets, Workflows, apps) so a reader can check what was counted. LLM rows count only when workspaceId equals the group's anthropicWorkspaceId.
  3. One row per UTC day, zero-filled. resolveRange(?days) gives 1–31 days (default 30, garbage → default; 31 because Cloudflare's analytics on a Workers account answer no range wider than 4w4d and hold no data older than that — cannot request a time range wider than 4w4d, cannot request data older than 4w4d, measured live — so a wider window is a refused read, not more history) ending today — the day the snapshot was taken (item 6), which is what today means everywhere on the page; today is flagged partialLastDay. The averages use full days only: Yesterday = last full day, 7-day average (absent, and said so, when the range has no full day). Projected month is two run-rates added and × 30.4: cloud from the last seven full days, LLM from the closed days that have LLM spend (from the first such day on, so a workspace younger than the range is not diluted by its leading zero days) — and while a rate has no full day to stand on, from the open day scaled to a full one by the fraction of the UTC day elapsed at the report's generatedAt (floored at one hour). The tile's second line names both rates and what each stands on. One 7-day rate over the totals would read an order of magnitude low on a young workspace, since model spend dwarfs the Cloudflare spend. A range with no full day (?days=1) leads with Today so far instead of an empty Yesterday. The range switcher is a pill row beside the group pills — today · 7d · 30d — not header small print; a shorter range costs fewer buckets, never more. The short preset is today in UTC, not a rolling 24 hours: both billing sources bucket by UTC day and the cost report offers nothing finer, so a rolling window would be a figure neither source can stand behind.
  4. LLM unavailable ≠ $0, and LLM is dollars, not a share. With no Admin key (NullLlmCostSource → null) or no workspace on the group, llmAvailable is false and the page says "LLM spend not configured"; the totals then exclude LLM rather than reporting a fake zero. The LLM spend tile is the range's LLM dollars with yesterday's and the open day's figures beneath — a share of the total was retired because model spend runs an order of magnitude above the Cloudflare spend, so a percentage said nothing. 4a. The open day is estimated, and says so. The Anthropic cost report is the invoice and closes late: a day gets its bucket some hours after midnight UTC and the open day never has one (nor does the usage report at bucket_width=1d, read live against the API when this was written). So the Anthropic source prices every day after the cost report's last bucket from the hourly usage report — GET /v1/organizations/usage_report/messages?bucket_width=1h&group_by[]=workspace_id&group_by[]=model&limit=24 for that day, paginated — summed per workspace × model and priced at ANTHROPIC_PRICES (list, per family: input, output, 5-minute and 1-hour cache writes, cache reads; a dated release id resolves to its family through anthropicPriceOf, and claude-fable-5-1 is a different family from claude-fable-5 because its cache reads bill differently). Such a row is estimated: true; the day carries llmEstimated and the page marks it (tile, table row, hover, a line under the chart). A model the table has no price for is not $0 in silence and does not fail the page: its tokens land in unpricedTokens / llmUnpricedTokens and the page says some tokens are outside the estimate. Closed days are never re-estimated: a cost-report bucket with no rows still closes its day. At most the trailing MAX_ESTIMATED_DAYS (three: today plus two days of report lag) are estimated, read concurrently — an older day with no bucket had no spend (a new workspace or organization), and a page load must never become one live usage read per day of a 31-day range. ANTHROPIC_PRICES is a copy of the pricing page; re-check it when the page moves. 4b. One price table for a run's tokens: costs.prices over Anthropic's list. Every dollar priced from a run's usage (the by-user report, item 10a; a run's own cost) comes through modelPriceOf(ref, prices): the configured costs.prices entry for the exact <provider>/<model> ref the run's spans name wins; a ref the table lacks falls back to ANTHROPIC_PRICES by family after the provider prefix is dropped (item 4a — so bedrock/claude-… prices as the same model), cache writes at the 5-minute rate, the one cache-write count a span carries; a model neither knows prices as unpriced, never $0. costs.prices is a mapping of <provider>/<model> → { input, output, cacheRead, cacheWrite } in USD per million tokens, all four kinds named (a provider that bills no cache writes says cacheWrite: 0; a kind left out would price at $0 in silence), each a finite number ≥ 0; a key without a provider, a value missing a kind or carrying anything but such a number, or a block that is not a mapping is refused at load by name (parseCostsConfig). There is no built-in price list for any provider but Anthropic: the table is the operator's, kept beside the config that names the models. The open day's estimate (item 4a) keeps pricing the Admin API's rows at the list alone: those rows name Anthropic models by id, not by ref. The table prices only what the spans did not price themselves: a model whose turns the proxy's meter row priced (model-proxy.md item 6) carries its own usd in the run's usage — kept as recorded (a provider-reported cost beats any table), null reading as unpriced tokens — and the table prices the models without the field (a record from before the meter row). The same table is the meter row's operator layer at the proxy (priceTurn). 4c. Every finished run is priced on its own page and in runs get. A run's record carries its usage (run-history.md item 56) — per model, since the meter row, with the usd its spans summed and the priceSources they named (a number when every turn that counted tokens carried one, null when such a turn lacked it (a turn that counted none — an errored call, a retry — leaves nothing out), absent before the field), which runCostOf keeps over the table; the runs service prices the rest through the same table (runCostOf(usage, costs.prices), RunsServiceDeps.prices) onto the view of every finished run — a persisted row, and a finished row the registry still holds, from the store's record — as cost: { usd, byModel }: usd is the run's dollars when every model it ran on has a price, null when one has none (a total that left a model out would understate the run; byModel[ref].usd says which), 0 for a run with no turns; a live run carries none (its usage is summed at finish). runs get answers it on every surface: the JSON as is, the text surfaces (CLI; runs get opts out of chat) as cost: $1.24 · <ref> $1.24 · <ref> unpriced or cost: unpriced · … — never $0 for an unknown model. The run page's header shows it beside the duration (live-view.md item 22): · $1.24 (formatUsd: cents from a dollar up, a tenth of a cent below, <$0.001 under that — a run that spent anything never reads as $0.000), · unpriced when usd is null, nothing for a record without usage; the per-model figures are its hover text. Cost reporting off (no costs: block, no Cloudflare token) changes none of this: the list prices every run; costs.prices extends them when configured. 4d. Each biller's invoice ties out per day against its own rows. A provider block may name an invoice API and an invoiceKeyEnv (providers.<block>.invoiceApi: anthropic-cost-report, openrouter-activity or openai-costs; the key an admin or management credential, never the block's inference key; the two keys are declared together or not at all, refused by name otherwise) — one LlmInvoiceSource per such block, read at every snapshot take into invoices (biller → InvoiceDay[]). The daily view then carries one tie-out per provider block (CostReport.billers, buildBillerTieOuts, pure): each biller's invoiced dollars per UTC day beside the summed model.turn rows whose ref names that block — an aggregator's usage (its own fee) beside the summed feeUsd the meter rows carried (model-proxy.md item 6; ModelUsage.feeUsd, summed per model and folded through addUsage), and its byok_usage_inference beside the remainder (attributedUsd − attributedFeeUsd). The attributed side is the meter's charged figure — each turn's own usd, the provider's, the operator's or the registry's price at the time — never the price table's repricing the dimension views show (item 10a, buildCostsByReport): an invoice ties out against what the wire said was charged, not a list-price estimate, so the tie-out and the tabs need not agree, and the page says so in one sentence on the tie-out card and one in the methodology. A model whose usd is null or absent contributes nothing to the attributed side — understated is honest, invented is not — and its tokens are counted beside the figure as the day's unpricedTokens, per biller and per UTC day, so a gap between the invoice and the attributed dollars is explained by what was never priced (the page shows the count as a column and on the biller's line; a fully priced day carries no field). The same absent-not-invented rule holds the other way: a day the source returned no row for (the sources hold closed days only, so a sourced biller's trailing days are uninvoiced; OpenRouter's activity window is roughly 30 days, one short of the range) keeps its invoice side absent and renders — beside the attributed spend, never an invented $0 (the unpriced column, by contrast, prints 0 on a fully priced day — a known zero, not the — of a missing row); a day with no invoice row, no nonzero attributed figure and no unpriced tokens is dropped — unpriced spend alone keeps its day, so the count renders. A biller without a source ties out against nothing (invoice: false) and the page says "no invoice"; a biller whose source fails at a take is warned by name and omitted from that snapshot ("no invoice" until the next one) while every other figure stays fresh. costs.anthropicAdminKeyEnv moved under the anthropic block as its invoiceKeyEnv; the old key is accepted for one release and still feeds the cost report (the LLM line, and the anthropic biller's invoice). The key must reach the process: any operator-chosen providers.<block>.invoiceKeyEnv name must also be added to the bot container's forward list (FORWARDED_OPTIONAL in deploy/cloudflare/worker.ts plus deploy/secrets.manifest.json) — a key set on the Worker but never forwarded reads as "no invoice" in production.
  5. Sources are seams. CloudflareUsageSource and LlmCostSource are interfaces; the real ones take an injectable fetch. The Cloudflare source POSTs one GraphQL query (containersUsageAdaptiveGroups + durableObjectsPeriodicGroups + durableObjectsInvocationsAdaptiveGroups, date-bucketed, exclusive end = start of the day after to) with the token only in Authorization; a non-200 or a GraphQL-level errors array (the API 200s on those) throws. The Anthropic source walks GET /v1/organizations/cost_report?group_by[]=workspace_id&bucket_width=1d&limit=31 page by page (next_page) — with ending_at at the start of the open day when the range has one (the report never holds that day, and the API answers 400 to a range that begins there), so a range that is only the open day asks the cost report nothing and is estimated outright — converts cent-strings to dollars, and refuses a non-USD row — or a report still paginating past 20 pages — rather than mis-summing or returning a truncated total; then the usage report for the open days (item 4a), with the same page cap and the same status-by-name failure. The invoice sources (item 4d) are the same seam: AnthropicCostReportSource.fetchInvoice sums the cost report's closed days across workspaces (the invoice, never the estimate); OpenRouterActivitySource reads GET /api/v1/activity on a management key and sums each day's usage and byok_usage_inference; OpenAICostsSource walks GET /v1/organization/costs on an admin key page by page, refusing a non-USD row or a report past 20 pages exactly as the Anthropic one does. No error message carries the credential.
  6. One snapshot, taken on an interval or on request; every read is arithmetic over it. A live read of the two billing sources takes seconds and, in a bad minute, half a minute (measured: p50 8 s and p95 31 s over two days of page loads, the Anthropic Admin API the variable part), while the figures move once a day — both sources bucket by UTC day and the cost report closes a day hours after midnight. So nothing on this surface reads a source in a request. CostsSnapshotter keeps ONE CostsSnapshot per installation — both sources' rows over the widest range the page offers (SNAPSHOT_DAYS = MAX_DAYS = 31 UTC days ending on the take day, account-wide: the Cloudflare read is the whole account and the LLM rows carry every workspace, so every configured group is arithmetic over the same document) plus the run history's usage cells over the same window (item 10; asked again while the store reports runs still being priced, at most MAX_USAGE_BACKFILL_ROUNDS = 25 calls, so the snapshot is whole), stamped with when the read began (takenAt), who asked (takenBy: schedule for the loop, else the caller's name — the linked person's for a dashboard session (asUser), else the display name the caller's adapter resolved (Caller.name, a Slack message's userName), the raw id only when no name is known: a take from Slack reads by casey, never by slack:U…) and how long it took (durationMs) — in memory and on a CostsSnapshotStore. A report is the snapshot's arithmetic (reportFromSnapshot, byReportFromSnapshot): resolveRange(?days) as of takenAt, then buildCostReport / buildCostsByReport over the snapshot's rows exactly as a live read would have called them — so a report is the one a live read at the snapshot's instant would have given, today is the take day, generatedAt is the take, and every report carries snapshot: { takenAt, takenBy, durationMs }. The refresh loop is the bot's own timer (startRefreshLoop, started in src/index.ts; like the delivery snapshot's and the run store's sweep, and for the same reason: the interval is config, the Worker crons are static, and a take is a cache write, not a run — so it is not a schedule-registry entry): a tick a minute (SNAPSHOT_TICK_MS), unref'd, takes a snapshot when none is stored or the stored one is older than costs.snapshot.everyHours (COSTS_SNAPSHOT_EVERY_HOURS: default 24, bounds 1–168 — daily, since the page is read about that often and the sources move no faster), and runs once at start so a stale snapshot after a restart is refreshed at once and a young one is left alone. costs snapshot takes one now on every surface (costs.snapshot, action costs:write, effect write, gated on the costs capability — command-registry item 28; the grant is never a baseline: a take reads two providers and replaces what every viewer sees, so the admins' all or a named grants entry holds it): the same take, single-flighted — a caller arriving while one runs shares it — answering the stamp and when the next scheduled one falls; a process without cost reporting is unavailable with the reason, and a take that fails is busy with the provider's words (the same 503 over HTTP, the code telling "retry" from "not here": the previous snapshot still serves and the loop retries by itself). The status (CostsSnapshotStatus: the stamp or null, the take in flight with who started it and when, everyHours, nextAt = when the loop tries next — takenAt + the interval, and no sooner than the backoff after failures in a row; null with nothing to wait for — and the last failed take, with how many have failed in a row (count), until one succeeds) is what the page shows (item 8a) and what subscribe hands its listeners on every transition. The store is the CostsSnapshotDO on the state Worker (WorkerCostsSnapshotStore: POST /costs/snapshot/get {} → {snapshot | null}, POST /costs/snapshot/put {snapshot} → {ok}, the same bearer as memory, run history and the delivery snapshot): one object, one row per part — the meta, each of the eight usage datasets, the LLM rows, the run usage — replaced whole in one transaction, read back as one document, so no single value nears the row limit as the account's rows grow; the Worker validates the shape with the guard the bot uses (isCostsSnapshot, shared by relative import) and fences the body at the snapshot ceiling (16 MB, like the delivery snapshot). InMemoryCostsSnapshotStore is the second implementation and the fallback without a state Worker; buildCostsSnapshotStore picks the Worker store behind whichever *.worker block names the state Worker (a cache earns no block of its own) and warns what a restart then costs otherwise. The store is read once per process (memory answers after that) and written on every take. Failures degrade, never refuse: a store that cannot be read is an absent snapshot (a warning, then the next tick takes one); a store that refuses a write is a warning and the snapshot in memory still serves; a take that fails keeps the previous snapshot serving, with the failure named in the status until one succeeds, and the loop retries with a backoff: its next attempt waits a tick after the first failure, doubling per failure in a row (retryBackoffMs: 1, 2, 4, 8 … minutes), an hour at most (RETRY_BACKOFF_MAX_MS) — so a provider that is down is asked hourly, not every minute — and never sooner than the schedule; a take on request (costs snapshot, the button) is never held back by it, and a landing take resets the count. The alert: with costs.snapshot.alertChannel set (a platform-namespaced channel, slack:C…; anything else is refused at load), the ALERT_AFTER_FAILURESth (third) failure in a row posts one line there — how many failed since when, the last error, when the loop tries next and that costs snapshot takes one at once, and whether a snapshot still serves — and the take that lands after it posts one recovery line; a post runs beside the take, never in its path, so a slow channel delays no caller; nothing is posted before the third, nor again on the fourth; an alert that cannot be posted (the channel gone, a namespace this process cannot post to, the Slack app not up) is a warning, never a failed take; a channel set in a process with no poster (the CLI) is a warning at wiring time and the status alone says. Before the first snapshot lands (the first boot of an installation, a store that holds nothing) the service answers NoCostsSnapshotError: the page is served with no report and the status alone (item 8a), a JSON twin is a 503 with Retry-After: 60, and nothing reads a source in the request.
  7. Routes. /costs and /costs.json → the first configured group; /costs/<group> and /costs/<group>.json; group slugs are [a-z0-9-]{1,40}. GET-only (405 otherwise). Unknown group → 404. Not configured → 503 naming the config keys. A twin asked before the first snapshot → 503 with Retry-After: 60 (item 6). A report that cannot be built → 502 with a capped reason, never a 500. JSON shape per day: durableObjects is DO duration cost keyed by namespace label; DO request cost is the sibling doRequestsUsd (not attributable to a namespace). A consumer wanting the full DO figure sums both — totals.byResource.durableObjects already does.
  8. Page. The server serves the shared web shell with the report + group list as the CostsSeed; web/green/pages/Costs.vue (+ CostChart.vue, the pure math in web/src/lib/costs.ts) renders inline SVG stacked bars (one rect per day × component, and one transparent column per day that is the one hover target: pointing at it opens a tooltip panel — the day and its total on top, then one row per component that cost anything, largest first, the LLM estimate tagged est. — a fixed-width, non-wrapping panel that is a child of the chart's host rather than of its scroll box (so it never grows the card or shows scrollbars) and flips to the left of the pointer near the right edge and above it near the bottom; the whole breakdown is also the column's aria-label; the browser's native SVG <title> tooltip was retired because it appears late and is easy to miss; the segments carry no tooltip of their own since the column above them takes the pointer), a legend whose entries link to where each series is billed, a per-resource split whose rows link likewise, and a full table view newest first (the open day on top). The Share of account tile names the account (costs.cloudflareAccountName, else the id's first eight hex) and links it to the Cloudflare dashboard and its billing page; the footer links every attributed Worker to its metrics page and every bucket to its R2 page; the LLM series links to Anthropic's cost page. The report carries account.id, account.name and generatedAt for this. Hostile strings render as text (the seed island escapes every angle bracket, and the components never render raw markup); the same WEB_HTML_HEADERS (strict CSP, X-Frame-Options: DENY, no-store) as /runs. Series colors are the validated categorical set with dark-mode steps, assigned by first appearance, never re-ranked. 8a. The page names its snapshot. The seed carries group (so the page is named before the first snapshot) and snapshot (the status of item 6), and one line under the range — snapshotLineOf, its age ticking by the minute while the page is open — says where every figure comes from: Snapshot from <Mon D, hh:mm UTC>, <age> on schedule|by <name> · next in <n> hours (an overdue next take reads next due now, then next due <n> minutes ago — overdue, never forever imminent); while a take is in flight, Taking a snapshot now — started <age> by <name> · showing the one from <time> meanwhile (the figures stay); the last failed take is appended until one succeeds — last attempt <age> failed: <reason>, with (<n> in a row) from the second on. With no snapshot yet and a failed take behind it the card's line says when the loop tries next instead of promising the first within a minute: No snapshot yet — next attempt in <n> minutes. With no report yet the page shows the group switcher and a Nothing to show yet card carrying that line — No snapshot yet — the first one is taken within a minute of startup — and how to take one (costs snapshot); no tile, chart or table is drawn. The method footer opens with Snapshot. and names costs.snapshot.everyHours and the command, never a live read. 8b. The status is live, and a holder of the grant can take a snapshot from the page. GET /costs/<group>?stream=1 (and /costs?stream=1, the first group) is the page's status feed: SSE (serveCostsStatus, transport-free like the runs index feed; the node:http caller starts the keepalive heartbeat) carrying the current CostsSnapshotStatus as its first frame — data: {"type":"status", …status} — then one frame per transition the snapshotter publishes (a take starting, landing or failing), for as long as the client listens; the subscription ends when the client leaves. The page (web/green/pages/Costs.vue) opens the feed on mount and repaints the status line from every frame (parseStatusFrame ignores anything that is not a status), so every viewer sees a take begin and end without reloading; when a frame's snapshot.takenAt differs from the one the figures were built from, the page re-reads its JSON twins for the range it shows (the by-user twin too on that tab) and replaces the report — the tiles never blank, the first snapshot after startup appears without a reload. One re-read at a time, keyed on the stamp it is for: a snapshot landing while a re-read is in flight is re-read after it (the earlier read may have been served before it landed), a failed re-read leaves the shown stamp behind so the next frame retries it — never a loop — and a stamp already shown re-reads nothing. The seed carries canSnapshot: whether the viewer's actor (record 0042, handed through CostsViewContext.actor) is admitted by the costs:write row for costs.snapshot — exactly the question /api/costs.snapshot will ask — and only then the status line carries a Snapshot now button, which posts /api/costs.snapshot through the command adapter (same-origin, the caller's own grants); it reads Taking… and is disabled while a take is in flight (whoever started it), and a refusal or a failed take (busy) is shown beside it in the adapter's words. The feed is behind the same Access gate as the page (item 9); it carries no figures, only the status.
  9. Access gate. /costs* sits in the same fail-closed Access branch as /runs* and /residents* in src/index.ts. The startup log states GET /costs (<groups>; LLM on|off; snapshot every <n> h) or the 503 reason.
  10. Cost by user, the data first. Who spent what is a question the run history answers, not the billing sources: every record carries usage (its model.turn tokens per model, summed at finish — and, since the meter row (model-proxy.md item 6), each model's usd summed from its turns' own figures where every turn has one, else null for unpriced, with the turns' distinct priceSources sorted beside, so the by-model view names provider, operator, registry or none as the source) and the store answers usage — one row per run, folded by the bot into usage cells, one per requester, thread, channel, agent and UTC day of finish, a child billed to its parent's requester, wall-clock summed, older records priced from their stored events as they are asked about (run-history.md item 56); the by-user report sums the cells per user. The requester is the person: a run an app relayed for them (slack-channel.md item 13) and a run their bound ingress token, MCP token or CLI sent (authorization.md item 15) carry their slack:U… as userId, so one person is one row whatever they came in through, and the current-user global view includes that attributed work; a credential nobody bound (http:cron, an unbound bearer) stays its own row. 10a. Cost by dimension, the page. ?view=users|threads|channels|agents|models on a group's page opens that dimension's tab — By user, By thread, By channel, By agent, By model (the tiles and the chart stay as the group's context; the daily split and table step aside) — and /costs/<group>/<view>.json is its twin (CostsByView → CostDimension through DIMENSION_OF_VIEW; an unknown view is the daily tab); the range and group pills keep the tab, and the tab keeps the range. The report (buildCostsByReport(dimension), pure and unit-tested over the usage cells of item 10; the service only fetches its two inputs) is one row per key over the range, largest total first — a user id with its display name, a thread key, a channel id — the view names a user or channel row the builder could not through the name directory (labelled: NameDirectory.person / .channel, src/core/names.ts, one ask per distinct key, a failure leaving the id; the page reads a channel's name as #name) — an agent name (unknown for a record naming none), or a <provider>/<model> ref — with runs (and turns: the count that means something on the model dimension, where a run may span models), LLM — the key's tokens per model priced through the price table (item 4b; the provider prefix dropped for the list, cache writes at the 5-minute rate, since the spans record one cache-write count), a model with no price reported as unpriced tokens on the row rather than $0 — Cloud allocated — each covered day's cloudUsd from the daily report split by the key's share of that day's run wall-clock, said to be an allocation, not a meter; a run belongs to exactly one user, thread, channel and agent, so those four dimensions partition the runs and the cloud, while a run may spend on several models, so the model dimension counts a run under each model it spent on and allocates no cloud (cloudAllocated: false; the rows carry LLM alone, the page shows no cloud column and no cloud clause); a day with spend and no runs is unallocated, never invented onto anyone — the total, and its share of everything attributed. Whatever the key, the rows' dollars sum to the same total and the reconciliation below is the runs' as a whole, the same on every tab. A namespaced key reads without its platform prefix with the platform beside it and the whole key on hover. A text filter narrows by key or name within the globally selected work view. The replacement page defaults to the current user; the global user menu selects an authorized read-only person view or privileged Everyone view. The page passes show-mine=false and has no second me checkbox or email-match disabled hint. The shared CostsByDimension component retains its optional me display filter for compatibility and isolated component tests; those tests do not promise a control on the replacement page. Its legacy viewer metadata (viewer is present on the user dimension only) remains a display aid, not authorization: the Access identity's email is matched against the Slack email of each user in the report (resolveUserEmail, one lookup per distinct Slack user, cached for the process), the matched ids ride in viewer.userIds, and the toggle is disabled with the reason when nothing matched. The lookup takes the platform-namespaced id (slack:U…), the same slackEmailLookup the MCP connect ticket uses (its first version passed the bare U… and matched nobody). The email read needs the users:read.email bot scope (in the manifest and REQUIRED_BOT_SCOPES, so /healthz names it when the installed app lacks it); without it nobody matches. The range is the history's, not the calendar's: coverage.from is the latest of the range's start, the store's oldest finish and its retention cutoff, the coverage line says where the data begins, that earlier days are past the retention window when the range was clamped, and how many runs are still being priced (pending, the backfill outstanding). One plain reconciliation line, over the covered days that have a workspace figure: LLM attributed against the group's own LLM figure (invoice or estimate) on those days, how many days that is, the unattributed remainder named for what it is (the router, review abridges, runs without a record, list-vs-invoice drift), then the days whose runs spent tokens against a zero figure — spend billed outside this workspace, the bot's key before it moved in — counted apart with their dollars and never subtracted into a negative remainder (a range with no figure at all says so instead of comparing; when the compared days themselves attribute more than the workspace shows — list above invoice, or a key that moved mid-day — the line says how much more in words, never a negative dollar), cloud allocated and cloud on days with no runs. With run history off the report is empty and the page says so (and the daily view's per-biller invoice tie-out, item 4d, is absent too: its attributed side is the same run usage). A dimension's report is arithmetic over the same snapshot as the daily one (item 6: the run usage the take read, priced and allocated for the requested range; the viewer matched at read time, on the user dimension alone — the other dimensions spend no email lookup), behind the same Access gate (item 9); before the first snapshot the page carries no dimension report and the twin is the same 503; a report that cannot be built is the same capped 502. 10b. costs by <dimension> is the same report on every command surface. costs.by (action costs:read, effect read, gated on the costs capability — command-registry.md item 28) answers the dimension's report for the first configured group over the default range, or --group and --days (1–31); an Access caller is the viewer (its me rows in viewer.userIds), a chat or machine caller is none. The read is a browser session's baseline (every group's read) and a grant for a Slack user or a token — never a chat baseline, since a by-user table names who spent what. Derived forms: costs by user --days 7 in chat and on the CLI, GET /api/costs.by?dimension=model, the costs_by MCP tool; the JSON is the twin's exactly. The text surfaces render a header (the dimension, the group, the range, the snapshot's stamp), one line per row largest first — runs (turns on the model dimension), LLM, cloud (none on the model dimension), total, share, unpriced tokens where a model had no price — then the coverage line and the tie-out, chat as ·-joined bullets since aligned columns collapse in a proportional font; an empty range says so. Cost reporting off is unavailable (the same words as the page's 503), an unknown group not_found, before the first snapshot busy (a minute away: retry, not "not here"), any other failure unavailable with the reason.

Validation criteria ​

#CriterionProof
1Container pricing reproduces Cloudflare's billing for a real resident row (→ $1.066); DO duration prices billable GB-s (a real bot DO row → $0.1378, always-on ≈ $0.135/day) and requests at $0.15/M; SQLite rows $0.001/M read and $1.00/M written; storage prorated per day over 30.4375 on peak bytes; Workers $0.30/M + $0.02/M CPU-ms from microseconds; R2 class A/B/free by named action and by verb for an unknown one; Workflow steps at $0.80 per 100,000 plus state at $0.20/GB-month prorated per day, with requests and CPU left on the Worker row[unit] costs.test.ts containerCostUsd, durable object pricing, the other meters Cloudflare bills a Workers deployment on (×5)
2Rows outside the group (other apps, other DO namespaces, other workers, other buckets, other/default workspaces) never reach the group's figures; a namespace is attributed through the Worker that hosts it (labelled from config or by its Worker); an R2 bucket by its <worker>-… name; a Workflow by its exact <worker>-refresh name into the group's own meter, the hosting Worker's request and CPU figures untouched; the report names its attribution[unit] buildCostReport › keeps only… (×2), › attributes an R2 bucket…, › attributes a Workflow…
2aThe whole account is priced the same way and the other tenant's rows land there and only there, so the group's share of the account is honest[unit] buildCostReport › prices the whole account…, web/src/pages/costs.test.ts::CostsPage::says what share of the account's whole Cloudflare spend this group is, and what was attributed to it
3Days are zero-filled oldest-first; totals and the per-resource split (nine meters) sum exactly; the group's Workers, rows and storage are priced and the other tenant's are not[unit] buildCostReport › emits one row…, › totals…, › splits cloud spend…, › prices the group's Workers…
4No LLM source → llmAvailable:false, page says not configured (not $0)[unit] costs.test.ts reports llm as unavailable, web/src/pages/costs.test.ts::CostsPage::says LLM spend is not configured instead of showing $0 when there is no source
4aA day's LLM row from the usage report is flagged on the day (llmEstimated) with the group's unpriced tokens, and never on a cost-report day; list prices resolve by family (dated id included, 5-1 ≠ 5), price every token kind per MTok, and answer undefined for an unknown model[unit] buildCostReport › flags a day whose LLM figure is an estimate…, src/core/modelPricing.test.ts::Anthropic list prices::*
4bThe price table (item 4b): costs.prices absent is the empty table; a table of per-million rates keyed by <provider>/<model> is carried, and a block that is not a mapping, a key without a provider, a value missing a kind or a rate that is not a finite number ≥ 0 is refused by name; the configured ref wins over the list, a ref the table lacks falls back to the list by family after the provider prefix is dropped, and a model neither knows is undefined; a usage is priced through the table — a configured provider's model at its rates, an overridden Anthropic model at the override, the rest at list[unit] src/core/modelPricing.test.ts::the price table — costs.prices over the list::*
4cA run's cost (item 4c): the sum when every model has a price, null (never $0, never a partial sum) when one has none with the model named, $0 with no turns, the configured table pricing what the list cannot; the runs service prices a persisted run's view and a finished registry row's from the store, a live row carries none; runs get prints the dollars per model or unpriced on the text surfaces and the JSON carries { usd, byModel }; the history seed carries cost and the run page's header shows · $… or · unpriced beside the duration with the per-model figures on hover, and nothing for a record without usage; the dollars print as cents from a dollar up, a tenth of a cent below, <$0.001 under that and $0.00 for no turns alone[unit] src/core/modelPricing.test.ts::runCostOf::*, src/core/modelPricing.test.ts::formatUsd::*, src/core/runsService.test.ts::RunsService.getRun::prices a finished run from its record — a persisted row and a finished registry row alike — through the configured table; a live run carries no cost, src/core/commands/runs.test.ts::runs.get / runs.events / runs.friction::runs.get renders the run's cost on the text surfaces — dollars per model, or unpriced — and carries it as JSON, src/channels/liveView.test.ts::live view on RunsService: history pages + index toggle::persisted run page (history mode)::the history seed carries the record's cost when the record has usage, and none for a record written before it, web/src/pages/runPage.test.ts::RunPage — history mode::heads with the run's cost beside the duration (item 22): dollars, unpriced for a model without a price, nothing without usage; the per-model figures on hover
4cSpan-priced usage kept over the table (model-proxy item 6): a model whose usd the spans summed keeps the figure, null reads as unpriced tokens and the run unpriced, a model without the field reprices from the table; the fold sums usd when every turn that counted tokens carries one, null when such a turn lacks it, kept over a zero-token errored turn, absent before the field, sources sorted and carried through addUsage and the by-model rows[unit] src/core/modelPricing.test.ts::llmUsdOfUsage — a model whose spans priced their own turns::*, src/core/runUsage.test.ts::usageOfEvents::the meter row folds per model…, src/core/runUsage.test.ts::usageOfEvents::addUsage folds usd…, src/core/costsBy.test.ts::buildCostsByReport::the by-model view carries the spans' own figures and sources through the cells (model-proxy item 6)
5?days clamps 1..31 (Cloudflare's 4w4d ceiling), garbage → 30, today flagged partial[unit] resolveRange
6Malformed costs: config throws at startup, absent → off; cloudflareAccountName optional, a non-string refused by name; snapshot.everyHours daily by default, a whole number of hours within 1–168, a malformed value or block refused by name; snapshot.alertChannel optional, a platform-namespaced channel id, anything else refused by name; prices absent is the empty table, a well-formed table is carried and a malformed one refused by name[unit] parseCostsConfig (×7)
6aThe report names the account it priced (account.id, optional account.name, never an empty name) and generatedAt[unit] buildCostReport › names the account it priced…
7Cloudflare source: bearer header, account-scoped variables, exclusive end date, row mapping for all eight datasets in one request (incl. durableObjectsPeriodicGroups for duration and rows, durableObjectsSqlStorageGroups, workersInvocationsAdaptive, the two R2 datasets, workflowsAdaptiveGroups folded to step endings per Workflow per day with state bytes 0; R2 bytes = payload + metadata); non-200 and GraphQL errors both throw; token never in URL/error[unit] CloudflareGraphqlUsageSource (×3)
8Anthropic source: x-api-key + anthropic-version, group_by[]=workspace_id, pagination via next_page, cents→dollars, non-USD refused, >20 pages refused (not truncated), key never in error[unit] AnthropicCostReportSource (×4)
8aAnthropic source, open days: the cost report is asked up to the open day's start only (a one-day partial range skips it), and every day after its last bucket (none when it has closed the range; at most the trailing three when it has closed nothing) is read from the hourly usage report (1h, group_by[] workspace and model, limit=24, paginated), summed per workspace × model and priced at list into one estimated row per workspace; an unknown model's tokens are unpricedTokens, not $0 and not a failure; a failing usage read fails by status without the key[unit] AnthropicCostReportSource › prices the open day…, › never asks the cost report for the open day…, › estimates every trailing day…, › estimates at most the trailing three days…, › reports the tokens of a model it has no price for…, › a failing usage report…
8bInvoice sources per biller (item 4d): the OpenRouter activity read sums usage and byok_usage_inference per day in range on a management-key bearer, failing by status without the key; the OpenAI costs read sums the daily buckets, paginates via next_page and refuses a non-USD row; the Anthropic invoice is the cost report's closed days summed across workspaces, never the estimate[unit] src/core/costs.test.ts::invoice sources per biller::OpenRouterActivitySource sums the activity rows'…, ::OpenAICostsSource sums the daily buckets'…, ::AnthropicCostReportSource.fetchInvoice sums the cost report's closed days…
8cThe per-biller tie-out (item 4d): three billers on one day tie out independently against the summed rows whose ref names each block; a BYOK day splits the aggregator's fee beside the summed feeUsd and byok_usage_inference beside the remainder; a biller without a source ties out against nothing and the page says "no invoice"; the wiring builds one source per block naming invoiceApi + invoiceKeyEnv, warns by name on a failing source or an unset key env (omitting that biller alone), and accepts the legacy costs.anthropicAdminKeyEnv for one release; a BYOK turn's feeUsd sums per model and folds through addUsage; the attributed side is the meter's charged figure (each turn's own usd, never the price table's repricing) and the page says so, with the tokens of turns that carried no usd counted beside it as the day's unpricedTokens per biller and per day (a purely unpriced day is kept for the count), absent on a fully priced day whose cell renders 0, never —[unit] src/core/costs.test.ts::buildBillerTieOuts::*, src/core/costsService.test.ts::invoice sources per biller through the wiring::*, src/core/runUsage.test.ts::usageOfEvents::a BYOK turn's feeUsd sums per model…, web/src/pages/costs.test.ts::CostsPage — invoice tie-out by biller::*
9Routing: index / group / .json twin match; traversal-shaped, over-long, and foreign paths don't[unit] parseCostsRoute
10Page: hostile strings inert; tiles from full days — the LLM tile in dollars with yesterday's and the open day's figures, the estimate said so; the projection as cloud + LLM run-rates with their bases named (closed LLM days, or today scaled), the average tile saying when there is no full day — plus the share-of-account tile with its numerator, denominator and the named, linked account (id fallback); a one-day range leads with today so far; every figure links to where it is billed (account, billing, containers, Durable Objects, Workers, R2 bucket, Worker metrics, Anthropic cost) with rel=noopener; one rect per day×component with no tooltip of its own and one column per day whose hover opens a tooltip panel with the day's whole breakdown (and carries it as its accessible label), the small platform meters (Workers, SQLite rows and storage, R2) stacked as one series and each listed in the split; the attribution (Workers, namespace and bucket counts) stated in the footer; legend + table newest first with the estimated day marked and the estimate explained; partial-day marker; method stated; sibling-group links; range switcher as a labelled nav with a today preset; JSON twin named[unit] web/src/pages/costs.test.ts::CostsPage (×22)
10aBy dimension, the arithmetic (item 10a): each model's tokens priced through the price table (list after dropping the provider prefix, cache writes at the 5-minute rate, an unknown model's tokens unpriced, the configured table pricing what the list cannot on every dimension); coverage is the latest of the range's start, the oldest finish and the retention cutoff, never past the range's end; users summed largest first with each day's cloud allocated by wall-clock share, a user's cells across threads, channels and agents folding into one row per user and per day; the same cells keyed by thread, channel and agent with cloud allocated along each, no label and no viewer, the totals and the tie-out agreeing across dimensions; by model each model's tokens under its ref with turns counted, a run that spent on two models under both, LLM alone with no cloud and the reconciliation still the runs' as a whole; the reconciliation against the group's figure for the covered days; a day with spend and no runs unallocated and the range clamped to where the history begins; history off → empty and said[unit] src/core/modelPricing.test.ts::llmUsdOfUsage::prices each model's tokens at list after dropping the provider prefix, cache writes at the 5-minute rate; an unknown model's tokens are unpriced, src/core/costsBy.test.ts::coverageFrom::is the latest of the range's start, the history's oldest finish and its retention cutoff, never past the range's end, ::buildCostsByReport::*
10aBy dimension, the names: a user or channel row with no label from the records is named through the view's name directory on the page and on the twin alike; a row the directory cannot name, and every other dimension, pass through untouched[unit] src/channels/costsView.test.ts::createCostsViewHandler::a dimension report names its user and channel rows through the name directory — page and twin alike — and leaves the rest alone
10bBy dimension, the service and the routes (item 10a): the service prices and allocates the snapshot's run usage over the daily range on the asked dimension and, on the user dimension alone, matches the viewer to their run ids by email, one cached lookup per Slack user — the other dimensions spend no lookup and carry no viewer; no email or no lookup → nothing matched and said; run usage absent → history off; the null service refuses like the daily report; every ?view= tab and its /<view>.json twin route, a twin never has a view, an unknown view is the daily tab; the page seeds the asked dimension's report beside the daily one with the viewer handed through, the daily page reads none, the twin serves it; a report that cannot be built is the capped 502[unit] src/core/costsService.test.ts::createCostsService::the by-user report prices and allocates over the snapshot's run usage for the range and matches the viewer to their run ids by email (one lookup per distinct Slack user, cached), ::createCostsService::the thread, channel, agent and model reports come from the same snapshot keyed by the dimension, with no viewer and no email lookup spent, ::createCostsService::with no viewer email, or no lookup wired, nothing is matched and the report says so; with run history off the report is empty and says so, ::NullCostsService — the service of a process without cost reporting::has no groups, and refuses a report or a snapshot with the reason the view shows; its status is empty, src/core/costsSnapshot.test.ts::reportFromSnapshot / byReportFromSnapshot::a dimension's report is the builder over the snapshot's run usage for the range on that dimension, history on, the viewer on the user dimension alone; with run usage absent the history is off and nothing is attributed, src/channels/costsView.test.ts::parseCostsRoute::matches every dimension tab (?view=users|threads|channels|agents|models) and its JSON twin /costs/<group>/<view>.json; a twin never has a view, an unknown view is the daily tab, ::createCostsViewHandler::?view=<dimension> seeds that dimension's report beside the daily one and hands the viewer through; the twin serves the report; the daily page reads no dimension report, ::createCostsViewHandler::a by-user read that fails upstream is a capped 502 like the daily one
10cBy dimension, the page (item 10a): Daily and the five dimensions as tabs above the tables, the daily tab with no dimension table; a tab lists one row per key largest first (name or key, runs, LLM, allocated cloud, total, share), marks the viewer's row, keeps the key on hover, says unpriced tokens; the text filter narrows by key or name with a shown subtotal inside the global selected owner scope; the replacement page has no local me checkbox or unmatched-email hint; the coverage line (where data begins, a clamped range, runs still being priced, history off); one reconciliation line; the tab kept on the range and group pills, the twin named, and a page served without its report says so; the model tab counts turns, has no cloud column, no me toggle and no cloud clause; a namespaced key reads without its platform prefix with the platform beside it and an empty match names the dimension[unit] web/src/pages/costs.test.ts::CostsPage::offers Daily and the five dimensions as tabs above the tables; the daily tab shows the daily tables and no dimension table, web/src/pages/costs.test.ts::CostsPage · By user (×8), web/src/pages/costs.test.ts::CostsPage · the other dimensions::*; web/green/pages/reportStates.test.ts::Green report states::keeps the global owner selection on a user cost breakdown without a second me filter
10cRetained component compatibility, not replacement-page UI: CostsByDimension with its default showMine=true filters viewer rows and explains an unmatched viewer. These are display-only helper proofs; the replacement page passes show-mine=false, and server/global scope determines accessible work.[unit] web/src/pages/costs.test.ts::CostsPage · By user::filters by name or id, and the me toggle keeps only the signed-in viewer's rows; the shown subtotal appears when rows are hidden, web/src/pages/costs.test.ts::CostsPage · By user::disables the me toggle, and says why in visible text the input describes, when the viewer matched no run user
10dDeployed: the By user tab attributes real runs to real requesters[agent] after deploy: sign in, load /costs/switchboard?days=7&view=users → the table lists readable requesters within the global selected owner scope who have priced runs this week; /costs/switchboard/users.json?days=7 → users[0].llmUsd > 0, reconciliation.attributedLlmUsd ≤ reconciliation.workspaceLlmUsd, pending falls to 0 once the backfill has walked the range; the default current-user view contains only readable work for that user; the global user menu changes the authorized read-only person or privileged Everyone scope, and the page has no local me checkbox
10ecosts by <dimension> [--days n] [--group g] (item 10b): on every surface with the catalogue's guarantees, costs:read held by a browser session's baseline and by the grant, hidden without the costs capability; the handler asks the service for the dimension over the first group and the default range, or the named group and --days, an Access caller as the viewer and a chat caller as none; the text render heads with the dimension, group, range and snapshot, one aligned line per row largest first with runs (turns on the model dimension), LLM, cloud (none on the model dimension), total and share, then the coverage and the tie-out, chat as bullets; an empty range is said; off is unavailable, an unknown group not_found, before the first snapshot busy, any other failure unavailable with the reason[unit] src/core/commandConformance.test.ts::command conformance — costs.by::*, src/core/authz/policy.test.ts::POLICY coverage::*, src/core/commands/costs.test.ts::costs.by::*
11Handler: falls through for other paths; 503 unconfigured naming the keys; 405 non-GET; the service asked on every request, with the hardened headers and the report, the groups and the snapshot's status as the seed; ?days passthrough; 404 unknown group; JSON twin no-store; a report that cannot be built → capped 502[unit] createCostsViewHandler (×8)
12Deployed: /costs behind Access renders the switchboard group with Cloudflare numbers matching the dashboard's billing view for the snapshot's day[agent] after deploy: sign in, load /costs/switchboard?days=7, compare yesterday's container total to the Cloudflare dash
13Deployed: LLM column appears once ANTHROPIC_ADMIN_KEY + anthropicWorkspaceId are set[agent] after deploy: sign in, fetch /costs/<group>.json → llmAvailable: true; the page shows the LLM spend tile and an LLM (Anthropic) series, not "LLM spend not configured". The group's anthropicWorkspaceId must be the workspace the bot's API key was created in (a key cannot move between workspaces; the org default workspace reports null)
14Deployed: the open day's LLM figure is non-zero while the bot runs, and marked as an estimate[agent] on a day the bot has run: /costs/<group>.json?days=1 → days[0].llmEstimated === true and days[0].llmUsd > 0; the page's LLM tile reads today $… (estimate) and the table row says LLM estimated
15The take (item 6): both sources read over the widest range ending on the take day and the run history over the same window, stamped who/when/how long; the run history asked again while rows are still being priced, bounded, the last answer kept; no run store → no run usage, an unconfigured LLM source → null rows[unit] src/core/costsSnapshot.test.ts::takeCostsSnapshot::*
16A report from the snapshot is the report a live read at the snapshot's instant would have given, for any ?days, today the take day, stamped; the by-user report likewise, history off when the snapshot carries no run usage[unit] src/core/costsSnapshot.test.ts::reportFromSnapshot / byReportFromSnapshot::*
17The snapshotter (item 6): the store read once then memory, a failing store read an absent snapshot with a warning; refresh single-flighted, stored, served from memory; a refused write a warning with memory still serving; a failed take keeps the previous snapshot and is named in the status until one succeeds; the status before, during and after a take; subscribers hear every transition; the loop takes when missing or older than everyHours, leaves a young snapshot alone, runs once at start, warns and retries on failure once the backoff has elapsed; failures in a row push nextAt back — a tick, doubled per failure, an hour at most, never sooner than the schedule — a take on request is not held back, a landing take resets the count; the alert posted once on the third failure in a row naming the count, the last error, the next attempt and what still serves, once on the recovery, not before the third nor again after, a failed post a warning, nothing posted with none wired[unit] src/core/costsSnapshot.test.ts::CostsSnapshotter::*
18The store (item 6): the shape guard accepts a snapshot with or without LLM rows and run usage and refuses a malformed one by field; the in-memory store; the Worker client posts the snapshot with the bearer and reads it back, throws with the Worker's words on a malformed answer, a refused bearer, a missing route or a refused write; startup picks the Worker store behind any *.worker block and warns otherwise[unit] src/core/costsSnapshotStore.test.ts::*
19The Durable Object (item 6): advertised on /healthz, bearer and POST only; put stores and get returns the snapshot verbatim, a later put replaces it whole; a body that is not a snapshot is refused (400) and the stored one kept; the snapshot route takes a body over the ordinary fence[worker] deploy/cloudflare-memory/costs.test.ts::CostsSnapshotDO routes::*
20The service (item 6): before the first snapshot both reports refuse with NoCostsSnapshotError and nothing reads a source; snapshot(by) takes one and every report after is arithmetic over it, stamped; a fresh process over the same store serves without a take; the production wiring is off without the Cloudflare token, LLM on with the admin key, memory with a warning without a state Worker[unit] src/core/costsService.test.ts::createCostsService::before the first snapshot lands both reports refuse with NoCostsSnapshotError…, ::createCostsService::snapshot(by) takes one now and answers its stamp…, ::createCostsService::reads the snapshot the store holds after a restart…, ::createCostsService::costsFromConfig…
21The handler before the first snapshot (items 6, 7): the page served with no report and the status — none yet, or the take in flight — and every twin a 503 with Retry-After, never a live read, never a 500[unit] src/channels/costsView.test.ts::createCostsViewHandler::before the first snapshot: the page is served with no report and the status…
22costs snapshot (item 6): on every surface with the catalogue's guarantees, costs:write admitted by the grant alone — never a baseline, the browser's read baseline included — and hidden without the costs capability; credited to the linked person's name, else the name the caller's adapter resolved, else the caller id, answering the stamp and the next due time; off is unavailable, a failed take busy[unit] src/core/commandConformance.test.ts::command conformance — costs.snapshot::*, src/core/authz/policy.test.ts::POLICY coverage::*, src/core/commands/costs.test.ts::costs.snapshot::*
23The page (item 8a): the status line names the snapshot's time, age, taker and the next due time, the method names the snapshot; a take in flight is said where the age is with the figures still shown; with no report the group is named, the switcher kept, the card says no snapshot has landed and how to take one, and nothing is priced; the line's wording for every state[unit] web/src/pages/costs.test.ts::CostsPage::says which snapshot the figures are from…, ::CostsPage::while a take is in flight the status line says so…, ::CostsPage::before the first snapshot there is no report…, web/src/lib/costs.test.ts::snapshotLineOf::*
24Deployed: the page is served from the snapshot — /costs/switchboard.json carries snapshot.takenAt within everyHours of now and answers in well under a second; the status line names it; costs snapshot in Slack (an admin) takes a new one and the page's stamp moves to it[agent] after deploy: sign in, GET /costs/switchboard.json?days=7 → snapshot.takenAt present and recent, response under 1 s; the page's status line reads Snapshot from … on schedule · next in …; post costs snapshot in the bot's channel → the reply names the new stamp and the page's line moves to by <name>
25The status feed and the button's admission (item 8b): ?stream=1 on the index and a group page routes to the feed, a twin never streams; the feed answers SSE headers, the current status as the first frame, then one frame per transition, and unsubscribes when the client leaves; canSnapshot is true for a costs:write holder and false for a member, the browser baseline and no actor[unit] src/channels/costsView.test.ts::parseCostsRoute::matches the status feed (?stream=1)…, ::createCostsViewHandler::?stream=1 serves the status feed…, ::createCostsViewHandler::seeds canSnapshot from the viewer's actor…
26The page on the feed (item 8b): opens /costs/<group>?stream=1, repaints the line from a frame, re-reads the twins once when a new takenAt lands and repaints the figures without a reload, ignores a non-status frame, closes the feed on unmount; the button only for a costs:write holder, posting /api/costs.snapshot, showing a refusal beside it, reading Taking… disabled while a take is in flight; the frame parser[unit] web/src/pages/costs.test.ts::CostsPage::opens the group's status feed and repaints the line from its frames…, ::CostsPage::a snapshot landing while a re-read is in flight is re-read after it, and a failed re-read is retried on the next frame — never looped, ::CostsPage::offers Snapshot now only to a costs:write holder…, web/src/lib/costs.test.ts::parseStatusFrame::*
27Deployed: two browser sessions on /costs; costs snapshot from one (an admin's button) → the other's status line reads Taking a snapshot now… within a second and, once it lands, names the new stamp and repaints the figures with no reload[agent] after deploy: open the page twice, click Snapshot now in one, watch the other's line and tiles move; curl -N the feed with a session → the first frame is the current status
28The costs page tracks each Ship child run's recorded dollars at finish, grouped by its durable unit key, and plots the sum of runs in the selected range against the original cap carried by each child. It uses the recorded per-model dollars that the cap gate uses, never a later reprice. The page labels the total as retained-run spend and says that earlier runs are excluded, retention can remove runs within the selected range, and Ship runs are shared across cost groups, so the line is not mistaken for a unit's lifetime spend. A missing or inconsistent cap draws no cap line; an unpriced child makes the shown total unknown from then on, while a zero-turn child with empty usage adds known $0. The report is part of the snapshot and JSON twin, so no history read occurs on page load; older snapshots without run points remain readable without falsely reporting an empty Ship history. The chart labels child and shown total dollars separately and links each point to its run.[unit] src/core/shipSpend.test.ts::buildShipSpendReport::*, src/core/costsService.test.ts::createCostsService::omits Ship spend for legacy run usage without child points, web/src/pages/costs.test.ts::CostsPage::shows Ship child cost over time against the unit cap

| Run usage includes measured routing-call counters exactly once, alongside agent turns. Character-based routing output estimates never enter token or cost accounting; absent provider counters are not fabricated. | [unit] src/core/runUsage.test.ts::usageOfEvents::counts measured routing usage once alongside agent turns without using output estimates | | Routing counters are priced through the selected model card and configured rates before merging with recorded agent charges; shared-model pricing preserves both charges. | [unit] src/core/dispatch/operatorCompletion.test.ts::operatorCompletion::prices measured routing tokens without replacing a shared model's recorded agent charge |

Attached browser work scope ​

A trusted Mine or person scope receives measured owned-work costs without shared account bills or inferred cloud allocation. All people and unscoped legacy callers retain the shared report. Old unit-spend records without native owner facts remain partial or unavailable; they are never guessed into a personal report.

CriterionEvidence
Personal cost bodies contain only readable owned work, and neither HTML nor JSON asks for shared bills[unit] src/core/costsService.test.ts::createCostsService::*, src/channels/costsView.test.ts::createCostsViewHandler::serves the personal work projection on HTML and JSON without asking for shared bills

| Personal spending is distinct from shared bills, preserves known subtotal and exclusions, and never invents unavailable totals | [unit] web/green/pages/personalReports.test.ts::Green personal reports::* |

Replacement reader parity ​

CriterionEvidence
The replacement retains exact day hover, account attribution links and method, charged invoice tie-out and unpriced-token distinctions[unit] web/src/pages/costs.test.ts::CostsPage::includes a legend and a table view so identity is never color-alone; dates read human with the ISO on hover, ::names the Cloudflare account and links every figure to where it can be dug into, ::marks the partial day and states the method, web/src/pages/costs.test.ts::CostsPage — invoice tie-out by biller::*