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.