Skip to content

feat(server): usage metering ledger (Phase 7 slice 1)#74

Merged
Isonimus merged 1 commit into
mainfrom
feat/usage-metering
Jun 24, 2026
Merged

feat(server): usage metering ledger (Phase 7 slice 1)#74
Isonimus merged 1 commit into
mainfrom
feat/usage-metering

Conversation

@Isonimus

Copy link
Copy Markdown
Contributor

What

The first slice of Phase 7 billing: a durable per-organization, per-UTC-calendar-month ledger of identity resolutions — the billable unit. Replaces the ephemeral Redis rate-limit windows as the metering source of truth. Measurement only — Stripe, public self-serve signup, and hard enforcement are deferred follow-ups (ADR-0007).

Builds on the organizations tenant layer (ADR-0005) — orgs are the billing anchor.

Billable unit & exactly-once counting

One billable resolution = one committed snapshot. incrementUsage() records it via an atomic UPSERT inside the existing resolution transaction in resolveSnapshot, so counting is exactly-once with no extra coordination:

  • the event_id dedup short-circuits before the transaction → retries/duplicates don't count;
  • the increment shares the snapshot's commit/rollback → no count without a stored observation, none double-counted.

POST /v1/resolve is a non-persisting preview (login-flow confidence/risk check) and intentionally does not count — you bill for committed observations, not previews.

Soft limits (measure + warn, never block)

  • organizations gains plan + monthly_resolution_limit (NULL = unlimited, so self-host is metered but never warned).
  • checkAndWarnThreshold() runs after commit (fire-and-forget; metering never delays/fails a resolution). Crossing 80% / 100% flips a once-per-period guard and emits logger.warn + Sentry.captureMessage — reusing the shipped error tracking (no-op without SENTRY_DSN), no new infra. Exceeding the limit never blocks traffic.

Surfacing & provisioning

  • GET /admin/usage (org-scoped) → current-period count, limit, %, recent history.
  • Observatory Usage page (progress bar + history). (No Dashboard card — usage is org-level, the Dashboard is project-scoped; a card there would imply per-project usage.)
  • set-org-plan <orgName> <plan> [limit|unlimited] CLI — thin/assisted onboarding until an org-management UI exists.

Schema (migration 015)

organizations.plan (default free) + monthly_resolution_limit (nullable); usage_counters(organization_id, period_start, resolution_count, warned_80, warned_100, updated_at), PK (organization_id, period_start). No backfill; self-host unchanged.

Tests

usage.integration.test.ts — exactly-once counting, dedup-doesn't-count, multi-project rollup, per-org isolation, threshold-guard flips (idempotent). Plus GET /admin/usage coverage in admin.integration. 148 server tests pass; type-check, lint, openapi:lint, and pnpm audit --audit-level=high all green.

Scaling note

One counter row per org/month is a documented write hotspot under high concurrency. Fine at current scale (single box, one design partner); deferred mitigation: Redis hot counter flushed periodically, or sharded counters.

Deferred follow-ups

Stripe (subscription/checkout/portal/webhooks + usage reporting); public self-serve signup; hard quota enforcement (402/429); per-project usage breakdown; Redis hot-counter optimization; Observatory org-management UI.

A durable per-organization, per-UTC-calendar-month ledger of identity
resolutions — the billable unit — replacing the ephemeral Redis rate-limit
windows as the source of truth. Measurement only: Stripe, public signup, and
hard enforcement are deferred follow-ups (ADR-0007).

- migration 015: organizations.plan + monthly_resolution_limit (NULL =
  unlimited); usage_counters(organization_id, period_start, resolution_count,
  warned_80, warned_100).
- lib/usage.ts: incrementUsage() — atomic UPSERT inside the resolution
  transaction in resolveSnapshot, so counting is exactly-once (the event_id
  dedup short-circuits before the tx; rollback un-counts). /v1/resolve is a
  non-persisting preview and intentionally does not count.
- checkAndWarnThreshold() — soft limits: crossing 80%/100% flips a once-per-
  period guard and emits logger.warn + Sentry.captureMessage (reusing the
  shipped error tracking; no-op without SENTRY_DSN). Never blocks traffic.
  NULL limit = no-op, so self-host is metered but never warned.
- GET /admin/usage (org-scoped) + Observatory Usage page (count vs limit,
  history); set-org-plan CLI for thin/assisted onboarding.
- docs: ADR-0007, OpenAPI /admin/usage, ROADMAP, CHANGELOG.
- tests: usage.integration (exactly-once, dedup, multi-project rollup, org
  isolation, threshold guards) + GET /admin/usage coverage. 148 server tests.
@Isonimus Isonimus merged commit 943a1d3 into main Jun 24, 2026
4 checks passed
@Isonimus Isonimus deleted the feat/usage-metering branch June 24, 2026 07:35
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