Skip to content
Home

API Implementation

Status: Draft Owner: Ben Last Updated: 2026-02-28

Overview

This document defines how the API product specification is implemented. It covers the GraphQL schema structure, resolver architecture, REST endpoints, authentication middleware, error handling, and pagination — built on the Architecture and Data Schema.

Guiding principles:

  • Schema-first. The GraphQL schema is the source of truth. Go resolver code is generated from it via gqlgen. TypeScript client types are generated from it via codegen.
  • Thin resolvers. Resolvers delegate to the Domain layer. No business logic in resolvers — they handle argument parsing, dataloader coordination, and response shaping.
  • Predictable errors. Mutations return typed union results. Queries use the standard GraphQL errors array. Infrastructure errors are HTTP-level. AI agents can handle every error case programmatically.
  • Minimal REST. REST endpoints exist only where the HTTP contract is dictated by something external. Everything else is GraphQL.

Transport Endpoints

GraphQL

POST /graphql/v1 — primary API surface for all entity queries, mutations, and relationship management.

Versioned to allow breaking schema changes during early development. Schema evolution (deprecation + additive changes) is the preferred approach within a version. A new version is introduced only when deprecation is insufficient.

REST

Minimal surface for operations where GraphQL adds no value:

EndpointMethodPurpose
/healthGETLiveness probe — process is running
/readyGETReadiness probe — dependencies connected, ready to serve
/importPOSTBulk import — multipart file upload for platform exports
/callbacks/{platform}POSTOAuth redirects, platform webhook receivers
/metricsGETPrometheus-compatible metrics

REST endpoints are unversioned — health checks, webhooks, and metrics have stable contracts that don’t evolve with the data model.

GraphQL Schema

File Organization

Schema files are organized by entity, merged by gqlgen at build time:

schema/
schema.graphql -- Query/Mutation roots, scalars, directives
common.graphql -- PageInfo, provenance types, error types, annotations
person.graphql -- Person, PersonConnection, person queries/mutations
platform_identity.graphql
conversation.graphql
message.graphql
event.graphql
document.graphql
attachment.graphql
resolution.graphql -- resolution candidates, confirm/reject/merge/split
timeline.graphql -- cross-entity timeline query
connector.graphql -- connector lifecycle types, register/unregister/sync mutations

Root Types

type Query {
# Entity queries
person(id: ID!): Person
persons(filter: PersonFilter, first: Int, after: String): PersonConnection!
message(id: ID!): Message
messages(filter: MessageFilter, first: Int, after: String): MessageConnection!
conversation(id: ID!): Conversation
conversations(filter: ConversationFilter, first: Int, after: String): ConversationConnection!
event(id: ID!): Event
events(filter: EventFilter, first: Int, after: String): EventConnection!
document(id: ID!): Document
documents(filter: DocumentFilter, first: Int, after: String): DocumentConnection!
# Cross-entity
timeline(filter: TimelineFilter!, first: Int, after: String): TimelineConnection!
# Resolution
resolutionCandidates(filter: CandidateFilter, first: Int, after: String): ResolutionCandidateConnection!
# Connectors
connector(id: ID!): Connector
connectors: [Connector!]!
}
type Mutation {
# Entity writes
createMessage(input: CreateMessageInput!): CreateMessageResult!
createMessages(input: [CreateMessageInput!]!): CreateMessagesResult!
updateMessage(id: ID!, input: UpdateMessageInput!): UpdateMessageResult!
deleteMessage(id: ID!): DeleteMessageResult!
# (similar patterns for other entity types)
# Relationship management
confirmResolution(id: ID!): ConfirmResolutionResult!
rejectResolution(id: ID!): RejectResolutionResult!
mergePersons(sourceId: ID!, targetId: ID!): MergePersonsResult!
splitIdentity(personId: ID!, identityId: ID!): SplitIdentityResult!
# Cross-entity references
createReference(input: CreateReferenceInput!): CreateReferenceResult!
deleteReference(id: ID!): DeleteReferenceResult!
# Connectors
registerConnector(input: RegisterConnectorInput!): RegisterConnectorResult!
unregisterConnector(id: ID!): UnregisterConnectorResult!
triggerSync(id: ID!): TriggerSyncResult!
# Annotations
annotate(input: AnnotateInput!): AnnotateResult!
removeAnnotation(id: ID!): RemoveAnnotationResult!
}

Entity Types

Entity types map directly to the ReadStore schema. Nested fields resolve through dataloaders.

type Person {
id: ID!
displayName: String
identities: [PlatformIdentity!]!
platforms: [String!]!
lastActiveAt: DateTime
identityCount: Int!
# Traversals
messages(filter: MessageFilter, first: Int, after: String): MessageConnection!
conversations(filter: ConversationFilter, first: Int, after: String): ConversationConnection!
events(filter: EventFilter, first: Int, after: String): EventConnection!
documents(filter: DocumentFilter, first: Int, after: String): DocumentConnection!
# Resolution context
pendingCandidates: [ResolutionCandidate!]!
# Annotations
annotations: [Annotation!]!
}
type Message {
id: ID!
conversationId: ID!
conversation: Conversation!
sender: MessageSender! # union: Person or PlatformIdentity
recipients: [Recipient!]!
contentType: ContentType!
body: String
sentAt: DateTime!
editedAt: DateTime
platform: String!
attachments: [Attachment!]!
platformExtensions: JSON
# Context retrieval
surrounding(before: Int, after: Int, filter: MessageFilter): [Message!]!
annotations: [Annotation!]!
}
type Conversation {
id: ID!
parent: Conversation
children(first: Int, after: String): ConversationConnection!
title: String
type: ConversationType!
platform: String!
lastMessageAt: DateTime
messageCount: Int
participantCount: Int
messages(filter: MessageFilter, first: Int, after: String): MessageConnection!
participants(at: DateTime): [Participant!]! # temporal membership query
annotations: [Annotation!]!
}

Events, Documents, and Attachments follow the same pattern — canonical fields as typed GraphQL fields, traversals to related entities, annotations.

Pagination (Relay Connections)

All list queries return Relay-style connections:

type MessageConnection {
edges: [MessageEdge!]!
pageInfo: PageInfo!
totalCount: Int
}
type MessageEdge {
node: Message!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}

Cursor encoding: Cursors are opaque to clients. Internally, they encode the sort key value(s) needed to resume — typically a (sent_at, id) pair for temporal ordering, or a (score, id) pair for relevance ordering. Encoded as base64 for transport.

Default page size: 25 items. Maximum page size: 100 items. Configurable per-deployment.

Filters

Filter input types compose freely, matching the product spec’s composability requirement:

input MessageFilter {
personId: ID # messages involving this person (sender or recipient)
conversationId: ID # scope to conversation
platform: String # filter by platform
contentType: ContentType # text, media, call, system
after: DateTime # sent after this time
before: DateTime # sent before this time
search: String # full-text search
provenance: [ProvenanceMethod!] # filter by relationship confidence
orderBy: MessageOrderBy # temporal (default), relevance
}
input TimelineFilter {
personId: ID
platform: String
after: DateTime! # required — timeline must be bounded
before: DateTime!
search: String
entityTypes: [EntityType!] # filter to specific entity types
}

Mutation Results (Union Error Types)

Mutations return union types for structured error handling:

union CreateMessageResult = CreateMessageSuccess | ValidationError | NotFoundError | PermissionError
type CreateMessageSuccess {
message: Message!
}
type ValidationError {
code: String!
message: String!
fields: [FieldError!]
}
type FieldError {
field: String!
message: String!
}
type NotFoundError {
code: String!
message: String!
entityType: String!
entityId: ID!
}
type PermissionError {
code: String!
message: String!
requiredPermission: String!
}

Batch mutations return per-item results:

type CreateMessagesResult {
results: [CreateMessageItemResult!]!
successCount: Int!
errorCount: Int!
}
union CreateMessageItemResult = CreateMessageSuccess | ValidationError

This allows partial success — some items in a batch can succeed while others fail, with structured errors for each failure.

Shared Types

scalar DateTime # ISO 8601
scalar JSON # arbitrary JSON (for platform extensions)
enum ContentType { TEXT, MEDIA, CALL, SYSTEM }
enum ConversationType { DM, GROUP, CHANNEL, THREAD, EMAIL_THREAD }
enum ProvenanceMethod { PLATFORM_REPORTED, EXACT_MATCH, EVIDENCE_ACCUMULATED, USER_CONFIRMED, USER_MERGED, AI_SUGGESTED, HEURISTIC }
type Provenance {
method: ProvenanceMethod!
source: String
confidence: Float
createdAt: DateTime!
}
type Annotation {
id: ID!
type: AnnotationType! # TAG, NOTE, FLAG
value: String!
createdAt: DateTime!
createdBy: String!
}
union MessageSender = Person | PlatformIdentity
type Recipient {
identity: PlatformIdentity!
person: Person # null if unresolved
role: RecipientRole # TO, CC, BCC, MENTION
}
type Participant {
identity: PlatformIdentity!
person: Person
joinedAt: DateTime!
leftAt: DateTime
}

Resolver Architecture

Layer Boundaries

GraphQL Request
→ Auth Middleware (resolve AuthContext)
→ gqlgen Resolver (parse args, call Domain)
→ Domain Layer (business rules, permission enforcement)
→ ReadStore / WriteStore (data access)
→ gqlgen Response (shape result)

Resolvers are thin — they translate GraphQL arguments into Domain method calls and Domain responses into GraphQL types. Business logic (permission checking, provenance rules, validation) lives in the Domain layer.

Dataloaders

Per-request dataloaders prevent N+1 queries on nested field resolution:

  • PersonLoader — batch-fetch persons by ID
  • ConversationLoader — batch-fetch conversations by ID
  • PlatformIdentityLoader — batch-fetch identities by ID
  • AttachmentLoader — batch-fetch attachments by message ID

Dataloaders are instantiated per request with the request’s AuthContext, ensuring tenant scoping is applied to every batch query.

When dataloaders are used: Simple ID-based lookups triggered by nested field resolution (message → sender, message → conversation).

When dataloaders are not used: Top-level queries with filters, sorting, and pagination go directly to the ReadStore through the Domain layer. These are single queries, not N+1 situations.

Context Retrieval

The surrounding field on Message implements the anchor-based windowing pattern from the API spec:

# "Give me 5 messages before and 10 messages after this message"
message(id: "...") {
id
body
surrounding(before: 5, after: 10) {
id
body
sentAt
sender { displayName }
}
}

This resolves to a single ReadStore query: messages in the same conversation, ordered by sent_at, anchored on the target message’s timestamp and ID.

Authentication Middleware

Authentication runs before GraphQL execution. Every request must carry an API key.

Request Flow

  1. Extract API key from Authorization: Bearer {key} header
  2. Look up key → resolve to AuthContext (tenant, tier, permissions, data scope, rate limits)
  3. If invalid or missing → HTTP 401 (never reaches GraphQL)
  4. If rate limited → HTTP 429 with rate limit headers
  5. If valid → attach AuthContext to request context, proceed to GraphQL execution

AuthContext Propagation

AuthContext flows through Go’s context.Context:

  • Resolvers access it to determine what the consumer can see
  • Domain layer uses it for permission enforcement
  • ReadStore queries apply DataScope as implicit filters (tenant, platform, person, time range scoping)
  • Dataloaders receive it at instantiation for batch query scoping

A scoped consumer physically cannot retrieve data outside their scope — the scope filters are injected at the query level in the ReadStore, not checked after retrieval.

Rate Limit Headers

Every response includes rate limit state:

X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 847
X-RateLimit-Reset: 1708099200

Rate limits are per-AuthContext (per API key), tracked in-memory initially (extractable to Redis for cloud deployment).

Error Handling

Error Layers

LayerMechanismExamples
InfrastructureHTTP status codes401 Unauthorized, 429 Rate Limited, 503 Service Unavailable
Query executionGraphQL errors arrayResolver failures, timeout, malformed query
Business logicMutation union typesValidation errors, not found, permission denied

Infrastructure errors return before GraphQL execution — the response body is a JSON error object, not a GraphQL response. This ensures consumers can distinguish “the API rejected my request” from “the query executed but had errors.”

Error Codes

Machine-readable error codes beyond HTTP status, namespaced by domain:

  • AUTH_INVALID_KEY, AUTH_EXPIRED_KEY, AUTH_INSUFFICIENT_PERMISSIONS
  • RATE_LIMIT_EXCEEDED
  • VALIDATION_REQUIRED_FIELD, VALIDATION_INVALID_FORMAT, VALIDATION_CONSTRAINT
  • NOT_FOUND_ENTITY
  • RESOLUTION_ALREADY_LINKED, RESOLUTION_REJECTED_PAIR
  • IMPORT_INVALID_FORMAT, IMPORT_TOO_LARGE
  • CONNECTOR_INFRASTRUCTURE_DISABLED, SYNC_ALREADY_IN_PROGRESS

Codes are stable identifiers — AI agents switch on these, not on human-readable messages.

Bulk Import (REST)

POST /import handles file uploads for platform data exports.

Request

Multipart form upload:

  • file — the export file (zip, mbox, json, csv)
  • platform — source platform identifier
  • format — export format hint (optional, auto-detected when possible)

Response

{
"importId": "...",
"status": "accepted",
"estimatedEntities": 15000
}

The import is processed asynchronously. Progress is queryable via GraphQL:

query {
importStatus(id: "...") {
id
status # ACCEPTED, PROCESSING, COMPLETED, FAILED
progress {
entitiesProcessed
entitiesTotal
errors
}
}
}

Performance Considerations

  • ReadStore queries only. The consumer API reads exclusively from the ReadStore — denormalized, indexed, no joins. Write path performance is independent.
  • Dataloader batching. Nested resolution is batched within each request, capped at one query per entity type per resolver level.
  • Pagination limits. Maximum page size (100) prevents unbounded result sets. totalCount is optional and computed only when requested (it requires a separate COUNT query).
  • Search vector precomputation. Full-text search uses precomputed tsvector columns in the ReadStore, updated by the Projector. Search queries hit GIN indexes, not live text processing.
  • Connection pooling. Database connections are pooled per-process. Cloud deployment may add PgBouncer for connection multiplexing across multiple API containers.

Privacy Considerations

  • AuthContext enforcement is mandatory. Every query path — resolvers, dataloaders, direct ReadStore access — receives and enforces the AuthContext. There is no code path that accesses data without tenant and permission scoping.
  • DataScope is query-level. Scoped consumers cannot see out-of-scope data even if they construct queries that reference it. Scope filters are applied in the ReadStore query, not post-retrieval.
  • Rate limit headers do not leak tenant information. Rate limits are per-key, and rate limit responses contain only the consumer’s own limits.
  • Error messages do not leak data. A NotFoundError for an entity outside the consumer’s scope is indistinguishable from a genuinely nonexistent entity.
  • Audit logging. All API access is logged to the access log (see Data Schema) with the AuthContext identity, operation, and outcome.

Product Specifications

  • API — Consumer-facing API contract this implementation serves
  • Data Model — Entity types exposed through the GraphQL schema
  • Entity Resolution — Resolution operations (confirm, reject, merge, split)
  • Ingestion — Write API and bulk import patterns

Technical Specifications

Decisions