Skip to content

Hosted control plane

Gettier's public product follows the hosted observability model: a narrow proxy and sensor relay run beside the customer's workloads, while history and analysis live in Gettier's authenticated dashboard. The durable decision is recorded in .claude/decisions/0002-hosted-control-plane.md; it supersedes the self-hosting direction in the founding architecture commit c7bd48d without changing the frozen contracts.

Identity and ingestion

Every write is scoped to an organization, project, and environment by its ingestion key. Those fields are never trusted from the request body. The first ingestion contract is:

POST /api/v1/envelope
Authorization: Bearer gt_live_...
Content-Type: application/json

{"events":[{"eventId":"evt_01","type":"gate.held","occurredAt":"2026-08-12T10:00:00Z","source":"proxy","sessionId":"s_01","payload":{"holds":2}}]}

Envelopes are limited to 1 MiB and 100 events. Supported v1 event types cover completed turns, held/released gates, sensor outcomes, and relay heartbeats. The route returns 202 only after events are persisted through the tenant-scoped control-plane store. GETTIER_INGESTION_KEYS is a local-development adapter. With DATABASE_URL, authentication uses resolve_ingestion_key(sha256(key)); the database stores only the hash and rejects revoked keys. Migration 004_control_plane.sql owns the catalog, RLS policies, immutable event table, and the narrowly granted pre-tenant authentication function.

Event IDs provide retry idempotency. Hosted Postgres enforces (tenant_id, event_id) as a primary key and uses conflict-safe insertion, closing cross-process retry races. The local SQLite adapter uses read-before-write and is not a production topology.

Provisioning and grants

Apply migrations with the admin role, then grant the non-superuser application role only:

GRANT SELECT ON organizations, projects, environments TO gettier_app;
GRANT SELECT, INSERT ON telemetry_events TO gettier_app;
GRANT EXECUTE ON FUNCTION resolve_ingestion_key(text) TO gettier_app;
GRANT EXECUTE ON FUNCTION resolve_dashboard_membership(text, text) TO gettier_app;
GRANT EXECUTE ON FUNCTION list_dashboard_contexts(text) TO gettier_app;
GRANT EXECUTE ON FUNCTION resolve_dashboard_context(text, text, text, text) TO gettier_app;
GRANT EXECUTE ON FUNCTION accept_dashboard_invitation(text, text, text, text) TO gettier_app;
GRANT EXECUTE ON FUNCTION transfer_dashboard_ownership(text, text, text, text) TO gettier_app;
GRANT EXECUTE ON FUNCTION list_dashboard_members(text, text) TO gettier_app;
GRANT EXECUTE ON FUNCTION assert_billing_ingestion(text, text, bigint) TO gettier_app;
GRANT SELECT ON organization_billing TO gettier_app;
GRANT SELECT, INSERT, UPDATE ON organization_invitations TO gettier_app;
GRANT SELECT, INSERT, DELETE ON organization_memberships TO gettier_app;

Provision a catalog row and one high-entropy key using the same admin DSN:

GETTIER_MIGRATE_URL='postgres://admin:…@db/gettier' \
  pnpm --filter @gettier/core provision-project acme acme checkout production

The command prints the raw gt_live_… credential once. It persists SHA-256 only. Revocation sets ingestion_keys.revoked_at; authentication stops succeeding immediately.

GETTIER_TRIAL_DAYS sets the trial window for an organization the command CREATES; omitted it keeps the schema's 14 days, and it is ignored for an organization that already exists, so re-provisioning a project never extends, shortens, or restarts a running trial. Set it past the end of a demo when there is no checkout to send anyone to — otherwise assert_billing_ingestion starts returning trial_expired on day 15 and every demo account stops accepting telemetry:

GETTIER_TRIAL_DAYS=365 GETTIER_MIGRATE_URL='postgres://admin:…@db/gettier' \
  pnpm --filter @gettier/core provision-project demo demo-co checkout production

Dashboard context

The authenticated dashboard displays GETTIER_ORGANIZATION, GETTIER_PROJECT, and GETTIER_ENVIRONMENT and derives telemetry summaries from stored evidence. SQLite is for local development; hosted deployments use Postgres with RLS. User authentication currently uses the signed dashboard session added after dashboard design commits 7b9d42c, 560447c, and 39c6e1d. Persistent memberships and role enforcement were added after hosted-control plane commit cd8b71a; Authorization Code + PKCE shipped in 9e20820, with provider conformance and signing-key rotation work tracked in docs/authentication.md.

Pricing hypothesis

The revised v1 model has no permanent free plan. Every account starts with a trial bounded by both time and usage: 14 days or 25,000 grounded turns, whichever comes first. Solo is $12/month or $10/month billed annually (one member, three projects, 50,000 turns/month, and 30-day retention). Team is $29/month or $24/month billed annually (unlimited collaborators, five projects, 250,000 turns/month, and 30-day retention). Business is $99/month or $79/month billed annually (25 projects, 1M turns/month, 90-day retention, OIDC SSO, and advanced RBAC). Enterprise is an annual custom agreement for custom volume/retention, dedicated database, data residency, SAML/SCIM, security review, and SLA. Features that have not shipped remain visibly identified as early-access targets on the landing page. Customers bring their own model-provider keys, so Gettier does not absorb inference cost.

Trial exhaustion and paid-plan allowance exhaustion fail closed on cost: accept no new telemetry rather than generating an unexpected bill. At trial expiry, keep the dashboard read-only for seven days and retain recoverable evidence for 30 days. A subscription resumes ingestion; otherwise deletion follows the disclosed retention lifecycle. For a paid allowance, customers upgrade or resume at the next billing cycle. Usage packs and opt-in metered overage can be tested later, but v1 must not enable surprise overages by default. Send lifecycle notices before trial expiry and before 80% and 100% of an allowance.

Migration 008_commercial_control_plane.sql creates trial state for existing and new organizations. assert_billing_ingestion serializes usage decisions per organization, counts immutable turn.completed telemetry in the current period, excludes idempotent duplicates, and rejects expired, inactive, or exhausted accounts before inserting an event. Settings shows usage and exposes owner-only Stripe Checkout and Billing Portal actions.

A refusal is reported to the caller as HTTP 402 carrying the gate's own reason (trial_expired, turn_limit_reached, subscription_past_due, billing_period_inactive, billing_missing), not as a 503 — the SDK retries 429 and 5xx and must not retry a refusal that will not change. isBillingLimitError recognises the error by name and reason rather than by instanceof, because Next bundles the ingestion route separately from the library that throws it and prototype identity does not survive that.

Refusal is also stated in the dashboard rather than left to be inferred from a ledger that stopped filling: ingestionAdmission re-derives the gate's decision for display and the shell renders it on every page. It carries no billing identity and is deliberately readable by any member — an expired trial stops telemetry for everyone, so everyone who sees the empty page can see why, while only an owner or admin gets the link to act. packages/core's ingestionAdmission is a read model, not a second gate; a test drives both it and assert_billing_ingestion over the same rows and fails if they disagree. Signed subscription webhooks reconcile through a separate admin DSN. Event IDs are idempotent, the current Subscription is re-read from Stripe, creation timestamps reject older state, and an update or deletion cannot mutate an organization linked to a newer Subscription ID.

This is a hypothesis, not an earned market fact. The organization base price avoids punishing customers for instrumenting more services or inviting collaborators; grounded turns, projects, and retention remain the scale dimensions. Validate activation before the earlier of the time/usage trial limits, trial-to-Solo conversion, storage cost per retained turn, relay traffic, replay compute, support load, willingness to pay, metering, and hard-limit behavior during early access before making paid plans generally available.

Stripe billing configuration

The application role can read billing state and execute the quota function, but cannot mutate subscriptions. Configure Stripe and a separate migration/admin DSN for the signed webhook:

STRIPE_SECRET_KEY='sk_live_...'
STRIPE_WEBHOOK_SECRET='whsec_...'
GETTIER_BILLING_DATABASE_URL='postgres://migration-role:...@db/gettier'
STRIPE_PRICE_SOLO_MONTHLY='price_...'
STRIPE_PRICE_SOLO_ANNUAL='price_...'
STRIPE_PRICE_TEAM_MONTHLY='price_...'
STRIPE_PRICE_TEAM_ANNUAL='price_...'
STRIPE_PRICE_BUSINESS_MONTHLY='price_...'
STRIPE_PRICE_BUSINESS_ANNUAL='price_...'

Register /api/billing/webhook for customer.subscription.created, .updated, and .deleted. Checkout copies the tenant into Subscription metadata; webhook processing refuses objects without it and derives the plan from the server-side price allowlist, including plan changes made in Portal. Portal configuration owns cancellation and payment-method changes. Price IDs are never accepted from clients.

Before general availability, schedule the lifecycle maintenance job that sends the documented trial/80%/100% notices and executes evidence deletion after evidence_delete_after. Enforcement, read-only access, and deletion deadlines are represented now; unattended notification delivery and physical retention cleanup remain operational rollout work rather than request-path logic.