Repository navigation
feat(045): usage-based cost model — billed cost, allowances, credits - #128
Conversation
… 4.0 Models released after the price table was last updated (2026-06-01) were not matched by resolveModelPricing() and silently fell back to the highest tier ever charged, $15/$75 per MTok. Every Sonnet 5, Opus 5, Fable 5 and Fable 5.1 row in anthropic_usage_metrics was therefore over-costed — up to 7.5x for Sonnet 5 — which is why per-user costs in the profile API diverged from the Claude Console, while the workspace figures (sourced from cost_report) stayed correct. - Add Fable 5.1 / Fable 5 ($10/$50), Opus 5 ($5/$25), Sonnet 5 ($2/$10) with their cache read and write rates. - Match the longest prefix rather than the first table entry, so claude-fable-5-1 is not swallowed by claude-fable-5. - Name the unknown-model fallback FALLBACK_PRICING instead of relying on MODEL_PRICING[0], which depended on array order. Run recalculateUnresolvedCosts() after deploy to re-price the affected historical rows. Analysis and remaining modelling gaps are documented in docs/anthropic-cost-accuracy.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BT4qCNYhERb5BQdG4uLxMv
Makes Anthropic's billed cost the source of truth for per-user Claude reporting, instead of re-deriving it from token counts and a hard-coded price table. The estate has inverted since the tracker was built: >=172 users are on subscription seats, 40 hold API keys, and only 4 had any API usage in September — and those users each own a workspace, so the billed workspace cost IS their cost. Covers: cost_report line items (group_by=description, which also supplies cache TTL, context window and service tier as data rather than assumptions), an attribution layer (billed / apportioned / unattributed), the current-day estimate that stays because cost_report is daily-only, sync-time reconciliation so a future divergence surfaces within a day, admin-correctable workspace ownership, and deprecation of the 12 dead boost-* pooled workspaces. Out of scope by decision: attributing project/client workspace spend (~46% of September) to cost centres — deferred to a separate feature. Two product questions are recorded as open rather than answered, notably whether a tier price should be reported as an allowance rather than a cost. Six independently deployable phases; no destructive migration. Includes spec, plan, research, data model, attribution contract, quickstart, tasks and the requirements checklist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BT4qCNYhERb5BQdG4uLxMv
Supersedes the narrower workspace-attribution draft (folder renamed) with one coherent specification covering the whole cost-model correction. The Hub models a usage-based tool as if it were a seat, and that single mistake shows up in three places: 1. Per-user cost is re-derived from token counts and a price table instead of read from cost_report. Where a workspace has one owner the billed cost IS that person's cost. 2. A tier price is summed into expected spend even when it is an allowance. Claude Console tiers contribute $3,650/month of expected against $398.69 of August consumption. 3. API access is prepaid, so invoices are credit top-ups, not period cost — a top-up inflates the month it lands in and leaves the following months looking free. Adds pricing_model (seat | usage) on access_tiers, expected spend from measured consumption with a trailing projection and an allowance fallback, allowance/consumption/purchase/balance labelling, credit purchases with a derived balance the Anthropic API cannot supply, and recorded workspace caps mirrored from the Console with a last-confirmed date and an allowance-mismatch flag. The Hub tracks; it does not enforce. The Admin API exposes no endpoint for spend limits or credit balance, and caps are set in the Console directly — so caps and the opening credit balance are admin-entered and their staleness is made visible rather than hidden. Still out of scope by decision: attributing project/client workspace spend (~46% of September), which stays visible as unattributed. Eight independently deployable phases, no destructive migration, no column renames. Two restatements (per-user history and expected spend) ship together behind one announcement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BT4qCNYhERb5BQdG4uLxMv
…pace The 36 assignments on the boost-* pools are already deactivated in the Claude Console, so the Hub is carrying licences that no longer exist. Revocation is now in scope (FR-028..030, US9 scenarios 4-6). Scoped by WORKSPACE, not by tier: assignment 262 carries a boost-advanced tier but sits on the Automations workspace, which is live and has current spend. Revoking by tier would take out a working licence. It is reported separately instead (new OQ-4). Revocation carries an explicit date rather than now(), because revoked_at decides which budget periods still count the assignment — dating them today would keep them in every past period's expected spend. Org API spend collapsed from $3,016.86 (June) to $194.89 (July), which points at an end-of-June deactivation; the date itself is new OQ-3. Adds Appendix A with the identifiers an implementer needs: the five tier ids to flip to usage pricing, the twelve workspace ids to deprecate, the thirty-six assignment ids to revoke, and the three indie assignments that must survive untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BT4qCNYhERb5BQdG4uLxMv
Resolves OQ-3 and OQ-4. All 37 Claude Console assignments — the 36 on pooled workspaces plus 262 on Automations — are revoked with revoked_at = 2026-07-01, matching when org API spend collapsed from $3,016.86 (June) to $194.89 (July). Budget periods ending before that date keep counting them; periods from July onward do not. Revoking 262 does not hide the Automations workspace's ongoing spend: that spend belongs to the workspace, which stays visible as unattributed under the out-of-scope project-workspace rule. FR-029 still governs any future boost-tier assignment found outside a pooled workspace. After the cleanup the Claude Console licence register reads $375/month of allowance rather than $3,650. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BT4qCNYhERb5BQdG4uLxMv
… the grain Verified group_by[]=description against the live API. Two defects in the data model surfaced, both of which would have broken SC-001. 1. Per-line-item rounding drifts. `amount` is a decimal string of CENTS with up to six decimal places. The existing Math.round per result row is safe today only because there is ~one row per workspace-day; with description grouping there are 12-14, and rounding each before summing drifts +/-1 cent per workspace-day. Line items now store bigint micro-cents and rounding happens once at the aggregate (contract R6-R8). The sample rolls up to exactly $29.28 and $8.86 — the figures the dashboard shows today. 2. inference_geo was missing from the unique grain. Haiku 4.5 reports not_available while every other model on the same workspace-day reports global, so two genuinely different rows would have collided and the upsert would have silently overwritten one. Also confirmed from the sample: 200k-1M context-window rows are real and material (13.6% of one workspace's day), service_tier and pagination behave as specified, and volume is ~1.1k rows/month — an order of magnitude below the earlier estimate, which takes the maxDuration risk down to low. The response is checked in as a test fixture so T014 asserts against real data rather than invented numbers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BT4qCNYhERb5BQdG4uLxMv
Follow-up to the precision fix: the quickstart still selected cost_cents from the line-item table, which no longer exists, and the plan still carried the pre-measurement 12k rows/month estimate. - Quickstart queries read cost_microcents and round at the aggregate, matching contract R6-R8, and note that 143569125 means $1.4357. - New step 0 runs the fixture-backed rollup test first, so the arithmetic can be verified with no database connection at all. - Plan scale/scope and risk 7 updated to the measured ~1.1k rows/month; the maxDuration risk is closed, with the wall-clock check kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BT4qCNYhERb5BQdG4uLxMv
Contract R9/R10 fix the one real design gap: attributed cost is stored per (user, date, model), so the daily figure needs a second largest-remainder pass, and billed cost with no usage row needs a carrier row rather than being dropped. Invariant I3a and the quickstart query assert both. Records the decisions taken: OQ-2 resolved to a trailing 3-complete-month projection window, the Claude Console opening credit balance ($422.72 as of 2026-09-18, admin-entered, not seeded), and workspace caps entered by hand. Refreshes the stale checklist (OQ-1/3/4 were already resolved), drops the revocation from the plan's out-of-scope list where it contradicted the spec, and fixes a D6/D8 cross-reference. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Migration 0032 (additive): attribution_mode + pricing_model enums, three new tables (anthropic_workspace_cost_items, anthropic_workspace_owners, credit_purchases), and nullable columns on access_tiers, ai_tools, anthropic_usage_metrics, anthropic_workspace_limits and anthropic_workspaces. pricing_model defaults to 'seat', so nothing changes on deploy. The data migration seeds ownership from resolved API keys (excluding the userId=0 sentinel and default-workspace keys, which stay unattributed), flips the five Claude Console tiers to 'usage' guarded by both id and name, and deprecates the 12 pooled boost-* workspaces by explicit id. The cost sync now requests group_by[]=description and stores line items in micro-cents; the daily rollup is DERIVED from them, so the two cannot drift. Rounding happens once, at the aggregate — per-row rounding was off by 2 cents on 2026-09-01 alone. Verified against the branch: 8455 line items, 923 workspace-days, zero disagreement with the rollup. Migration reviewed by drizzle-migration-reviewer; its findings on FK indexes, ON DELETE rules, token_type width and empty-string/NULL collision are folded in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
src/lib/anthropic/cost-attribution.ts is pure: deriveMode, apportion (largest remainder, ties by ascending userId, even split when every weight is zero) and distributeAcrossModels, the second pass that spreads a user's daily figure over their (user, date, model) rows so the stored parts still sum to the day. The sync step reads the rollup, ownership and computed costs, then writes attributed_cost_cents / attribution_mode in two batched statements. It clears the window first, so an ownership correction cannot leave a stale figure behind, and writes a __billed_only__ carrier row where billed cost exists but the usage report covers nothing — the cost is never silently dropped. Key resolution now maintains anthropic_workspace_owners: stale `resolved` rows are removed so moving a key moves ownership rather than adding a co-owner, and `manual` rows are never touched. Verified against the branch: 3208 attributed rows over 923 workspace-days, with zero I1/I2/I3 violations. The idempotency test caught a real defect — carrier rows were being read back as usage, losing their cost on every re-sync. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six consumers move to COALESCE(attributed_cost_cents, computed_cost_cents): the profile API, the Claude user actions, the MCP tools, the org actions, the user-bucket histogram and the saved forecast scenarios. Every existing field keeps its name and meaning; the values are now billed-based for complete days. Added alongside: costData.attribution (method, billed/estimated split, workspace), a per-day method, attributionMethod on list_claude_users and the user table, and an AttributionBadge that stays silent for `billed` and marks only apportioned / estimated / mixed — in words, not colour. The today-estimate calibration deliberately stays on the computed basis: it measures how far the token-derived estimate sits from the bill, so reading billed cost on both sides would pin the ratio at 1.0 and destroy the signal. Carrier rows are excluded from it for the same reason. Attribution now runs once per sync over the range it touched, and is non-fatal — a failure there leaves correct billed figures and the previous attribution in place rather than failing the whole cost sync. Verified against the branch for August: both single-owner users match their workspace's billed total to the cent (SC-001). The restatement this implies: Marlon $630.85 -> $254.66, Oliver $174.94 -> $23.37. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
src/lib/expected-spend.ts is pure: seat tiers take the tier-price path and are byte-identical to before (pinned by a test against sumExpectedSpendCents); usage tiers use measured consumption for a completed period, a trailing 3-complete-month mean for an open one, and the allowance only when there is no history at all — marked as the placeholder it is. Months with no data are skipped rather than counted as zero, and the period's own partial month never feeds a projection. Tiers gain a pricing model on create and edit; every price for a usage tier is now labelled an allowance (tool detail, assignment dialogs and table, user detail), with seat wording untouched. The budget's Expected column states its basis whenever it is not purely tier prices, and combineExpectedSpend reports the weakest basis so a placeholder is never hidden inside a total. August on the branch: usage-tool expected spend moves from $3,650 of allowances to $406.48 measured, while seat tools stay at $11,147 to the cent (SC-004). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
src/lib/credits.ts derives opening + purchases − consumption, counting only events strictly after the as-of date so the opening balance is not double-charged. No opening balance recorded means "unavailable", never a balance derived from an assumed zero — and a negative balance is shown rather than clamped, because it is the signal that a top-up is missing or the opening figure is stale. Server actions record, reverse and re-read a purchase, and record the opening balance with the date an admin read it in the console. Period cost excludes billed_costs rows whose invoice has a credit_purchases entry — by exclusion, not by unpicking the link, so reclassifying stays reversible — and the purchases are reported beside the cost, never inside it. The Claude dashboard's credits panel stops saying the balance is unavailable from Anthropic and shows the derived figure with its as-of date, the opening/purchased/consumed arithmetic, and a plain statement that the Hub derived it rather than reading it. Claude Console's opening balance is recorded on the branch: $422.72 as of 2026-09-18, as supplied by the budget owner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
src/lib/anthropic/reconciliation.ts compares billed against the Hub's own COMPUTED figure — not the attributed one, which for a sole-owner workspace is the billed number and would always agree, measuring nothing. Tolerance is the larger of 5% and $5, so small workspaces stay quiet and large ones stay honest. Run against live September data it immediately names the defect this feature exists to close: Oliver 7.35x, Marlon 1.63x. Workspaces with spend and no owner are collected into ONE line rather than one warning each — project and client workspaces are unattributed by design, and per-workspace noise would teach an admin to ignore the column; what matters is a PERSON's workspace appearing in that list, which means their key stopped resolving. The usage sync now names models missing from the price table, replacing the pricing_resolved flag that nothing has ever read. Both kinds are marked "Warning:" and the sync view says they are not failures. Recorded caps stamp confirmed_at/confirmed_by on every save, and the workspace list shows owners, the allowance-mismatch flag with both figures, and three distinct states — no cap recorded / a recorded cap of $0.00 / a recorded cap — each stating the Hub mirrors the console and does not enforce. Deprecated workspaces are excluded from alerting and from the list by default. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Workspace ownership is listable and correctable. A third source, `excluded`, says "this user is NOT an owner" and survives re-resolution — needed because a project workspace's API key can belong to a person, and attributing the project's spend to them would be wrong. Every ownership read honours it, and a correction re-attributes immediately rather than waiting for the next sync. The workspace list shows who each workspace is billed or apportioned to, or states plainly that it is unattributed. Deprecated pools are out of the list and out of alerting, reachable via an explicit toggle, with history untouched. The 37 pooled Claude Console assignments are revoked with revoked_at = 2026-07-01 through revokeLicenseCore, which now accepts a backdate — `now()` would have left licences that ended in June counting toward every period since. Verified on the branch: 3 active assignments left ($375/month, down from $3,650), 37 change-history rows written, and $12,010.42 of deprecated history still readable. Automations is excluded from ownership per spec OQ-4: its $5.26 stays visible at org level and is attributed to nobody, which is the deferred project- workspace case rather than a defect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The announcement covers both restatements in one place, as planned: per-user cost moving onto Anthropic's billed figure, and expected spend moving off allowances, with the real before/after per person and per period. It says plainly that the earlier numbers were overstated rather than that spending fell, and that pre-July per-person figures are apportioned estimates of a known total. docs/anthropic-cost-accuracy.md now says up front that its architecture is superseded, and CLAUDE.md records the feature. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The budget's expanded period shows credit purchases as their own row, labelled a prepayment and kept out of the Actual column. The workspace card gains the control the Automations case needed: an admin can say a workspace is not a person's, which excludes its resolved owner and re-attributes immediately — the key keeps resolving, the spend stays unattributed. All 61 tasks are complete: lint clean, 818 unit tests and the attribution integration suite green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
|
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Core attribution, ownership, credit, forecasting, and sync-status paths contain unresolved correctness and security issues.
Review effort: Balanced
Findings: 6
Open (16)
Partition seat assignments by pricing model · New Aggregate purchases from recorded purchase amounts · New Select owner_user_ids in the outer workspace query · New Enforce unique invoice purchases atomically · New Require complete coverage for measured multi-month periods · New Delete stale cost items during transactional resync · New Stop counting revoked licences from July periods · New Add UI callers for credit and opening balance actions · New Display expected-value basis per period · New Provide admin workspace owner management · New Seed excluded ownership tombstones for known workspaces · New Report mixed attribution modes correctly · New Exclude the current partial month from history · New Apply pricing-aware semantics across all allowance surfaces · New Build billed model breakdowns from stored line items · New Do not count unknown-model warnings as sync failures · New
What changed in this PR
Introduces billed-cost attribution, usage-based forecasting, prepaid credits, workspace ownership, and cap tracking for Anthropic usage.
Changes:
- Stores billed line items and attributes workspace costs to users.
- Distinguishes seat costs from usage allowances and projections.
- Adds credit balances, ownership controls, reconciliation, migrations, UI, and tests.
| File | Description |
|---|---|
tests/unit/sync/anthropic-workspace-backfill.test.ts |
Updates DB execution mock. |
tests/unit/sync/anthropic-cost-line-items.test.ts |
Tests line-item aggregation and rounding. |
tests/unit/expected-spend.test.ts |
Tests pricing-model forecasts. |
tests/unit/credits.test.ts |
Tests derived credit balances. |
tests/unit/anthropic/reconciliation.test.ts |
Tests divergence warnings. |
tests/unit/anthropic-pricing.test.ts |
Tests new model pricing. |
tests/integration/cost-attribution.test.ts |
Verifies attribution invariants. |
src/types/index.ts |
Adds attribution, credit, and workspace fields. |
src/lib/validators.ts |
Validates tier pricing models. |
src/lib/sync/sources/anthropic-workspace.ts |
Ingests and attributes billed costs. |
src/lib/sync/sources/anthropic-usage.ts |
Reports unknown pricing models. |
src/lib/scenarios/queries.ts |
Uses attributed costs in forecasts. |
src/lib/profile-data.ts |
Returns billed-based profile costs. |
src/lib/price-labels.ts |
Defines cost/allowance wording. |
src/lib/measured-consumption.ts |
Loads monthly measured consumption. |
src/lib/mcp/data.ts |
Adds attribution to MCP responses. |
src/lib/expected-spend.ts |
Implements expected-spend rules. |
src/lib/db/schema.ts |
Adds pricing, attribution, credit, and ownership schema. |
src/lib/db/migrations/meta/_journal.json |
Registers migration 0032. |
src/lib/db/migrations/0032_curious_weapon_omega.sql |
Applies schema and data migration. |
src/lib/credits.ts |
Derives prepaid balances. |
src/lib/core/tools.ts |
Persists tier pricing models. |
src/lib/core/assignments.ts |
Supports historical revocation dates. |
src/lib/anthropic/reconciliation.ts |
Detects billed/computed divergence. |
src/lib/anthropic/queries.ts |
Loads ownership, caps, and allowances. |
src/lib/anthropic/cost-attribution.ts |
Implements exact cost apportionment. |
src/lib/anthropic-sync.ts |
Synchronizes resolved ownership. |
src/lib/anthropic-pricing.ts |
Adds Claude 5 pricing and robust matching. |
src/components/profile/cost-tracking-section.tsx |
Displays attribution provenance. |
src/components/error-popover.tsx |
Distinguishes sync warnings. |
src/components/claude/workspace-budget-list.tsx |
Shows owners, caps, and mismatches. |
src/components/claude/users-table.tsx |
Adds attribution badges. |
src/components/claude/org-credits-panel.tsx |
Displays derived credit balance. |
src/components/claude/attribution-badge.tsx |
Defines attribution labels. |
src/app/users/[id]/user-detail-client.tsx |
Labels usage allowances. |
src/app/tools/[id]/tool-detail-client.tsx |
Adds pricing-model controls. |
src/app/claude/page.tsx |
Loads credits and deprecated workspaces. |
src/app/budget/components/period-allocations-table.tsx |
Shows bases and credit purchases. |
src/app/assignments/assignments-client.tsx |
Labels costs versus allowances. |
src/app/assignments/[id]/assignment-detail-client.tsx |
Updates tier price wording. |
src/actions/credits.ts |
Adds credit purchase and balance actions. |
src/actions/budget.ts |
Applies usage forecasts and credit exclusions. |
src/actions/anthropic-users.ts |
Switches user analytics to attributed costs. |
src/actions/anthropic-global.ts |
Adds ownership and cap administration. |
src/actions/alerts.ts |
Excludes deprecated workspaces. |
specs/045-usage-based-cost-model/research.md |
Records design research. |
specs/045-usage-based-cost-model/quickstart.md |
Documents verification steps. |
specs/045-usage-based-cost-model/plan.md |
Describes implementation phases. |
specs/045-usage-based-cost-model/contracts/pricing-and-credits.md |
Defines pricing and credit rules. |
specs/045-usage-based-cost-model/contracts/cost-attribution.md |
Defines attribution rules. |
specs/045-usage-based-cost-model/checklists/requirements.md |
Tracks specification readiness. |
scripts/revoke-pooled-console-assignments.ts |
Revokes deprecated assignments. |
scripts/attribute-anthropic-costs.ts |
Provides manual re-attribution. |
docs/profile-api-integration-guide.md |
Documents attribution response fields. |
docs/anthropic-cost-accuracy.md |
Explains the superseded cost model. |
docs/045-restatement-announcement.md |
Communicates historical restatement. |
CLAUDE.md |
Records the feature summary. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| pricingModel: | ||
| assignments.find((a) => a.pricingModel === "usage") !== undefined | ||
| ? "usage" | ||
| : "seat", |
| const creditPurchaseCents = period.billedCosts | ||
| .filter((bc) => creditPurchaseBilledCostIds.has(bc.id)) | ||
| .reduce((s, bc) => s + bc.amountCents, 0); |
| -- intent, and nothing has ever compared them (045 W4). | ||
| COALESCE(o.owner_count, 0) AS owner_count, | ||
| o.owner_names, | ||
| o.allowance_sum_cents |
| table.toolId, | ||
| table.purchasedAt, | ||
| ), | ||
| index("credit_purchases_invoice_id_idx").on(table.invoiceId), |
| // A completed period with data has a right answer; use it. | ||
| if (period.isComplete) { | ||
| const measured = period.months | ||
| .filter((m) => measuredByMonth.has(m)) | ||
| .reduce((sum, m) => sum + (measuredByMonth.get(m) ?? 0), 0); | ||
| if (period.months.some((m) => measuredByMonth.has(m))) { | ||
| return { cents: measured, basis: "measured" }; | ||
| } |
| // otherwise. The field keeps its name and meaning; its VALUE is now | ||
| // billed-based for complete days. | ||
| costCents: sql<string>`coalesce(sum(coalesce(${anthropicUsageMetrics.attributedCostCents}, ${anthropicUsageMetrics.computedCostCents})), 0)`, | ||
| attributionMethod: sql<string>`coalesce(max(${anthropicUsageMetrics.attributionMode}::text), 'estimated')`, |
| SELECT to_char(date, 'YYYY-MM') AS month, SUM(cost_cents)::bigint AS cents | ||
| FROM anthropic_workspace_costs | ||
| GROUP BY 1 | ||
| ORDER BY 1 |
| export function priceWording( | ||
| pricingModel: PricingModel | null | undefined, | ||
| ): PriceWording { | ||
| return pricingModel === "usage" ? USAGE : SEAT; | ||
| } |
| const modelEntry: DailyModelCost = { | ||
| model: row.model, | ||
| costCents: row.computedCostCents, | ||
| costCents: rowCents, | ||
| inputTokens, | ||
| outputTokens: row.outputTokens, |
| const unknown = await unknownModelWarning(); | ||
| if (unknown) { | ||
| const warning = `Warning: ${unknown}`; | ||
| counts.errorCount += 1; | ||
| counts.errorMessage = counts.errorMessage | ||
| ? `${counts.errorMessage}; ${warning}`.slice(0, 1000) | ||
| : warning.slice(0, 1000); |
Reconciliation always has something to report — project workspaces have spend and no owner by design — and every finding went through appendError, which increments errorCount. Two consequences, both found on the preview deployment rather than in tests: - Every cost sync ended `partial`, every hour, because the framework marks a run partial whenever errorCount > 0. - The dashboard's "last synced" indicator froze. The cost sync stamps the sentinel row behind it only on a run with no errors, so after the first finding it would never update again, making a healthy sync look stopped. The usage sync's unpriced-model warning had the same flaw. appendSyncWarning (src/lib/sync/warnings.ts) records a finding in errorMessage, the field the sync view renders whatever the outcome, without touching errorCount. Real errors still count, and still keep the indicator from claiming a full sync. The popover now decides "warning, not a failure" from the run's outcome rather than from the message prefix, which only worked because errors happened to be appended first. The unit tests missed this because their mocked database returns no rows, so no warning ever fired. The new run-level tests answer the reconciliation query with real-shaped rows; all three fail against the previous code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The note saying what Expected is based on sat inline in the column header. Table headers do not wrap (whitespace-nowrap), so the sentence stretched the Expected column across the table. It is now an info icon beside the header, with the explanation in a tooltip: seat licences use the tier price, and usage-based tools use what was spent for closed months and the 3-month average for open ones, because their licence price is an allowance rather than a cost. The icon is a focusable button, so keyboard users get the tooltip too, and it is absent on a seat-only budget, leaving that header unchanged. Checked in a real browser against the worktree database: the Expected header is now 115px, against 194px for Planned and 133px for Actual, and the table fits inside its card. basisLabel in src/lib/expected-spend.ts lost its only caller and is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>


What this changes
The Hub was built when developers shared pooled Anthropic workspaces. It has been modelling a usage-based tool as if it were a seat ever since, and that shows up in three places. All three are fixed together, because fixing one alone leaves a visible contradiction on screen.
1. Per-user cost is read, not re-derived. Cost came from token counts × a hard-coded price table. Every model released after the table was last updated fell back to the highest rate ever charged — overstating one user's August spend 7.6×. The cost sync now requests
group_by[]=description, stores line items in micro-cents, and attributes the billed total: whole to a sole owner (billed), split by measured usage across several (apportioned), or to nobody (unattributed). For a complete day, no price table is consulted at all.2. A tier price stops being a cost when it is an allowance.
access_tiers.pricing_model(seat|usage, defaultseat) decides. Seat tiers are byte-identical to before, pinned by a test againstsumExpectedSpendCents. Usage tiers use measured consumption for completed periods and a trailing 3-complete-month mean for open ones, falling back to the allowance only with no history — and marked as the placeholder it is.3. Credit purchases are cash, not period cost. API access is prepaid. An invoice can be recorded as a top-up, excluded from period cost by exclusion rather than by unpicking the link, and the Hub derives a balance (opening + purchases − consumption) that the Admin API cannot supply.
Plus: recorded workspace caps mirrored from the Console with a last-confirmed date and an allowance-mismatch flag, sync-time reconciliation so a future divergence surfaces within a day, admin-correctable ownership, and retirement of the 12 dead
boost-*pools.Verified against real data
Applied to an isolated Neon branch forked from production (
wt/045-usage-based-cost-model). Production was never written to.Reconciliation, run against live September data, immediately names the defect this feature exists to close: Oliver 7.35×, Marlon 1.63×.
Restatement
Historical per-user figures move, and so does expected spend.
docs/045-restatement-announcement.mdcovers both in one announcement with before/after per person and per period, written for the people whose numbers changed. Headline: Marlon's August $630.85 → $254.66, Oliver's $174.94 → $23.37. Svenja's barely moves ($29.30 → $29.31) — every model she used was already in the price table, which is the clearest evidence the change is doing what it claims.Saved forecast scenarios (041) read the same data and move with it.
Two decisions to review before merge
The revocation date has an off-by-one against its own intent. OQ-3 says periods "from July onward" should stop counting the pooled licences, but the Hub's overlap rule is inclusive, so
revoked_at = 2026-07-01leaves July still counting all $3,700; the drop lands in August. May and June are correctly untouched. If July should be clean, the date needs to be2026-06-30— a one-word change and a re-run of the script. I kept the literally agreed date rather than quietly shifting it.Automationsneeded an ownership exclusion. Its API key resolves to a person, so attribution made its spend theirs — contradicting OQ-4, which says it stays unattributed. A third ownership source,excluded, exists for exactly this and is applied. The workspace card exposes it as "not a person's workspace?".Migration
0032is additive: 2 enums, 3 tables, 9 nullable/defaulted columns, no rename, no rewrite. Reviewed bydrizzle-migration-reviewerbefore it ran; its findings on FK indexes,ON DELETErules,token_typewidth and an empty-string/NULL collision are folded in. The data migration seeds ownership, flips five tiers by id and name, and deprecates 12 workspaces by explicit id — all idempotent.pricing_modeldefaults toseat, so the schema change alone changes nothing on deploy.Checks
tsc --noEmitclean ·eslint --max-warnings=0clean · 818 unit tests + attribution integration suite green.Phases
Each is independently deployable and leaves the app correct: schema → line-item ingestion → attribution → read-path swap → pricing models → credits → reconciliation and caps → ownership and retirement. Phases 4 and 5 carry the restatement and should ship together.
🤖 Generated with Claude Code