The free tier in code

On this page

A free account gets a monthly allowance: hours of audio, chats, reports, maps and popcorn analyses, plus a project cap. Every limit is checked and counted in one place, a ledger in Postgres, under one lock per billing account. Paid accounts never touch any of it.

The core is the allowance capability in dembrane/platform/packages/allowance/src/. The namespaces call it where the work starts. The previous lifetime one-hour lock is retired: is_over_cap no longer locks a conversation.

The one rule every limit follows

flowchart TD
  R["Request that would spend a free unit"] --> A{"Resolve the billing account"}
  A -- "paid, or unknown tier" --> U["Unlimited: do the work, count nothing"]
  A -- "free client workspace" --> P["Pool into the org's free account"]
  A -- "free" --> L["Open a transaction and take the account lock"]
  P --> L
  L --> C{"used + amount > limit?"}
  C -- "yes" --> W["402 FREE_TIER_LIMIT with the limit name"]
  C -- "no" --> T["Count it in free_usage and write the work's row, same transaction"]
  T --> G{"Did the work produce nothing?"}
  G -- "no" --> K["Stays counted"]
  G -- "yes: failed, cancelled or never started" --> B["Give back exactly once: clear the stamp and subtract, under the lock"]

The allowance

Concept Where How it works
Who is free isFreeTier in free.ts The tier is exactly "free". Every other tier, null or a missing account is unlimited. This is the only “is free” check.
Which account pays allowanceForWorkspace, allowanceForProject in account.ts The workspace’s own billing account. If that account is free and has no org_id (a client workspace), and the org’s oldest live billing account is also free, usage pools into the org’s account. Returns null for unlimited.
Counted projects allowanceForCountedProject As above, but null for a sample project. Chats, reports, maps and popcorn use this, so samples are never counted.
The period periodStart in period.ts The UTC calendar month. A free account downgraded this month counts from downgraded_at instead. Every period resets on the 1st (nextReset).
The limits freeLimits in limits.ts Rows in tier_limit (tier free) overlay FREE_LIMIT_DEFAULTS. Cached for 60 seconds per process. A failed read serves the defaults without caching them.

downgraded_at is stamped by a workspace tier change, by billing.expire-tiers and by a staff tier move (tierMovePatch in staff/src/routes.ts).

The limits and their defaults

Staff can change the first nine. The rest are fixed in code.

Limit key Default Counted as 402 limit
audio_seconds_month 18000 (5 hours) ledger audio_seconds audio_month
audio_seconds_conversation 3600 (1 hour) sum of conversation_chunk.audio_seconds audio_conversation
projects 1 live non-sample projects across the account’s workspaces projects
chats_month 2 ledger chats chats
chat_turns_per_chat 20 user messages in the chat this period chat_turns
reports_month 1 ledger reports report
maps_month 1 ledger maps map
popcorn_analyses_month 12 ledger popcorn_analyses popcorn
popcorn_cadence_minutes 5 the live cadence floor none
One free organisation per manager fixed orgs the user owns or admins organisations
One recording project per account fixed billing_account.recording_project_id recording_project

The ledger and the lock

free_usage holds one row per (billing_account_id, period_start, key). The functions are in ledger.ts.

Function Does
withAllowanceLock(db, a, fn) One transaction holding pg_advisory_xact_lock(hashtext('free_usage:<account id>')). The lock is per account, not per period, because the project cap spans periods.
take Refuses when used + amount would pass the limit, else adds. Takes the lock itself.
add Adds without a limit check. Used for live chunks, which have their own grace.
giveBack Subtracts, never below zero.
usedThisPeriod Every meter for the account’s current period.
claimOnce Inserts a marker key (notice:audio_out) once per account and period.

charge.ts wraps the one-unit-per-start pattern for maps and popcorn: takeFreeUnit, requireFreeUnitRoom (a check only) and chargeFreeUnit, which returns a giveBack that runs at most once.

Where each limit is enforced

Audio

All audio rules are in conversations/src/free-audio.ts.

sequenceDiagram
  participant P as Portal
  participant A as API
  participant S as Object storage
  participant M as Media app
  participant D as Postgres
  participant W as Worker
  P->>A: POST .../conversations/initiate
  A->>D: requireAudioStart (recording project, month left)
  P->>A: POST .../get-upload-url
  A->>D: liveChunkCheck (402 only past limit + 120 s grace)
  A-->>P: presigned form
  P->>S: upload the chunk
  P->>A: POST .../confirm-upload
  A->>M: POST /decoded-seconds (15 s budget)
  A->>D: account lock: verdict, add seconds, insert chunk (one transaction)
  A-->>P: chunk, plus recording_stop at the limit
  Note over P: WrapUpModal, then finish. useRecordingLimit also stops at conversation_limit_seconds
  W->>D: conversations.chunk prepare: meterLiveChunk if the chunk is still unmetered

The portal’s project read (GET /api/participant/projects/:project_id) carries is_taking_recordings and conversation_limit_seconds from audioOpen.

Projects and organisations

Chats

freeChats in chats/src/free.ts:

Reports

In projects/src/free.ts and projects/src/reports.ts:

Maps

startFreeMap (map/src/free.ts) wraps requestGeneration behind POST /api/v2/bff/map/projects/:project_id/generate. A free map still generating answers a second click without counting. The map is given back if starting throws or starts nothing new. A map that started is never given back.

Popcorn

popcornAllowance in popcorn/src/free.ts, used by the tick in popcorn/src/tick/run.ts:

The analysis page

requestAnalysisRun (analysis/src/bff.ts, POST /api/v2/bff/analysis/projects/:project_id/runs) charges by recipe: arguments and deduplicated_arguments take a map; stakeholders and tensions take a popcorn analysis. It checks room before any dependency starts, then charges at most once per unit per request. The charge is given back when no run starts. A popcorn-class run is stamped on analysis_run. refundFailedRuns (analysis/src/free.ts) gives back failed runs with no output.

Present

Samples and paid accounts

The 402 and the dashboard

Every refusal is freeLimitError(limit, params) from allowance/src/errors.ts: a PaymentRequiredError with code billing.tier_limit. The body is:

{
  "code": "billing.tier_limit",
  "params": { "limit": "chats" },
  "detail": { "used": 2, "cap": 2, "error": "FREE_TIER_LIMIT", "limit": "chats", "upgrade_cta_tier": "changemaker" }
}

The extra detail fields depend on the limit (used and cap, remaining_seconds, file_seconds and limit_seconds, or org_id).

In dembrane/frontend/src:

What Where
Reading the 402 isFreeTierLimitError, freeLimitDetails, portalRecordingRefusal and recordingStopOf in lib/freeTier.ts
The walls A WallKey per limit in components/workspace/gateWalls.ts, opened through UpgradeModal in components/workspace/FeatureGate.tsx
The meter useFreeAllowance reads GET /api/v2/workspaces/:workspace_id/free-allowance (allowanceSnapshot). FreeAllowanceCard shows it on the workspace settings page.
Used-up banners FreeAllowanceBanner on new chat, the report form and the map page. PopcornFreeNotice, MapFreeNotice, ChatTurnLimitCard.
Recording useProjectRecording and RecordingPausedNotice on a project that is not the recording project. The upload dropzone prechecks lengths with readMediaSeconds and precheckUploads.
Portal NotTakingRecordings (never mentions plans), WrapUpModal on recording_stop or the conversation limit

useAllowanceApplies treats sample projects as exempt. audioUsedUp counts under 60 seconds left as used up, to match the notice job.

Staff

Background jobs

Job Every Free tier work
billing.free-audio-out 5 minutes Tells a free account’s admins once per period that the month’s recording hours are used: a FREE_AUDIO_OUT notification and an email. Runs only with BILLING_CUSTOMER_JOBS=on (default off).
reports.backfill-scheduled 5 minutes Also runs failStuckReports.
popcorn.reconcile-ticks 1 minute Also runs settleStuckRuns: counted runs still running after 4.5 hours end in error and are given back under the popcorn rules.
analysis.sweep 1 minute Also runs refundFailedRuns for runs that failed outside a worker.
conversations.chunk per chunk prepare counts unmetered live chunks with meterLiveChunk.

Database changes

erDiagram
  billing_account ||--o{ free_usage : "uses per period"
  billing_account |o--o| project : "recording_project_id"
  tier_limit {
    text tier PK
    text key PK
    bigint value
    uuid updated_by
  }
  free_usage {
    uuid billing_account_id PK
    timestamptz period_start PK
    text key PK
    bigint amount
  }

Stamps sit on the rows they pay for: project_chat.free_period_start; free_period_start and free_account_id on project_report, agent_loop_run and analysis_run. conversation_chunk.audio_seconds holds what a chunk counted.

Migration Does
0028_expand_free_tier_allowance Creates free_usage and tier_limit.
0029_expand_clear_over_cap Clears every is_over_cap stamp left by the retired lock.
0030_expand_free_tier_recording Adds billing_account.recording_project_id and conversation_chunk.audio_seconds.
0031_expand_free_limit_keys Deletes retired limit keys and popcorn_sessions usage rows.
0032_expand_free_chat_period Adds project_chat.free_period_start.
0033_expand_free_report_period Adds project_report.free_period_start.
0034_expand_free_popcorn_run Adds agent_loop_run.free_period_start.
0035_expand_free_tier_launch_backfill Counts this period’s existing audio, chats, reports, maps and popcorn analyses into free_usage. Stamps the chats, reports and popcorn runs it counted.
0036_expand_free_account_stamp Adds free_account_id to agent_loop_run and project_report, and fills it on stamped rows.
0037_expand_free_analysis_run Adds the stamp columns to analysis_run, plus indexes for the popcorn stuck-run sweep and the failed-run refund.

Deploying

Adding a new limit

  1. Add the key and its default to LIMIT_KEYS and FREE_LIMIT_DEFAULTS in allowance/src/limits.ts. Add it to FreeLimitKey in hooks/useFreeLimits.ts and give it a label and unit in FreeLimitsPanel.
  2. If it is a monthly count, add a ledger key to USAGE_KEYS (ledger.ts) and a meter to allowanceSnapshot (snapshot.ts) and the frontend FreeAllowance type. For one unit per started generation, add it to UNITS in charge.ts.
  3. Add the 402 name to FreeLimitName (errors.ts) and to FreeTierLimit in lib/freeTier.ts.
  4. Enforce it where the work starts. Resolve with allowanceForCountedProject (or allowanceForWorkspace). Take inside withAllowanceLock in the same transaction as the work’s row. Throw freeLimitError on refusal.
  5. If the work can produce nothing, stamp its row with the period and account, and give back with a conditional clear plus giveBack under the lock. Add a sweep for work that can die without reaching its error path.
  6. Add a WallKey with its popover and action lines in gateWalls.ts, catch the 402 with isFreeTierLimitError, and call invalidateFreeAllowance after a spend or refund.
  7. Write integration tests next to the namespace (test/free-*.integration.test.ts, test/bypass-*.integration.test.ts): two takes at once, a give-back that runs once, the month boundary, paid and sample projects untouched.
  8. A new stamp column is an expand migration. Generate it with bun run db:generate.

Related pages

Comments