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:
| Endpoint | Method | Purpose |
|---|---|---|
/health | GET | Liveness probe — process is running |
/ready | GET | Readiness probe — dependencies connected, ready to serve |
/import | POST | Bulk import — multipart file upload for platform exports |
/callbacks/{platform} | POST | OAuth redirects, platform webhook receivers |
/metrics | GET | Prometheus-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 mutationsRoot 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 | ValidationErrorThis allows partial success — some items in a batch can succeed while others fail, with structured errors for each failure.
Shared Types
scalar DateTime # ISO 8601scalar 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
- Extract API key from
Authorization: Bearer {key}header - Look up key → resolve to AuthContext (tenant, tier, permissions, data scope, rate limits)
- If invalid or missing → HTTP 401 (never reaches GraphQL)
- If rate limited → HTTP 429 with rate limit headers
- 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: 1000X-RateLimit-Remaining: 847X-RateLimit-Reset: 1708099200Rate limits are per-AuthContext (per API key), tracked in-memory initially (extractable to Redis for cloud deployment).
Error Handling
Error Layers
| Layer | Mechanism | Examples |
|---|---|---|
| Infrastructure | HTTP status codes | 401 Unauthorized, 429 Rate Limited, 503 Service Unavailable |
| Query execution | GraphQL errors array | Resolver failures, timeout, malformed query |
| Business logic | Mutation union types | Validation 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_PERMISSIONSRATE_LIMIT_EXCEEDEDVALIDATION_REQUIRED_FIELD,VALIDATION_INVALID_FORMAT,VALIDATION_CONSTRAINTNOT_FOUND_ENTITYRESOLUTION_ALREADY_LINKED,RESOLUTION_REJECTED_PAIRIMPORT_INVALID_FORMAT,IMPORT_TOO_LARGECONNECTOR_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 identifierformat— 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.
totalCountis optional and computed only when requested (it requires a separate COUNT query). - Search vector precomputation. Full-text search uses precomputed
tsvectorcolumns 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
NotFoundErrorfor 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.
Related Documents
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
- Architecture — GraphQL with gqlgen, AuthContext model, REST/GraphQL boundary
- Data Schema — ReadStore tables that resolvers query, WriteStore for mutations
- Module Interfaces — Store interfaces that resolvers consume (MessageReader, PersonReader, etc.)
- Search & Indexing — SearchReader interface used by search resolvers
- Security & Privacy — Security architecture overview and guarantees
- Security & Privacy (Internal) — KeyManager, RateLimiter, QueryScope interfaces used by middleware
Decisions
- ADR-003: GraphQL as Primary API — Why GraphQL was chosen as the primary API transport
- ADR-004: UUIDv7 Primary Keys — Why UUIDv7, relevant to cursor-based pagination