From 814540efd3f17d383779e6200be1c0bdf5b8c398 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Tue, 28 Jul 2026 09:53:27 -0500 Subject: [PATCH 1/3] fix(gastown): gate billing UI on announcement flag, not shadow metering The terminal-bar estimate pill, cost strings, and automatic-start control were gated on billing.enabled, which reflects shadow metering (CONTAINER_USAGE binding present) and is intentionally on in production before launch. This surfaced billing estimates to users while GASTOWN_BILLING_ENABLED and GASTOWN_BILLING_ANNOUNCEMENT_ENABLED were both off in production. Gate user-facing billing UI on the announcement flag or enforcing instead. Thread GASTOWN_BILLING_ANNOUNCEMENT_ENABLED from the town layouts through MayorTerminalBar into TerminalBar/TabBar. A town already paused_by_user stays interactive so the user can resume it. --- .specs/gastown-usage-based-billing.md | 20 +++++++++--- .../gastown/[townId]/MayorTerminalBar.tsx | 10 +++++- .../src/app/(app)/gastown/[townId]/layout.tsx | 6 +++- .../[id]/gastown/[townId]/layout.tsx | 7 +++- .../src/components/gastown/TerminalBar.tsx | 32 +++++++++++++++++-- 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/.specs/gastown-usage-based-billing.md b/.specs/gastown-usage-based-billing.md index d1c821796d..c32bee6b5b 100644 --- a/.specs/gastown-usage-based-billing.md +++ b/.specs/gastown-usage-based-billing.md @@ -461,10 +461,14 @@ Metering and enforcement are separately controlled: - **Announcement** — the default-off `GASTOWN_BILLING_ANNOUNCEMENT_ENABLED` web flag MAY show advance notice; it MUST NOT alter backend billing behavior. -The status object distinguishes these: `enabled` means metering is active (drives the run estimate -and the automatic-start control), while `enforcing` gates cold-start/terminal blocking in the UI. -Enforcement and announcement flags are always enabled in local development while retaining -default-off production behavior; metering runs in development because the meter binding is present. +The status object distinguishes these: `enabled` means metering is active (usage is reported and +the run estimate is populated), while `enforcing` gates cold-start/terminal blocking in the UI. +`enabled` MUST NOT gate user-facing billing UI: it is on in production for shadow metering before +billing is announced. User-facing billing UI (the estimate pill, cost strings, and automatic-start +control) is shown only when billing is announced (`GASTOWN_BILLING_ANNOUNCEMENT_ENABLED`) or enforced +(`enforcing`), or when the town is already `paused_by_user` so the user can resume it. Enforcement and +announcement flags are always enabled in local development while retaining default-off production +behavior; metering runs in development because the meter binding is present. Dashboards MUST expose awake seconds, base Cloudflare cost, customer charge, gross multiplier, unclosed intervals, SKU admission denials, RPC failures, graceful-stop duration, forced stops, and @@ -503,6 +507,14 @@ counts of `warn` and `stop` verdicts. ## Changelog +### 2026-07-28 -- Gate user-facing billing UI on announcement, not metering + +- User-facing billing UI (estimate pill, cost strings, automatic-start control) is now gated on the + announcement flag (`GASTOWN_BILLING_ANNOUNCEMENT_ENABLED`) or `enforcing`, not on `billing.enabled`. + Because metering (`enabled`) is on in production for shadow data, keying the UI off `enabled` + surfaced billing estimates to users before launch. The status field `enabled` remains + metering-only; the web `TerminalBar` receives the announcement flag from the town layouts. + ### 2026-07-24 -- Separate reporting from enforcement - Metering now always reports to the meter when the `CONTAINER_USAGE` binding is present; diff --git a/apps/web/src/app/(app)/gastown/[townId]/MayorTerminalBar.tsx b/apps/web/src/app/(app)/gastown/[townId]/MayorTerminalBar.tsx index b6244c608e..c37a12eb55 100644 --- a/apps/web/src/app/(app)/gastown/[townId]/MayorTerminalBar.tsx +++ b/apps/web/src/app/(app)/gastown/[townId]/MayorTerminalBar.tsx @@ -8,12 +8,20 @@ import { GASTOWN_URL } from '@/lib/constants'; export function MayorTerminalBar({ params, basePath, + billingAnnouncementEnabled, }: { params: Promise<{ townId: string }>; basePath?: string; + billingAnnouncementEnabled?: boolean; }) { const { townId } = use(params); // Track dashboard navigation context and sync to TownDO useGastownUiContext(townId, GASTOWN_URL); - return ; + return ( + + ); } diff --git a/apps/web/src/app/(app)/gastown/[townId]/layout.tsx b/apps/web/src/app/(app)/gastown/[townId]/layout.tsx index 3b9e9f3659..ec17d917bc 100644 --- a/apps/web/src/app/(app)/gastown/[townId]/layout.tsx +++ b/apps/web/src/app/(app)/gastown/[townId]/layout.tsx @@ -3,6 +3,7 @@ import { DrawerStackProvider } from '@/components/gastown/DrawerStack'; import { renderDrawerContent } from '@/components/gastown/DrawerStackContent'; import { TerminalBarPadding } from '@/components/gastown/TerminalBarPadding'; import { HideAppTopbar } from '@/components/gastown/HideAppTopbar'; +import { GASTOWN_BILLING_ANNOUNCEMENT_ENABLED } from '@/lib/config.server'; import { MayorTerminalBar } from './MayorTerminalBar'; import { OnboardingTooltipsWrapper } from './OnboardingTooltipsWrapper'; @@ -18,7 +19,10 @@ export default function TownLayout({ {children} - + diff --git a/apps/web/src/app/(app)/organizations/[id]/gastown/[townId]/layout.tsx b/apps/web/src/app/(app)/organizations/[id]/gastown/[townId]/layout.tsx index 0e6dd77999..6a1d93d37d 100644 --- a/apps/web/src/app/(app)/organizations/[id]/gastown/[townId]/layout.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/gastown/[townId]/layout.tsx @@ -3,6 +3,7 @@ import { DrawerStackProvider } from '@/components/gastown/DrawerStack'; import { renderDrawerContent } from '@/components/gastown/DrawerStackContent'; import { TerminalBarPadding } from '@/components/gastown/TerminalBarPadding'; import { HideAppTopbar } from '@/components/gastown/HideAppTopbar'; +import { GASTOWN_BILLING_ANNOUNCEMENT_ENABLED } from '@/lib/config.server'; import { MayorTerminalBar } from '@/app/(app)/gastown/[townId]/MayorTerminalBar'; import { OnboardingTooltips } from '@/components/gastown/OnboardingTooltips'; @@ -21,7 +22,11 @@ export default async function OrgTownLayout({ {children} - + diff --git a/apps/web/src/components/gastown/TerminalBar.tsx b/apps/web/src/components/gastown/TerminalBar.tsx index 9911737f7c..14f96a1828 100644 --- a/apps/web/src/components/gastown/TerminalBar.tsx +++ b/apps/web/src/components/gastown/TerminalBar.tsx @@ -52,6 +52,13 @@ type TerminalBarProps = { townId: string; /** Override base path for org-scoped routes (e.g. /organizations/[id]/gastown/[townId]) */ basePath?: string; + /** + * Whether user-facing billing UI (estimate pill, cost strings, automatic-start + * control) may be shown. Driven by GASTOWN_BILLING_ANNOUNCEMENT_ENABLED. This is + * intentionally separate from `billing.enabled`, which only reflects shadow + * metering and is on in production before billing is announced to users. + */ + billingAnnouncementEnabled?: boolean; }; type BillingStatus = GastownOutputs['gastown']['getBillingStatus']; @@ -61,7 +68,11 @@ type BillingStatus = GastownOutputs['gastown']['getBillingStatus']; * Agent terminal tabs are opened/closed via TerminalBarContext. * Can be positioned at bottom/top/right/left with drag-to-resize. */ -export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarProps) { +export function TerminalBar({ + townId, + basePath: basePathOverride, + billingAnnouncementEnabled = false, +}: TerminalBarProps) { const trpc = useGastownTRPC(); const townBasePath = basePathOverride ?? `/gastown/${townId}`; const { state: sidebarState, isMobile } = useSidebar(); @@ -378,6 +389,7 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP setPosition={setPosition} closeTab={closeTab} billing={billing} + billingAnnouncementEnabled={billingAnnouncementEnabled} townId={townId} /> {!collapsed && ( @@ -452,6 +465,7 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP setPosition={setPosition} closeTab={closeTab} billing={billing} + billingAnnouncementEnabled={billingAnnouncementEnabled} townId={townId} /> void; closeTab: (tabId: string) => void; billing?: BillingStatus; + billingAnnouncementEnabled: boolean; townId: string; }) { const borderClass = horizontal ? 'border-b border-white/[0.06]' : 'border-r border-white/[0.06]'; @@ -605,6 +622,15 @@ function TabBar({ const showRunCostTooltip = billing?.estimatedRunCharge !== undefined && (billing.state === 'running' || billing.state === 'warning' || billing.state === 'stopping'); + // Whether to surface user-facing billing UI. `billing.enabled` only reflects + // shadow metering (on in production before launch), so it must NOT gate what + // users see. Show billing UI once it is announced or enforced. A town already + // paused by the user stays interactive so they can resume it. + const showBilling = + billing !== undefined && + (billingAnnouncementEnabled || + billing.enforcing || + billing.runPolicy === 'paused_by_user'); return (
- {horizontal && billing && (billing.enabled || billing.runPolicy === 'paused_by_user') && ( + {horizontal && billing && showBilling && (
)} - {!horizontal && billing && (billing.enabled || billing.runPolicy === 'paused_by_user') && ( + {!horizontal && billing && showBilling && (
Date: Tue, 28 Jul 2026 10:04:35 -0500 Subject: [PATCH 2/3] style(gastown): oxfmt showBilling expression --- apps/web/src/components/gastown/TerminalBar.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/components/gastown/TerminalBar.tsx b/apps/web/src/components/gastown/TerminalBar.tsx index 14f96a1828..79b8e741a8 100644 --- a/apps/web/src/components/gastown/TerminalBar.tsx +++ b/apps/web/src/components/gastown/TerminalBar.tsx @@ -628,9 +628,7 @@ function TabBar({ // paused by the user stays interactive so they can resume it. const showBilling = billing !== undefined && - (billingAnnouncementEnabled || - billing.enforcing || - billing.runPolicy === 'paused_by_user'); + (billingAnnouncementEnabled || billing.enforcing || billing.runPolicy === 'paused_by_user'); return (
Date: Tue, 28 Jul 2026 10:38:09 -0500 Subject: [PATCH 3/3] fix(gastown): gate billing UI solely on announcement flag Drop the enforcing and paused_by_user disjuncts from showBilling so GASTOWN_BILLING_ANNOUNCEMENT_ENABLED is the single gate for the estimate pill, cost strings, and automatic-start control. Enforcement is true in local dev (GASTOWN_BILLING_ENABLED), which was keeping the UI visible even with the announcement flag off. --- .specs/gastown-usage-based-billing.md | 22 +++++++++---------- .../src/components/gastown/TerminalBar.tsx | 13 +++++------ 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/.specs/gastown-usage-based-billing.md b/.specs/gastown-usage-based-billing.md index c32bee6b5b..2ca9aa8fbc 100644 --- a/.specs/gastown-usage-based-billing.md +++ b/.specs/gastown-usage-based-billing.md @@ -463,12 +463,12 @@ Metering and enforcement are separately controlled: The status object distinguishes these: `enabled` means metering is active (usage is reported and the run estimate is populated), while `enforcing` gates cold-start/terminal blocking in the UI. -`enabled` MUST NOT gate user-facing billing UI: it is on in production for shadow metering before -billing is announced. User-facing billing UI (the estimate pill, cost strings, and automatic-start -control) is shown only when billing is announced (`GASTOWN_BILLING_ANNOUNCEMENT_ENABLED`) or enforced -(`enforcing`), or when the town is already `paused_by_user` so the user can resume it. Enforcement and -announcement flags are always enabled in local development while retaining default-off production -behavior; metering runs in development because the meter binding is present. +User-facing billing UI (the estimate pill, cost strings, and automatic-start control) is gated +solely on the announcement flag (`GASTOWN_BILLING_ANNOUNCEMENT_ENABLED`). Neither `enabled` (shadow +metering, on in production before launch) nor `enforcing` (backend enforcement) may reveal billing +UI to users until billing is announced. Enforcement and announcement flags are always enabled in +local development while retaining default-off production behavior; metering runs in development +because the meter binding is present. Dashboards MUST expose awake seconds, base Cloudflare cost, customer charge, gross multiplier, unclosed intervals, SKU admission denials, RPC failures, graceful-stop duration, forced stops, and @@ -509,11 +509,11 @@ counts of `warn` and `stop` verdicts. ### 2026-07-28 -- Gate user-facing billing UI on announcement, not metering -- User-facing billing UI (estimate pill, cost strings, automatic-start control) is now gated on the - announcement flag (`GASTOWN_BILLING_ANNOUNCEMENT_ENABLED`) or `enforcing`, not on `billing.enabled`. - Because metering (`enabled`) is on in production for shadow data, keying the UI off `enabled` - surfaced billing estimates to users before launch. The status field `enabled` remains - metering-only; the web `TerminalBar` receives the announcement flag from the town layouts. +- User-facing billing UI (estimate pill, cost strings, automatic-start control) is now gated solely + on the announcement flag (`GASTOWN_BILLING_ANNOUNCEMENT_ENABLED`), not on `billing.enabled` or + `billing.enforcing`. Because metering (`enabled`) is on in production for shadow data, keying the + UI off `enabled` surfaced billing estimates to users before launch. The status field `enabled` + remains metering-only; the web `TerminalBar` receives the announcement flag from the town layouts. ### 2026-07-24 -- Separate reporting from enforcement diff --git a/apps/web/src/components/gastown/TerminalBar.tsx b/apps/web/src/components/gastown/TerminalBar.tsx index 79b8e741a8..97cd91ac0b 100644 --- a/apps/web/src/components/gastown/TerminalBar.tsx +++ b/apps/web/src/components/gastown/TerminalBar.tsx @@ -622,13 +622,12 @@ function TabBar({ const showRunCostTooltip = billing?.estimatedRunCharge !== undefined && (billing.state === 'running' || billing.state === 'warning' || billing.state === 'stopping'); - // Whether to surface user-facing billing UI. `billing.enabled` only reflects - // shadow metering (on in production before launch), so it must NOT gate what - // users see. Show billing UI once it is announced or enforced. A town already - // paused by the user stays interactive so they can resume it. - const showBilling = - billing !== undefined && - (billingAnnouncementEnabled || billing.enforcing || billing.runPolicy === 'paused_by_user'); + // Whether to surface user-facing billing UI (estimate pill, cost strings, + // automatic-start control). Gated solely on the announcement flag. `billing.enabled` + // reflects shadow metering (on in production before launch) and `billing.enforcing` + // reflects backend enforcement; neither should reveal billing UI to users until + // billing is announced. + const showBilling = billing !== undefined && billingAnnouncementEnabled; return (