Skip to content

Contracts

The wire types shared between Primer's services. They are strict by default: unknown fields are rejected rather than ignored, and every model is immutable once constructed.

That strictness is the point. A field silently dropped between two services is a bug that surfaces as missing data much later, somewhere else.

The web app does not restate them. Each service's OpenAPI schema is written to schemas/ by scripts/dump_openapi.py, and the TypeScript types are generated from those files into apps/web/src/lib/api/generated/. Both are checked in, and CI regenerates and diffs both — so a field renamed here is a failed build rather than a runtime surprise in a browser.

uv run python scripts/dump_openapi.py   # contracts -> schemas/
pnpm run generate:api                   # schemas/ -> TypeScript

What is not generated is the part that decides who a request is from: the identity headers this server forwards to Primer are hand-written and reviewed, in apps/web/src/lib/server/. A tool emitting that from a document describing something else is not a saving worth making.

Identity

primer_contracts.identity

The internal principal that services authorize against.

Principal is derived by the Control API from trusted edge identity. It is never populated directly from a browser-supplied header by any other service.

Principal

Bases: WireModel

An authenticated Primer identity acting on a request.

DeploymentCapabilities

Bases: WireModel

What this deployment can actually do, as the browser needs to know it.

Sent to the web app so it can hide what will not work rather than offering it and failing. Every field is a checked fact or an operator's declaration - none is inferred from a model's name.

is_admin

is_admin(
    principal: Principal,
    *,
    auth_enabled: bool,
    admin_group: str | None,
) -> bool

Whether this principal may see and change how the deployment is wired.

One rule, shared, because two services enforce it and a deployment where they disagreed about who is an administrator would be a deployment whose answer depends on which door you knocked on.

Group membership is the whole test. Primer implements no authentication and no roles of its own; the operator names a group in their identity provider, the proxy asserts it, and this is where that assertion is read. It stays consistent with groups never granting access to a resource - no library becomes readable because of this, only the deployment's own configuration.

Fails closed. Authentication on with no group named means nobody is an administrator, which is the safe reading of an operator who has not made the decision yet: the alternative is that every user of a shared deployment can repoint it at an endpoint of their own.

With authentication off there is exactly one identity and it is whoever is running Primer - a single-user Compose stack, by construction. There is nobody to withhold this from, and withholding it would leave that deployment with no way to reach its own settings at all.

Errors

primer_contracts.errors

Stable, machine-readable error contracts (RFC 9457 style).

ErrorCode

Bases: StrEnum

Error codes clients may branch on. Values are part of the contract.

ProblemDetail

Bases: WireModel

Sanitized error body. Operational context stays in logs, not here.

Libraries

primer_contracts.libraries

Library contracts.

A library has one owner, and owner_user_id is reported alongside the acting principal rather than standing in for authorization: a caller compares the two to tell its own libraries from ones shared with it, and never to decide what it may do with either. Control decides that.

LibrarySummary

Bases: WireModel

A library as returned by the Control API.

LibraryShare

Bases: WireModel

One person a library has been shared with.

Identified by email as well as by id, because an id is not something the owner can check their intent against. Sharing is a decision about a person, and the owner has to be able to see that the person on the list is the one they meant.

Deliberately carries no role. Read access is the only thing a share grants today, and a field named role that always says the same word would be a promise the authorization model does not keep.

Documents

primer_contracts.documents

Document and ingestion-status contracts.

IngestionStatus

Bases: StrEnum

Deterministic states a user can observe for a document version.

DocumentSummary

Bases: WireModel

A document and the state of its current immutable version.

ReindexSummary

Bases: WireModel

What a library-wide rebuild actually started.

Two numbers rather than one, because they differ for a reason worth showing. A document already being rebuilt is not restarted - two workers writing different generations of one version, only one of which is ever activated - so pressing the button twice queues nothing the second time. Reporting only "queued" would make that look like a failure.

Chunks

primer_contracts.chunks

The unit of retrievable content, passed from ingestion to retrieval.

Chunks cross a service boundary as Primer contracts rather than as Haystack documents. Retrieval owns the document store exclusively, so the shape Haystack wants is its business alone; putting a Haystack type on the wire would spread that ownership across two services and make an upgrade there a coordinated release here.

DocumentChunk

Bases: WireModel

One passage, with everything needed to authorize and cite it.

Scope is carried on every chunk, not attached at index time. A chunk that could not name its library could not be filtered by one, and retrieval's isolation guarantee is exactly that filter.

Retrieval

primer_contracts.retrieval

Retrieval contracts.

Every request carries both the acting principal and an explicit library scope. The Retrieval service rejects unscoped queries, so the scope is required here rather than inferred from ownership.

SourceLocator

Bases: WireModel

Where a passage sits inside its source document.

Locators are user-meaningful positions only. Filesystem or object-store paths are deliberately absent from citation-facing contracts.

RetrievalRequest

Bases: WireModel

A user- and library-scoped search.

RetrievedChunk

Bases: WireModel

A ranked passage with the scope needed to authorize and cite it.

Ingestion jobs

primer_contracts.ingestion

Contracts for the cluster-internal job transition protocol.

Workers own no ingestion state. Every transition goes through the Control API, which is the only writer of job rows, so two workers handed the same message cannot both decide they are the one doing the work.

Messages on the broker carry a job id and nothing else. Everything a worker needs arrives in the claim response, so a message that sat in a queue across a reindex cannot act on a stale copy of the document it was published with.

StageName

Bases: StrEnum

The units of work a message can ask for.

ClaimOutcome

Bases: StrEnum

Why a worker may or may not proceed.

Only CLAIMED authorizes work. The rest are ordinary, expected results of at-least-once delivery, not errors: a worker that sees them acknowledges the message and stops.

FailureDisposition

Bases: StrEnum

What a failure means for the job, decided by the worker.

The worker is the only party that knows whether a failure was transient, so it says so explicitly rather than leaving Control to infer it from an error code.

JobClaim

Bases: WireModel

Everything a worker needs, delivered at claim time.

The scope fields are not conveniences: every chunk this job produces carries them, and retrieval filters on them, so a claim that could not name its library could not produce retrievable content.

StageClaim

Bases: WireModel

Ask to enter a stage. The job is named in the path, not the body.

StageCompletion

Bases: WireModel

Report that a claimed stage finished.

The generation is echoed back so Control can refuse a completion for work that a reindex has already superseded.

StageFailure

Bases: WireModel

Report that a claimed stage failed.

code and detail are shown to users, so they must stay sanitized; exception traces belong in worker logs, correlated by job id.

TransitionResult

Bases: WireModel

The outcome of a completion, failure, or heartbeat.

Indexing

primer_contracts.indexing

The Retrieval service's cluster-internal API.

Retrieval owns the vector store exclusively. Nothing outside it names a collection, a table, or a filter syntax; callers name a library and a generation, and Retrieval decides what that means for the backend it was configured with.

IndexRequest

Bases: WireModel

Write one generation's chunks.

Chunks are written into a pending generation, which nothing searches until it is activated. A half-finished rebuild is therefore invisible rather than partially answering questions.

GenerationQuery

Bases: WireModel

Address one generation of one version.

GenerationCount

Bases: WireModel

How many chunks a generation actually holds.

Activation compares this against what ingestion produced. A generation that is short of its expected count is an incomplete index, and activating it would silently drop the missing passages from every future answer.

LibraryAccessRequest

Bases: WireModel

Ask Control whether a principal may read a library, and what to search.

LibraryScope

Bases: WireModel

Permission and search scope in one answer.

They travel together because they are decided from the same rows: asking separately would leave a window where a library became readable, or stopped being, between the two calls.

SearchRequest

Bases: WireModel

Search one library, within a known set of active generations.

Both scope fields are required and neither has a default. A search that could omit them would, on the day someone forgot, quietly return another user's documents - so omission is a validation error, not an empty filter.

DeleteRequest

Bases: WireModel

Remove one generation's chunks.

Deletion is addressed by generation, so retiring a superseded index and erasing a deleted document are the same operation with the same idempotency.

PurgeRequest

Bases: WireModel

Remove a version's chunks, optionally sparing one generation.

Retiring a superseded build and erasing a deleted document are the same operation with one parameter different, so they share a code path rather than two that must stay in agreement about what "gone" means.