diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,21 @@
+# Changelog
+
+## 0.2.0.0 — 2026-11-12
+
+### Breaking Changes
+
+- Replace positional route types with named routes and typed success and RFC 9457 problem responses.
+- Require indexed rejection details in partial batch acknowledgements.
+- Remove legacy monitoring endpoints from the API.
+
+### New Features
+
+- Publish versioned event contracts, validation, and generated OpenAPI and schema artifacts.
+- Add bounded filters, grouping, whole-range and percentile aggregation, funnels, paths, acquisition, session, and experiment metrics.
+- Add cohorts, saved metric definitions, organizations, identified user profiles and history, and resolved user selection.
+- Declare client error and web vital events.
+
+### Bug Fixes
+
+- Reject storage-incompatible JSON before ingestion acceptance.
+- Exclude diagnostic events from session engagement and document the users pagination minimum as one.
diff --git a/app/Contracts.hs b/app/Contracts.hs
new file mode 100644
--- /dev/null
+++ b/app/Contracts.hs
@@ -0,0 +1,15 @@
+-- | Write the public event schema catalog to @docs/contracts/events.json@.
+module Main (main) where
+
+import Data.Aeson.Encode.Pretty (Config (..), Indent (Spaces), defConfig, encodePretty')
+import Data.ByteString.Lazy qualified as BSL
+import Kansoku.Event (defaultRegistry, schemaCatalog)
+import System.Directory (createDirectoryIfMissing)
+
+main :: IO ()
+main = do
+  createDirectoryIfMissing True "docs/contracts"
+  BSL.writeFile "docs/contracts/events.json" (encodePretty' config (schemaCatalog defaultRegistry) <> "\n")
+  putStrLn "wrote docs/contracts/events.json"
+  where
+    config = defConfig {confIndent = Spaces 2, confCompare = compare, confTrailingNewline = False}
diff --git a/app/OpenApi.hs b/app/OpenApi.hs
new file mode 100644
--- /dev/null
+++ b/app/OpenApi.hs
@@ -0,0 +1,15 @@
+-- | Write the OpenAPI document to @docs/api/openapi.json@.
+module Main (main) where
+
+import Data.Aeson.Encode.Pretty (Config (..), Indent (Spaces), defConfig, encodePretty')
+import Data.ByteString.Lazy qualified as BSL
+import Kansoku.Api.OpenApi (kansokuOpenApi)
+import System.Directory (createDirectoryIfMissing)
+
+main :: IO ()
+main = do
+  createDirectoryIfMissing True "docs/api"
+  BSL.writeFile "docs/api/openapi.json" (encodePretty' config kansokuOpenApi <> "\n")
+  putStrLn "wrote docs/api/openapi.json"
+  where
+    config = defConfig {confIndent = Spaces 2, confCompare = compare, confTrailingNewline = False}
diff --git a/kansoku-api.cabal b/kansoku-api.cabal
new file mode 100644
--- /dev/null
+++ b/kansoku-api.cabal
@@ -0,0 +1,99 @@
+cabal-version: 3.0
+name: kansoku-api
+version: 0.2.0.0
+synopsis: Servant API and wire DTO types for Kansoku.
+description:
+  Shared Servant route types, analytics event types, metric DTOs, validation,
+  and the Kansoku prelude used by Kansoku packages and clients.
+
+license: BSD-3-Clause
+author: Nadeem Bitar
+maintainer: nadeem@gmail.com
+category: Web
+build-type: Simple
+extra-source-files: CHANGELOG.md
+
+common common
+  ghc-options:
+    -Wall
+    -Wcompat
+    -Wredundant-constraints
+    -Werror=missing-fields
+
+  default-language: GHC2024
+  default-extensions:
+    DeriveAnyClass
+    DuplicateRecordFields
+    MultilineStrings
+    OverloadedLabels
+    OverloadedStrings
+
+library
+  import: common
+  hs-source-dirs: src
+  exposed-modules:
+    Kansoku.Api
+    Kansoku.Api.OpenApi
+    Kansoku.Api.Response
+    Kansoku.Auth.Api
+    Kansoku.Cohort.Api
+    Kansoku.Cohort.Types
+    Kansoku.Event
+    Kansoku.Event.Contract
+    Kansoku.Event.Registry
+    Kansoku.Event.Types
+    Kansoku.Event.Validation
+    Kansoku.Ingest.Api
+    Kansoku.Metrics.Api
+    Kansoku.Metrics.Definition
+    Kansoku.Metrics.DefinitionApi
+    Kansoku.Metrics.Filter
+    Kansoku.Metrics.Limits
+    Kansoku.Prelude
+    Kansoku.Users.Api
+    Kansoku.Users.Types
+
+  build-depends:
+    aeson >=2.2.1,
+    base >=4.21 && <5,
+    bytestring,
+    containers,
+    generic-lens >=2.2 && <2.4,
+    http-api-data,
+    http-media,
+    http-types,
+    lens ^>=5.3,
+    openapi-hs,
+    relay-pagination ==0.1.1.2,
+    relay-pagination-servant ==0.1.1.2,
+    scientific >=0.3.8 && <0.4,
+    servant,
+    servant-openapi-hs,
+    sop-core,
+    text,
+    time,
+    uuid,
+
+-- Writes docs/api/openapi.json deterministically from the served route type.
+executable kansoku-openapi
+  import: common
+  main-is: OpenApi.hs
+  hs-source-dirs: app
+  build-depends:
+    aeson-pretty,
+    base >=4.21 && <5,
+    bytestring,
+    directory,
+    kansoku-api,
+
+-- Writes docs/contracts/events.json deterministically from the event registry.
+executable kansoku-contracts
+  import: common
+  main-is: Contracts.hs
+  hs-source-dirs: app
+  build-depends:
+    aeson-pretty,
+    base >=4.21 && <5,
+    bytestring,
+    directory,
+    kansoku-api,
diff --git a/src/Kansoku/Api.hs b/src/Kansoku/Api.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Api.hs
@@ -0,0 +1,37 @@
+-- | Shared servant API type for the Kansoku service.
+module Kansoku.Api
+  ( KansokuAPI (..),
+    SchemaAPI,
+    kansokuAPI,
+  )
+where
+
+import Kansoku.Cohort.Api (Cohorts)
+import Kansoku.Event.Contract (SchemaCatalog)
+import Kansoku.Ingest.Api (IngestRoutes)
+import Kansoku.Metrics.Api (MetricsRoutes)
+import Kansoku.Metrics.DefinitionApi (MetricDefinitions)
+import Kansoku.Prelude
+import Kansoku.Users.Api (Users)
+import Servant.API (Description, Get, JSON, NamedRoutes, type (:>))
+import Servant.API.Generic (type (:-))
+
+-- | The analytics contract served and documented by Kansoku.
+data KansokuAPI mode = KansokuAPI
+  { ingest :: mode :- NamedRoutes IngestRoutes,
+    metrics :: mode :- NamedRoutes MetricsRoutes,
+    metricDefinitions :: mode :- NamedRoutes MetricDefinitions,
+    users :: mode :- NamedRoutes Users,
+    cohorts :: mode :- NamedRoutes Cohorts,
+    schemas :: mode :- SchemaAPI
+  }
+  deriving stock (Generic)
+
+kansokuAPI :: Proxy (NamedRoutes KansokuAPI)
+kansokuAPI = Proxy
+
+type SchemaAPI =
+  "v1"
+    :> "schemas"
+    :> Description "Public event schema catalog. The same contract is committed at docs/contracts/events.json."
+    :> Get '[JSON] SchemaCatalog
diff --git a/src/Kansoku/Api/OpenApi.hs b/src/Kansoku/Api/OpenApi.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Api/OpenApi.hs
@@ -0,0 +1,568 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE TypeApplications #-}
+{-# LANGUAGE UndecidableInstances #-}
+{-# OPTIONS_GHC -Wno-orphans #-}
+
+module Kansoku.Api.OpenApi
+  ( kansokuOpenApi,
+  )
+where
+
+import Data.Aeson qualified as A
+import Data.Char (toLower, toUpper)
+import Data.OpenApi qualified as O
+import Data.OpenApi.Declare (runDeclare)
+import Data.Text qualified as Text
+import Kansoku.Api (kansokuAPI)
+import Kansoku.Api.Response (ProblemDetails, problemFieldName)
+import Kansoku.Cohort.Types
+import Kansoku.Event
+  ( AccountId,
+    AnalyticsEvent,
+    AnonymousId,
+    CausationId,
+    ContractProperty,
+    CorrelationId,
+    EventId,
+    EventName,
+    EventSource,
+    EventVersions,
+    OrganizationId,
+    SchemaCatalog,
+    SessionId,
+    SourceEventId,
+    UserId,
+    VersionedEventSchema,
+  )
+import Kansoku.Ingest.Api (BatchAck, BeaconEnvelope, IngestAck, IngestEvent, RejectedEvent)
+import Kansoku.Metrics.Api (AcquisitionMeasure, AggregateBy, CohortMode, ExperimentMeasure, FunnelAttribution, FunnelCounting, FunnelMeasure, FunnelMode, MetricPoint, MetricResult, OrganizationAttribution, PathBy, RetentionMode, SessionMeasure, TimeGrain, UserWindow)
+import Kansoku.Metrics.Definition
+import Kansoku.Metrics.Filter (FilterOp, MetricFilter)
+import Kansoku.Metrics.Limits (maxMetricPoints)
+import Kansoku.Prelude
+import Kansoku.Users.Types
+import Relay.Pagination.Servant.OpenApi ()
+import Servant.API (type (:>))
+import Servant.API.Experimental.Auth (AuthProtect)
+import Servant.OpenApi (HasOpenApi, toOpenApi)
+
+instance (HasOpenApi sub) => HasOpenApi (AuthProtect "kansoku-ingest" :> sub) where
+  toOpenApi _ =
+    toOpenApi (Proxy @sub)
+      & addSecurity "writeKey" [(401, "Missing, malformed, unknown, or revoked credential"), (503, "Storage or credential store unavailable")]
+
+instance (HasOpenApi sub) => HasOpenApi (AuthProtect "kansoku-metrics" :> sub) where
+  toOpenApi _ =
+    toOpenApi (Proxy @sub)
+      & addSecurity
+        "serviceToken"
+        [ (401, "Missing, malformed, unknown, or revoked credential"),
+          (403, "A valid credential lacks metrics-read permission"),
+          (503, "Storage or credential store unavailable")
+        ]
+
+instance (HasOpenApi sub) => HasOpenApi (AuthProtect "kansoku-beacon" :> sub) where
+  toOpenApi _ =
+    toOpenApi (Proxy @sub)
+      & addProblemResponses [(401, "Unknown or revoked body write key"), (403, "Service credentials cannot use beacon"), (503, "Storage or credential store unavailable")]
+      & O.allOperations
+      . O.description
+      ?~ "Body-authenticated public write key only. Rejects competing Authorization or X-Api-Key headers and duplicate envelope keys. text/plain supports browser sendBeacon without preflight; queueing is not server acknowledgement."
+
+instance O.ToSchema BeaconEnvelope
+
+instance O.ToSchema AccountId where
+  declareNamedSchema =
+    O.genericDeclareNamedSchema textNewtypeOptions
+
+instance O.ToSchema IngestEvent where
+  declareNamedSchema _ = O.declareNamedSchema (Proxy @AnalyticsEvent)
+
+instance O.ToSchema AnalyticsEvent
+
+instance O.ToSchema AnonymousId where
+  declareNamedSchema =
+    O.genericDeclareNamedSchema textNewtypeOptions
+
+instance O.ToSchema BatchAck
+
+instance O.ToSchema CausationId where
+  declareNamedSchema =
+    O.genericDeclareNamedSchema textNewtypeOptions
+
+instance O.ToSchema ContractProperty where
+  declareNamedSchema =
+    O.genericDeclareNamedSchema contractPropertySchemaOptions
+
+instance O.ToSchema CorrelationId where
+  declareNamedSchema =
+    O.genericDeclareNamedSchema textNewtypeOptions
+
+instance O.ToSchema EventId where
+  declareNamedSchema =
+    O.genericDeclareNamedSchema textNewtypeOptions
+
+instance O.ToSchema EventName where
+  declareNamedSchema =
+    O.genericDeclareNamedSchema textNewtypeOptions
+
+instance O.ToSchema EventSource where
+  declareNamedSchema =
+    O.genericDeclareNamedSchema lowerEnumOptions
+
+instance O.ToSchema EventVersions
+
+instance O.ToSchema ProblemDetails where
+  declareNamedSchema = O.genericDeclareNamedSchema O.defaultSchemaOptions {O.fieldLabelModifier = problemFieldName}
+
+instance O.ToSchema IngestAck
+
+instance O.ToSchema RejectedEvent
+
+instance O.ToParamSchema MetricFilter where
+  toParamSchema _ =
+    O.toParamSchema (Proxy @Text)
+      & O.description
+      ?~ "A URL-decoded JSON term or {or:[term,...]} (1..8 non-nested terms), or shorthand dimension:op:value terms separated by | for OR. Repeat filter for AND (at most 8 groups, 16384 total decoded bytes). Literal | in values requires JSON. Operators: eq, neq, in, contains, gt, lt, set, unset; set/unset omit value."
+
+instance O.ToSchema MetricPoint where
+  declareNamedSchema proxy = do
+    named <- O.genericDeclareNamedSchema O.defaultSchemaOptions proxy
+    let nullableText = mempty & O.type_ ?~ O.OpenApiTypeArray [O.OpenApiString, O.OpenApiNull]
+        groupSchema =
+          mempty
+            & O.type_
+            ?~ O.OpenApiTypeSingle O.OpenApiObject
+            & O.additionalProperties
+            ?~ O.AdditionalPropertiesSchema (O.Inline nullableText)
+            & O.description
+            ?~ "Full dimension names map to scalar text or null. Empty string and null are distinct."
+    pure
+      ( named
+          & O.schema
+          . O.properties
+          . at "group"
+          ?~ O.Inline groupSchema
+          & O.schema
+          . O.properties
+          . at "bucket"
+          ?~ O.Inline nullableText
+      )
+
+instance O.ToSchema MetricResult where
+  declareNamedSchema proxy = do
+    named <- O.genericDeclareNamedSchema O.defaultSchemaOptions proxy
+    pure $
+      named
+        & O.schema
+        . O.description
+        ?~ "A complete aggregate answer with at most 10000 points. Larger answers return the permanent metric_result_too_large problem; no partial result or cursor pages are returned."
+        & O.schema
+        . O.properties
+        . ix "points"
+        %~ \case
+          O.Inline schema -> O.Inline (schema & O.maxItems ?~ toInteger maxMetricPoints)
+          reference -> reference
+
+instance O.ToSchema OrganizationId where
+  declareNamedSchema =
+    O.genericDeclareNamedSchema textNewtypeOptions
+
+instance O.ToSchema SchemaCatalog
+
+instance O.ToSchema SessionId where
+  declareNamedSchema =
+    O.genericDeclareNamedSchema textNewtypeOptions
+
+instance O.ToSchema SourceEventId where
+  declareNamedSchema =
+    O.genericDeclareNamedSchema textNewtypeOptions
+
+instance O.ToSchema TimeGrain where
+  declareNamedSchema =
+    O.genericDeclareNamedSchema lowerEnumOptions {O.constructorTagModifier = \name -> if name == "NoTimeBucket" then "none" else map toLower name}
+
+instance O.ToParamSchema AcquisitionMeasure where
+  toParamSchema _ = O.toParamSchema (Proxy @Text) & O.enum_ ?~ ["sessions", "users", "converted_users", "conversion_rate"]
+
+instance O.ToParamSchema SessionMeasure where
+  toParamSchema _ = O.toParamSchema (Proxy @Text) & O.enum_ ?~ ["count", "bounce_rate", "average_duration_seconds"]
+
+instance O.ToParamSchema AggregateBy where
+  toParamSchema _ = O.toParamSchema (Proxy @Text) & O.enum_ ?~ ["users", "organizations"]
+
+instance O.ToParamSchema OrganizationAttribution where
+  toParamSchema _ = O.toParamSchema (Proxy @Text) & O.enum_ ?~ ["event", "user_organization"]
+
+instance O.ToParamSchema UserWindow where
+  toParamSchema _ = O.toParamSchema (Proxy @Text) & O.enum_ ?~ ["calendar", "rolling7", "rolling30"]
+
+instance O.ToParamSchema RetentionMode where
+  toParamSchema _ = O.toParamSchema (Proxy @Text) & O.enum_ ?~ ["on", "on_or_after"]
+
+instance O.ToParamSchema PathBy where
+  toParamSchema _ = O.toParamSchema (Proxy @Text) & O.enum_ ?~ ["event", "route"]
+
+instance O.ToParamSchema FunnelMode where
+  toParamSchema _ = O.toParamSchema (Proxy @Text) & O.enum_ ?~ ["sequential", "strict", "any_order"]
+
+instance O.ToParamSchema FunnelCounting where
+  toParamSchema _ = O.toParamSchema (Proxy @Text) & O.enum_ ?~ ["uniques", "totals"]
+
+instance O.ToParamSchema FunnelAttribution where
+  toParamSchema _ = O.toParamSchema (Proxy @Text) & O.enum_ ?~ (["first_touch", "last_touch"] <> [toJSON ("step:" <> Text.pack (show n)) | n <- [1 .. 20 :: Int]])
+
+instance O.ToParamSchema FunnelMeasure where
+  toParamSchema _ = O.toParamSchema (Proxy @Text) & O.enum_ ?~ ["count", "time_to_convert"]
+
+instance O.ToParamSchema ExperimentMeasure where
+  toParamSchema _ = O.toParamSchema (Proxy @Text) & O.enum_ ?~ ["exposures", "diagnostics"]
+
+instance O.ToParamSchema CohortMode where
+  toParamSchema _ = O.toParamSchema (Proxy @Text) & O.enum_ ?~ ["window", "lifetime"]
+
+instance O.ToParamSchema TimeGrain where
+  toParamSchema _ = O.toParamSchema (Proxy @Text) & O.enum_ ?~ ["hour", "day", "week", "month", "none"]
+
+instance O.ToSchema UserId where
+  declareNamedSchema =
+    O.genericDeclareNamedSchema textNewtypeOptions
+
+instance O.ToSchema VersionedEventSchema
+
+textNewtypeOptions :: O.SchemaOptions
+textNewtypeOptions =
+  O.defaultSchemaOptions
+    { O.unwrapUnaryRecords = True
+    }
+
+lowerEnumOptions :: O.SchemaOptions
+lowerEnumOptions =
+  O.defaultSchemaOptions
+    { O.constructorTagModifier = map toLower
+    }
+
+contractPropertySchemaOptions :: O.SchemaOptions
+contractPropertySchemaOptions =
+  O.defaultSchemaOptions
+    { O.fieldLabelModifier = \case
+        "propertyType" -> "type"
+        "validationPattern" -> "pattern"
+        field -> field
+    }
+
+kansokuOpenApi :: O.OpenApi
+kansokuOpenApi =
+  toOpenApi kansokuAPI
+    & O.info
+    . O.title
+    .~ "Kansoku Analytics API"
+    & O.info
+    . O.version
+    .~ "1.0.0"
+    & O.info
+    . O.description
+    ?~ "HTTP ingestion, public event schemas, and typed metrics query API for the Kansoku analytics service."
+    & O.components
+    . O.securitySchemes
+    .~ authSecuritySchemes
+    & O.applyTags
+      [ O.Tag "ingestion" (Just "Event ingestion endpoints") Nothing,
+        O.Tag "metrics" (Just "Typed metric query endpoints") Nothing,
+        O.Tag "schemas" (Just "Published event schema contracts") Nothing
+      ]
+    & addProblemResponses [(405, "Method not allowed"), (406, "Not acceptable"), (413, "Payload too large"), (500, "Internal server error")]
+    & withUnsupportedBodyMedia
+    & withRetryAfterHeader
+    & withOperationIds
+    & withUserPageProblems
+
+authSecuritySchemes :: O.SecurityDefinitions
+authSecuritySchemes =
+  O.SecurityDefinitions
+    ( mempty
+        & at "writeKey"
+        ?~ O.SecurityScheme
+          (O.SecuritySchemeApiKey (O.ApiKeyParams "X-Api-Key" O.ApiKeyHeader))
+          (Just "Kansoku write key (`ksk_w_…`) or service token; Bearer transport is also accepted.")
+        & at "serviceToken"
+        ?~ O.SecurityScheme
+          (O.SecuritySchemeHttp (O.HttpSchemeBearer Nothing))
+          (Just "Kansoku service token (`ksk_s_…`); `X-Api-Key` transport is also accepted.")
+    )
+
+addSecurity :: Text -> [(Int, Text)] -> O.OpenApi -> O.OpenApi
+addSecurity schemeName responses document =
+  addProblemResponses
+    responses
+    ( document
+        & O.allOperations
+        . O.security
+        .~ [O.SecurityRequirement (mempty & at schemeName ?~ [])]
+    )
+
+-- | Pre-handler and typed failures share one schema reference and media type.
+addProblemResponses :: [(Int, Text)] -> O.OpenApi -> O.OpenApi
+addProblemResponses responses document =
+  foldr addResponse (document & O.components . O.schemas %~ (<> definitions)) responses
+  where
+    (definitions, problemSchema) = runDeclare (O.declareSchemaRef (Proxy @ProblemDetails)) mempty
+    addResponse (status, responseDescription) =
+      O.allOperations
+        . at (fromIntegral status)
+        ?~ problemResponse problemSchema status responseDescription
+
+problemResponse :: O.Referenced O.Schema -> Int -> Text -> O.Referenced O.Response
+problemResponse problemSchema status responseDescription =
+  O.Inline
+    ( mempty
+        & O.description
+        .~ responseDescription
+        & O.content
+        . at "application/problem+json"
+        ?~ (mempty & O.schema ?~ problemSchema)
+        & O.headers
+        .~ ( if status == 401
+               then mempty & at "WWW-Authenticate" ?~ O.Inline (mempty & O.description ?~ "Bearer authentication challenge" & O.required ?~ True & O.schema ?~ O.Inline (O.toSchema (Proxy @Text)))
+               else mempty
+           )
+    )
+
+withUnsupportedBodyMedia :: O.OpenApi -> O.OpenApi
+withUnsupportedBodyMedia document =
+  document & O.allOperations %~ \operation ->
+    if isJust (operation ^. O.requestBody)
+      then operation & at 415 ?~ problemResponse problemSchema 415 "Unsupported media type"
+      else operation
+  where
+    (_, problemSchema) = runDeclare (O.declareSchemaRef (Proxy @ProblemDetails)) mempty
+
+-- MultiVerb emits this header on every typed 429. Mark it required explicitly
+-- while preserving the generated response/body and leaving public schemas alone.
+withRetryAfterHeader :: O.OpenApi -> O.OpenApi
+withRetryAfterHeader document =
+  document & O.allOperations %~ \operation ->
+    case operation ^. at 429 of
+      Just (O.Inline response) ->
+        operation
+          & at 429
+          ?~ O.Inline
+            ( response
+                & O.headers
+                . at "Retry-After"
+                ?~ O.Inline
+                  (mempty & O.description ?~ "Positive whole seconds before retrying" & O.required ?~ True & O.schema ?~ O.Inline (O.toSchema (Proxy @Int)))
+            )
+      _ -> operation
+
+-- | Assign deterministic operation identifiers from method and path. They are
+-- derived rather than authored, so a path rename intentionally changes the id.
+-- Existing explicit identifiers always win.
+withOperationIds :: O.OpenApi -> O.OpenApi
+withOperationIds =
+  O.paths %~ imap setForPath
+  where
+    setForPath path item =
+      item
+        & O.get
+        . _Just
+        . O.operationId
+        %~ orSet ("get" <> key)
+        & O.post
+        . _Just
+        . O.operationId
+        %~ orSet ("post" <> key)
+        & O.put
+        . _Just
+        . O.operationId
+        %~ orSet ("put" <> key)
+        & O.delete
+        . _Just
+        . O.operationId
+        %~ orSet ("delete" <> key)
+      where
+        key = camel path
+
+    orSet identifier = Just . fromMaybe identifier
+
+-- | Turn @/metrics/feature-adoption@ into @MetricsFeatureAdoption@.
+-- Capture braces are separators, so future captures remain stable identifier
+-- fragments without punctuation.
+camel :: FilePath -> Text
+camel =
+  Text.concat . fmap capitalize . filter (not . Text.null) . Text.split isSeparator . Text.pack
+  where
+    isSeparator char = char `elem` ("/-{}" :: String)
+    capitalize segment =
+      case Text.uncons segment of
+        Just (first, rest) -> Text.cons (toUpper first) rest
+        Nothing -> segment
+
+instance O.ToSchema CohortCondition where
+  declareNamedSchema proxy = do
+    named <- O.genericDeclareNamedSchema O.defaultSchemaOptions proxy
+    pure (named & O.schema . O.required %~ filter (/= "filters") & O.schema . O.properties . ix "withinDays" %~ boundedInteger 1 365)
+    where
+      boundedInteger low high = \case
+        O.Inline schema -> O.Inline (schema & O.minimum_ ?~ low & O.maximum_ ?~ high)
+        reference -> reference
+
+instance O.ToSchema CohortDefinition where
+  declareNamedSchema proxy = do
+    named <- O.genericDeclareNamedSchema O.defaultSchemaOptions proxy
+    pure
+      ( named
+          & O.schema
+          . O.required
+          %~ filter (`notElem` ["conditions", "traits"])
+          & O.schema
+          . O.properties
+          . ix "kind"
+          %~ (\case O.Inline schema -> O.Inline (schema & O.enum_ ?~ ["behavioural", "static"]); reference -> reference)
+          & O.schema
+          . O.properties
+          . ix "conditions"
+          %~ (\case O.Inline schema -> O.Inline (schema & O.maxItems ?~ 4); reference -> reference)
+      )
+
+instance O.ToSchema Cohort
+
+instance O.ToSchema CohortMembers where
+  declareNamedSchema proxy = do
+    named <- O.genericDeclareNamedSchema O.defaultSchemaOptions proxy
+    pure (named & O.schema . O.properties . ix "userIds" %~ (\case O.Inline schema -> O.Inline (schema & O.maxItems ?~ 50000); reference -> reference))
+
+instance O.ToSchema CohortSize
+
+instance O.ToSchema MetricFilter where
+  declareNamedSchema _ = do
+    operator <- O.declareSchemaRef (Proxy @FilterOp)
+    let term =
+          mempty
+            & O.type_
+            ?~ O.OpenApiTypeSingle O.OpenApiObject
+            & O.required
+            .~ ["dimension", "op"]
+            & O.properties
+            .~ (mempty & at "dimension" ?~ O.Inline (O.toSchema (Proxy @Text)) & at "op" ?~ operator & at "value" ?~ O.Inline mempty)
+            & O.additionalProperties
+            ?~ O.AdditionalPropertiesAllowed False
+        alternatives =
+          mempty
+            & O.type_
+            ?~ O.OpenApiTypeSingle O.OpenApiArray
+            & O.minItems
+            ?~ 1
+            & O.maxItems
+            ?~ 8
+            & O.items
+            ?~ O.OpenApiItemsObject (O.Inline term)
+        group =
+          mempty
+            & O.type_
+            ?~ O.OpenApiTypeSingle O.OpenApiObject
+            & O.required
+            .~ ["or"]
+            & O.properties
+            .~ (mempty & at "or" ?~ O.Inline alternatives)
+            & O.additionalProperties
+            ?~ O.AdditionalPropertiesAllowed False
+    pure (O.NamedSchema (Just "MetricFilter") (mempty & O.oneOf ?~ [O.Inline term, O.Inline group]))
+
+instance O.ToSchema FilterOp where
+  declareNamedSchema _ = pure (O.NamedSchema (Just "FilterOp") (O.toSchema (Proxy @Text) & O.enum_ ?~ ["eq", "neq", "in", "contains", "gt", "lt", "set", "unset"]))
+
+instance O.ToSchema Aggregation where
+  declareNamedSchema = O.genericDeclareNamedSchema O.defaultSchemaOptions {O.constructorTagModifier = \case "UniqueCount" -> "unique_count"; x -> map toLower x}
+
+instance O.ToSchema MetricDefinition where
+  declareNamedSchema proxy = do
+    named <- O.genericDeclareNamedSchema O.defaultSchemaOptions proxy
+    pure (named & O.schema . O.description ?~ "Simple custom metric: name has 1..200 characters; eventNames has 1..100 validated names; aggregation is count, unique_count, sum, average or percentile. Percentile requires a finite quantile strictly between zero and one, using exact inclusive (R-7) interpolation on finite Float64 samples; empty groups emit no point. Quantile is forbidden for other aggregations and ratio components. Sum/average/percentile require a numeric aggregationProperty declared across every listed event. Filters are grammar strings: at most eight AND groups, each with up to eight OR terms and 16384 decoded UTF-8 bytes combined with request filters. groupBy uses up to two distinct full dimension names. Day is the default grain; request grouping/grain override saved defaults. No SQL is accepted.")
+
+instance O.ToSchema RatioMetric where
+  declareNamedSchema proxy = do
+    named <- O.genericDeclareNamedSchema O.defaultSchemaOptions proxy
+    pure (named & O.schema . O.description ?~ "Ratio of two simple definitions; component grouping must be empty and timeGrain null. Each side supports count, unique_count, sum or average and its own filters. Request filters apply to both sides within each population's eight-group/16384-byte budget. Ratios are unbucketed, reject event-property grouping and return zero for a zero denominator. Names are labels, never builtin dispatch keys.")
+
+instance O.ToSchema SavedMetric
+
+instance O.ToSchema CustomDefinition where
+  declareNamedSchema _ = do
+    simple <- O.declareSchemaRef (Proxy @MetricDefinition)
+    ratio <- O.declareSchemaRef (Proxy @RatioMetric)
+    pure (O.NamedSchema (Just "CustomDefinition") (mempty & O.oneOf ?~ [simple, ratio]))
+
+instance O.ToSchema UserSummary where
+  declareNamedSchema proxy = do
+    named <- O.genericDeclareNamedSchema O.defaultSchemaOptions proxy
+    pure (nullableUserFields ["firstActivityAt", "lastActivityAt"] named & O.schema . O.required .~ ["userId", "traits", "firstActivityAt", "lastActivityAt"])
+
+instance O.ToSchema UserActivityCounts
+
+instance O.ToSchema A.Value where
+  declareNamedSchema _ = pure (O.NamedSchema Nothing mempty)
+
+instance O.ToSchema TraitObservation
+
+instance O.ToSchema UserOrganization
+
+instance O.ToSchema UserProfile where
+  declareNamedSchema proxy = do
+    named <- O.genericDeclareNamedSchema O.defaultSchemaOptions proxy
+    org <- O.declareSchemaRef (Proxy @UserOrganization)
+    let nullableOrg = mempty & O.oneOf ?~ [org, O.Inline (mempty & O.type_ ?~ O.OpenApiTypeSingle O.OpenApiNull)]
+    pure
+      ( nullableUserFields ["firstActivityAt", "lastActivityAt"] named
+          & O.schema
+          . O.properties
+          . at "organization"
+          ?~ O.Inline nullableOrg
+          & O.schema
+          . O.required
+          .~ ["userId", "traits", "firstActivityAt", "lastActivityAt", "retainedEventCount", "identityEvidence", "resolvedAnonymousCount", "organization"]
+      )
+
+instance O.ToSchema UserEvent where
+  declareNamedSchema proxy = do
+    named <- O.genericDeclareNamedSchema O.defaultSchemaOptions proxy
+    pure
+      ( nullableUserFields ["host", "path"] named
+          & O.schema
+          . O.properties
+          . at "eventReceivedAt"
+          .~ Nothing
+          & O.schema
+          . O.required
+          .~ ["eventId", "eventName", "occurredAt", "source", "sourceService", "sessionId", "host", "path", "isPageView", "properties"]
+      )
+
+nullableUserFields :: [Text] -> O.NamedSchema -> O.NamedSchema
+nullableUserFields fields named = foldr addNullable named fields
+  where
+    addNullable field result = result & O.schema . O.properties . at field ?~ O.Inline (mempty & O.type_ ?~ O.OpenApiTypeArray [O.OpenApiString, O.OpenApiNull])
+
+-- User selections also fail before Relay parsing, with the shared problem body.
+withUserPageProblems :: O.OpenApi -> O.OpenApi
+withUserPageProblems =
+  O.paths
+    %~ imap
+      ( \path item ->
+          if path `elem` ["/users", "/users/{userId}/events"]
+            then
+              item
+                & O.get
+                . _Just
+                %~ ( \operation -> case operation ^. at 400 of
+                       Just (O.Inline response) -> pageBounds operation & at 400 ?~ O.Inline (response & O.content . at "application/problem+json" ?~ (mempty & O.schema ?~ problemSchema))
+                       _ -> pageBounds operation
+                   )
+            else item
+      )
+  where
+    (_, problemSchema) = runDeclare (O.declareSchemaRef (Proxy @ProblemDetails)) mempty
+
+    pageBounds operation = operation & O.parameters %~ map bound
+    bound (O.Inline parameter)
+      | parameter ^. O.name `elem` ["first", "last"] = O.Inline (parameter & O.schema . _Just %~ (\case O.Inline schema -> O.Inline (schema & O.minimum_ ?~ 1); reference -> reference))
+    bound parameter = parameter
diff --git a/src/Kansoku/Api/Response.hs b/src/Kansoku/Api/Response.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Api/Response.hs
@@ -0,0 +1,243 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+-- | Shared response machinery for every Kansoku API slice.
+module Kansoku.Api.Response
+  ( AcceptedResponses,
+    ProblemDetails (..),
+    KansokuResult (..),
+    ProblemJSON,
+    problemFieldName,
+    problem,
+    problemCatalog,
+    OkResponses,
+    badRequest,
+    notFound,
+    tooLarge,
+    rateLimited,
+    unauthorized,
+    unavailable,
+    forbidden,
+  )
+where
+
+import Data.Aeson qualified as Aeson
+import Data.SOP (I (..), NS (..))
+import Data.Text.Encoding qualified as Text.Encoding
+import GHC.TypeLits (Symbol)
+import Kansoku.Prelude
+import Network.HTTP.Media ((//))
+import Network.HTTP.Types.Status (status400, status401, status403, status404, status405, status406, status413, status415, status429, status500, status503, statusMessage)
+import Servant.API (Accept (..), Header, JSON, MimeRender (..), MimeUnrender (..))
+import Servant.API.MultiVerb (AsUnion (..), RespondAs, WithHeaders)
+
+-- | RFC 9457 problem document. `about:blank` uses the HTTP status phrase.
+data ProblemDetails = ProblemDetails
+  { problemType :: !Text,
+    title :: !Text,
+    status :: !Int,
+    detail :: !Text,
+    code :: !Text,
+    retryable :: !Bool
+  }
+  deriving stock (Generic, Eq, Show)
+
+problemFieldName :: String -> String
+problemFieldName "problemType" = "type"
+problemFieldName field = field
+
+instance ToJSON ProblemDetails where
+  toJSON = Aeson.genericToJSON Aeson.defaultOptions {Aeson.fieldLabelModifier = problemFieldName}
+
+instance FromJSON ProblemDetails where
+  parseJSON = Aeson.genericParseJSON Aeson.defaultOptions {Aeson.fieldLabelModifier = problemFieldName}
+
+-- | Errors have a distinct media type; successful payloads remain JSON.
+data ProblemJSON
+
+instance Accept ProblemJSON where
+  contentType _ = "application" // "problem+json"
+
+instance MimeRender ProblemJSON ProblemDetails where
+  mimeRender _ = Aeson.encode
+
+instance MimeUnrender ProblemJSON ProblemDetails where
+  mimeUnrender _ = Aeson.eitherDecode
+
+-- | What every analytics handler returns. The success status is selected by
+-- the route's response list; error constructors map to fixed statuses.
+data KansokuResult a
+  = KansokuOk a
+  | KansokuBadRequest !ProblemDetails
+  | KansokuNotFound !ProblemDetails
+  | KansokuTooLarge !ProblemDetails
+  | KansokuUnavailable !ProblemDetails
+  | KansokuRateLimited !ProblemDetails !Int
+  deriving stock (Generic, Eq, Show, Functor)
+
+type RateLimitedResponse =
+  WithHeaders
+    '[Header "Retry-After" Int]
+    (ProblemDetails, Int)
+    (RespondAs ProblemJSON 429 "Rate limited" ProblemDetails)
+
+type OkResponses (desc :: Symbol) a =
+  '[ RespondAs JSON 200 desc a,
+     RespondAs ProblemJSON 400 "Malformed request" ProblemDetails,
+     RespondAs ProblemJSON 404 "Not found" ProblemDetails,
+     RespondAs ProblemJSON 413 "Payload too large" ProblemDetails,
+     RespondAs ProblemJSON 503 "Storage unavailable" ProblemDetails,
+     RateLimitedResponse
+   ]
+
+type AcceptedResponses (desc :: Symbol) a =
+  '[ RespondAs JSON 202 desc a,
+     RespondAs ProblemJSON 400 "Malformed request" ProblemDetails,
+     RespondAs ProblemJSON 404 "Not found" ProblemDetails,
+     RespondAs ProblemJSON 413 "Payload too large" ProblemDetails,
+     RespondAs ProblemJSON 503 "Storage unavailable" ProblemDetails,
+     RateLimitedResponse
+   ]
+
+-- These instances deliberately spell out the positional mapping. The final
+-- empty case is an exhaustiveness witness: growing either response list makes
+-- the instance stop compiling until the result mapping is updated.
+instance
+  AsUnion
+    '[ RespondAs JSON 200 desc a,
+       RespondAs ProblemJSON 400 "Malformed request" ProblemDetails,
+       RespondAs ProblemJSON 404 "Not found" ProblemDetails,
+       RespondAs ProblemJSON 413 "Payload too large" ProblemDetails,
+       RespondAs ProblemJSON 503 "Storage unavailable" ProblemDetails,
+       RateLimitedResponse
+     ]
+    (KansokuResult a)
+  where
+  toUnion = \case
+    KansokuOk value -> Z (I value)
+    KansokuBadRequest err -> S (Z (I err))
+    KansokuNotFound err -> S (S (Z (I err)))
+    KansokuTooLarge err -> S (S (S (Z (I err))))
+    KansokuUnavailable err -> S (S (S (S (Z (I err)))))
+    KansokuRateLimited err delay -> S (S (S (S (S (Z (I (err, delay)))))))
+  fromUnion = \case
+    Z (I value) -> KansokuOk value
+    S (Z (I err)) -> KansokuBadRequest err
+    S (S (Z (I err))) -> KansokuNotFound err
+    S (S (S (Z (I err)))) -> KansokuTooLarge err
+    S (S (S (S (Z (I err))))) -> KansokuUnavailable err
+    S (S (S (S (S (Z (I (err, delay))))))) -> KansokuRateLimited err delay
+    S (S (S (S (S (S impossible))))) -> case impossible of {}
+
+instance
+  AsUnion
+    '[ RespondAs JSON 202 desc a,
+       RespondAs ProblemJSON 400 "Malformed request" ProblemDetails,
+       RespondAs ProblemJSON 404 "Not found" ProblemDetails,
+       RespondAs ProblemJSON 413 "Payload too large" ProblemDetails,
+       RespondAs ProblemJSON 503 "Storage unavailable" ProblemDetails,
+       RateLimitedResponse
+     ]
+    (KansokuResult a)
+  where
+  toUnion = \case
+    KansokuOk value -> Z (I value)
+    KansokuBadRequest err -> S (Z (I err))
+    KansokuNotFound err -> S (S (Z (I err)))
+    KansokuTooLarge err -> S (S (S (Z (I err))))
+    KansokuUnavailable err -> S (S (S (S (Z (I err)))))
+    KansokuRateLimited err delay -> S (S (S (S (S (Z (I (err, delay)))))))
+  fromUnion = \case
+    Z (I value) -> KansokuOk value
+    S (Z (I err)) -> KansokuBadRequest err
+    S (S (Z (I err))) -> KansokuNotFound err
+    S (S (S (Z (I err)))) -> KansokuTooLarge err
+    S (S (S (S (Z (I err))))) -> KansokuUnavailable err
+    S (S (S (S (S (Z (I (err, delay))))))) -> KansokuRateLimited err delay
+    S (S (S (S (S (S impossible))))) -> case impossible of {}
+
+-- | Central status/title/retry policy. Callers supply a stable code and safe detail.
+problem :: Int -> Text -> Text -> ProblemDetails
+problem status code detail =
+  ProblemDetails
+    { problemType = "about:blank",
+      title =
+        Text.Encoding.decodeUtf8
+          ( statusMessage
+              ( case status of
+                  400 -> status400
+                  401 -> status401
+                  403 -> status403
+                  404 -> status404
+                  405 -> status405
+                  406 -> status406
+                  413 -> status413
+                  415 -> status415
+                  429 -> status429
+                  503 -> status503
+                  _ -> status500
+              )
+          ),
+      status,
+      detail,
+      code,
+      retryable = status == 503 || status == 429
+    }
+
+badRequest, notFound, tooLarge, rateLimited, unauthorized, forbidden, unavailable :: Text -> Text -> ProblemDetails
+badRequest = catalogProblem 400
+notFound = catalogProblem 404
+tooLarge = catalogProblem 413
+rateLimited = catalogProblem 429
+unauthorized = catalogProblem 401
+forbidden = catalogProblem 403
+unavailable = catalogProblem 503
+
+-- | Stable public codes and their HTTP statuses. Titles and retry policy come
+-- from 'problem'; human-readable detail is supplied by the failure producer.
+problemCatalog :: [(Text, Int)]
+problemCatalog =
+  [ ("invalid_cohort", 400),
+    ("cohort_not_found", 404),
+    ("cohort_store_unavailable", 503),
+    ("invalid_date_range", 400),
+    ("query_range_too_large", 400),
+    ("too_many_funnel_steps", 400),
+    ("too_many_retention_days", 400),
+    ("metric_result_too_large", 400),
+    ("malformed_request", 400),
+    ("validation_failed", 400),
+    ("event_id_conflict", 400),
+    ("invalid_path_options", 400),
+    ("invalid_funnel_steps", 400),
+    ("invalid_funnel_step", 400),
+    ("invalid_funnel_window", 400),
+    ("invalid_retention_days", 400),
+    ("invalid_experiment_time_grain", 400),
+    ("invalid_experiment_name", 400),
+    ("invalid_event_name", 400),
+    ("invalid_conversion_event", 400),
+    ("unknown_property", 400),
+    ("invalid_property_filter", 400),
+    ("invalid_property", 400),
+    ("invalid_group_by", 400),
+    ("invalid_metric_definition", 400),
+    ("unauthorized", 401),
+    ("forbidden", 403),
+    ("not_found", 404),
+    ("metric_not_found", 404),
+    ("not_implemented", 404),
+    ("method_not_allowed", 405),
+    ("not_acceptable", 406),
+    ("payload_too_large", 413),
+    ("batch_too_large", 413),
+    ("rate_limited", 429),
+    ("unsupported_media_type", 415),
+    ("internal_error", 500),
+    ("auth_store_unavailable", 503),
+    ("queue_unavailable", 503),
+    ("clickhouse_unavailable", 503)
+  ]
+
+catalogProblem :: Int -> Text -> Text -> ProblemDetails
+catalogProblem fallbackStatus code = problem (fromMaybe fallbackStatus (lookup code problemCatalog)) code
diff --git a/src/Kansoku/Auth/Api.hs b/src/Kansoku/Auth/Api.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Auth/Api.hs
@@ -0,0 +1,16 @@
+-- | Type-level authentication boundaries for the two analytics route families.
+module Kansoku.Auth.Api
+  ( BeaconContext,
+    IngestAuth,
+    MetricsAuth,
+  )
+where
+
+import Servant.API.Experimental.Auth (AuthProtect)
+
+type IngestAuth = AuthProtect "kansoku-ingest"
+
+type MetricsAuth = AuthProtect "kansoku-metrics"
+
+-- Request context only; the envelope supplies the sole credential.
+type BeaconContext = AuthProtect "kansoku-beacon"
diff --git a/src/Kansoku/Cohort/Api.hs b/src/Kansoku/Cohort/Api.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Cohort/Api.hs
@@ -0,0 +1,51 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE UndecidableInstances #-}
+
+module Kansoku.Cohort.Api where
+
+import Data.SOP (I (..), NS (..))
+import Data.Time (Day)
+import Data.UUID (UUID)
+import Kansoku.Api.Response
+import Kansoku.Auth.Api (MetricsAuth)
+import Kansoku.Cohort.Types
+import Kansoku.Prelude
+import Relay.Pagination (Connection)
+import Relay.Pagination.Servant (RelayPage, RelayPageError)
+import Servant.API
+import Servant.API.MultiVerb
+
+type Reply a = MultiVerb 'GET '[JSON, ProblemJSON] (OkResponses "Cohort result" a) (KansokuResult a)
+
+type PageResponses a = '[RespondAs JSON 200 "Cohort page" (Connection a), RespondAs JSON 400 "Invalid pagination" RelayPageError, RespondAs ProblemJSON 404 "Not found" ProblemDetails, RespondAs ProblemJSON 503 "Storage unavailable" ProblemDetails]
+
+data PageResult a = PageOk (Connection a) | PageBad RelayPageError | PageMissing ProblemDetails | PageUnavailable ProblemDetails
+
+instance AsUnion (PageResponses a) (PageResult a) where
+  toUnion = \case
+    PageOk x -> Z (I x)
+    PageBad x -> S (Z (I x))
+    PageMissing x -> S (S (Z (I x)))
+    PageUnavailable x -> S (S (S (Z (I x))))
+  fromUnion = \case
+    Z (I x) -> PageOk x
+    S (Z (I x)) -> PageBad x
+    S (S (Z (I x))) -> PageMissing x
+    S (S (S (Z (I x)))) -> PageUnavailable x
+    S (S (S (S impossible))) -> case impossible of {}
+
+type PageReply a = MultiVerb 'GET '[JSON, ProblemJSON] (PageResponses a) (PageResult a)
+
+type AtCohort sub = "cohorts" :> MetricsAuth :> Capture "id" UUID :> sub
+
+data Cohorts mode = Cohorts
+  { createCohort :: mode :- "cohorts" :> MetricsAuth :> ReqBody '[JSON] CohortDefinition :> MultiVerb 'POST '[JSON, ProblemJSON] (OkResponses "Saved cohort" Cohort) (KansokuResult Cohort),
+    getCohort :: mode :- AtCohort (Reply Cohort),
+    listCohorts :: mode :- "cohorts" :> MetricsAuth :> RelayPage 20 100 :> PageReply Cohort,
+    updateCohort :: mode :- AtCohort (ReqBody '[JSON] CohortDefinition :> MultiVerb 'PUT '[JSON, ProblemJSON] (OkResponses "Saved cohort" Cohort) (KansokuResult Cohort)),
+    deleteCohort :: mode :- AtCohort (MultiVerb 'DELETE '[JSON, ProblemJSON] (OkResponses "Deleted cohort" Bool) (KansokuResult Bool)),
+    putMembers :: mode :- AtCohort ("members" :> ReqBody '[JSON] CohortMembers :> MultiVerb 'PUT '[JSON, ProblemJSON] (OkResponses "Cohort size" CohortSize) (KansokuResult CohortSize)),
+    getMembers :: mode :- AtCohort ("members" :> QueryParam' '[Required, Strict] "from" Day :> QueryParam' '[Required, Strict] "to" Day :> RelayPage 20 100 :> PageReply Text),
+    getSize :: mode :- AtCohort ("size" :> QueryParam' '[Required, Strict] "from" Day :> QueryParam' '[Required, Strict] "to" Day :> Reply CohortSize)
+  }
+  deriving stock (Generic)
diff --git a/src/Kansoku/Cohort/Types.hs b/src/Kansoku/Cohort/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Cohort/Types.hs
@@ -0,0 +1,64 @@
+-- | Saved cohort definitions and management payloads.
+module Kansoku.Cohort.Types where
+
+import Data.Aeson qualified as A
+import Data.Maybe (catMaybes)
+import Data.Text qualified as T
+import Data.UUID (UUID)
+import Kansoku.Metrics.Filter (MetricFilter)
+import Kansoku.Prelude
+
+data CohortCondition = CohortCondition
+  {did :: !(Maybe Text), didNot :: !(Maybe Text), atLeast :: !(Maybe Int), atMost :: !(Maybe Int), withinDays :: !Int, filters :: ![MetricFilter]}
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (ToJSON)
+
+instance FromJSON CohortCondition where
+  parseJSON = A.withObject "cohort condition" $ \o ->
+    CohortCondition
+      <$> o A..:? "did"
+      <*> o A..:? "didNot"
+      <*> o A..:? "atLeast"
+      <*> o A..:? "atMost"
+      <*> o A..: "withinDays"
+      <*> o A..:? "filters" A..!= []
+
+data CohortDefinition = CohortDefinition
+  {name :: !Text, kind :: !Text, conditions :: ![CohortCondition], traits :: ![MetricFilter]}
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (ToJSON)
+
+instance FromJSON CohortDefinition where
+  parseJSON = A.withObject "cohort definition" $ \o ->
+    CohortDefinition
+      <$> o A..: "name"
+      <*> o A..: "kind"
+      <*> o A..:? "conditions" A..!= []
+      <*> o A..:? "traits" A..!= []
+
+data Cohort = Cohort {id :: !UUID, definition :: !CohortDefinition, createdAt :: !UTCTime, updatedAt :: !UTCTime}
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (ToJSON, FromJSON)
+
+newtype CohortMembers = CohortMembers {userIds :: [Text]}
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (ToJSON, FromJSON)
+
+newtype CohortSize = CohortSize {size :: Integer}
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (ToJSON, FromJSON)
+
+validateDefinition :: CohortDefinition -> Either Text ()
+validateDefinition CohortDefinition {name, kind, conditions, traits} = do
+  require (not (T.null (T.strip name)) && T.length name <= 200) "name must contain 1..200 characters"
+  require (kind `elem` ["behavioural", "static"]) "kind must be behavioural or static"
+  require (length conditions <= 4) "at most four conditions are supported"
+  require (if kind == "static" then null conditions && null traits else not (null conditions && null traits)) "static cohorts have no conditions; behavioural cohorts require conditions or traits"
+  mapM_ condition conditions
+  where
+    require True _ = Right ()
+    require False message = Left message
+    condition CohortCondition {did, didNot, atLeast, atMost, withinDays} = do
+      require ((isJust did /= isJust didNot) && all (not . T.null) (catMaybes [did, didNot])) "exactly one of did or didNot is required"
+      require (withinDays >= 1 && withinDays <= 365) "withinDays must be in 1..365"
+      require (all (>= 0) (catMaybes [atLeast, atMost]) && maybe True (\lo -> maybe True (>= lo) atMost) atLeast) "occurrence bounds must be nonnegative and ordered"
diff --git a/src/Kansoku/Event.hs b/src/Kansoku/Event.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Event.hs
@@ -0,0 +1,126 @@
+-- | Public event API.
+--
+-- Identity stitching uses ordinary analytics events named @identify@, @alias@,
+-- and @group@. The smart constructors here reserve those names and fill the
+-- rest of the event with empty optional fields.
+module Kansoku.Event
+  ( module Kansoku.Event.Contract,
+    module Kansoku.Event.Registry,
+    module Kansoku.Event.Types,
+    module Kansoku.Event.Validation,
+    aliasEvent,
+    baseEvent,
+    groupEvent,
+    identifyEvent,
+    identifyOnceEvent,
+  )
+where
+
+import Data.Aeson (Object, Value (String))
+import Data.Aeson.KeyMap qualified as KeyMap
+import Data.Generics.Labels ()
+import Kansoku.Event.Contract
+import Kansoku.Event.Registry
+import Kansoku.Event.Types
+import Kansoku.Event.Validation
+import Kansoku.Prelude
+
+identifyEvent ::
+  EventId ->
+  EventSource ->
+  UTCTime ->
+  UTCTime ->
+  Maybe AnonymousId ->
+  Maybe UserId ->
+  Object ->
+  AnalyticsEvent
+identifyEvent eid src occ recv anonId usr traits =
+  baseEvent eid (EventName "identify") src occ recv
+    & #schemaVersion
+    ?~ 2
+    & #anonymousId
+    .~ anonId
+    & #userId
+    .~ usr
+    & #properties
+    .~ traits
+
+-- | Set-once initializes current traits; callers may explicitly override the version.
+identifyOnceEvent :: EventId -> EventSource -> UTCTime -> UTCTime -> Maybe AnonymousId -> Maybe UserId -> Object -> AnalyticsEvent
+identifyOnceEvent eid src occ recv anonId usr traits =
+  identifyEvent eid src occ recv anonId usr traits & #eventName .~ EventName "identify_once" & #schemaVersion ?~ 1
+
+aliasEvent ::
+  EventId ->
+  EventSource ->
+  UTCTime ->
+  UTCTime ->
+  Maybe AnonymousId ->
+  Maybe UserId ->
+  Object ->
+  AnalyticsEvent
+aliasEvent eid src occ recv anonId usr properties =
+  baseEvent eid (EventName "alias") src occ recv
+    & #anonymousId
+    .~ anonId
+    & #userId
+    .~ usr
+    & #properties
+    .~ ( maybe
+           id
+           (KeyMap.insert "previousId" . String . unAnonymousId)
+           anonId
+           (maybe id (KeyMap.insert "newId" . String . unUserId) usr properties)
+       )
+
+groupEvent ::
+  EventId ->
+  EventSource ->
+  UTCTime ->
+  UTCTime ->
+  Maybe UserId ->
+  Maybe OrganizationId ->
+  Object ->
+  AnalyticsEvent
+groupEvent eid src occ recv usr org properties =
+  baseEvent eid (EventName "group") src occ recv
+    & #schemaVersion
+    ?~ 2
+    & #userId
+    .~ usr
+    & #organizationId
+    .~ org
+    & #properties
+    .~ properties
+
+baseEvent :: EventId -> EventName -> EventSource -> UTCTime -> UTCTime -> AnalyticsEvent
+baseEvent eid name src occ recv =
+  AnalyticsEvent
+    { eventId = eid,
+      eventName = name,
+      source = src,
+      sourceService = Nothing,
+      occurredAt = occ,
+      receivedAt = recv,
+      sentAt = Nothing,
+      anonymousId = Nothing,
+      userId = Nothing,
+      identifiers = Nothing,
+      organizationId = Nothing,
+      accountId = Nothing,
+      sessionId = Nothing,
+      url = Nothing,
+      referrer = Nothing,
+      utmSource = Nothing,
+      utmMedium = Nothing,
+      utmCampaign = Nothing,
+      utmContent = Nothing,
+      utmTerm = Nothing,
+      correlationId = Nothing,
+      causationId = Nothing,
+      sourceEventId = Nothing,
+      sourceGlobalPosition = Nothing,
+      schemaVersion = Nothing,
+      properties = mempty,
+      context = mempty
+    }
diff --git a/src/Kansoku/Event/Contract.hs b/src/Kansoku/Event/Contract.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Event/Contract.hs
@@ -0,0 +1,113 @@
+-- | Stable, serializable publication of the in-source event registry.
+module Kansoku.Event.Contract
+  ( ContractProperty (..),
+    EventVersions (..),
+    SchemaCatalog (..),
+    VersionedEventSchema (..),
+    propertyTypeText,
+    schemaCatalog,
+  )
+where
+
+import Data.Aeson.Types (Options (..))
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Kansoku.Event.Registry
+import Kansoku.Event.Types (EventName (..), allowedIdentifierKindNames)
+import Kansoku.Prelude
+
+data ContractProperty = ContractProperty
+  { name :: !Text,
+    propertyType :: !Text,
+    required :: !Bool,
+    validationPattern :: !(Maybe Text),
+    description :: !(Maybe Text),
+    minimum :: !(Maybe Int),
+    minLength :: !(Maybe Int),
+    maxLength :: !(Maybe Int),
+    multipleOf :: !(Maybe Int)
+  }
+  deriving stock (Generic, Eq, Show)
+
+data VersionedEventSchema = VersionedEventSchema
+  { version :: !Int,
+    note :: !Text,
+    properties :: ![ContractProperty]
+  }
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (FromJSON, ToJSON)
+
+newtype EventVersions = EventVersions
+  { versions :: NonEmpty VersionedEventSchema
+  }
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (FromJSON, ToJSON)
+
+data SchemaCatalog = SchemaCatalog
+  { catalogVersion :: !Int,
+    service :: !Text,
+    identifierKinds :: ![Text],
+    events :: !(Map Text EventVersions)
+  }
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (FromJSON, ToJSON)
+
+contractPropertyOptions :: Options
+contractPropertyOptions =
+  defaultOptions
+    { omitNothingFields = True,
+      fieldLabelModifier = \case
+        "propertyType" -> "type"
+        "validationPattern" -> "pattern"
+        field -> field
+    }
+
+instance ToJSON ContractProperty where
+  toJSON = genericToJSON contractPropertyOptions
+
+instance FromJSON ContractProperty where
+  parseJSON = genericParseJSON contractPropertyOptions
+
+propertyTypeText :: PropertyType -> Text
+propertyTypeText = \case
+  PTString -> "string"
+  PTNumber -> "number"
+  PTBool -> "bool"
+  PTObject -> "object"
+  PTArray -> "array"
+  PTAny -> "any"
+
+schemaCatalog :: EventRegistry -> SchemaCatalog
+schemaCatalog (EventRegistry registry) =
+  SchemaCatalog
+    { catalogVersion = 5,
+      service = "kansoku",
+      identifierKinds = allowedIdentifierKindNames,
+      events =
+        Map.fromList
+          [ (unEventName eventName, EventVersions (toContractSchema <$> schemas))
+          | (eventName, schemas) <- Map.toAscList registry
+          ]
+    }
+
+toContractSchema :: EventSchema -> VersionedEventSchema
+toContractSchema EventSchema {version, note, properties} =
+  VersionedEventSchema
+    { version = version,
+      note = note,
+      properties = toContractProperty <$> properties
+    }
+
+toContractProperty :: PropertySpec -> ContractProperty
+toContractProperty PropertySpec {name, propertyType, required, validationPattern, description} =
+  ContractProperty
+    { name = name,
+      propertyType = propertyTypeText propertyType,
+      required = required,
+      validationPattern = case validationPattern of Just SqlNamePattern -> Just (propertyPatternText SqlNamePattern); Just WebVitalMetricPattern -> Just (propertyPatternText WebVitalMetricPattern); _ -> Nothing,
+      description = description,
+      minimum = case validationPattern of Just NonNegativeNumberPattern -> Just 0; Just NonNegativeIntegerPattern -> Just 0; _ -> Nothing,
+      minLength = case validationPattern of Just NonEmptyStringPattern -> Just 1; Just (BoundedStringPattern _) -> Just 1; _ -> Nothing,
+      maxLength = case validationPattern of Just (BoundedStringPattern limit) -> Just limit; _ -> Nothing,
+      multipleOf = case validationPattern of Just NonNegativeIntegerPattern -> Just 1; _ -> Nothing
+    }
diff --git a/src/Kansoku/Event/Registry.hs b/src/Kansoku/Event/Registry.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Event/Registry.hs
@@ -0,0 +1,220 @@
+-- | In-memory event schema registry for extra validation of known event names.
+module Kansoku.Event.Registry
+  ( EventRegistry (..),
+    EventSchema (..),
+    PropertyPattern (..),
+    PropertySpec (..),
+    PropertyType (..),
+    defaultRegistry,
+    governedTraits,
+    lookupTrait,
+    organizationTraits,
+    lookupOrganizationTrait,
+    emptyRegistry,
+    knownVersions,
+    latestSchema,
+    lookupSchemaVersion,
+    lookupVersions,
+    propertyPatternText,
+    registerSchema,
+  )
+where
+
+import Data.List (find, sortOn)
+import Data.List.NonEmpty qualified as NonEmpty
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as Map
+import Data.Text qualified as Text
+import Kansoku.Event.Types (EventName (..))
+import Kansoku.Prelude
+
+-- | A loose description of an expected property's JSON type.
+data PropertyType = PTString | PTNumber | PTBool | PTObject | PTArray | PTAny
+  deriving stock (Generic, Eq, Show)
+
+-- | A syntactic constraint applied after a property's JSON type is validated.
+data PropertyPattern = SqlNamePattern | NonEmptyStringPattern | NonNegativeNumberPattern | NonNegativeIntegerPattern | BoundedStringPattern Int | WebVitalMetricPattern
+  deriving stock (Generic, Eq, Show)
+
+propertyPatternText :: PropertyPattern -> Text
+propertyPatternText = \case
+  SqlNamePattern -> "[a-z0-9_.:-]{1,128}"
+  NonEmptyStringPattern -> "nonempty string"
+  BoundedStringPattern limit -> "string of 1–" <> Text.pack (show limit) <> " Unicode code points"
+  WebVitalMetricPattern -> "^(LCP|INP|CLS|FCP|TTFB)$"
+  NonNegativeNumberPattern -> "number >= 0"
+  NonNegativeIntegerPattern -> "finite integer >= 0"
+
+data PropertySpec = PropertySpec
+  { name :: !Text,
+    propertyType :: !PropertyType,
+    required :: !Bool,
+    validationPattern :: !(Maybe PropertyPattern),
+    description :: !(Maybe Text)
+  }
+  deriving stock (Generic, Eq, Show)
+
+data EventSchema = EventSchema
+  { eventName :: !EventName,
+    version :: !Int,
+    note :: !Text,
+    properties :: ![PropertySpec]
+  }
+  deriving stock (Generic, Eq, Show)
+
+-- | Every version list is ascending and has exactly the versions 1..n. Released
+-- versions are immutable; replacing an existing pair is intended for
+-- idempotent construction, not in-place production schema edits.
+newtype EventRegistry = EventRegistry (Map EventName (NonEmpty EventSchema))
+  deriving stock (Generic, Eq, Show)
+
+emptyRegistry :: EventRegistry
+emptyRegistry = EventRegistry Map.empty
+
+registerSchema :: EventSchema -> EventRegistry -> EventRegistry
+registerSchema schema@EventSchema {eventName, version = newVersion} (EventRegistry registry) =
+  EventRegistry (Map.alter upsert eventName registry)
+  where
+    upsert Nothing = Just (schema :| [])
+    upsert (Just versions) =
+      Just
+        ( NonEmpty.fromList
+            ( sortOn
+                versionOf
+                (schema : filter ((/= newVersion) . versionOf) (NonEmpty.toList versions))
+            )
+        )
+
+lookupVersions :: EventName -> EventRegistry -> Maybe (NonEmpty EventSchema)
+lookupVersions name (EventRegistry registry) = Map.lookup name registry
+
+lookupSchemaVersion :: EventName -> Int -> EventRegistry -> Maybe EventSchema
+lookupSchemaVersion name version registry =
+  find ((== version) . versionOf) . NonEmpty.toList =<< lookupVersions name registry
+
+latestSchema :: EventName -> EventRegistry -> Maybe EventSchema
+latestSchema name registry = NonEmpty.last <$> lookupVersions name registry
+
+knownVersions :: EventName -> EventRegistry -> [Int]
+knownVersions name registry =
+  maybe [] (fmap versionOf . NonEmpty.toList) (lookupVersions name registry)
+
+versionOf :: EventSchema -> Int
+versionOf EventSchema {version} = version
+
+defaultRegistry :: EventRegistry
+defaultRegistry =
+  foldr
+    registerSchema
+    emptyRegistry
+    [ EventSchema
+        (EventName "alias")
+        1
+        "Bind an anonymous identifier to a user; top-level IDs must match. Works with SDK alias helpers."
+        [ PropertySpec "previousId" PTString True (Just NonEmptyStringPattern) (Just "Anonymous identifier; must equal anonymousId."),
+          PropertySpec "newId" PTString True (Just NonEmptyStringPattern) (Just "User identifier; must equal userId.")
+        ],
+      EventSchema
+        (EventName "client_error")
+        1
+        "Short sanitized client error labels only; do not send stack traces or personal data."
+        [ PropertySpec "message" PTString True (Just (BoundedStringPattern 256)) (Just "Sanitized error message, 1–256 Unicode code points; do not send stack traces or personal data."),
+          PropertySpec "type" PTString True (Just (BoundedStringPattern 128)) (Just "Sanitized error class/category such as TypeError or UnhandledRejection, 1–128 Unicode code points; do not send stack traces or personal data."),
+          plainProperty "handled" PTBool True "False for uncaught errors or unhandled rejections; explicit application reports may set true. Do not send stack traces or personal data."
+        ],
+      EventSchema
+        (EventName "web_vital")
+        1
+        "One page-performance measurement; milliseconds for LCP, INP, FCP and TTFB, unitless for CLS."
+        [ PropertySpec "metric" PTString True (Just WebVitalMetricPattern) (Just "LCP (Largest Contentful Paint), INP (Interaction to Next Paint), CLS (Cumulative Layout Shift), FCP (First Contentful Paint), or TTFB (Time to First Byte)."),
+          PropertySpec "value" PTNumber True (Just NonNegativeNumberPattern) (Just "Nonnegative measurement: milliseconds for LCP, INP, FCP and TTFB; unitless for CLS, which may exceed one. Zero is valid.")
+        ],
+      EventSchema (EventName "group") 1 "Stamp organization context; requires a nonempty top-level organizationId." [],
+      EventSchema (EventName "group") 2 "Set current organization traits; omitted traits remain unchanged." organizationTraits,
+      EventSchema
+        (EventName "page_leave")
+        1
+        "Page lifecycle event; not engagement. Top-level URL identifies the page."
+        [PropertySpec "durationMs" PTNumber True (Just NonNegativeNumberPattern) (Just "Observed page duration in milliseconds.")],
+      EventSchema
+        (EventName "page_view")
+        1
+        initialSchemaNote
+        [plainProperty "url" PTString True "Full URL of the viewed page."],
+      EventSchema
+        (EventName "session_start")
+        1
+        initialSchemaNote
+        [plainProperty "sessionId" PTString True "Client-generated session identifier."],
+      EventSchema
+        (EventName "identify")
+        1
+        initialSchemaNote
+        [],
+      EventSchema (EventName "identify") 2 "Set current governed user traits; requires a nonempty top-level userId. Omitted traits are unchanged; identify v1 remains identity-only." governedTraits,
+      EventSchema (EventName "identify_once") 1 "Initialize governed user traits only when no set exists; earliest set-once wins. Requires a nonempty top-level userId." governedTraits,
+      EventSchema
+        (EventName "user_registered")
+        1
+        initialSchemaNote
+        [plainProperty "userId" PTString True "Identifier of the newly registered user."],
+      EventSchema
+        (EventName "subscription_started")
+        1
+        initialSchemaNote
+        [ plainProperty "plan" PTString True "Plan identifier the subscription started on.",
+          plainProperty "amount" PTNumber False "Monetary amount of the subscription, in the account's currency."
+        ],
+      EventSchema
+        (EventName "payment_completed")
+        1
+        initialSchemaNote
+        [ plainProperty "amount" PTNumber True "Monetary amount of the completed payment, in the account's currency.",
+          plainProperty "plan" PTString False "Plan associated with the completed payment."
+        ],
+      EventSchema
+        (EventName "feature_used")
+        1
+        initialSchemaNote
+        [plainProperty "feature" PTString True "Stable identifier of the feature used."],
+      -- Emit an exposure when a user is shown a variant, not merely assigned
+      -- one. Re-exposures are allowed because analytics count unique users.
+      EventSchema
+        (EventName "experiment_viewed")
+        1
+        initialSchemaNote
+        [ sqlNameProperty "experiment" "Stable identifier of the experiment.",
+          sqlNameProperty "variant" "Variant shown to the user."
+        ]
+    ]
+
+-- | One catalog owns ingest schemas, storage eligibility and user: dimensions.
+-- Changing any trait type requires an explicit storage/schema migration.
+governedTraits :: [PropertySpec]
+governedTraits =
+  [ plainProperty name PTString False ("Current user trait " <> name <> "; analytics observation, never an authorization fact.")
+  | name <- ["plan", "signup_source", "initial_plan", "country", "role"]
+  ]
+    <> [PropertySpec "company_size" PTNumber False (Just NonNegativeIntegerPattern) (Just "Finite nonnegative integer company size; numeric comparisons use Float64.")]
+
+organizationTraits :: [PropertySpec]
+organizationTraits =
+  [plainProperty name PTString False ("Current organization " <> name) | name <- ["plan", "industry", "country"]]
+    <> [PropertySpec "seats" PTNumber False (Just NonNegativeIntegerPattern) (Just "Finite nonnegative integer seat count.")]
+
+lookupOrganizationTrait :: Text -> Maybe PropertySpec
+lookupOrganizationTrait key = find (\PropertySpec {name} -> name == key) organizationTraits
+
+lookupTrait :: Text -> Maybe PropertySpec
+lookupTrait key = find (\PropertySpec {name} -> name == key) governedTraits
+
+initialSchemaNote :: Text
+initialSchemaNote = "Initial schema (kansoku MVP)."
+
+plainProperty :: Text -> PropertyType -> Bool -> Text -> PropertySpec
+plainProperty name propertyType required description =
+  PropertySpec name propertyType required Nothing (Just description)
+
+sqlNameProperty :: Text -> Text -> PropertySpec
+sqlNameProperty name description =
+  PropertySpec name PTString True (Just SqlNamePattern) (Just description)
diff --git a/src/Kansoku/Event/Types.hs b/src/Kansoku/Event/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Event/Types.hs
@@ -0,0 +1,172 @@
+-- | Canonical analytics event types and their JSON wire contract.
+module Kansoku.Event.Types
+  ( AccountId (..),
+    AnalyticsEvent (..),
+    AnonymousId (..),
+    CausationId (..),
+    CorrelationId (..),
+    EventId (..),
+    IdentifierKind (..),
+    EventName (..),
+    EventSource (..),
+    OrganizationId (..),
+    SessionId (..),
+    SourceEventId (..),
+    UserId (..),
+    analyticsEventOptions,
+    allowedIdentifierKindNames,
+    eventSourceToText,
+    identifierKindFromText,
+    identifierKindToText,
+  )
+where
+
+import Data.Aeson (Object, Value (String), withText)
+import Data.Aeson.Types (Options (..))
+import Data.Int (Int64)
+import Data.Map.Strict (Map)
+import Kansoku.Prelude
+
+-- | The origin category of an event. Exactly the six values the spec allows.
+data EventSource = Web | App | Service | Worker | Email | Agent
+  deriving stock (Generic, Eq, Show, Enum, Bounded)
+
+-- | Render to the exact lowercase wire strings.
+eventSourceToText :: EventSource -> Text
+eventSourceToText s = case s of
+  Web -> "web"
+  App -> "app"
+  Service -> "service"
+  Worker -> "worker"
+  Email -> "email"
+  Agent -> "agent"
+
+-- | Closed set of identifier-signal kinds accepted in an event's
+-- @identifiers@ object. Adding a constructor also requires a confidence and
+-- retention policy in the ClickHouse identity migrations.
+data IdentifierKind = Cookie | Device | Fingerprint | EmailSha256 | EmailHmac
+  deriving stock (Generic, Eq, Ord, Show, Enum, Bounded)
+
+identifierKindToText :: IdentifierKind -> Text
+identifierKindToText kind = case kind of
+  Cookie -> "cookie"
+  Device -> "device"
+  Fingerprint -> "fingerprint"
+  EmailSha256 -> "email_sha256"
+  EmailHmac -> "email_hmac"
+
+identifierKindFromText :: Text -> Maybe IdentifierKind
+identifierKindFromText name = case name of
+  "cookie" -> Just Cookie
+  "device" -> Just Device
+  "fingerprint" -> Just Fingerprint
+  "email_sha256" -> Just EmailSha256
+  "email_hmac" -> Just EmailHmac
+  _ -> Nothing
+
+allowedIdentifierKindNames :: [Text]
+allowedIdentifierKindNames = identifierKindToText <$> [minBound .. maxBound]
+
+instance ToJSON EventSource where
+  toJSON = String . eventSourceToText
+
+instance FromJSON EventSource where
+  parseJSON = withText "EventSource" $ \t -> case t of
+    "web" -> pure Web
+    "app" -> pure App
+    "service" -> pure Service
+    "worker" -> pure Worker
+    "email" -> pure Email
+    "agent" -> pure Agent
+    other ->
+      fail
+        ( "unknown event source: "
+            <> show other
+            <> " (expected one of web, app, service, worker, email, agent)"
+        )
+
+newtype EventId = EventId {unEventId :: Text}
+  deriving stock (Generic, Show)
+  deriving newtype (Eq, Ord, FromJSON, ToJSON)
+
+newtype EventName = EventName {unEventName :: Text}
+  deriving stock (Generic, Show)
+  deriving newtype (Eq, Ord, FromJSON, ToJSON)
+
+newtype AnonymousId = AnonymousId {unAnonymousId :: Text}
+  deriving stock (Generic, Show)
+  deriving newtype (Eq, Ord, FromJSON, ToJSON)
+
+newtype UserId = UserId {unUserId :: Text}
+  deriving stock (Generic, Show)
+  deriving newtype (Eq, Ord, FromJSON, ToJSON)
+
+newtype OrganizationId = OrganizationId {unOrganizationId :: Text}
+  deriving stock (Generic, Show)
+  deriving newtype (Eq, Ord, FromJSON, ToJSON)
+
+newtype AccountId = AccountId {unAccountId :: Text}
+  deriving stock (Generic, Show)
+  deriving newtype (Eq, Ord, FromJSON, ToJSON)
+
+newtype SessionId = SessionId {unSessionId :: Text}
+  deriving stock (Generic, Show)
+  deriving newtype (Eq, Ord, FromJSON, ToJSON)
+
+newtype CorrelationId = CorrelationId {unCorrelationId :: Text}
+  deriving stock (Generic, Show)
+  deriving newtype (Eq, Ord, FromJSON, ToJSON)
+
+newtype CausationId = CausationId {unCausationId :: Text}
+  deriving stock (Generic, Show)
+  deriving newtype (Eq, Ord, FromJSON, ToJSON)
+
+newtype SourceEventId = SourceEventId {unSourceEventId :: Text}
+  deriving stock (Generic, Show)
+  deriving newtype (Eq, Ord, FromJSON, ToJSON)
+
+-- | The canonical analytics event record. Field names intentionally match the
+-- JSON wire keys from docs/initial-spec.md.
+data AnalyticsEvent = AnalyticsEvent
+  { eventId :: !EventId,
+    eventName :: !EventName,
+    source :: !EventSource,
+    sourceService :: !(Maybe Text),
+    occurredAt :: !UTCTime,
+    receivedAt :: !UTCTime,
+    sentAt :: !(Maybe UTCTime),
+    anonymousId :: !(Maybe AnonymousId),
+    userId :: !(Maybe UserId),
+    identifiers :: !(Maybe (Map Text Text)),
+    organizationId :: !(Maybe OrganizationId),
+    accountId :: !(Maybe AccountId),
+    sessionId :: !(Maybe SessionId),
+    url :: !(Maybe Text),
+    referrer :: !(Maybe Text),
+    utmSource :: !(Maybe Text),
+    utmMedium :: !(Maybe Text),
+    utmCampaign :: !(Maybe Text),
+    utmContent :: !(Maybe Text),
+    utmTerm :: !(Maybe Text),
+    correlationId :: !(Maybe CorrelationId),
+    causationId :: !(Maybe CausationId),
+    sourceEventId :: !(Maybe SourceEventId),
+    sourceGlobalPosition :: !(Maybe Int64),
+    schemaVersion :: !(Maybe Int),
+    properties :: !Object,
+    context :: !Object
+  }
+  deriving stock (Generic, Eq, Show)
+
+analyticsEventOptions :: Options
+analyticsEventOptions =
+  defaultOptions
+    { omitNothingFields = True,
+      rejectUnknownFields = False
+    }
+
+instance ToJSON AnalyticsEvent where
+  toJSON = genericToJSON analyticsEventOptions
+
+instance FromJSON AnalyticsEvent where
+  parseJSON = genericParseJSON analyticsEventOptions
diff --git a/src/Kansoku/Event/Validation.hs b/src/Kansoku/Event/Validation.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Event/Validation.hs
@@ -0,0 +1,328 @@
+-- | Pure validation for analytics events.
+module Kansoku.Event.Validation
+  ( ValidationError (..),
+    renderValidationError,
+    validationReason,
+    declaredSchemaError,
+    validateEvent,
+    validateEventWith,
+    governedTraitEligible,
+  )
+where
+
+import Data.Aeson (Value (..))
+import Data.Aeson.Key qualified as Key
+import Data.Aeson.KeyMap qualified as KeyMap
+import Data.Char (isAsciiLower, isDigit)
+import Data.Generics.Labels ()
+import Data.Map.Strict qualified as Map
+import Data.Scientific qualified as Scientific
+import Data.Set qualified as Set
+import Data.Text qualified as Text
+import Data.Time.Format (defaultTimeLocale, formatTime)
+import Kansoku.Event.Registry
+import Kansoku.Event.Types
+import Kansoku.Prelude
+
+data ValidationError
+  = EmptyEventId
+  | EventIdConflict
+  | EmptyEventName
+  | ReceivedBeforeOccurred UTCTime UTCTime
+  | UnsupportedTimestamp Text UTCTime
+  | InvalidSchemaVersion Int
+  | UnknownSchemaVersion EventName Int [Int]
+  | UnknownIdentifierKind Text [Text]
+  | EmptyIdentifierValue Text
+  | IdentifierValueTooLong Text Int Int
+  | IdentifierValueBadCharset Text
+  | MissingRequiredProperty EventName Text
+  | WrongPropertyType EventName Text PropertyType
+  | InvalidPropertyValue EventName Text Text
+  | InvalidIdentityBinding Text
+  | IncompatibleJsonPaths Text
+  deriving stock (Generic, Eq, Show)
+
+-- | Validate against an explicit registry. Unknown event names are allowed; the
+-- registry adds stricter property checks for names it knows.
+validateEventWith :: EventRegistry -> AnalyticsEvent -> Either [ValidationError] AnalyticsEvent
+validateEventWith registry event =
+  case structuralErrors <> identifierErrors <> schemaErrors <> identityErrors of
+    [] -> Right event
+    errs -> Left errs
+  where
+    structuralErrors =
+      [EmptyEventId | unEventId ((event ^. #eventId)) == ""]
+        <> [EmptyEventName | unEventName ((event ^. #eventName)) == ""]
+        <> [ ReceivedBeforeOccurred ((event ^. #occurredAt)) ((event ^. #receivedAt))
+           | (event ^. #receivedAt) < (event ^. #occurredAt)
+           ]
+        <> [ UnsupportedTimestamp field value
+           | (field, value) <- [("occurredAt", event ^. #occurredAt), ("receivedAt", event ^. #receivedAt)],
+             value < read "1970-01-01 00:00:00 UTC" || value >= read "2100-01-01 00:00:00 UTC"
+           ]
+        <> [ InvalidSchemaVersion version
+           | Just version <- [event ^. #schemaVersion],
+             not (validSchemaVersion version)
+           ]
+        <> [ IncompatibleJsonPaths field
+           | (field, object) <- [("properties", event ^. #properties), ("context", event ^. #context)],
+             incompatibleObject object
+           ]
+
+    identifierErrors =
+      case (event ^. #identifiers) of
+        Nothing -> []
+        Just ids -> concatMap identifierError (Map.toList ids)
+
+    identifierError (kind, value) =
+      [ UnknownIdentifierKind kind allowedIdentifierKindNames
+      | isNothing (identifierKindFromText kind)
+      ]
+        <> [EmptyIdentifierValue kind | Text.null value]
+        <> [ IdentifierValueTooLong kind (Text.length value) maxIdentifierValueLength
+           | Text.length value > maxIdentifierValueLength
+           ]
+        <> [ IdentifierValueBadCharset kind
+           | not (Text.all isIdentifierValueChar value)
+           ]
+
+    schemaErrors =
+      case lookupVersions ((event ^. #eventName)) registry of
+        Nothing -> []
+        Just _
+          | not (maybe True validSchemaVersion (event ^. #schemaVersion)) -> []
+          | otherwise ->
+              let requested = fromMaybe 1 (event ^. #schemaVersion)
+               in case lookupSchemaVersion ((event ^. #eventName)) requested registry of
+                    Nothing -> [UnknownSchemaVersion ((event ^. #eventName)) requested (knownVersions ((event ^. #eventName)) registry)]
+                    Just schema -> concatMap validateProperty ((schema ^. #properties))
+
+    identityErrors = case unEventName (event ^. #eventName) of
+      "alias" ->
+        [ InvalidIdentityBinding field
+        | (field, top) <- [("previousId", unAnonymousId <$> event ^. #anonymousId), ("newId", unUserId <$> event ^. #userId)],
+          case (top, KeyMap.lookup (Key.fromText field) (event ^. #properties)) of
+            (Just value, Just (String property)) -> Text.null value || value /= property
+            _ -> True
+        ]
+      "identify" | fromMaybe 1 (event ^. #schemaVersion) == 2 -> traitIdentityErrors
+      "identify_once" -> traitIdentityErrors
+      "group" -> [InvalidIdentityBinding "organizationId" | maybe True (Text.null . unOrganizationId) (event ^. #organizationId)]
+      _ -> []
+
+    traitIdentityErrors = [InvalidIdentityBinding "userId" | maybe True (Text.null . unUserId) (event ^. #userId)]
+
+    validateProperty :: PropertySpec -> [ValidationError]
+    validateProperty spec =
+      case KeyMap.lookup (Key.fromText ((spec ^. #name))) ((event ^. #properties)) of
+        Nothing
+          | (spec ^. #required) -> [MissingRequiredProperty ((event ^. #eventName)) ((spec ^. #name))]
+          | otherwise -> []
+        Just value
+          | not (propertyMatches ((spec ^. #propertyType)) value) ->
+              [WrongPropertyType ((event ^. #eventName)) ((spec ^. #name)) ((spec ^. #propertyType))]
+          | not (propertyPatternMatches ((spec ^. #validationPattern)) value) ->
+              [InvalidPropertyValue ((event ^. #eventName)) ((spec ^. #name)) ("expected " <> maybe "valid value" propertyPatternText (spec ^. #validationPattern))]
+          | otherwise -> []
+
+-- | Server-owned eligibility. Never infer governed state from legacy v1
+-- identify data or from Float64-rounded native JSON values.
+governedTraitEligible :: AnalyticsEvent -> Bool
+governedTraitEligible event =
+  let operation = (unEventName (event ^. #eventName), fromMaybe 1 (event ^. #schemaVersion))
+   in operation `elem` [("identify", 2), ("identify_once", 1), ("group", 2)] && case validateEvent event of
+        Right _ -> True
+        Left _ -> False
+
+validateEvent :: AnalyticsEvent -> Either [ValidationError] AnalyticsEvent
+validateEvent = validateEventWith defaultRegistry
+
+-- | Native JSON flattens object keys with dots. Only non-null leaves occupy
+-- paths (arrays, including empty arrays, are leaves). Prefixes may coexist.
+-- Each object inside an array starts an independent path scope, even in mixed
+-- or nested arrays. Do not merge paths across array elements.
+-- Keep diagnostics bounded: at most one fixed error per public object, with no
+-- producer-controlled keys or values copied into responses or telemetry.
+incompatibleObject :: KeyMap.KeyMap Value -> Bool
+incompatibleObject object = duplicate Set.empty (leaves Nothing (Object object))
+  where
+    duplicate _ [] = False
+    duplicate seen ((path, value) : rest) =
+      Set.member path seen || incompatibleArray value || duplicate (Set.insert path seen) rest
+    leaves prefix (Object fields) = concatMap (\(key, value) -> leaves (Just (maybe "" (<> ".") prefix <> Key.toText key)) value) (KeyMap.toList fields)
+    leaves _ Null = []
+    leaves (Just path) value = [(path, value)]
+    leaves Nothing _ = []
+    incompatibleArray (Array values) = any incompatibleValue values
+    incompatibleArray _ = False
+    incompatibleValue (Object fields) = incompatibleObject fields
+    incompatibleValue value = incompatibleArray value
+
+-- | Render a validation failure for logs and rejection responses. Callers may
+-- show this text to people, but must use a rejected event's index for machine
+-- correlation rather than parsing this prose.
+renderValidationError :: ValidationError -> Text
+renderValidationError = \case
+  EmptyEventId ->
+    "eventId must not be empty"
+  EventIdConflict ->
+    "event_id_conflict: eventId already identifies different producer content"
+  IncompatibleJsonPaths field ->
+    field <> " contains incompatible JSON paths: non-null values must have distinct flattened paths"
+  EmptyEventName ->
+    "eventName must not be empty"
+  ReceivedBeforeOccurred occurred received ->
+    "receivedAt ("
+      <> renderTimestamp received
+      <> ") precedes occurredAt ("
+      <> renderTimestamp occurred
+      <> "); occurredAt is in the future relative to the server clock"
+  InvalidIdentityBinding field ->
+    "invalid top-level identity binding for " <> field
+  UnsupportedTimestamp field _ ->
+    field <> " must be in the supported UTC range [1970-01-01, 2100-01-01)"
+  InvalidSchemaVersion version ->
+    "schemaVersion "
+      <> Text.pack (show version)
+      <> " is outside the supported range 1..65535"
+  UnknownSchemaVersion name requested known ->
+    "event '"
+      <> unEventName name
+      <> "': schemaVersion "
+      <> Text.pack (show requested)
+      <> " is unknown; known versions: "
+      <> Text.pack (show known)
+  UnknownIdentifierKind kind allowed ->
+    "identifier kind '"
+      <> kind
+      <> "' is not supported; allowed kinds: "
+      <> Text.intercalate ", " allowed
+  EmptyIdentifierValue kind ->
+    "identifier '" <> kind <> "' must not be empty"
+  IdentifierValueTooLong kind actual maxAllowed ->
+    "identifier '"
+      <> kind
+      <> "' is "
+      <> Text.pack (show actual)
+      <> " characters; maximum is "
+      <> Text.pack (show maxAllowed)
+  IdentifierValueBadCharset kind ->
+    "identifier '"
+      <> kind
+      <> "' must contain only printable non-space ASCII characters"
+  MissingRequiredProperty name propertyName ->
+    "event '"
+      <> unEventName name
+      <> "': missing required property '"
+      <> propertyName
+      <> "'"
+  WrongPropertyType name propertyName expected ->
+    "event '"
+      <> unEventName name
+      <> "': property '"
+      <> propertyName
+      <> "' must be of type "
+      <> renderPropertyType expected
+  InvalidPropertyValue name propertyName rule ->
+    "event '"
+      <> unEventName name
+      <> "': property '"
+      <> propertyName
+      <> "' has an invalid value; "
+      <> rule
+  where
+    renderTimestamp = Text.pack . formatTime defaultTimeLocale "%FT%T%QZ"
+
+maxIdentifierValueLength :: Int
+maxIdentifierValueLength = 256
+
+validSchemaVersion :: Int -> Bool
+validSchemaVersion version = version >= 1 && version <= 65535
+
+isIdentifierValueChar :: Char -> Bool
+isIdentifierValueChar char = char > ' ' && char <= '~'
+
+renderPropertyType :: PropertyType -> Text
+renderPropertyType = \case
+  PTString -> "string"
+  PTNumber -> "number"
+  PTBool -> "boolean"
+  PTObject -> "object"
+  PTArray -> "array"
+  PTAny -> "any"
+
+propertyMatches :: PropertyType -> Value -> Bool
+propertyMatches expected value =
+  case expected of
+    PTString -> case value of
+      String _ -> True
+      _ -> False
+    PTNumber -> case value of
+      Number _ -> True
+      _ -> False
+    PTBool -> case value of
+      Bool _ -> True
+      _ -> False
+    PTObject -> case value of
+      Object _ -> True
+      _ -> False
+    PTArray -> case value of
+      Array _ -> True
+      _ -> False
+    PTAny -> True
+
+propertyPatternMatches :: Maybe PropertyPattern -> Value -> Bool
+propertyPatternMatches Nothing _ = True
+propertyPatternMatches (Just (BoundedStringPattern limit)) (String value) = not (Text.null value) && Text.length value <= limit
+propertyPatternMatches (Just WebVitalMetricPattern) (String value) = value `elem` ["LCP", "INP", "CLS", "FCP", "TTFB"]
+propertyPatternMatches (Just NonEmptyStringPattern) (String value) = not (Text.null value)
+propertyPatternMatches (Just NonNegativeNumberPattern) (Number value) = value >= 0
+propertyPatternMatches (Just NonNegativeIntegerPattern) (Number value) =
+  let numeric = Scientific.toRealFloat value :: Double
+   in not (isInfinite numeric || isNaN numeric) && value >= 0 && Scientific.isInteger value
+propertyPatternMatches (Just SqlNamePattern) (String value) = isSqlSafeName value
+propertyPatternMatches (Just _) _ = False
+
+-- Keep this predicate aligned with Kansoku.Metrics.Query.Validate.mkSqlName.
+-- It lives in kansoku-api because this package cannot depend on kansoku-core.
+isSqlSafeName :: Text -> Bool
+isSqlSafeName value =
+  not (Text.null value)
+    && Text.length value <= 128
+    && Text.all allowed value
+  where
+    allowed char =
+      isAsciiLower char
+        || isDigit char
+        || char `elem` ("_.:-" :: String)
+
+-- | Finite reason codes: never derive telemetry labels from payload values.
+validationReason :: ValidationError -> Text
+validationReason = \case
+  EmptyEventId -> "empty_event_id"
+  EventIdConflict -> "event_id_conflict"
+  IncompatibleJsonPaths {} -> "incompatible_json_paths"
+  EmptyEventName -> "empty_event_name"
+  ReceivedBeforeOccurred {} -> "future_timestamp"
+  UnsupportedTimestamp {} -> "unsupported_timestamp"
+  InvalidSchemaVersion {} -> "invalid_schema_version"
+  UnknownSchemaVersion {} -> "unknown_schema_version"
+  UnknownIdentifierKind {} -> "unknown_identifier_kind"
+  EmptyIdentifierValue {} -> "empty_identifier_value"
+  IdentifierValueTooLong {} -> "identifier_value_too_long"
+  IdentifierValueBadCharset {} -> "identifier_value_bad_charset"
+  MissingRequiredProperty {} -> "missing_required_property"
+  WrongPropertyType {} -> "wrong_property_type"
+  InvalidPropertyValue {} -> "invalid_property_value"
+  InvalidIdentityBinding {} -> "invalid_identity_binding"
+
+declaredSchemaError :: ValidationError -> Bool
+declaredSchemaError = \case
+  UnknownSchemaVersion {} -> True
+  InvalidSchemaVersion {} -> True
+  MissingRequiredProperty {} -> True
+  WrongPropertyType {} -> True
+  InvalidPropertyValue {} -> True
+  InvalidIdentityBinding {} -> True
+  _ -> False
diff --git a/src/Kansoku/Ingest/Api.hs b/src/Kansoku/Ingest/Api.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Ingest/Api.hs
@@ -0,0 +1,152 @@
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
+
+-- | Public servant API for analytics ingestion.
+module Kansoku.Ingest.Api
+  ( BeaconAPI,
+    BeaconEnvelope (..),
+    PlainTextJSON,
+    decodeBeaconEnvelope,
+    BatchAck (..),
+    IngestAck (..),
+    IngestRoutes (..),
+    IngestEvent (..),
+    ingestEvent,
+    RejectedEvent (..),
+  )
+where
+
+import Data.Aeson (Object, Value (Object), withObject, (.:))
+import Data.Aeson qualified as Aeson
+import Data.Aeson.Decoding (toEitherValue)
+import Data.Aeson.Decoding.ByteString.Lazy (lbsToTokens)
+import Data.Aeson.Decoding.Tokens (TkRecord (..), Tokens (..))
+import Data.Aeson.Types (parseEither)
+import Data.ByteString.Lazy qualified as LBS
+import Data.Set qualified as Set
+import Data.Text qualified as Text
+import Kansoku.Api.Response (AcceptedResponses, KansokuResult, ProblemJSON)
+import Kansoku.Auth.Api (BeaconContext, IngestAuth)
+import Kansoku.Event (AnalyticsEvent)
+import Kansoku.Prelude
+import Network.HTTP.Media ((//), (/:))
+import Servant.API
+  ( Accept (..),
+    JSON,
+    MimeRender (..),
+    MimeUnrender (..),
+    ReqBody,
+    type (:>),
+  )
+import Servant.API.Generic (type (:-))
+import Servant.API.MultiVerb (MultiVerb)
+import Servant.API.Verbs qualified as Servant
+
+-- | Ingestion routes keyed by field name. Field order is not load-bearing.
+data IngestRoutes mode = IngestRoutes
+  { postEvent :: mode :- IngestAuth :> "v1" :> "events" :> ReqBody '[JSON] IngestEvent :> MultiVerb 'Servant.POST '[JSON, ProblemJSON] (AcceptedResponses "Event accepted" IngestAck) (KansokuResult IngestAck),
+    postBatch :: mode :- IngestAuth :> "v1" :> "batch" :> ReqBody '[JSON] [IngestEvent] :> MultiVerb 'Servant.POST '[JSON, ProblemJSON] (AcceptedResponses "Batch accepted" BatchAck) (KansokuResult BatchAck),
+    postBeacon :: mode :- BeaconAPI '[PlainTextJSON, JSON]
+  }
+  deriving stock (Generic)
+
+-- | This context supplies request metadata and deferred body authentication;
+-- it does not read a header credential.
+type BeaconAPI media = BeaconContext :> "v1" :> "beacon" :> ReqBody media BeaconEnvelope :> MultiVerb 'Servant.POST '[JSON, ProblemJSON] (AcceptedResponses "Batch accepted" BatchAck) (KansokuResult BatchAck)
+
+-- Deliberately no Show instance: the envelope contains a credential.
+data BeaconEnvelope = BeaconEnvelope
+  { writeKey :: !Text,
+    batch :: ![IngestEvent]
+  }
+  deriving stock (Generic, Eq)
+  deriving anyclass (ToJSON)
+
+instance FromJSON BeaconEnvelope where
+  parseJSON = withObject "BeaconEnvelope" $ \object -> do
+    key <- object .: "writeKey"
+    unless (not (Text.null key) && Text.length key <= 256 && Text.all (\c -> c > ' ' && c <= '~') key) $
+      fail "invalid beacon envelope"
+    BeaconEnvelope key <$> object .: "batch"
+
+data PlainTextJSON
+
+instance Accept PlainTextJSON where
+  contentTypes _ = ("text" // "plain" /: ("charset", "utf-8")) :| ["text" // "plain"]
+
+instance MimeRender PlainTextJSON BeaconEnvelope where
+  mimeRender _ = Aeson.encode
+
+instance MimeUnrender PlainTextJSON BeaconEnvelope where
+  mimeUnrender _ = decodeBeaconEnvelope
+
+-- The specialized JSON decoder keeps both media types identical and prevents
+-- Servant's parse-error response from including producer input or credentials.
+instance {-# OVERLAPPING #-} MimeUnrender JSON BeaconEnvelope where
+  mimeUnrender _ = decodeBeaconEnvelope
+
+-- | Reject duplicate root keys before Aeson builds a last-value-wins map.
+-- Token keys are already unescaped, including Unicode escape spellings.
+decodeBeaconEnvelope :: LBS.ByteString -> Either String BeaconEnvelope
+decodeBeaconEnvelope bytes = either (const (Left "invalid beacon envelope")) Right $ do
+  let tokens = lbsToTokens bytes
+  case tokens of
+    TkRecordOpen fields -> unique Set.empty fields
+    _ -> Left "object required"
+  (value, trailing) <- toEitherValue tokens
+  unless (LBS.all (\w -> w `elem` [32, 9, 10, 13]) trailing) (Left "trailing data")
+  parseEither parseJSON value
+  where
+    unique seen (TkPair key value)
+      | Set.member key seen = Left "duplicate field"
+      | otherwise = do
+          (_, next) <- toEitherValue value
+          unique (Set.insert key seen) next
+    unique _ (TkRecordEnd _) = Right ()
+    unique _ (TkRecordErr _) = Left "malformed object"
+
+data IngestAck = IngestAck
+  { accepted :: !Int,
+    eventId :: !Text
+  }
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (FromJSON, ToJSON)
+
+-- | One rejected event in a batch. The zero-based index is the stable
+-- machine-usable correlation key; the error text is for people, not parsers.
+data RejectedEvent = RejectedEvent
+  { index :: !Int,
+    eventId :: !(Maybe Text),
+    errors :: ![Text]
+  }
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (FromJSON, ToJSON)
+
+-- | A parsed batch is always acknowledged with HTTP 202. Valid events are
+-- enqueued and invalid events are reported here so clients never retry them.
+data BatchAck = BatchAck
+  { accepted :: !Int,
+    rejected :: ![RejectedEvent]
+  }
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (FromJSON, ToJSON)
+
+-- | The parsed event and the exact original event object. Only the object is
+-- represented on the wire; normalized queue records remain AnalyticsEvent.
+data IngestEvent = IngestEvent
+  { parsedEvent :: !AnalyticsEvent,
+    originalPayload :: !Object
+  }
+  deriving stock (Generic, Eq, Show)
+
+instance FromJSON IngestEvent where
+  parseJSON = withObject "AnalyticsEvent" $ \object ->
+    IngestEvent <$> parseJSON (Object object) <*> pure object
+
+instance ToJSON IngestEvent where
+  toJSON = Object . originalPayload
+
+ingestEvent :: AnalyticsEvent -> IngestEvent
+ingestEvent event = case toJSON event of
+  Object object -> IngestEvent event object
+  _ -> error "AnalyticsEvent JSON must be an object"
diff --git a/src/Kansoku/Metrics/Api.hs b/src/Kansoku/Metrics/Api.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Metrics/Api.hs
@@ -0,0 +1,424 @@
+-- | Public servant API and DTOs for semantic metric queries.
+module Kansoku.Metrics.Api
+  ( MetricPoint (..),
+    MetricQuery,
+    AggregateBy (..),
+    OrganizationAttribution (..),
+    AcquisitionMeasure (..),
+    SessionMeasure (..),
+    UserWindow (..),
+    RetentionMode (..),
+    CohortMode (..),
+    PathBy (..),
+    FunnelMode (..),
+    FunnelMeasure (..),
+    FunnelCounting (..),
+    FunnelAttribution (..),
+    parseFunnelAttribution,
+    ExperimentMeasure (..),
+    MetricResult (..),
+    MetricsRoutes (..),
+    TimeGrain (..),
+    timeGrainText,
+  )
+where
+
+import Data.Aeson qualified as Aeson
+import Data.Map.Strict (Map)
+import Data.Text qualified as Text
+import Data.Time (Day)
+import Kansoku.Api.Response (KansokuResult, OkResponses, ProblemJSON)
+import Kansoku.Auth.Api (MetricsAuth)
+import Kansoku.Metrics.Filter (MetricFilter)
+import Kansoku.Prelude
+import Servant.API
+  ( Description,
+    JSON,
+    QueryParam,
+    QueryParam',
+    QueryParams,
+    Required,
+    type (:>),
+  )
+import Servant.API.Generic (type (:-))
+import Servant.API.MultiVerb (MultiVerb)
+import Servant.API.Verbs qualified as Servant
+import Web.HttpApiData (FromHttpApiData (..), ToHttpApiData (..))
+
+data AggregateBy = AggregateUsers | AggregateOrganizations
+  deriving stock (Generic, Eq, Show)
+
+instance FromHttpApiData AggregateBy where
+  parseUrlPiece "users" = Right AggregateUsers
+  parseUrlPiece "organizations" = Right AggregateOrganizations
+  parseUrlPiece _ = Left "aggregate must be users or organizations"
+
+instance ToHttpApiData AggregateBy where
+  toUrlPiece AggregateUsers = "users"
+  toUrlPiece AggregateOrganizations = "organizations"
+
+data OrganizationAttribution = EventOrganization | UserOrganization
+  deriving stock (Generic, Eq, Show)
+
+instance FromHttpApiData OrganizationAttribution where
+  parseUrlPiece "event" = Right EventOrganization
+  parseUrlPiece "user_organization" = Right UserOrganization
+  parseUrlPiece _ = Left "attribution must be event or user_organization"
+
+instance ToHttpApiData OrganizationAttribution where
+  toUrlPiece EventOrganization = "event"
+  toUrlPiece UserOrganization = "user_organization"
+
+type OrganizationOptions result =
+  Description "aggregate=users (default) or organizations merges member timelines and excludes empty organization IDs. The organizations route always counts organizations. attribution=event (default) preserves event organization IDs; user_organization fills only missing IDs from the resolved user current group membership. Current org:plan, org:industry, org:country and numeric org:seats traits reclassify historical reports. Experiments retain user aggregation. See Organisations in the query guide."
+    :> QueryParam "aggregate" AggregateBy
+    :> QueryParam "attribution" OrganizationAttribution
+    :> result
+
+type OrganizationMetricQuery = MetricParameters (OrganizationOptions MetricResponse)
+
+data TimeGrain = Hour | Day | Week | Month | NoTimeBucket
+  deriving stock (Generic, Eq, Ord, Show)
+
+timeGrainText :: TimeGrain -> Text
+timeGrainText = \case
+  Hour -> "hour"
+  Day -> "day"
+  Week -> "week"
+  Month -> "month"
+  NoTimeBucket -> "none"
+
+parseTimeGrainText :: Text -> Maybe TimeGrain
+parseTimeGrainText = \case
+  "hour" -> Just Hour
+  "day" -> Just Day
+  "week" -> Just Week
+  "month" -> Just Month
+  "none" -> Just NoTimeBucket
+  _ -> Nothing
+
+instance ToJSON TimeGrain where
+  toJSON = Aeson.String . timeGrainText
+
+instance FromJSON TimeGrain where
+  parseJSON =
+    Aeson.withText "TimeGrain" $ \value ->
+      maybe (fail ("unknown time grain: " <> Text.unpack value)) pure (parseTimeGrainText value)
+
+instance FromHttpApiData TimeGrain where
+  parseUrlPiece value =
+    maybe (Left ("unknown time grain: " <> value)) Right (parseTimeGrainText value)
+
+instance ToHttpApiData TimeGrain where
+  toUrlPiece = timeGrainText
+
+data MetricPoint = MetricPoint
+  { bucket :: !(Maybe Text),
+    group :: !(Map Text (Maybe Text)),
+    value :: !Double
+  }
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (FromJSON, ToJSON)
+
+data MetricResult = MetricResult
+  { metric :: !Text,
+    points :: ![MetricPoint]
+  }
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (FromJSON, ToJSON)
+
+data AcquisitionMeasure = AcquisitionSessions | AcquisitionUsers | AcquisitionConvertedUsers | AcquisitionConversionRate
+  deriving stock (Generic, Eq, Show)
+
+instance FromHttpApiData AcquisitionMeasure where
+  parseUrlPiece "sessions" = Right AcquisitionSessions
+  parseUrlPiece "users" = Right AcquisitionUsers
+  parseUrlPiece "converted_users" = Right AcquisitionConvertedUsers
+  parseUrlPiece "conversion_rate" = Right AcquisitionConversionRate
+  parseUrlPiece _ = Left "measure must be sessions, users, converted_users or conversion_rate"
+
+instance ToHttpApiData AcquisitionMeasure where
+  toUrlPiece AcquisitionSessions = "sessions"
+  toUrlPiece AcquisitionUsers = "users"
+  toUrlPiece AcquisitionConvertedUsers = "converted_users"
+  toUrlPiece AcquisitionConversionRate = "conversion_rate"
+
+data SessionMeasure = SessionCount | SessionBounceRate | SessionAverageDuration
+  deriving stock (Generic, Eq, Show)
+
+instance FromHttpApiData SessionMeasure where
+  parseUrlPiece "count" = Right SessionCount
+  parseUrlPiece "bounce_rate" = Right SessionBounceRate
+  parseUrlPiece "average_duration_seconds" = Right SessionAverageDuration
+  parseUrlPiece _ = Left "measure must be count, bounce_rate or average_duration_seconds"
+
+instance ToHttpApiData SessionMeasure where
+  toUrlPiece SessionCount = "count"
+  toUrlPiece SessionBounceRate = "bounce_rate"
+  toUrlPiece SessionAverageDuration = "average_duration_seconds"
+
+data UserWindow = CalendarWindow | Rolling7 | Rolling30
+  deriving stock (Generic, Eq, Show)
+
+instance FromHttpApiData UserWindow where
+  parseUrlPiece "calendar" = Right CalendarWindow
+  parseUrlPiece "rolling7" = Right Rolling7
+  parseUrlPiece "rolling30" = Right Rolling30
+  parseUrlPiece _ = Left "window must be calendar, rolling7 or rolling30"
+
+instance ToHttpApiData UserWindow where
+  toUrlPiece CalendarWindow = "calendar"
+  toUrlPiece Rolling7 = "rolling7"
+  toUrlPiece Rolling30 = "rolling30"
+
+data RetentionMode = RetentionOn | RetentionOnOrAfter
+  deriving stock (Generic, Eq, Show)
+
+instance FromHttpApiData RetentionMode where
+  parseUrlPiece "on" = Right RetentionOn
+  parseUrlPiece "on_or_after" = Right RetentionOnOrAfter
+  parseUrlPiece _ = Left "mode must be on or on_or_after"
+
+instance ToHttpApiData RetentionMode where
+  toUrlPiece RetentionOn = "on"
+  toUrlPiece RetentionOnOrAfter = "on_or_after"
+
+data CohortMode = WindowCohort | LifetimeCohort
+  deriving stock (Generic, Eq, Show)
+
+instance FromHttpApiData CohortMode where
+  parseUrlPiece "window" = Right WindowCohort
+  parseUrlPiece "lifetime" = Right LifetimeCohort
+  parseUrlPiece _ = Left "cohort must be window or lifetime"
+
+instance ToHttpApiData CohortMode where
+  toUrlPiece WindowCohort = "window"
+  toUrlPiece LifetimeCohort = "lifetime"
+
+data PathBy = PathEvent | PathRoute
+  deriving stock (Generic, Eq, Show)
+
+instance FromHttpApiData PathBy where
+  parseUrlPiece "event" = Right PathEvent
+  parseUrlPiece "route" = Right PathRoute
+  parseUrlPiece _ = Left "by must be event or route"
+
+instance ToHttpApiData PathBy where
+  toUrlPiece PathEvent = "event"
+  toUrlPiece PathRoute = "route"
+
+data FunnelMode = FunnelSequential | FunnelStrict | FunnelAnyOrder
+  deriving stock (Generic, Eq, Show)
+
+instance FromHttpApiData FunnelMode where
+  parseUrlPiece "sequential" = Right FunnelSequential
+  parseUrlPiece "strict" = Right FunnelStrict
+  parseUrlPiece "any_order" = Right FunnelAnyOrder
+  parseUrlPiece _ = Left "mode must be sequential, strict or any_order"
+
+instance ToHttpApiData FunnelMode where
+  toUrlPiece FunnelSequential = "sequential"
+  toUrlPiece FunnelStrict = "strict"
+  toUrlPiece FunnelAnyOrder = "any_order"
+
+data FunnelCounting = FunnelUniques | FunnelTotals
+  deriving stock (Generic, Eq, Show)
+
+instance FromHttpApiData FunnelCounting where
+  parseUrlPiece "uniques" = Right FunnelUniques
+  parseUrlPiece "totals" = Right FunnelTotals
+  parseUrlPiece _ = Left "counting must be uniques or totals"
+
+instance ToHttpApiData FunnelCounting where
+  toUrlPiece FunnelUniques = "uniques"
+  toUrlPiece FunnelTotals = "totals"
+
+data FunnelAttribution = FunnelFirstTouch | FunnelLastTouch | FunnelStep !Int
+  deriving stock (Generic, Eq, Show)
+
+instance FromHttpApiData FunnelAttribution where
+  parseUrlPiece "first_touch" = Right FunnelFirstTouch
+  parseUrlPiece "last_touch" = Right FunnelLastTouch
+  parseUrlPiece value = case Text.stripPrefix "step:" value of
+    Just number
+      | not (Text.null number),
+        Text.all (\c -> c >= '0' && c <= '9') number ->
+          case reads (Text.unpack number) :: [(Integer, String)] of
+            [(n, "")] | n >= 1 && n <= 20 -> Right (FunnelStep (fromInteger n))
+            _ -> Left "attribution step must be 1..20 and within the requested steps"
+    _ -> Left "attribution must be first_touch, last_touch or step:<n>"
+
+parseFunnelAttribution :: Text -> Either Text FunnelAttribution
+parseFunnelAttribution = parseUrlPiece
+
+instance ToHttpApiData FunnelAttribution where
+  toUrlPiece FunnelFirstTouch = "first_touch"
+  toUrlPiece FunnelLastTouch = "last_touch"
+  toUrlPiece (FunnelStep n) = "step:" <> Text.pack (show n)
+
+data FunnelMeasure = FunnelCount | FunnelTimeToConvert
+  deriving stock (Generic, Eq, Show)
+
+instance FromHttpApiData FunnelMeasure where
+  parseUrlPiece "count" = Right FunnelCount
+  parseUrlPiece "time_to_convert" = Right FunnelTimeToConvert
+  parseUrlPiece _ = Left "measure must be count or time_to_convert"
+
+instance ToHttpApiData FunnelMeasure where
+  toUrlPiece FunnelCount = "count"
+  toUrlPiece FunnelTimeToConvert = "time_to_convert"
+
+data ExperimentMeasure = ExperimentExposures | ExperimentDiagnostics
+  deriving stock (Generic, Eq, Show)
+
+instance FromHttpApiData ExperimentMeasure where
+  parseUrlPiece "exposures" = Right ExperimentExposures
+  parseUrlPiece "diagnostics" = Right ExperimentDiagnostics
+  parseUrlPiece _ = Left "measure must be exposures or diagnostics"
+
+instance ToHttpApiData ExperimentMeasure where
+  toUrlPiece ExperimentExposures = "exposures"
+  toUrlPiece ExperimentDiagnostics = "diagnostics"
+
+type MetricParameters result =
+  Description "user_id is filter-only: eq/neq accept one nonempty opaque string; in accepts 1..20 raw string IDs before deduplication (1024 UTF-8 bytes each). Only identified subjects match, including neq; anonymous/missing subjects fail each term. Resolved event identity includes pre-login history; sessions/acquisition select the first resolved user and retain the whole session. Supported only on events, page-views, users (calendar/rolling7/rolling30), sessions, acquisition, revenue, feature-adoption and custom. Other routes return invalid_metric; groupBy=user_id returns invalid_group_by. Saved simple defaults AND with requests; ratio component selection stays side-local. Existing route grains, populations, budgets and 10000 complete-point cap apply; selecting one user is not a keyed lookup. Explicit timeGrain=none returns one null bucket per populated group on calendar users, simple custom metrics, acquisition, events, page views and sessions. Other routes reject none. Omission preserves defaults. Every metric requires a positive half-open from/to range of at most 366 days and returns at most 10000 complete points. groupBy accepts one or two distinct dimensions: event_name, source, source_service, host, route, enrichment/acquisition fields prop:<declared scalar property>, current user:<governed trait>, organization, or org:<governed organization trait> on event sources. Repeated filter parameters are AND-combined JSON terms, JSON {or:[term,...]} groups, or shorthand dimension:op:value terms separated by | for OR (literal | requires JSON; omit value for set/unset): at most 8 groups of at most 8 terms, 100 in values, 1024 UTF-8 bytes per string and 16384 decoded bytes combined. Only page-views, users and custom additionally accept the session: namespace described on those routes. Events lacking an eligible session fail every session term, including unset; ordinary missing/null values are unset. Comparisons including neq do not match unset values. Group keys keep full dimension names and null differs from an empty string. Unsupported parameters are errors. See the documented route matrix."
+    :> MetricArguments result
+
+type MetricArguments result =
+  QueryParam' '[Required] "from" Day
+    :> QueryParam' '[Required] "to" Day
+    :> QueryParam "groupBy" Text
+    :> QueryParam "timeGrain" TimeGrain
+    :> QueryParams "filter" MetricFilter
+    :> result
+
+type MetricResponse = MultiVerb 'Servant.GET '[JSON, ProblemJSON] (OkResponses "Metric result" MetricResult) (KansokuResult MetricResult)
+
+type MetricQuery = MetricParameters MetricResponse
+
+type WebMetricQuery = MetricParameters (QueryParam "includeBots" Bool :> MetricResponse)
+
+type SessionQuery =
+  Description "user_id is filter-only: eq/neq accept one nonempty opaque string; in accepts 1..20 raw string IDs before deduplication (1024 UTF-8 bytes each). Only identified subjects match, including neq; anonymous/missing subjects fail each term. Resolved event identity includes pre-login history; sessions/acquisition select the first resolved user and retain the whole session. Supported only on events, page-views, users (calendar/rolling7/rolling30), sessions, acquisition, revenue, feature-adoption and custom. Other routes return invalid_metric; groupBy=user_id returns invalid_group_by. Saved simple defaults AND with requests; ratio component selection stays side-local. Existing route grains, populations, budgets and 10000 complete-point cap apply; selecting one user is not a keyed lookup. Whole retained web sessions keyed by account/source_service/nonempty session_id; assemble before selecting the start date or UTC bucket. timeGrain=none aggregates selected sessions directly, including rates and averages; ungrouped empty whole ranges return one zero point. Optional containsEvent selects whole sessions containing any-of 1..20 comma-separated event names across retained web history, including matches beyond the date range. Names have 1..128 lowercase ASCII letters, digits, underscore, dot, colon or hyphen; trim entries, reject empties, bound the raw list before deduplication. Invalid lists return invalid_contains_event before storage; valid absent names select zero sessions. All three measures use the selected whole sessions. Defaults: measure=count, day grain, no grouping, includeBots=false. bounce_rate is exactly one page_view and no engagement divided by all selected sessions; lifecycle/identity/exposure events and the exact names client_error/web_vital are not engagement, regardless of schema version. average_duration_seconds includes every selected session, including zero-duration visits, and measures last minus first observed event including page_leave, client_error and web_vital. Attribute grouping/filters select first page_view, else earliest event; entry_page/exit_page come from page views. Arbitrary prop: filters/grouping are rejected. Any bot event excludes the whole session unless includeBots=true. Empty ungrouped buckets return zero; nonexistent groups are omitted. One/two dimensions, at most 8 AND filter groups with up to 8 OR terms each (100 in values, 1024 UTF-8 bytes per string, 16384 decoded bytes), positive half-open range at most 366 days, at most 10000 complete points. Late arrivals and retained-history changes can change start/attribution/duration."
+    :> MetricArguments (QueryParam "measure" SessionMeasure :> QueryParam "containsEvent" Text :> QueryParam "includeBots" Bool :> MetricResponse)
+
+type UserQuery =
+  Description "user_id is filter-only: eq/neq accept one nonempty opaque string; in accepts 1..20 raw string IDs before deduplication (1024 UTF-8 bytes each). Only identified subjects match, including neq; anonymous/missing subjects fail each term. Resolved event identity includes pre-login history; sessions/acquisition select the first resolved user and retain the whole session. Supported only on events, page-views, users (calendar/rolling7/rolling30), sessions, acquisition, revenue, feature-adoption and custom. Other routes return invalid_metric; groupBy=user_id returns invalid_group_by. Saved simple defaults AND with requests; ratio component selection stays side-local. Existing route grains, populations, budgets and 10000 complete-point cap apply; selecting one user is not a keyed lookup. Exact canonical active subjects; identity/profile-only events are excluded. window=calendar (default), rolling7 or rolling30. Calendar supports timeGrain=none for exact distinct subjects across the whole range. Rolling windows require day grain and count [d-(N-1),d+1) UTC for each returned day d. Filters/grouping apply to the same activity population; up to two dimensions and eight bounded repeated JSON filters. Bot rows are excluded unless includeBots=true. Positive half-open range at most 366 days, to no later than the current UTC day (only closed days), at most 10000 complete points. People can occur in multiple session-channel groups; grouped distinct counts cannot be summed into a whole-range distinct total. Rolling activity retains its lookback while session attribution uses all retained history. Session-scoped filters/grouping on this route use session:<attribute>: channel, referrer_host, utm_source, utm_medium, utm_campaign, utm_content, utm_term, entry_page, exit_page, device_class, browser_family, os_family, country_code, event_name, source, source_service, host, route, is_bot. is_bot is Boolean; others are nullable strings. Session first page (otherwise earliest event) supplies attribution across all retained history; event dates and identities remain unchanged. Existing channel describes each event; session:channel selects every eligible event in matching visits. Missing/non-web sessions group under null and fail every session predicate, including unset/neq; real sessions with missing attributes can satisfy unset. Presence applies inside each OR alternative. Full prefixed group keys are returned; groupBy=route,session:referrer_host is supported. Nested session dimensions and other routes are excluded."
+    :> MetricArguments (QueryParam "window" UserWindow :> QueryParam "includeBots" Bool :> OrganizationOptions MetricResponse)
+
+type DailyActivityQuery =
+  Description "user_id is filter-only: eq/neq accept one nonempty opaque string; in accepts 1..20 raw string IDs before deduplication (1024 UTF-8 bytes each). Only identified subjects match, including neq; anonymous/missing subjects fail each term. Resolved event identity includes pre-login history; sessions/acquisition select the first resolved user and retain the whole session. Supported only on events, page-views, users (calendar/rolling7/rolling30), sessions, acquisition, revenue, feature-adoption and custom. Other routes return invalid_metric; groupBy=user_id returns invalid_group_by. Saved simple defaults AND with requests; ratio component selection stays side-local. Existing route grains, populations, budgets and 10000 complete-point cap apply; selecting one user is not a keyed lookup. Exact canonical daily activity with retained-history lookback; identity/profile-only events are excluded throughout. Day grain only. Filters and one/two group dimensions define the same population throughout lookback; each group is a separate population. Bot rows are excluded unless includeBots=true. Positive half-open range at most 366 days; to no later than the current UTC day; at most 10000 complete points. Stickiness emits daily_active, rolling30_active and ratio (0..1) in group.measure. Lifecycle emits nonnegative new, returning, resurrecting and dormant in group.status; first means first retained matching activity. Empty ungrouped days return zero measures."
+    :> MetricArguments (QueryParam "includeBots" Bool :> MetricResponse)
+
+type AcquisitionQuery = Description "user_id is filter-only: eq/neq accept one nonempty opaque string; in accepts 1..20 raw string IDs before deduplication (1024 UTF-8 bytes each). Only identified subjects match, including neq; anonymous/missing subjects fail each term. Resolved event identity includes pre-login history; sessions/acquisition select the first resolved user and retain the whole session. Supported only on events, page-views, users (calendar/rolling7/rolling30), sessions, acquisition, revenue, feature-adoption and custom. Other routes return invalid_metric; groupBy=user_id returns invalid_group_by. Saved simple defaults AND with requests; ratio component selection stays side-local. Existing route grains, populations, budgets and 10000 complete-point cap apply; selecting one user is not a keyed lookup. Acquisition conversions select one first session per resolved person before grouping. converted_users and conversion_rate require conversionEvents: a single comma-separated list of 1..20 event names (1..128 lowercase ASCII letters, digits, underscore, dot, colon or hyphen); trim entries, reject empty entries, deduplicate after the raw bound. Legacy measures reject the list. Rates use the selected first-session population, not legacy users. Conversions may come from any source in the range at or after session start. includeBots controls whole sessions and individual conversion events. " :> MetricParameters (QueryParam "measure" AcquisitionMeasure :> QueryParam "conversionEvents" Text :> QueryParam "includeBots" Bool :> MetricResponse)
+
+-- | Metric routes keyed by field name. Six fields deliberately share
+-- 'MetricQuery'; their runtime path-to-handler guard lives in RoutingSpec.
+-- Record field order is not load-bearing for servant routing.
+data MetricsRoutes mode = MetricsRoutes
+  { events :: mode :- MetricsAuth :> "metrics" :> "events" :> MetricQuery,
+    pageViews :: mode :- MetricsAuth :> "metrics" :> "page-views" :> Description "user_id is filter-only: eq/neq accept one nonempty opaque string; in accepts 1..20 raw string IDs before deduplication (1024 UTF-8 bytes each). Only identified subjects match, including neq; anonymous/missing subjects fail each term. Resolved event identity includes pre-login history; sessions/acquisition select the first resolved user and retain the whole session. Supported only on events, page-views, users (calendar/rolling7/rolling30), sessions, acquisition, revenue, feature-adoption and custom. Other routes return invalid_metric; groupBy=user_id returns invalid_group_by. Saved simple defaults AND with requests; ratio component selection stays side-local. Existing route grains, populations, budgets and 10000 complete-point cap apply; selecting one user is not a keyed lookup. Canonical page views with individual bot-row exclusion unless includeBots=true. Session-scoped filters/grouping on this route use session:<attribute>: channel, referrer_host, utm_source, utm_medium, utm_campaign, utm_content, utm_term, entry_page, exit_page, device_class, browser_family, os_family, country_code, event_name, source, source_service, host, route, is_bot. is_bot is Boolean; others are nullable strings. Session first page (otherwise earliest event) supplies attribution across all retained history; event dates and identities remain unchanged. Existing channel describes each event; session:channel selects every eligible event in matching visits. Missing/non-web sessions group under null and fail every session predicate, including unset/neq; real sessions with missing attributes can satisfy unset. Presence applies inside each OR alternative. Full prefixed group keys are returned; groupBy=route,session:referrer_host is supported. Nested session dimensions and other routes are excluded." :> WebMetricQuery,
+    sessions :: mode :- MetricsAuth :> "metrics" :> "sessions" :> SessionQuery,
+    paths ::
+      mode
+        :- MetricsAuth
+          :> "metrics"
+          :> "paths"
+          :> Description "user_id is filter-only: eq/neq accept one nonempty opaque string; in accepts 1..20 raw string IDs before deduplication (1024 UTF-8 bytes each). Only identified subjects match, including neq; anonymous/missing subjects fail each term. Resolved event identity includes pre-login history; sessions/acquisition select the first resolved user and retain the whole session. Supported only on events, page-views, users (calendar/rolling7/rolling30), sessions, acquisition, revenue, feature-adoption and custom. Other routes return invalid_metric; groupBy=user_id returns invalid_group_by. Saved simple defaults AND with requests; ratio component selection stays side-local. Existing route grains, populations, budgets and 10000 complete-point cap apply; selecting one user is not a keyed lookup. Exact canonical-user paths ordered by millisecond time then event ID. by=event (default) requires start or end; by=route selects nonbot page_view rows, collapses consecutive identical routes, and permits entry mode with neither anchor. start uses the first occurrence; end uses the last and walks backwards. Anchors are mutually exclusive. steps=1..10 (default 5) counts transitions; top=1..20 (default 10) retains K transitions per step and sums the remainder into one (other) to (other) transition. window=1..7776000 seconds (default range capped at 90 days) is inclusive from the anchor. events/exclude are comma-separated event names, at most 100 each; exclude wins. Filters select rows before anchoring. Exhausted sequences emit (end), including in reverse mode. group={step,from,to}, value=users, bucket=null; order step ascending, value descending, from then to. Positive half-open range at most 366 days; complete responses, native work budgets and global 10000-point cap."
+          :> QueryParam' '[Required] "from" Day
+          :> QueryParam' '[Required] "to" Day
+          :> QueryParam "start" Text
+          :> QueryParam "end" Text
+          :> QueryParam "steps" Int
+          :> QueryParam "top" Int
+          :> QueryParam "window" Int
+          :> QueryParam "events" Text
+          :> QueryParam "exclude" Text
+          :> QueryParam "by" PathBy
+          :> QueryParams "filter" MetricFilter
+          :> MetricResponse,
+    funnel ::
+      mode
+        :- MetricsAuth
+          :> "metrics"
+          :> "funnel"
+          :> Description "user_id is filter-only: eq/neq accept one nonempty opaque string; in accepts 1..20 raw string IDs before deduplication (1024 UTF-8 bytes each). Only identified subjects match, including neq; anonymous/missing subjects fail each term. Resolved event identity includes pre-login history; sessions/acquisition select the first resolved user and retain the whole session. Supported only on events, page-views, users (calendar/rolling7/rolling30), sessions, acquisition, revenue, feature-adoption and custom. Other routes return invalid_metric; groupBy=user_id returns invalid_group_by. Saved simple defaults AND with requests; ratio component selection stays side-local. Existing route grains, populations, budgets and 10000 complete-point cap apply; selecting one user is not a keyed lookup. aggregate=users (default) or organizations; organizations merge member timelines, excluding empty organization IDs. attribution=event or user_organization selects organization attribution with first-touch property selection; user_organization fills only missing event IDs from current resolved-user membership. Existing property attribution values retain event organization attribution. Canonical subject timelines ordered by millisecond occurred_at then event_id, with immutable duplicates removed. At most 20 steps; one event cannot satisfy repeated steps. mode=any_order opens one window at the earliest matching step and counts distinct step positions; mode=sequential (default) allows intervening events; strict requires adjacency including automatic events, accepts current-user filters over complete participant timelines, and rejects event filters. counting=uniques (default) selects maximum reach, earliest completion, earliest start, then event IDs for sequential/strict. counting=totals counts nonoverlapping entries, restarting after completion, expiry or interruption; any-order entries start at any step, others at step one. Totals add an ungrouped measure=entries point per variant and label reach rows measure=count. There is no exclusion grace period. window is inclusive seconds from first step, default range capped at 7776000. Repeat exclude up to 20 distinct validated names; exclusions between matched steps stop a chain, and cannot overlap steps. measure=count (default) emits cumulative reach. time_to_convert emits exact nearest-rank p50_seconds/p90_seconds for reached steps >=2, from the same selected chain. One/two group dimensions use the earliest eligible start (per attempt for totals). prop attribution=first_touch (default), last_touch or step:<n> uses the start, last matched event or named step; absent/unreached values are other. Validate declarations in that step, or compatible declared properties across steps when touch varies; user dimensions use participant current traits. Sequential event filters select step rows and cannot hide exclusions. Positive half-open range at most 366 days; native execution/memory limits; at most 10000 complete points or HTTP 400."
+          :> QueryParam' '[Required] "from" Day
+          :> QueryParam' '[Required] "to" Day
+          :> QueryParam' '[Required] "steps" Text
+          :> QueryParam "window" Int
+          :> QueryParam "mode" FunnelMode
+          :> QueryParam "measure" FunnelMeasure
+          :> QueryParam "counting" FunnelCounting
+          :> QueryParam "attribution" Text
+          :> QueryParams "exclude" Text
+          :> QueryParam "groupBy" Text
+          :> QueryParams "filter" MetricFilter
+          :> QueryParam "aggregate" AggregateBy
+          :> MultiVerb 'Servant.GET '[JSON, ProblemJSON] (OkResponses "Metric result" MetricResult) (KansokuResult MetricResult),
+    retention ::
+      mode
+        :- MetricsAuth
+          :> "metrics"
+          :> "retention"
+          :> Description "user_id is filter-only: eq/neq accept one nonempty opaque string; in accepts 1..20 raw string IDs before deduplication (1024 UTF-8 bytes each). Only identified subjects match, including neq; anonymous/missing subjects fail each term. Resolved event identity includes pre-login history; sessions/acquisition select the first resolved user and retain the whole session. Supported only on events, page-views, users (calendar/rolling7/rolling30), sessions, acquisition, revenue, feature-adoption and custom. Other routes return invalid_metric; groupBy=user_id returns invalid_group_by. Saved simple defaults AND with requests; ratio component selection stays side-local. Existing route grains, populations, budgets and 10000 complete-point cap apply; selecting one user is not a keyed lookup. Exact canonical retention over closed UTC days. mode=on (default) counts the exact offset day; on_or_after counts any day from offset through the largest requested offset, including unrequested intermediate days. cohort=window (default) means first matching activity in range; lifetime means first matching activity in retained history, not first ever. Date bounds select the observed activity interval and cohort dates. Offset 0 always emits cohort size. Omit immature on offsets; on_or_after omits nonzero offsets unless the full maximum horizon is closed by to. to cannot exceed the current UTC day. Day grain only; filters/grouping define identical populations throughout lookback. Bot rows are excluded unless includeBots=true. Up to two dimensions, eight bounded JSON filters, 32 distinct offsets 1..365, range at most 366 days and 10000 complete points."
+          :> MetricArguments (QueryParam "days" Text :> QueryParam "mode" RetentionMode :> QueryParam "cohort" CohortMode :> QueryParam "includeBots" Bool :> OrganizationOptions MetricResponse),
+    acquisition :: mode :- MetricsAuth :> "metrics" :> "acquisition" :> AcquisitionQuery,
+    featureAdoption :: mode :- MetricsAuth :> "metrics" :> "feature-adoption" :> OrganizationMetricQuery,
+    conversion :: mode :- MetricsAuth :> "metrics" :> "conversion" :> Description "user_id is filter-only: eq/neq accept one nonempty opaque string; in accepts 1..20 raw string IDs before deduplication (1024 UTF-8 bytes each). Only identified subjects match, including neq; anonymous/missing subjects fail each term. Resolved event identity includes pre-login history; sessions/acquisition select the first resolved user and retain the whole session. Supported only on events, page-views, users (calendar/rolling7/rolling30), sessions, acquisition, revenue, feature-adoption and custom. Other routes return invalid_metric; groupBy=user_id returns invalid_group_by. Saved simple defaults AND with requests; ratio component selection stays side-local. Existing route grains, populations, budgets and 10000 complete-point cap apply; selecting one user is not a keyed lookup. Exact ordered signup conversion: starters are the denominator; only starters with a later signup_completed in the millisecond/event-ID timeline within range (window capped at 90 days) convert. Zero denominator returns zero; ratio stays 0..1. Group ownership is the earliest eligible start. timeGrain and prop grouping are rejected." :> OrganizationMetricQuery,
+    users :: mode :- MetricsAuth :> "metrics" :> "users" :> UserQuery,
+    stickiness :: mode :- MetricsAuth :> "metrics" :> "stickiness" :> DailyActivityQuery,
+    lifecycle :: mode :- MetricsAuth :> "metrics" :> "lifecycle" :> DailyActivityQuery,
+    revenue :: mode :- MetricsAuth :> "metrics" :> "revenue" :> MetricQuery,
+    organizations :: mode :- MetricsAuth :> "metrics" :> "organizations" :> OrganizationMetricQuery,
+    custom :: mode :- MetricsAuth :> "metrics" :> "custom" :> Description "user_id is filter-only: eq/neq accept one nonempty opaque string; in accepts 1..20 raw string IDs before deduplication (1024 UTF-8 bytes each). Only identified subjects match, including neq; anonymous/missing subjects fail each term. Resolved event identity includes pre-login history; sessions/acquisition select the first resolved user and retain the whole session. Supported only on events, page-views, users (calendar/rolling7/rolling30), sessions, acquisition, revenue, feature-adoption and custom. Other routes return invalid_metric; groupBy=user_id returns invalid_group_by. Saved simple defaults AND with requests; ratio component selection stays side-local. Existing route grains, populations, budgets and 10000 complete-point cap apply; selecting one user is not a keyed lookup. Ad-hoc or saved account-scoped metrics. Percentile requires a declared numeric property and finite quantile strictly between zero and one; exact inclusive (R-7) interpolation over canonical finite samples, independently per group/bucket. Empty populations emit no points. Quantile is forbidden on other aggregations, ratios and saved execution overrides. Existing resource limits apply. Saved defaults, ad-hoc requests and both ratio component filters accept session attributes; request filters select both sides and component filters remain side-local. Custom bot populations are unchanged. Session-scoped filters/grouping on this route use session:<attribute>: channel, referrer_host, utm_source, utm_medium, utm_campaign, utm_content, utm_term, entry_page, exit_page, device_class, browser_family, os_family, country_code, event_name, source, source_service, host, route, is_bot. is_bot is Boolean; others are nullable strings. Session first page (otherwise earliest event) supplies attribution across all retained history; event dates and identities remain unchanged. Existing channel describes each event; session:channel selects every eligible event in matching visits. Missing/non-web sessions group under null and fail every session predicate, including unset/neq; real sessions with missing attributes can satisfy unset. Presence applies inside each OR alternative. Full prefixed group keys are returned; groupBy=route,session:referrer_host is supported. Nested session dimensions and other routes are excluded." :> MetricParameters (QueryParam "metric" Text :> QueryParam "events" Text :> QueryParam "aggregation" Text :> QueryParam "property" Text :> QueryParam "quantile" Double :> QueryParam "denominatorEvents" Text :> QueryParam "denominatorAggregation" Text :> MetricResponse),
+    experiments ::
+      mode
+        :- MetricsAuth
+          :> "metrics"
+          :> "experiments"
+          :> Description "user_id is filter-only: eq/neq accept one nonempty opaque string; in accepts 1..20 raw string IDs before deduplication (1024 UTF-8 bytes each). Only identified subjects match, including neq; anonymous/missing subjects fail each term. Resolved event identity includes pre-login history; sessions/acquisition select the first resolved user and retain the whole session. Supported only on events, page-views, users (calendar/rolling7/rolling30), sessions, acquisition, revenue, feature-adoption and custom. Other routes return invalid_metric; groupBy=user_id returns invalid_group_by. Saved simple defaults AND with requests; ratio component selection stays side-local. Existing route grains, populations, budgets and 10000 complete-point cap apply; selecting one user is not a keyed lookup. Current user: filters/grouping select exact canonical populations; segmented exposures bypass the raw sketch. measure=exposures (default without segmentation) returns daily-rollup uniqCombined raw-identity exposure counts (~0.8% sketch error at scale); it differs from exact stitched users. measure=diagnostics uses exact canonical subjects and valid experiment/variant exposures, assigning each user once per experiment/requested bucket to earliest occurred_at,event_id. group.measure emits exposed_users, multiple_exposure (>1 observed variant), assigned_users and variant_share; per-variant shares sum to one when nonempty. Repeated exposures do not increase assignment. Zero users emit zero totals and no variant rows; missing/invalid names are excluded. All observed variants remain visible, including zero assignments. Day/week/month or whole range by omission; explicit hour and none rejected. First observed exposure is not an authoritative randomization log; contamination invalidates naive sample-ratio interpretation. Positive half-open range at most 366 days; at most 10000 complete points and bounded native work."
+          :> QueryParam' '[Required] "from" Day
+          :> QueryParam' '[Required] "to" Day
+          :> QueryParam "experiment" Text
+          :> QueryParam "timeGrain" TimeGrain
+          :> QueryParam "measure" ExperimentMeasure
+          :> QueryParam "groupBy" Text
+          :> QueryParams "filter" MetricFilter
+          :> MultiVerb 'Servant.GET '[JSON, ProblemJSON] (OkResponses "Metric result" MetricResult) (KansokuResult MetricResult),
+    experimentConversion ::
+      mode
+        :- MetricsAuth
+          :> "metrics"
+          :> "experiments"
+          :> "conversion"
+          :> Description "user_id is filter-only: eq/neq accept one nonempty opaque string; in accepts 1..20 raw string IDs before deduplication (1024 UTF-8 bytes each). Only identified subjects match, including neq; anonymous/missing subjects fail each term. Resolved event identity includes pre-login history; sessions/acquisition select the first resolved user and retain the whole session. Supported only on events, page-views, users (calendar/rolling7/rolling30), sessions, acquisition, revenue, feature-adoption and custom. Other routes return invalid_metric; groupBy=user_id returns invalid_group_by. Saved simple defaults AND with requests; ratio component selection stays side-local. Existing route grains, populations, budgets and 10000 complete-point cap apply; selecting one user is not a keyed lookup. Every implemented metric requires a positive half-open from/to range of at most 366 days and returns a complete answer of at most 10000 points, or a permanent HTTP 400 problem asking the caller to narrow the query. Current user: filters/grouping are supported; event selection is rejected. Per-variant conversion for one experiment: exposed unique users, users with a conversion event at or after their first exposure (earlier conversions do not count), and the rate. Users exposed to multiple variants count in each. Counts and rates only; no significance testing."
+          :> QueryParam' '[Required] "experiment" Text
+          :> QueryParam' '[Required] "event" Text
+          :> QueryParam' '[Required] "from" Day
+          :> QueryParam' '[Required] "to" Day
+          :> QueryParam "groupBy" Text
+          :> QueryParams "filter" MetricFilter
+          :> MultiVerb 'Servant.GET '[JSON, ProblemJSON] (OkResponses "Metric result" MetricResult) (KansokuResult MetricResult),
+    experimentFunnel ::
+      mode
+        :- MetricsAuth
+          :> "metrics"
+          :> "experiments"
+          :> "funnel"
+          :> Description "user_id is filter-only: eq/neq accept one nonempty opaque string; in accepts 1..20 raw string IDs before deduplication (1024 UTF-8 bytes each). Only identified subjects match, including neq; anonymous/missing subjects fail each term. Resolved event identity includes pre-login history; sessions/acquisition select the first resolved user and retain the whole session. Supported only on events, page-views, users (calendar/rolling7/rolling30), sessions, acquisition, revenue, feature-adoption and custom. Other routes return invalid_metric; groupBy=user_id returns invalid_group_by. Saved simple defaults AND with requests; ratio component selection stays side-local. Existing route grains, populations, budgets and 10000 complete-point cap apply; selecting one user is not a keyed lookup. Canonical user timelines ordered by millisecond occurred_at then event_id, with immutable duplicates removed. At most 20 steps; one event cannot satisfy repeated steps. mode=any_order opens one window at the earliest matching step and counts distinct step positions; mode=sequential (default) allows intervening events; strict requires adjacency including automatic events, accepts current-user filters over complete participant timelines, and rejects event filters. counting=uniques (default) selects maximum reach, earliest completion, earliest start, then event IDs for sequential/strict. counting=totals counts nonoverlapping entries, restarting after completion, expiry or interruption; any-order entries start at any step, others at step one. Totals add an ungrouped measure=entries point per variant and label reach rows measure=count. There is no exclusion grace period. window is inclusive seconds from first step, default range capped at 7776000. Repeat exclude up to 20 distinct validated names; exclusions between matched steps stop a chain, and cannot overlap steps. measure=count (default) emits cumulative reach. time_to_convert emits exact nearest-rank p50_seconds/p90_seconds for reached steps >=2, from the same selected chain. One/two group dimensions use the earliest eligible start (per attempt for totals). prop attribution=first_touch (default), last_touch or step:<n> uses the start, last matched event or named step; absent/unreached values are other. Validate declarations in that step, or compatible declared properties across steps when touch varies; user dimensions use participant current traits. Sequential event filters select step rows and cannot hide exclusions. Positive half-open range at most 366 days; native execution/memory limits; at most 10000 complete points or HTTP 400. Per-variant timelines include only events at or after that user/variant first exposure inside the range. Multi-variant users remain in each variant."
+          :> QueryParam' '[Required] "experiment" Text
+          :> QueryParam' '[Required] "steps" Text
+          :> QueryParam' '[Required] "from" Day
+          :> QueryParam' '[Required] "to" Day
+          :> QueryParam "window" Int
+          :> QueryParam "mode" FunnelMode
+          :> QueryParam "measure" FunnelMeasure
+          :> QueryParam "counting" FunnelCounting
+          :> QueryParam "attribution" FunnelAttribution
+          :> QueryParams "exclude" Text
+          :> QueryParam "groupBy" Text
+          :> QueryParams "filter" MetricFilter
+          :> MultiVerb 'Servant.GET '[JSON, ProblemJSON] (OkResponses "Metric result" MetricResult) (KansokuResult MetricResult)
+  }
+  deriving stock (Generic)
diff --git a/src/Kansoku/Metrics/Definition.hs b/src/Kansoku/Metrics/Definition.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Metrics/Definition.hs
@@ -0,0 +1,105 @@
+-- | Semantic metric definitions shared by the metrics registry and query compiler.
+module Kansoku.Metrics.Definition
+  ( Aggregation (..),
+    MetricDefinition (..),
+    RatioMetric (..),
+    CustomDefinition (..),
+    SavedMetric (..),
+    parseAggregationText,
+    customName,
+  )
+where
+
+import Data.Aeson qualified as Aeson
+import Data.Aeson.KeyMap qualified as KeyMap
+import Data.Text qualified as Text
+import Data.UUID (UUID)
+import Kansoku.Metrics.Api (TimeGrain)
+import Kansoku.Prelude
+
+data Aggregation = Count | UniqueCount | Sum | Average | Percentile | Ratio
+  deriving stock (Generic, Eq, Ord, Show)
+
+aggregationText :: Aggregation -> Text
+aggregationText = \case
+  Count -> "count"
+  UniqueCount -> "unique_count"
+  Sum -> "sum"
+  Average -> "average"
+  Percentile -> "percentile"
+  Ratio -> "ratio"
+
+parseAggregationText :: Text -> Maybe Aggregation
+parseAggregationText = \case
+  "count" -> Just Count
+  "unique_count" -> Just UniqueCount
+  "sum" -> Just Sum
+  "average" -> Just Average
+  "percentile" -> Just Percentile
+  "ratio" -> Just Ratio
+  _ -> Nothing
+
+instance ToJSON Aggregation where
+  toJSON = Aeson.String . aggregationText
+
+instance FromJSON Aggregation where
+  parseJSON =
+    Aeson.withText "Aggregation" $ \value ->
+      maybe (fail ("unknown aggregation: " <> Text.unpack value)) pure (parseAggregationText value)
+
+data MetricDefinition = MetricDefinition
+  { name :: !Text,
+    description :: !(Maybe Text),
+    eventNames :: ![Text],
+    aggregation :: !Aggregation,
+    aggregationProperty :: !(Maybe Text),
+    quantile :: !(Maybe Double),
+    filters :: ![Text],
+    groupBy :: ![Text],
+    timeGrain :: !(Maybe TimeGrain)
+  }
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (FromJSON)
+
+-- Keep legacy JSON shapes while allowing the optional percentile parameter.
+instance ToJSON MetricDefinition where
+  toJSON def = case Aeson.genericToJSON Aeson.defaultOptions def of
+    Aeson.Object fields | isNothing (quantile def) -> Aeson.Object (KeyMap.delete "quantile" fields)
+    value -> value
+
+data RatioMetric = RatioMetric
+  { name :: !Text,
+    description :: !(Maybe Text),
+    numerator :: !MetricDefinition,
+    denominator :: !MetricDefinition
+  }
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (FromJSON, ToJSON)
+
+-- | Untagged wire union: a simple definition, or numerator/denominator definitions.
+data CustomDefinition = CustomSimple !MetricDefinition | CustomRatio !RatioMetric
+  deriving stock (Generic, Eq, Show)
+
+instance ToJSON CustomDefinition where
+  toJSON (CustomSimple x) = toJSON x
+  toJSON (CustomRatio x) = toJSON x
+
+instance FromJSON CustomDefinition where
+  parseJSON value =
+    Aeson.withObject
+      "metric definition"
+      ( \o -> do
+          let ratio = KeyMap.member "numerator" o || KeyMap.member "denominator" o
+              allowed = if ratio then ["name", "description", "numerator", "denominator"] else ["name", "description", "eventNames", "aggregation", "aggregationProperty", "quantile", "filters", "groupBy", "timeGrain"]
+          unless (all (`elem` allowed) (KeyMap.keys o)) (fail "unexpected or mixed metric definition fields")
+          if ratio then CustomRatio <$> Aeson.parseJSON value else CustomSimple <$> Aeson.parseJSON value
+      )
+      value
+
+customName :: CustomDefinition -> Text
+customName (CustomSimple MetricDefinition {name}) = name
+customName (CustomRatio RatioMetric {name}) = name
+
+data SavedMetric = SavedMetric {id :: !UUID, definition :: !CustomDefinition, createdAt :: !UTCTime, updatedAt :: !UTCTime}
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (FromJSON, ToJSON)
diff --git a/src/Kansoku/Metrics/DefinitionApi.hs b/src/Kansoku/Metrics/DefinitionApi.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Metrics/DefinitionApi.hs
@@ -0,0 +1,23 @@
+-- | Account-scoped saved metric definitions and cursor pages.
+module Kansoku.Metrics.DefinitionApi where
+
+import Data.UUID (UUID)
+import Kansoku.Api.Response
+import Kansoku.Auth.Api (MetricsAuth)
+import Kansoku.Cohort.Api (PageReply)
+import Kansoku.Metrics.Definition
+import Kansoku.Prelude
+import Relay.Pagination.Servant (RelayPage)
+import Servant.API
+import Servant.API.MultiVerb
+
+type AtDefinition sub = "metric-definitions" :> MetricsAuth :> Capture "id" UUID :> sub
+
+data MetricDefinitions mode = MetricDefinitions
+  { createDefinition :: mode :- "metric-definitions" :> MetricsAuth :> ReqBody '[JSON] CustomDefinition :> MultiVerb 'POST '[JSON, ProblemJSON] (OkResponses "Saved metric" SavedMetric) (KansokuResult SavedMetric),
+    getDefinition :: mode :- AtDefinition (MultiVerb 'GET '[JSON, ProblemJSON] (OkResponses "Saved metric" SavedMetric) (KansokuResult SavedMetric)),
+    listDefinitions :: mode :- "metric-definitions" :> MetricsAuth :> RelayPage 20 100 :> PageReply SavedMetric,
+    updateDefinition :: mode :- AtDefinition (ReqBody '[JSON] CustomDefinition :> MultiVerb 'PUT '[JSON, ProblemJSON] (OkResponses "Saved metric" SavedMetric) (KansokuResult SavedMetric)),
+    deleteDefinition :: mode :- AtDefinition (MultiVerb 'DELETE '[JSON, ProblemJSON] (OkResponses "Deleted metric" Bool) (KansokuResult Bool))
+  }
+  deriving stock (Generic)
diff --git a/src/Kansoku/Metrics/Filter.hs b/src/Kansoku/Metrics/Filter.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Metrics/Filter.hs
@@ -0,0 +1,197 @@
+-- | The bounded JSON grammar shared by metric producers and the server.
+module Kansoku.Metrics.Filter
+  ( FilterOp (..),
+    MetricFilter,
+    filterTerms,
+    mapFilterTerms,
+    dimension,
+    op,
+    value,
+    mkMetricFilter,
+    filterOpText,
+    parseMetricFilter,
+    validateMetricFilters,
+    maxFilters,
+    maxFilterBytes,
+  )
+where
+
+import Data.Aeson qualified as Aeson
+import Data.Aeson.KeyMap qualified as KeyMap
+import Data.Aeson.Types (Parser)
+import Data.Bifunctor (first)
+import Data.ByteString qualified as BS
+import Data.ByteString.Lazy qualified as LBS
+import Data.Foldable (toList, traverse_)
+import Data.List (sort)
+import Data.Text qualified as Text
+import Data.Text.Encoding qualified as Text
+import Kansoku.Prelude hiding (op)
+import Web.HttpApiData (FromHttpApiData (..), ToHttpApiData (..))
+
+data FilterOp = FilterEq | FilterNeq | FilterIn | FilterContains | FilterGt | FilterLt | FilterSet | FilterUnset
+  deriving stock (Generic, Eq, Show)
+
+filterOpText :: FilterOp -> Text
+filterOpText = \case
+  FilterEq -> "eq"
+  FilterNeq -> "neq"
+  FilterIn -> "in"
+  FilterContains -> "contains"
+  FilterGt -> "gt"
+  FilterLt -> "lt"
+  FilterSet -> "set"
+  FilterUnset -> "unset"
+
+instance ToJSON FilterOp where
+  toJSON = Aeson.String . filterOpText
+
+instance FromJSON FilterOp where
+  parseJSON = Aeson.withText "filter operator" $ \raw ->
+    maybe (fail "unsupported filter operator") pure $
+      lookup raw [(filterOpText operator, operator) | operator <- [FilterEq, FilterNeq, FilterIn, FilterContains, FilterGt, FilterLt, FilterSet, FilterUnset]]
+
+data MetricFilter
+  = MetricFilter
+      { dimension :: !Text,
+        op :: !FilterOp,
+        value :: !(Maybe Aeson.Value),
+        decodedBytes :: !Int
+      }
+  | MetricFilterGroup {alternatives :: ![MetricFilter], decodedBytes :: !Int}
+  deriving stock (Generic, Show)
+
+filterTerms :: MetricFilter -> [MetricFilter]
+filterTerms (MetricFilterGroup terms _) = terms
+filterTerms term = [term]
+
+mapFilterTerms :: (MetricFilter -> MetricFilter) -> MetricFilter -> MetricFilter
+mapFilterTerms f (MetricFilterGroup terms bytes) = MetricFilterGroup (map f terms) bytes
+mapFilterTerms f term = f term
+
+instance Eq MetricFilter where
+  left == right = toJSON left == toJSON right
+
+instance ToJSON MetricFilter where
+  toJSON (MetricFilterGroup terms _) = Aeson.object ["or" Aeson..= terms]
+  toJSON MetricFilter {dimension, op, value} =
+    Aeson.object (["dimension" Aeson..= dimension, "op" Aeson..= op] <> maybe [] (\operand -> ["value" Aeson..= operand]) value)
+
+instance FromJSON MetricFilter where
+  parseJSON input@(Aeson.Object object) | KeyMap.member "or" object = do
+    unless (KeyMap.keys object == ["or"]) (fail "OR groups require exactly or")
+    terms <- object Aeson..: "or"
+    unless (not (null terms) && length terms <= 8 && all isAtomic terms) (fail "OR groups require 1..8 non-nested terms")
+    pure (MetricFilterGroup terms (fromIntegral (LBS.length (Aeson.encode input))))
+  parseJSON input = parseTerm input
+
+isAtomic :: MetricFilter -> Bool
+isAtomic MetricFilter {} = True
+isAtomic _ = False
+
+parseTerm :: Aeson.Value -> Parser MetricFilter
+parseTerm = Aeson.withObject "metric filter" $ \object -> do
+  dimension <- object Aeson..: "dimension"
+  op <- object Aeson..: "op"
+  let noOperand = op `elem` [FilterSet, FilterUnset]
+      expected = if noOperand then ["dimension", "op"] else ["dimension", "op", "value"]
+  unless (sort (KeyMap.keys object) == sort expected) (fail "filter requires exactly dimension/op and value, except set/unset")
+  unless (not (Text.null dimension) && utf8Size dimension <= 1024) (fail "filter dimension must be a nonempty string of at most 1024 UTF-8 bytes")
+  optionalOperand <- if noOperand then pure Nothing else Just <$> object Aeson..: "value"
+  case (op, optionalOperand) of
+    (_, Nothing) -> pure ()
+    (FilterIn, Just (Aeson.Array values)) -> do
+      let operands = toList values
+      unless (not (null operands) && length operands <= 100) (fail "in requires 1..100 scalar values")
+      types <- traverse scalarType operands
+      case types of
+        kind : rest | all (== kind) rest -> pure ()
+        _ -> fail "in values must have the same scalar type"
+    (FilterIn, _) -> fail "in requires an array"
+    (FilterContains, Just operand@(Aeson.String _)) -> void (scalarType operand)
+    (FilterContains, _) -> fail "contains requires a string"
+    (operator, Just operand) | operator `elem` [FilterGt, FilterLt] -> do
+      kind <- scalarType operand
+      unless (kind == "number") (fail "gt/lt require a finite number")
+    (_, Just operand) -> void (scalarType operand)
+  let result = MetricFilter {dimension, op, value = optionalOperand, decodedBytes = 0}
+  pure result {decodedBytes = utf8Size (toUrlPiece result)}
+
+scalarType :: Aeson.Value -> Parser Text
+scalarType = \case
+  Aeson.String text -> do
+    unless (utf8Size text <= 1024) (fail "filter strings are limited to 1024 UTF-8 bytes")
+    pure "string"
+  operand@(Aeson.Number _) -> case Aeson.fromJSON operand :: Aeson.Result Double of
+    Aeson.Success number | not (isInfinite number || isNaN number) -> pure "number"
+    _ -> fail "filter numbers must be finite Float64 values"
+  Aeson.Bool _ -> pure "boolean"
+  _ -> fail "filter operands must be non-null strings, finite numbers or booleans"
+
+instance FromHttpApiData MetricFilter where
+  parseUrlPiece = parseMetricFilter
+
+instance ToHttpApiData MetricFilter where
+  toUrlPiece = Text.decodeUtf8 . LBS.toStrict . Aeson.encode
+
+maxFilters, maxFilterBytes :: Int
+maxFilters = 8
+maxFilterBytes = 16384
+
+parseMetricFilter :: Text -> Either Text MetricFilter
+parseMetricFilter raw
+  | utf8Size raw > maxFilterBytes = Left "decoded filters are limited to 16384 UTF-8 bytes"
+  | Text.isPrefixOf "{" (Text.stripStart raw) = do
+      parsed <- first Text.pack (Aeson.eitherDecodeStrict' (Text.encodeUtf8 raw))
+      pure parsed {decodedBytes = utf8Size raw}
+  | otherwise = do
+      let pieces = Text.splitOn "|" raw
+      unless (length pieces <= 8) (Left "OR groups support at most eight terms")
+      terms <- traverse shorthand pieces
+      pure $ case terms of
+        [term] -> term {decodedBytes = utf8Size raw}
+        _ -> MetricFilterGroup terms (utf8Size raw)
+  where
+    shorthand text = do
+      let parts = Text.splitOn ":" text
+          (name, rest) = case parts of
+            prefix : key : tail | prefix `elem` ["prop", "user", "org", "session"] -> (prefix <> ":" <> key, tail)
+            key : tail -> (key, tail)
+            [] -> ("", [])
+      case rest of
+        [operator] | Just op <- lookup operator [("set", FilterSet), ("unset", FilterUnset)] -> mkMetricFilter name op Nothing
+        operator : operands
+          | not (null operands),
+            Just op <- lookup operator [(filterOpText x, x) | x <- [FilterEq, FilterNeq, FilterIn, FilterContains, FilterGt, FilterLt]] ->
+              let operand = Text.intercalate ":" operands
+                  scalar = case Aeson.eitherDecodeStrict' (Text.encodeUtf8 operand) of
+                    Right v@(Aeson.String _) -> v
+                    Right v@(Aeson.Number _) -> v
+                    Right v@(Aeson.Bool _) -> v
+                    _ -> Aeson.String operand
+                  parsed = if op == FilterIn then either (const (Aeson.String operand)) id (Aeson.eitherDecodeStrict' (Text.encodeUtf8 operand)) else scalar
+               in mkMetricFilter name op (Just parsed)
+        _ -> Left "expected dimension:op:value (literal | requires the JSON form)"
+
+-- The HTTP boundary also checks the original decoded byte lengths, including
+-- whitespace. This check covers direct compiler calls and typed producers.
+validateMetricFilters :: [MetricFilter] -> Either Text [MetricFilter]
+validateMetricFilters filters
+  | length filters > maxFilters = Left "at most eight filters are supported"
+  | sum (map decodedBytes filters) > maxFilterBytes = Left "decoded filters are limited to 16384 UTF-8 bytes"
+  | otherwise = traverse validate filters
+  where
+    validate input = do
+      let terms = filterTerms input
+      unless (not (null terms) && length terms <= 8) (Left "OR groups require 1..8 terms")
+      traverse_ (\term -> void (mkMetricFilter (dimension term) (op term) (value term))) terms
+      pure input
+
+mkMetricFilter :: Text -> FilterOp -> Maybe Aeson.Value -> Either Text MetricFilter
+mkMetricFilter name operator operand =
+  case Aeson.fromJSON (Aeson.object (["dimension" Aeson..= name, "op" Aeson..= operator] <> maybe [] (\input -> ["value" Aeson..= input]) operand)) of
+    Aeson.Error message -> Left (Text.pack message)
+    Aeson.Success result -> Right result
+
+utf8Size :: Text -> Int
+utf8Size = BS.length . Text.encodeUtf8
diff --git a/src/Kansoku/Metrics/Limits.hs b/src/Kansoku/Metrics/Limits.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Metrics/Limits.hs
@@ -0,0 +1,40 @@
+-- | Fixed public limits for complete aggregate answers.
+module Kansoku.Metrics.Limits
+  ( maxSelectedUserIds,
+    maxAcquisitionConversionEvents,
+    maxSessionContainsEvents,
+    maxQueryDays,
+    maxFunnelSteps,
+    maxRetentionDays,
+    maxRetentionOffset,
+    maxMetricPoints,
+    maxPathSteps,
+    maxPathTop,
+    maxPathWindowSeconds,
+    maxPathEvents,
+  )
+where
+
+maxQueryDays :: Integer
+maxQueryDays = 366
+
+maxFunnelSteps, maxRetentionDays, maxRetentionOffset, maxMetricPoints :: Int
+maxFunnelSteps = 20
+maxRetentionDays = 32
+maxRetentionOffset = 365
+maxMetricPoints = 10000
+
+maxPathSteps, maxPathTop, maxPathWindowSeconds, maxPathEvents :: Int
+maxPathSteps = 10
+maxPathTop = 20
+maxPathWindowSeconds = 7776000
+maxPathEvents = 100
+
+maxAcquisitionConversionEvents :: Int
+maxAcquisitionConversionEvents = 20
+
+maxSessionContainsEvents :: Int
+maxSessionContainsEvents = 20
+
+maxSelectedUserIds :: Int
+maxSelectedUserIds = 20
diff --git a/src/Kansoku/Prelude.hs b/src/Kansoku/Prelude.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Prelude.hs
@@ -0,0 +1,46 @@
+{-# LANGUAGE PackageImports #-}
+
+-- | The Kansoku project prelude. Every Kansoku module imports this instead of
+-- re-importing Text, Generic, the aeson class machinery, lens operators, etc.
+module Kansoku.Prelude
+  ( module X,
+    module Control.Lens,
+    eventAesonOptions,
+  )
+where
+
+import "aeson" Data.Aeson as X
+  ( FromJSON,
+    Options,
+    SumEncoding (..),
+    ToJSON,
+    camelTo2,
+    defaultOptions,
+    fromJSON,
+    genericParseJSON,
+    genericToJSON,
+    parseJSON,
+    toEncoding,
+    toJSON,
+  )
+import "aeson" Data.Aeson qualified as Aeson
+import "base" Control.Applicative as X ((<|>))
+import "base" Control.Monad as X (guard, unless, void, when)
+import "base" Control.Monad.IO.Class as X (MonadIO, liftIO)
+import "base" Data.List.NonEmpty as X (NonEmpty (..))
+import "base" Data.Maybe as X (fromMaybe, isJust, isNothing)
+import "base" Data.Proxy as X (Proxy (..))
+import "base" GHC.Generics as X (Generic)
+import "lens" Control.Lens
+import "text" Data.Text as X (Text)
+import "time" Data.Time as X (UTCTime, getCurrentTime)
+
+-- | Standard Aeson options for Kansoku event/command sum types. Encodes as
+-- {"type": "snake_case_tag", "data": {...}} with snake_cased constructor tags.
+eventAesonOptions :: Options
+eventAesonOptions =
+  defaultOptions
+    { Aeson.sumEncoding = Aeson.TaggedObject "type" "data",
+      Aeson.constructorTagModifier = camelTo2 '_',
+      Aeson.tagSingleConstructors = True
+    }
diff --git a/src/Kansoku/Users/Api.hs b/src/Kansoku/Users/Api.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Users/Api.hs
@@ -0,0 +1,26 @@
+{-# LANGUAGE DataKinds #-}
+
+module Kansoku.Users.Api where
+
+import Data.Time (Day)
+import Kansoku.Api.Response
+import Kansoku.Auth.Api (MetricsAuth)
+import Kansoku.Cohort.Api (PageReply)
+import Kansoku.Metrics.Filter (MetricFilter)
+import Kansoku.Prelude
+import Kansoku.Users.Types
+import Relay.Pagination.Servant (RelayPage)
+import Servant.API
+import Servant.API.MultiVerb
+
+type RequiredDates sub = QueryParam' '[Required, Strict] "from" Day :> QueryParam' '[Required, Strict] "to" Day :> sub
+
+type Dates sub = QueryParam "from" Day :> QueryParam "to" Day :> sub
+
+data Users mode = Users
+  { activityCounts :: mode :- "users" :> "activity-counts" :> MetricsAuth :> Description "Exact distinct identified and anonymous activity subjects. Required paired UTC dates [from,to), 1..366 days; includeBots defaults false. Excludes identify/identify_once/alias/group and empty subjects. Sum equals whole-range metrics/users with timeGrain=none. No directory filters." :> RequiredDates (QueryParam "includeBots" Bool :> MultiVerb 'GET '[JSON, ProblemJSON] (OkResponses "Exact activity counts" UserActivityCounts) (KansokuResult UserActivityCounts)),
+    events :: mode :- "users" :> MetricsAuth :> Capture "userId" Text :> "events" :> Description "Resolved retained history including pre-login and identity events. Paired optional UTC dates [from,to), 1..366 days; includeBots defaults false. One event_name eq/in filter, 1..100 names of 1..128 characters. Occurrence/receipt/event ID descending. Exact-version declared scalar properties only, excluding identifiers, alias payloads and context. Identity/group properties require governed catalogs. Relay page size 1..100, default 20; both directions, current reads without snapshot. Unknown/foreign/erased user: user_not_found 404. Nonpagination errors use problem+json; Relay pagination errors use JSON." :> Dates (QueryParam "includeBots" Bool :> QueryParams "filter" MetricFilter :> RelayPage 20 100 :> PageReply UserEvent),
+    getProfile :: mode :- "users" :> MetricsAuth :> Capture "userId" Text :> Description "Current identified-user profile. RetainedEventCount includes all event kinds and bots; activity extrema exclude bots and identify/identify_once/alias/group. Traits include winning observedAt and set/set_once provenance. Evidence counts never expose values and fingerprint validity is 180 days; resolvedAnonymousCount counts original anonymous subjects stitched to this user. Organization is current explicit membership with governed traits or null. Unknown, foreign and erased users share user_not_found 404. One deadline and owned connection, eventual cross-table consistency. Analytical inference, never authorization." :> MultiVerb 'GET '[JSON, ProblemJSON] (OkResponses "User profile" UserProfile) (KansokuResult UserProfile),
+    directory :: mode :- "users" :> MetricsAuth :> Description "Identified-user Relay directory. Optional paired UTC dates [from,to), 1..366 days, select activity but extrema span all retained history. search is literal case-insensitive substring, 1..256 code points. order is lastActivity (default) or firstActivity: timestamp descending, null last, userId ascending. includeBots defaults false; identify/identify_once/alias/group are not activity. Without dates profile-only users have null extrema. Up to eight typed user trait filters and one cohort eq/neq UUID, AND only, 16384 decoded bytes. Page size 1..100, default 20, first/after or last/before. Keep selections fixed; current reads require refresh/deduplication after concurrent changes. Exhaustion is a complete error; unknown/repeated scalar parameters are rejected. Relay pagination errors use JSON, other errors problem+json." :> Dates (QueryParam "search" Text :> QueryParam "order" Text :> QueryParam "includeBots" Bool :> QueryParams "filter" MetricFilter :> RelayPage 20 100 :> PageReply UserSummary)
+  }
+  deriving stock (Generic)
diff --git a/src/Kansoku/Users/Types.hs b/src/Kansoku/Users/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/Kansoku/Users/Types.hs
@@ -0,0 +1,76 @@
+-- | Public, deliberately small identified-user read models.
+module Kansoku.Users.Types where
+
+import Data.Aeson (Value)
+import Data.Aeson qualified
+import Data.Map.Strict (Map)
+import Kansoku.Prelude
+
+data UserSummary = UserSummary
+  { userId :: !Text,
+    traits :: !(Map Text Value),
+    firstActivityAt :: !(Maybe Text),
+    lastActivityAt :: !(Maybe Text)
+  }
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (ToJSON, FromJSON)
+
+data UserActivityCounts = UserActivityCounts {identified :: !Integer, anonymous :: !Integer}
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (ToJSON, FromJSON)
+
+data TraitObservation = TraitObservation {value :: !Value, observedAt :: !Text, operation :: !Text}
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (ToJSON, FromJSON)
+
+data UserOrganization = UserOrganization {organizationId :: !Text, traits :: !(Map Text Value)}
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (ToJSON, FromJSON)
+
+data UserProfile = UserProfile
+  { userId :: !Text,
+    traits :: !(Map Text TraitObservation),
+    firstActivityAt :: !(Maybe Text),
+    lastActivityAt :: !(Maybe Text),
+    retainedEventCount :: !Integer,
+    identityEvidence :: !(Map Text Integer),
+    resolvedAnonymousCount :: !Integer,
+    organization :: !(Maybe UserOrganization)
+  }
+  deriving stock (Generic, Eq, Show)
+  deriving anyclass (ToJSON, FromJSON)
+
+-- | Receipt time is cursor state only and is never serialized as a node field.
+data UserEvent = UserEvent
+  { eventId :: !Text,
+    eventName :: !Text,
+    occurredAt :: !Text,
+    source :: !Text,
+    sourceService :: !Text,
+    sessionId :: !Text,
+    host :: !(Maybe Text),
+    path :: !(Maybe Text),
+    isPageView :: !Bool,
+    properties :: !(Map Text Value),
+    eventReceivedAt :: !Text
+  }
+  deriving stock (Generic, Eq, Show)
+
+instance ToJSON UserEvent where
+  toJSON UserEvent {eventId, eventName, occurredAt, source, sourceService, sessionId, host, path, isPageView, properties} =
+    Data.Aeson.object ["eventId" Data.Aeson..= eventId, "eventName" Data.Aeson..= eventName, "occurredAt" Data.Aeson..= occurredAt, "source" Data.Aeson..= source, "sourceService" Data.Aeson..= sourceService, "sessionId" Data.Aeson..= sessionId, "host" Data.Aeson..= host, "path" Data.Aeson..= path, "isPageView" Data.Aeson..= isPageView, "properties" Data.Aeson..= properties]
+
+instance FromJSON UserEvent where
+  parseJSON = Data.Aeson.withObject "user event" $ \o ->
+    UserEvent
+      <$> o Data.Aeson..: "eventId"
+      <*> o Data.Aeson..: "eventName"
+      <*> o Data.Aeson..: "occurredAt"
+      <*> o Data.Aeson..: "source"
+      <*> o Data.Aeson..: "sourceService"
+      <*> o Data.Aeson..: "sessionId"
+      <*> o Data.Aeson..:? "host"
+      <*> o Data.Aeson..:? "path"
+      <*> o Data.Aeson..: "isPageView"
+      <*> o Data.Aeson..: "properties"
+      <*> pure ""
