Skip to content

feat(usage): add enterprise organization usage monitoring - #7182

Merged
icecrasher321 merged 13 commits into
stagingfrom
feat/organization-usage-monitoring
Aug 28, 2026
Merged

feat(usage): add enterprise organization usage monitoring#7182
icecrasher321 merged 13 commits into
stagingfrom
feat/organization-usage-monitoring

Conversation

@icecrasher321

Copy link
Copy Markdown
Collaborator

Enterprise org admins have no way to see where their pooled credits go. The billing page hides the usage-limit field and the credit-usage drill-down for enterprise, and credit-usage-view is personal-scope only — so the one audience that negotiates a pooled commitment is the one audience that can't watch it burn.

Everything needed already lived in usage_log, the single source of cost truth, where every row billed to an org is stamped billing_entity_type='organization' at charge time. This is the read surface over it, plus one narrow write so BYOK usage is captured instead of discarded.

Usage tracking

Settings → Organization → Usage tracking, gated on hosted + enterprise with a USAGE_MONITORING_ENABLED self-hosted override, matching every other enterprise feature.

Five tabs, one question each: Overview (how much, and what kind of work), Members, Workspaces (select one to drill into its Sources and Workflows), Models, BYOK. Behind an All events action sits the full ledger, mirroring how settings/billing/credit-usage already relates to Billing.

Design notes worth reviewing:

  • Only the visible tab's dimension is fetched. Three of six breakdown dimensions aren't index-covered and heap-fetch per row, so a tab nobody opens never pays for one. Custom ranges are capped at 92 days for the same reason.
  • Every ranked list closes with an explicit Other (N more) row, so each list reconciles to the headline figure. Five lists that don't add up is the classic "the numbers are wrong" bug.
  • One window abstraction. buildUsageAnalyticsScope carries the reporting branch copied from getBillingPeriodUsageCost — reporting periods match created_at, stripe/default periods match the billing_period_* stamps exactly. Diverging here is how this panel would come to disagree with the billing page about the same period.
  • Buckets are date_trunc, not the logs dashboard's epoch-modulo arithmetic. A billing period starts at an arbitrary instant, so modulo buckets would cut every day mid-afternoon and each bar would straddle two calendar days.
  • readUsageTimeSeries groups by the output alias, not the expression — Postgres matches GROUP BY syntactically, and re-rendering the fragment produces a textually different one that it rejects outright.

Track unbilled (BYOK) model usage

BYOK spans already reached costSummary with real token counts and were dropped at exactly one gate, if (modelData.total > 0). A new model_unbilled category records them at zero cost.

  • Written only at the terminal execution boundary, keyed on stableEventKey with the existing onConflictDoNothing, so a resumed run writes once with cumulative tokens and the cost reconciliation path is untouched.
  • Written only when billing attribution is already resolved, and the targets.length === 0 bail stays keyed on billable targets — otherwise a BYOK-only run with no billable target (a zero-base-charge custom-block child) would newly hit the attribution requirement and start erroring.
  • Safe by construction: every billing read over usage_log is SUM(cost), so zero-cost rows change no total anywhere. A distinct category (rather than cost = 0 on category='model') means existing WHERE category = 'model' queries cannot see them unless they ask.

Rollout: the enum value must be released before any code writes it. Migration 0310 is ADD VALUE IF NOT EXISTS, and Postgres cannot use a new enum value in the transaction that adds it.

Collapse organization settings onto the workspace plane

/organization/[id]/settings/* was a second plane with no UI entry point anywhere — nothing linked to it from a sidebar, menu, or button — whose nine sections all already render on the workspace plane (navigation.test.ts already asserted that parity). Deleted, along with its renderer and unavailable page.

planes.organization is replaced by a single unified.organizationSection marker that now derives both ORGANIZATION_PLANE_UNIFIED_SECTIONS and the section map in the workspace gate — the latter was a hand-written literal that had to stay in lockstep with the registry.

Two things that would actually have broken, fixed:

  • Usage-threshold emails built /organization/{id}/settings/billing as their CTA. Now workspace-scoped.
  • POST /api/v1/admin/organizations and instance-org provisioning could strand an admin with zero workspaces. Both now backfill one, the same way enterprise-owner-claim already did.

Plain org members keep their read-only roster access via allowNonOrgAdmin plus a members-only carve-out in the gate, rather than losing a capability silently.

Shared chart module

LineChart moves to components/charts/ with a matching BarChart sibling built from the same geometry, tooltip, and theme modules, so the two cannot drift apart. The move also breaks the import edge from the chart to logs/utils@/blocks/registry, which would otherwise have pulled the entire executable block registry into the settings chunk.

The logs dashboard renders identically — the lift held a zero-visual-diff bar, including keeping two pre-existing quirks verbatim (see below).

Permissions

Four layers agree and are now tested: sidebar visibility, the section page gate, the settings/usage/events sub-route (which sits outside [section] and inherits none of its checks, so it repeats the gate itself), and the API. The operation names session only — an org's pooled ledger discloses every member's model spend, so an API key is refused before authority is even checked. Authority is checked before entitlement, so a non-admin learns nothing about the org's plan.

Per-member credit caps stay hosted-only. The usage-limit route 404s where Sim doesn't own billing and checkOrganizationMemberUsageLimit no-ops, so the Manage credits row action is gated on isHosted rather than offering an action that could only fail.

Testing

  • bunx turbo run type-check — 26/26
  • bun run test2,377 files, 35,091 tests, 0 failures
  • check:api-validation:strict, check:react-query, check:client-boundary, check:native-typecheck — all pass
  • All 12 analytics reads verified against a real local Postgres for reconciliation (sum(rows) + other === total, sum(series) === totals), since the global drizzle mock makes unit tests SQL-blind here
  • Query plans checked against the prod read-only replica (4.8M rows). An earlier COUNT(DISTINCT user_id) cost 830ms of a 909ms summary for a figure that was never rendered; removing it took the summary to 74ms.

Notes for review

Two pre-existing bugs found and deliberately not fixed, because both would change the logs dashboard and this PR held a zero-visual-diff bar on that lift:

  1. stroke='hsl(var(--border))' on the chart axis lines — --border is a hex (#d8d8d8), never an HSL triplet, so this renders as hsl(#d8d8d8), which is invalid, and the axis lines have never painted in either theme. Verbatim from the original line-chart.tsx.
  2. The grid lines read --muted from the block globals.css marks @depricated … Do not modify. The new BarChart track uses the live --border token instead — light-mode --muted is #f5f5f5, which at 12% opacity on white is a 1.2/255 delta, i.e. invisible.

Screenshots in the docs page are Cloud; the BYOK tab has no image yet because model_unbilled only starts accumulating once this ships.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Aug 28, 2026 3:02am

Request Review

@greptile-apps

greptile-apps Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds enterprise organization usage monitoring backed by the usage ledger, including analytics APIs, settings views, event exports, and zero-cost BYOK records. It also consolidates organization settings onto workspace routes and introduces shared chart components.

  • Adds organization usage summaries, dimensional breakdowns, event browsing, and CSV export.
  • Records unbilled model usage for BYOK analytics.
  • Moves organization settings sections into the workspace settings plane.
  • Refactors shared line and bar chart infrastructure.
  • Removes the unsafe best-effort workspace attachment from organization creation.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/sim/lib/billing/core/usage-analytics.ts Resolves reporting windows and calendar-aligned time-series buckets; the prior custom-range and timezone densification defects are addressed at HEAD.
apps/sim/app/api/v1/admin/organizations/route.ts Creates only the organization and owner membership, consistently removing the previously unsafe best-effort workspace attachment behavior.
apps/sim/lib/billing/core/usage-analytics-queries.ts Adds organization-scoped aggregate, breakdown, event, and time-series ledger queries.
apps/sim/lib/logs/execution/logger.ts Adds terminal-boundary recording of zero-cost model_unbilled usage for BYOK executions.
apps/sim/ee/organization-usage/components/usage-monitoring.tsx Introduces the enterprise organization usage monitoring settings surface and its tab navigation.
packages/db/migrations/0311_usage_log_model_unbilled_category.sql Adds the model_unbilled usage category required before BYOK ledger writes begin.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Admin[Enterprise organization admin] --> Settings[Workspace organization settings]
  Settings --> SummaryAPI[Usage summary API]
  Settings --> BreakdownAPI[Usage breakdown API]
  Settings --> EventsAPI[Usage events and export APIs]
  SummaryAPI --> Auth[Session, authority, and entitlement checks]
  BreakdownAPI --> Auth
  EventsAPI --> Auth
  Auth --> Analytics[Usage analytics scope]
  Analytics --> Ledger[(usage_log)]
  Execution[Terminal model execution] --> BYOK[model_unbilled rows]
  BYOK --> Ledger
Loading

Reviews (14): Last reviewed commit: "fix(usage): reject an empty custom date ..." | Re-trigger Greptile

Comment thread apps/sim/lib/billing/core/usage-analytics.ts Outdated
Comment thread apps/sim/lib/billing/core/usage-analytics.ts
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 28, 2026

Copy link
Copy Markdown

@cubic-dev-ai review

@icecrasher321 I have started the AI code review. It will take a few minutes to complete.

Comment thread apps/sim/app/api/v1/admin/organizations/route.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 95 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread apps/sim/app/api/organizations/[id]/usage/summary/route.ts
Comment thread apps/sim/lib/billing/core/usage-analytics.ts Outdated
Comment thread apps/sim/ee/organization-usage/components/usage-consumers.tsx Outdated
Comment thread apps/sim/ee/organization-usage/components/usage-summary.tsx
Comment thread apps/sim/components/charts/use-chart-theme.ts
Comment thread apps/sim/lib/billing/core/usage-log.ts Outdated
Comment thread apps/sim/lib/billing/core/usage-log.ts Outdated
Comment thread apps/sim/lib/core/utils/timezone.ts Outdated
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 28, 2026

Copy link
Copy Markdown

@cubic-dev-ai review

@icecrasher321 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 95 files

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

Re-trigger cubic

Comment thread apps/sim/lib/billing/core/usage-analytics.ts Outdated
Comment thread apps/sim/app/api/v1/admin/organizations/route.ts Outdated
Comment thread apps/sim/lib/billing/core/usage-analytics.ts
@icecrasher321
icecrasher321 force-pushed the feat/organization-usage-monitoring branch from 31b650d to 54e3f27 Compare August 28, 2026 00:51
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 28, 2026

Copy link
Copy Markdown

@cubic-dev-ai review

@icecrasher321 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 96 files

Requires human review: Auto-approval blocked because this review re-detected 2 unresolved issues already reported by Cubic.

Re-trigger cubic

Comment thread apps/sim/lib/billing/core/usage-log.ts Outdated
Comment thread apps/sim/lib/core/utils/timezone.ts
Comment thread apps/sim/ee/organization-usage/components/usage-monitoring.tsx Outdated
Comment thread apps/sim/ee/organization-usage/components/usage-monitoring.tsx Outdated
Comment thread apps/sim/app/api/organizations/[id]/usage/export/route.ts
Comment thread apps/sim/components/charts/bar-chart.tsx Outdated
Comment thread apps/sim/components/charts/bar-chart.tsx Outdated
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 28, 2026

Copy link
Copy Markdown

@cubic-dev-ai review

@icecrasher321 I have started the AI code review. It will take a few minutes to complete.

Comment thread apps/sim/app/api/v1/admin/organizations/route.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 96 files

Requires human review: Auto-approval blocked because this review re-detected 3 unresolved issues already reported by Cubic.
You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.

Re-trigger cubic

Comment thread apps/sim/app/api/v1/admin/organizations/route.ts Outdated
Comment thread apps/sim/app/api/organizations/[id]/usage/breakdown/route.ts
Comment thread apps/sim/app/api/v1/admin/organizations/route.ts Outdated
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 28, 2026

Copy link
Copy Markdown

@cubic-dev-ai review

@icecrasher321 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 99 files

You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.

Re-trigger cubic

Comment thread apps/sim/ee/organization-usage/hooks/use-usage-window.ts Outdated
Comment thread apps/sim/lib/api/contracts/organization-usage.ts Outdated
icecrasher321 and others added 12 commits August 27, 2026 19:53
Enterprise org admins had no way to see where their pooled credits go. The
billing page hides the usage-limit field and the credit-usage drill-down for
enterprise, and the credit-usage view is personal-scope only — so the one
audience that negotiates a pooled commitment was the one audience that could
not watch it burn.

Everything needed already existed in `usage_log`, which is the single source of
cost truth and is stamped `billing_entity_type='organization'` at charge time.
This adds the read surface over it, plus one narrow write so BYOK usage is
captured rather than discarded.

Settings → Organization → Usage tracking, gated on hosted + enterprise with a
`USAGE_MONITORING_ENABLED` self-hosted override, matching the other enterprise
features.

Five tabs, each answering one question: Overview (how much, and what kind of
work), Members, Workspaces (drill into one for its Sources and Workflows),
Models, BYOK. Only the visible tab's dimension is fetched — half the dimensions
heap-scan the ledger, so a tab nobody opens never pays for one. Every ranked
list closes with an explicit `Other (N more)` row so it reconciles to the
headline figure.

Track unbilled (BYOK) model usage
---------------------------------
BYOK spans already reached `costSummary` with real token counts and were
discarded at a single `if (modelData.total > 0)` gate. A new `model_unbilled`
usage_log category records them at zero cost, written only at the terminal
execution boundary and only when billing attribution is already resolved, so a
BYOK-only run with no billable target still bails safely instead of hitting the
attribution requirement. Every billing read over `usage_log` is `SUM(cost)`, so
zero-cost rows change no total anywhere.

Collapse organization settings onto the workspace plane
------------------------------------------------------
`/organization/[id]/settings/*` was a second plane with no UI entry point
anywhere, whose nine sections all already render on the workspace plane. It is
deleted, along with its renderer and unavailable page. `planes.organization` is
replaced by a single `unified.organizationSection` marker that now derives both
`ORGANIZATION_PLANE_UNIFIED_SECTIONS` and the section map in the workspace gate,
removing a hand-maintained duplicate. Usage-threshold emails now link to the
workspace-scoped billing page, and the two org-provisioning paths that could
strand an admin with zero workspaces now backfill one.

Shared chart module
-------------------
`LineChart` moves to `components/charts/` with a matching `BarChart` sibling
built from the same geometry, tooltip, and theme modules. The move also breaks
the import edge from the chart to `logs/utils` → `@/blocks/registry`, which
would otherwise have pulled the entire block registry into the settings chunk.
Logs dashboard renders identically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`check:tool-registry-boundary` ratchets each route's module count, and the
settings section entry exceeded its allowance. Attribution, measured by stubbing
the section out and by walking `origin/staging` in a scratch worktree:

  origin/staging               2112  (baseline 2083 — already +29, unrecorded)
  this branch, section stubbed 2115
  this branch                  2130  (+15 for the whole UsageMonitoring subtree)

So the overrun is a shared budget: staging had spent 29 of the 42 allowed before
this branch existed, and 15 more tipped it over. The 15 are all first-party —
the panel, its contracts, hooks, and the shared chart module — with no accidental
edge into a registry or a heavy barrel, so there is nothing to cut.

Only the four entries this branch is responsible for are re-recorded. Running
`--update-baseline` wholesale rewrote 411 lines, absorbing staging's drift across
~60 unrelated routes into this PR; the six routes that shrank are left
informational for whoever earned them to claim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three review findings, all real.

Densification walked UTC dates while `readUsageTimeSeries` groups by
`date_trunc($bucket, created_at AT TIME ZONE $timezone)` — the viewer's calendar.
For a non-UTC viewer the edge buckets never matched, so their cost stayed in the
headline while their bar read zero. Week and month were worse: Postgres aligns
those to Monday and the 1st, so a cursor stepping from an arbitrary period start
shared no key with the query at all and the whole chart came back zeroed against
a correct total. That is reachable today through an annual enterprise period,
which resolves to `week`.

`densifyUsageSeries` now takes the timezone, derives its first and last bucket
through `Intl.DateTimeFormat('en-CA', { timeZone })`, and truncates both to the
bucket boundary so the keys are the ones `date_trunc` emits. Stepping stays civil
`YYYY-MM-DD` arithmetic — UTC as a proleptic calendar, never converted back to an
instant, so no DST transition can shift a bucket.

The custom-range picker passed `showTime`, so it serialized its end bound as an
inclusive `…T23:59:59` local wall time; the resolver then added a further day.
Every custom range covered 24 hours too many, a legal 92-day selection measured
93 and was rejected, and the wall-clock string parsed as local while the rest of
the window logic is UTC. Dropped `showTime`: a time of day is precision a
day-bucketed panel cannot render, and bare `YYYY-MM-DD` bounds parse as UTC
midnight, which is what makes the half-open `+ DAY_MS` correct.

Admin organization provisioning answered 500 for state it had already committed,
and the existing-membership check then blocked the retry, leaving an organization
no workspace could reach. Attachment is a follow-on effect, not part of creating
the organization, and it is deliberately not folded into the creation
transaction: it runs its own under a lock order that exists to avoid deadlocking
against invitation acceptance, and re-deriving that in a route is how a deadlock
ships. Its failure is now caught and logged, and the endpoint returns the
organization it created.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second review round. The findings that were real, and what changed.

Billing aggregates. `getBillingPeriodWorkflowRunCount` counts distinct workflow
executions with no category predicate, and its own contract says executions with
no billable usage are excluded. A BYOK-only run with a zero base charge writes
nothing but a `model_unbilled` row, so it would newly appear in a figure that
feeds the enterprise billing preview; the count now excludes unbilled categories.
`recordUsage` also admits an unbilled entry only at exactly zero cost — the whole
safety argument for the category is that every aggregate is `SUM(cost)`.

Reconciliation. Breakdown rows and their remainder were each rounded to credits
independently and compared against a separately rounded total, so with sub-credit
fractions they could not add up — which is precisely what the `Other` row exists
to prevent. They now go through one `apportionCredits` pass. The event list and
the CSV also counted a row stamped exactly on the window end, which the summary
excluded, because the ledger filter is `lte` while an analytics window is
half-open; `endDateExclusive` makes the two agree.

BYOK ranking. The tab is denominated in tokens and every row costs zero, so
ranking by cost fell through to an alphabetical tiebreak — the "top providers"
were whichever sorted first, and the hidden tail's tokens were dropped entirely.
It ranks by tokens, and the remainder carries its own token total.

Gating. The usage entry carried `hideWhenBillingDisabled` copied from Members,
but the sidebar applies that filter before it consults `selfHostedOverride`, so
it hid the section from exactly the deployment the override exists to serve.
Members can carry the flag because it has no override to reach.

Error classification. An over-long custom range threw past the orchestration
policy and answered 500 on all four routes. A shared policy maps it to 400, the
export route makes the same classification in its catch, and the picker now
refuses the range up front rather than committing one the API will reject.

Also: the segmented meter's overage tone was unreachable (both counts clamped to
`segments`, so the comparison could never hold) and now scales both against
`max(total, used)`; the allowance is only compared against the current period,
since a rolling window can exceed a limit neither period did; `source` accepts a
scalar and validates against the source enum instead of an unchecked cast; the
bar chart's axis tick uses the same unit-aware formatter as its tooltip; and
`assertValidTimezone` strips control characters before echoing a rejected value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pen period

Third review round.

Custom ranges were anchored on UTC instants. The picker offers calendar days and
sends `YYYY-MM-DD`, which arrives parsed as UTC midnight, so every non-UTC
viewer's selection was shifted by their offset — a range labelled "Aug 1–31"
covered half of Jul 31 and half of Aug 31 twelve hours east — and it contradicted
the series, whose buckets are already the viewer's calendar days. The resolver
now takes the timezone and reinterprets the same civil dates as midnight there,
through `zonedWallClockToUtc`, and counts the span in civil days so a range
containing a DST transition is not measured as 91.96. The timezone is threaded
through the breakdown, events, and export inputs as well; the contract already
carried it but only the summary consumed it, so the four surfaces would have
resolved one range four ways.

A deployment with no subscription resolves to `defaultBillingPeriod()`, the open
pair 1970…9999. Rendered as a period that produced a thousand monthly buckets
ending in 2053, stopped only by the densifier's loop guard — measured, not
inferred. Self-hosted is exactly where it is reachable, since the usage flag
opens the panel on deployments with no plan at all. An unbounded period now shows
a rolling 30-day window, and its predecessor steps back by that window rather
than by a span of eight millennia. Kept as one window rather than clamping only
the chart, so the series still sums to the headline.

The summary's delta used the `previous-period` preset, which must always return
something and therefore approximates a stripe period's predecessor by stepping
back the current period's length. Stripe periods are not equal-length, so the
comparison could be measured against a window that is not the previous period —
contradicting the comment directly above it. It now calls `resolvePreviousPeriod`
and shows no delta when there is no exact predecessor.

Reversed custom bounds measured a negative span, passed the cap, and returned an
inverted range matching nothing — "no usage" rather than a bad request. They now
throw, classified 400 alongside the too-large error.

Admin organization provisioning reported an unqualified success when workspace
attachment failed. It now returns `attachedWorkspaceIds` and records it on the
audit event, so the incomplete state is visible to the caller and durable after
the fact, rather than known only to the logs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ytics scopes

Fourth review round. The first item is a break this branch introduced last round.

`getBillingPeriodWorkflowRunCount` was rewritten to exclude unbilled categories via
`<> ALL(${UNBILLED_USAGE_CATEGORIES})`. Interpolating a JavaScript array into a
`sql` template emits parenthesized scalar binds, so the statement rendered as
`ALL(($1))` and Postgres rejects it: "op ANY/ALL (array) requires array on right
side" — verified against a real database. Its only caller builds the enterprise
billing preview, so that preview would have thrown on every request. It now uses
`notInArray`. Unit tests could not have caught it; `@sim/db` is mocked, so no
statement is ever rendered.

The ledger listing filtered on `created_at` while the analytics scope matches a
stripe or default period on the stamps rows carry. The event list and the CSV
therefore covered a different set than the totals above them — rows created inside
the period but stamped to another, and the reverse. Both now derive their filter
from one `usageWindowLedgerFilter`, which mirrors `buildUsageAnalyticsScope` case
for case, with a test asserting the two branch on the same discriminant.

An invalid timezone reached `assertValidTimezone` and surfaced as a 500 for what is
an ordinary bad query param; it is now refused by the contract as a 400, with the
SQL-boundary assertion left in place as the backstop it is.

A bookmarked workspace id that no longer resolves opened a detail view with an
untitled header and empty sections, against this repo's own deep-link rule. It now
falls back to the list once the list has loaded.

Also: `Cache-Control: no-store` on the CSV, which is every member's spend behind
session auth and the one response a browser will cache; Export no longer gated on
an unrelated summary query; the bar chart keeps its measurement ref on the empty
branch; and the admin create-organization contract declares `attachedWorkspaceIds`
plus `workspaceAttachmentFailed`, so a caller can tell an owner with no workspaces
from provisioning that is genuinely incomplete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…iews

Fifth review round, plus a migration renumber — staging took 0310, so the enum
addition is now 0311.

Unbilled rows leaked into every credit-denominated dimension. They carry a user, a
workspace, a workflow and `source = 'workflow'` like any other row, so a BYOK-only
member appeared in Members at zero credits and their runs inflated the event counts
on Workspaces and Sources. Excluded with `HAVING SUM(cost) > 0` rather than a
`category` predicate: `category` is not in
`usage_log_billing_entity_created_at_cost_idx`, so filtering on it would force a
heap fetch on `member` and `source`, the two dimensions that are index-only and the
reason first paint is cheap. `cost` is in that index, and only an unbilled row can
sum to zero.

Ranking BYOK by tokens last round was half a change: `share` still divided cost by
a total cost of zero, so every provider's bar rendered at the same minimum width
and the ranking was invisible. Share is now measured in whatever the list is ranked
by, derived from the same argument so the two cannot disagree.

Series buckets were each rounded to credits independently, so any day under half a
credit rendered as zero — an organization spending a fraction of a credit a day drew
a flat chart beneath a positive headline. They now go through one `apportionCredits`
pass, the same rule the breakdown rows use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t edges

Removes the workspace attachment from admin organization creation, on the
maintainer's call. Three review rounds went into its failure semantics, and the
conclusion each time was that it cannot be made honest inline: it runs its own
transaction under a lock order that exists to avoid deadlocking against invitation
acceptance, so it could only ever be best-effort after the organization committed.

This codebase already solves the problem properly. `AdminMemberOperationView`
tracks workspace moves with `pending | processing | dead_letter | applied` and
per-workspace retry, and the enterprise-owner-claim path creates the workspace and
the organization in one transaction and enqueues an outbox event for the rest.
Provisioning belongs on one of those, not inline in a create call that also
relocates the owner's billing payer as a side effect. The endpoint is back to
creating an organization and its owner membership, and the contract says why.

The rest are edges found in review:

- A zero bucket rendered as a 3px colored bar. `chartPlotBand` clamps drawn
  geometry off the axis rule, but applied to zero it floored every densified empty
  day at the band — the opposite of what densifying zeros is for.
- `preset=custom` without both dates fell back to the raw period, bypassing the
  unbounded-period bound added last round. It now recurses through `current-period`
  so there is one rule rather than two copies.
- `Date.parse` accepts `2026-02-30` and rolls it forward, so a February request
  silently returned a window starting March 2. Dates now round-trip.
- `?limit=` coerced to `0` and answered 400 instead of using the declared default.
- The period picker bound to the raw URL preset, so a partial custom deep link read
  "Custom range" over current-period data — and suppressed the allowance that was
  exactly comparable to it.
- The CSV rendered a sub-credit charge as the string "0 credits", losing it, and
  left the column unsummable. Export rows now carry unrounded credits and the CSV
  writes a bare number.

Also regenerates the docs manifest, which CI flagged for the new docs page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A network-level export failure produced an unhandled rejection and no toast. The
action is fire-and-forget, so a dropped connection or a failed blob read was
indistinguishable from a click that never registered.

`resolveUsageBucket` ceiled its day count, so a 92-day range spanning the autumn
fall-back measured 92 days and one hour, counted as 93, and silently rendered
weekly bars for the longest range the picker allows. Rounded instead, with a test
on both sides of the threshold.

The client's custom-range guard only checked that both bounds were present, while
the contract — tightened last round — rejects a date that does not exist. A deep
link carrying `2026-02-30` therefore satisfied "resolved custom" and every query on
the page answered 400, where before it merely returned a shifted window. Tightening
one end without the other made a bad link worse; the client now applies the same
calendar round-trip, so it falls back as the partial-link guard intends. The
contract's own check also now covers the date portion of a datetime, not only the
bare form.

The export loop omitted `cursorCreatedAt`, whose documentation names this exact
caller. Each page therefore resolved its cursor against the primary before reading
the replica — up to 99 avoidable round-trips for a capped export.

Both chart roots become `overflow-x-auto`. `CHART_MIN_WIDTH` says the chart
"scrolls rather than compresses", and the code clipped: below the floor the
rightmost bars and axis labels were cut off. That comment was added by this branch
during the lift, so the contradiction is this PR's rather than inherited. At or
above the floor there is no overflow and nothing renders differently. Applied to
both charts because the floor lives in the shared hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…links

`organizationId` was accepted and validated on every usage query and read by no
handler — all four map `params.id`, which is also the value that gets authorized.
It could never widen access, but it was an API that read as though the query
mattered. Removed from the shared window schema and from the hooks and export URL
that were sending it.

The client's custom-range guard checked that both bounds were real dates but not
their ordering or their span, while this PR added a 400 for each. A bookmarked link
with reversed or over-long bounds was therefore marked resolved and failed all four
queries, instead of degrading to the default window the guard exists to provide. It
now checks all three conditions the resolver enforces.

The charts' empty state pinned itself to the clamped minimum width, which forced
horizontal overflow in a narrow container — fallout from adding scrolling to the
chart root, which that branch returns before reaching. It takes the container width
now; the floor protects axis labels, and this branch draws none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous round added `isUsableCustomRange` and left `isResolvedCustom` calling
`isCalendarDate` — the edit replacing the guard did not apply, so the function
shipped unused and the fix it described never took effect. It is wired now, and a
new `organization-usage.test.ts` covers the contract's side of these rules so a
dropped edit here fails a test rather than a review.

That contract check also had a hole of its own: `if (!datePart) return true` treated
a value with no `YYYY-MM-DD` prefix as nothing to verify, so anything `Date.parse`
accepted passed. `2026-08` was read as August 1 — a window the caller never asked
for, returned as though it had. The prefix is required now, anchored so a trailing
suffix cannot slip past, and the client mirrors it.

Both charts drop `useRef(generateShortId(7))` for `useId`. The reported hydration
mismatch is not real — both return early while `containerWidth === null`, which
holds on the server and on the first client render, so the gradient never exists in
hydrated markup. The waste is real: a ref initializer runs every render and all but
the first result is discarded, which is the repo's own lazy-init rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rewriting the date refinement last round dropped the `Date.parse` guard that had
been there, so `2026-08-01Tgarbage` was accepted, the route built an `Invalid Date`,
and `civilBoundKey`'s `toISOString` threw — a 500 for a malformed query string,
which is the class of failure this PR has been converting into 400s. Verified
directly rather than inferred: the schema returned `accepted: true` for a value
whose `Date` was invalid.

A datetime carrying an offset was wrong in a quieter way. The check validated its
date part while the resolver read the UTC day off the whole value, so
`2026-08-01T22:00:00-05:00` displayed as August 1 and queried August 2.

Both contract and client now accept a bare `YYYY-MM-DD` and nothing else, which is
the only form the picker produces — `showTime` was removed early in this PR — so
the looser rules bought nothing and cost two defects. `isUsableCustomRange` also
drops its defensive `slice(0, 10)`: tolerating a shape we have decided not to
accept is how the loose rule crept in to begin with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@icecrasher321
icecrasher321 force-pushed the feat/organization-usage-monitoring branch from ea3df07 to 8e38399 Compare August 28, 2026 02:54
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 28, 2026

Copy link
Copy Markdown

@cubic-dev-ai review

@icecrasher321 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 99 files

You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.

Re-trigger cubic

Comment thread apps/sim/lib/api/contracts/organization-usage.ts Outdated
`if (!value) return true` let `?start-date=` through, the route's ternary turned
the empty string into `undefined`, and the partial-selection fallback answered
about the current period rather than the range named in the request.

Absent stays valid — the picker clears the param rather than blanking it, and a
missing bound is a real state the resolver handles. Explicitly blank is only
reachable from a hand-built request, where a 400 beats a window nobody asked for.

Deliberately different from `usageLimitSchema`, which does coerce `''` to its
default: that field declares one, so omission has a documented meaning. These
bounds declare none, so treating blank as absent substitutes a different answer
rather than a default one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@greptile

@icecrasher321

Copy link
Copy Markdown
Collaborator Author

@cubic-dev-ai review

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 28, 2026

Copy link
Copy Markdown

@cubic-dev-ai review

@icecrasher321 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 99 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.

Re-trigger cubic

@icecrasher321
icecrasher321 merged commit f7693cd into staging Aug 28, 2026
30 checks passed
@icecrasher321
icecrasher321 deleted the feat/organization-usage-monitoring branch August 28, 2026 03:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant