0.8.0: 0.2.0 added the
subscription Contribution to Plugin API 0.1.0, 0.3.0 added connector,
0.3.1 the instance scope to connector fetch requests, the declared
checkpoint bound and _ in plugin ids and extension namespaces, 0.4.0
connector attachments, 0.5.0 the connector push mode (receive), and
0.6.0 the ingestion Contribution, 0.7.0 the retrieval Contribution, and
0.8.0 segment-only segment_and_embed requests (spaces: []). JSON Schemas in this
directory are the source of truth; SDKs and the Contract Runner implement them,
not the other way round. Design context: ADR 0001 (docs/adr/0001-plugin-cli-and-contract-runner-in-quivr-binary.md),
ADR 0002 (docs/adr/0002-record-version-identity-from-submitted-input.md)
and the glossary in CONTEXT.md.
The Manifest, Part, Extensions, Relation, SourceIdentity and Provenance shapes
are not defined here. They come from
contracts/shared/v0/manifest.schema.json,
the single source also used by the public HTTP contract, so a normalizer
returns exactly the Manifest a client could submit as kind: "manifest".
go test ./contracts/ (part of make test) fails if an openapi.yaml
component for one of those shapes is anything but an alias to the shared file,
or if a plugin schema declares a $defs entry with a shared name; the bundler
used by make contracts also rejects a malformed alias. Reviewers still keep
equivalent copies under other names out of both contracts.
Contributions
Plugin API 0.7 accepts five Contributions, and a manifest declares at least one:
The names
enricher, validator, projector and retriever
are reserved (declare retrieval for search). A manifest that declares them is rejected
(reserved_contribution).
Plugin API versions
A minor Plugin API version only adds to the previous one. This engine implements0.8.0 and still serves every 0.1 to 0.7 plugin unchanged: a
manifest is compatible when its plugin_api range admits any supported version
(0.1.0, 0.2.0, 0.3.0, 0.3.1, 0.4.0, 0.5.0, 0.6.0, 0.7.0 or 0.8.0), and the engine speaks the highest one the range admits.
A manifest field introduced by a later minor version needs a range that admits
it: contributions.connector.attachments (0.4) with plugin_api: ">=0.3.0 <0.4.0"
is incompatible_plugin_api at that field, and so is a kind’s push mode (0.5)
at its modes.
A patch version only adds optional fields; a plugin that validates requests
strictly accepts them once it is built with an SDK of that version.
quivr plugin inspect reports that negotiated version. Discovery must serve
a supported version inside the declared range that is at least the version
each declared Contribution needs; SDKs serve the negotiated one. A
Contribution needs a range that admits the version that introduced it:
subscription with plugin_api: ">=0.1.0 <0.2.0" is
incompatible_plugin_api at /contributions/subscription. Discovery lists
Contributions as a set, in any order.
HTTP routes
The plugin serves JSON over HTTP. Paths are versioned by the Plugin API major version.- Errors. Every non-2xx response carries the error envelope
{code, message, retryable}.retryable: trueasks the engine to retry within the declared retry intent.retryable: falseis terminal. - Unavailability. A connection failure, a timeout, or a 5xx without a valid envelope counts as plugin unavailability, not a plugin decision.
- Manifest digest.
manifest_digestissha256:followed by the lowercase hex SHA-256 of the exact bytes of thequivr-plugin.yamlthe plugin was built from.quivr plugin inspectprints the same value. - Future Contributions use
/v0/contributions/<name>, with one sub-route per operation when a Contribution has several (connector/fetch).
Manifest (quivr-plugin.yaml)
Version ranges
A range is one or more comparators separated by whitespace, and every comparator must hold. A comparator is an optional operator (>=, >, <=,
<, =) directly followed by MAJOR.MINOR.PATCH. No operator means =.
Shorthands (^, ~, x), || and pre-release versions inside a range are
not part of the grammar.
Versions compare by SemVer 2.0.0 precedence, so 0.2.0-rc.1 satisfies
<0.2.0. A range that no release version satisfies, such as
>=0.3.0 <0.2.0, is invalid. fixtures/ranges.json is normative for every
implementation.
This engine implements Plugin API 0.3.1 (and serves 0.1.0, 0.2.0 and 0.3.0) and reports
engine version 0.1.0. Release builds may override the engine version.
quivr plugin inspect --json reports both, and the negotiated Plugin API
version under compatibility.plugin_api.version.
Invocation context
The normalizer request carries:- the invocation id and idempotency key;
- the Organization, Corpus, Record and Record Version ids;
- the source namespace and Record Key;
- the input Blob id, media type, size and SHA-256;
- the submitted extensions and provenance;
- the validated plugin configuration;
- a reference to the input Blob: a short-lived signed GET URL or, in local
development only, a
file://URL.
Validation rules
Checked today.quivr plugin inspect checks a manifest against:
- the manifest schema, including unknown fields;
- well-formed and satisfiable ranges;
- compatibility with this engine and a supported Plugin API version, and with the version each declared Contribution needs;
- reserved Contributions and reserved fields;
- subscription expression and configuration schemas that compile as JSON
Schema 2020-12 (
invalid_expression_schema,invalid_config_schema); - connector kind config and credential schemas that compile
(
invalid_config_schema,invalid_credential_schema); - extension namespaces equal to the plugin id or prefixed by
<id>.; - configuration and extension schemas that compile as JSON Schema 2020-12;
- duplicate secret names.
quivr plugin test (the Contract
Runner, below) checks them on every normalizer response with the validation in
internal/plugins (CheckNormalizerOutput), and the engine applies the same
function to every invocation of a pinned normalizer before anything is
recorded.
- Blob Parts may reference only the input Blob. A normalizer response
must not introduce other Blobs. A Blob Part must name the input Blob id
with its media type (
foreign_blob,unverified_blob). The public Blob Part carries no checksum. The reference is resolved to the verified input Blob, and its SHA-256 must equal the request’sinput.sha256. The runner re-hashes the file it served. Checked by the Contract Runner; enforced by the engine (THE-683). - Response size cap. A response larger than the declared
max_response_bytes(default 4 MiB) is invalid output (response_too_large). The engine caps the declared value at 16 MiB. Checked by the Contract Runner; enforced by the engine (THE-683). - Namespace ownership. Response extensions, top-level and on Parts, may
use only namespaces and schema versions the plugin declares, with data
valid against the declared schema (
undeclared_namespace,undeclared_schema_version,invalid_extension). Clients may not write plugin-owned namespaces. The output side is checked by the Contract Runner. The engine enforces both sides (THE-684): it registers the pinned plugin’s namespaces at startup (refusing a namespace not prefixed by the plugin id,foreign_namespace, or clashing with a built-in one,namespace_conflict), publishes valid output extensions on the Version, fails invalid ones asnormalizer_invalid_output, and rejects client writes with 422extension_namespace_owned. Retrieval mappings may point at/extensions/{plugin namespace}/....
max_parts is also invalid
(too_many_parts).
Subscription Contribution
An alert rule (since Plugin API 0.2). The core sends one Record Version and a batch of distinct evaluations; the plugin answers one decision per evaluation. A plugin pinned at startup (plugins in the core configuration,
beside the normalizers) is installed as the evaluator <id>@<version>, and
Subscriptions pin it by plugin_id and version.
When the core asks. When a Record Version becomes searchable
(record.retrieval_ready), and again once its embeddings are attached
(record.enrichment_available) for every Subscription that answered
not_ready. A rule that needs enrichment answers not_ready while enriched
is false. Nothing is evaluated at acceptance time, before the Version is
searchable. The core batches every due Subscription of one Record Version
pinned to the plugin, across Subscriptions and owners, into as few requests as
max_batch_size allows. A Subscription Version that decided a Record Version
(match or no_match) is not asked about it again, even when a later trigger
arrives while it is being decided, so a rule that calls a paid backend pays
once per Record Version. One worker at a time claims the due evaluations of a
Record Version, so they are not split between concurrent requests.
Previews. The API also asks when a client previews a proposed Subscription
(POST /v0/subscription-previews): one request per recent Record Version,
each with one evaluation whose subscriptions holds a single synthetic
reference whose Subscription and Subscription Version ids are preview (its
Saved Query ids too, when the Saved Query is inline). The decision is
returned to the client and never stored. So the API process must reach the
plugin’s endpoint too, and a plugin that calls a paid backend pays for each
Record Version a preview judges (at most 50 per preview).
Request (subscription-request.schema.json):
-
invocation_id,idempotency_key,organization_idand the validated plugin (installer)configuration, as for every Contribution; -
record: the Corpus, Record and Record Version ids,enriched(embedding coverage in the active generation) and the canonical textparts(key,role,text; at most 256, unique keys, Blob Parts are not sent); -
the record’s metadata, which rules may test besides the text. They are
optional in the schema and the core always sends them (the extensions when
the Version has some):
source: the Sourcenamespace,record_keyand, when the producer sent one, the Sourcepositionof the accepted revision;accepted_at: when Quivr accepted the revision (RFC 3339, UTC);provenance:origin(connectorfor a revision a Connector Instance acquired,clientotherwise; a client cannot claimconnector), theproducerandproducer_version, theconnector(instance_id,kind) and the externalnormalization(plugin_id,plugin_version,contribution,fallback);extensions: the Version’s structured metadata by namespace, such as an author, a section or categories when the source provides them.
-
evaluations: 1 tomax_batch_sizeitems, each with anidunique in the request, the pinned Saved Query Versionexpression, the pinned Subscription evaluatorconfiguration, and thesubscriptions(Subscription, Subscription Version, Saved Query and Saved Query Version ids, and the Subscriptionownerwhen it has one) it stands for.
timeout_ms, capped by the core at 30 seconds.
Per-Part vector and per-evaluation query_vector are reserved for
local-vector matching; Plugin API 0.2 never sends them and the schemas reject
them.
Response (subscription-response.schema.json): decisions, exactly one
per requested evaluation id.
A rule that cannot decide at all (a backend down) answers with the error
envelope. The core keeps every evaluation of the batch pending and retries it
with backoff (from one second up to five minutes) until it is decided; a
plugin that is down, times out, serves another manifest or answers outside
these rules is treated the same way. Plugin unavailability never becomes a
negative decision: alerts are delayed, never lost or skipped. An error
envelope applies to the whole request. A
retryable: true envelope (a backend
down) retries the batch as one; after a terminal envelope or an invalid answer
the core halves the batch until the evaluation that fails is alone, so the
other evaluations are decided. A request the schemas reject is refused
with retryable: false.
Evidence bounds. They mirror the bounds the monitoring engine applies to
Match evidence (internal/monitoring), and CheckSubscriptionOutput in
internal/plugins judges every answer:
Expression and configuration schemas. The core validates Saved Query
expressions against
expression_schema and Subscription configurations
against configuration_schema when it pins them, so a rule receives only
values its schemas accept (ValidateSubscriptionItem, codes
invalid_expression and invalid_subscription_configuration). Creating a
Subscription or a Subscription Version whose expression or configuration the
schemas refuse is 422 with that code, the request member at fault in field
and the first schema issue in message. To offer
several kinds of alert, discriminate them with a oneOf over a constant
kind property; quivr plugin inspect lists the kinds. An installation may
accept only some of them: the pin’s optional kinds in the core startup
configuration (for example ["keywords"], when the plugin has no credentials
for the backend of another kind) must name declared kinds, and the core
refuses a Saved Query of any other kind with invalid_expression.
Connector Contribution
A source collector (since Plugin API 0.3). The core keeps everything durable: Connector Instances, schedules and leases, Acquisition Checkpoints, Deposited Credentials and Connector Health. The plugin only fetches, and is stateless between invocations. Pinned connector plugins provide Connector Instance kinds (Run a connector plugin).fetch (connector-fetch-request.schema.json) carries invocation_id,
organization_id, the installer configuration, the Connector Instance
(connector: instance_id, kind, config, and since 0.3.1 the corpus_id
and source_namespace the instance writes to, so a plugin can bind Relation
targets to full Source Identities), the decrypted credential (null
for a kind without one), the opaque checkpoint the plugin returned last (null
on a first run), now, page_in_run (0, then counting up while the plugin
answers more: true) and reads_today. The response carries:
items, at mostmax_items. Each has arecord_key(unique in the page), an optionalrevisionandsource_position, and exactly one ofcontent(the sharedTextContentorManifestContent) andwithdraw: true(a Tombstone, such as a post deleted at the source). Extensions, on the item and its Parts, use only namespaces the plugin declares. A Manifest has no Blob Parts (blob_part_not_allowed): binary Parts areattachments(key,parent_key,role,media_type, optionalsize_bytes,sha256andextensions, and an opaqueref), which need Manifest content and a manifest that declaresattachments(attachments_unsupportedotherwise). The core asks for their bytes later, only for an item it does not already have (Connector attachments);checkpoint(required, any JSON value of at mostmax_checkpoint_bytesserialized as compact JSON, 64 KiB unless the manifest declares more), which resumes after this page. The core persists it and advances it only after the page’s items are accepted, so a migrated plugin keeps reading the checkpoints it wrote;more, which asks for another page in the same run and needs a checkpoint that moved;- optional
reads(source resources read, for usage counters),diagnostics(an object of at most 16 KiB, shown as Connector Health diagnostics) andnotice(a code that ends a run that otherwise completed, such as a spend cap); not_due: truewhen the source asked not to be polled yet, with no items,more: falseand the request’s checkpoint unchanged; the core skips the run.
CheckConnectorOutput in
internal/plugins judges every answer: the rules above, invalid_item,
duplicate_record_key, too_many_items, checkpoint_too_large,
diagnostics_too_large, invalid_not_due, the engine’s Manifest rules with
the attachments as Parts, declared namespaces and response_too_large.
Connector attachments
Since Plugin API 0.4, a manifest that declarescontributions.connector.attachments
(max_bytes, at most and by default 25 MiB, and timeout_ms, at most and by
default 120 s, per call) lets items carry attachments. The plugin never writes
to storage on its own. For each attachment of an item the core has not accepted
yet:
describe_attachment(connector-describe-attachment-request.schema.json) carries the usual envelope, theitem(record_key,revision, currentextensions) and theattachmentdescriptor. The plugin reads the bytes and answers{"size_bytes", "sha256"}(lowercase hex), or{"skip": "<code>"}with optionalitem_extensionsthat replace the item’s, for example to record the skipped attachment. A size abovemax_bytesisattachment_too_large: answer{"skip": "too_large"}instead. A descriptor that already carries an exactsize_bytesandsha256is not described.- The core reuses a verified Blob with the same identity, or issues a
grant: one presigned PUT (
url,method,headers,size_bytes,sha256,media_type,expires_at) that storage accepts only with exactly those bytes. A grant is a capability: never log or return it (credential_leak). upload_attachment(connector-upload-attachment-request.schema.json) carries the same fields and thegrant. The plugin sends the PUT with exactly the grant’s headers and answers{"status": "uploaded"}. Bytes that changed at the source since they were described are a source errorattachment_changed; the core describes the attachment again once, then rejects only that item.- The core reads the stored bytes back and checks their size and SHA-256 before the Blob becomes a Blob Part of the item. Bytes that fail the check are never used, the item is not accepted and the checkpoint does not move.
CheckAttachmentAnswer and CheckUploadAnswer in internal/plugins judge
the answers for the engine and the Contract Runner.
check_credential (connector-check-credential-request.schema.json)
carries the same Instance, credential and now, and answers
{"status": "ok"} with an optional expires_at, which drives the
credential_expiring health state. A refused credential is an access error.
Errors. A connector error envelope carries error_class:
A connector error without
error_class, or whose retryable disagrees with
it, is wrong_error_class. Refusing an invalid request stays a terminal
envelope, with or without a class.
Credentials reach the plugin only in the invocation that needs them. A
plugin never logs, returns or stores them; the Go SDK redacts them from its
logs and error messages, and the Contract Runner checks that none appears in a
response or in the plugin’s output.
Connector fixtures (connector-fixture.schema.json, a file with a
top-level connector property) hold the kind, config, credential, an
optional plugin configuration, a starting checkpoint (default null), now
(default 2026-01-01T00:00:00Z), max_pages (default 10) and what to expect:
the Record Keys (and more) of each page, an error (error_class, optional
code) or the check_credential answer. The Instance is
dev-connector-<digest>, invocations dev-invocation-<digest>-<page>, from the
first 16 hex digits of the SHA-256 of the fixture bytes; fetch requests carry
corpus_id dev-corpus and source_namespace dev-namespace, and for a
push kind the webhook_url https://quivr.invalid/v0/connector-webhooks/<instance>.
fixtures/connectors/feed.json is a normative example. A push kind’s fixture
lists receive cases (since 0.5): a request (method, query, headers,
and a text body or a body_base64) and what to expect (verdict,
status, record_keys, body_contains, or an error), relayed with the
fixture’s config, credential and checkpoint; fixtures/connectors/push.json
is a normative example.
Connector push
Since Plugin API 0.5, a kind withmodes: [pull, push] also receives what
the source sends. The core owns one public route per Connector Instance,
/v0/connector-webhooks/<instance> under the deployment’s public_url, and
sends that address to the plugin as connector.webhook_url in fetch
requests, so the plugin registers it with the source and is never exposed
itself. For each GET or POST to the route, with a body of at most 1 MiB,
the core calls receive (connector-receive-request.schema.json) with the
Instance (and its corpus_id and source_namespace), the credential, the
current checkpoint (read-only), now, reads_today and the relayed
request: method, raw query, headers by lowercase name (at most 64; no
hop-by-hop header or Cookie) and body_base64, the exact bytes a signature
covers. The deadline is timeout_ms, capped at 8 seconds.
The plugin verifies the request with the credential and answers
(connector-receive-response.schema.json) a verdict and the response the
core returns to the source (status, optional content_type and a text
body of at most 64 KiB):
acceptedwith a 2xx status: the request is authentic. Itsitems(the fetch item shape, without attachments) are ingested before the source is answered, through the same path as a pull run’s, so an item both paths return converges on the same Receipt. A challenge, such as a CRC check, isacceptedwith no items and the answer in the body.readscounts toward the usage counters.refusedwith a 4xx status and no items: not authentic or not for this instance. The core answers the source and changes nothing.
CheckReceiveOutput judges every answer (invalid_verdict, the item rules,
declared namespaces). An answer carries no checkpoint: pull runs own it. When
the plugin is unavailable, or answers a transient error, the source gets a
503 with Retry-After; an access or source error is a 500. Those failures
show in Connector Health (health.push), never a refused request.
A fetch answer may report the kind’s push channel in push: state
active, pending (with an optional code saying why) or failed (with
error_class and code). An access failure is the access_error health
state while pull carries on. With active, poll_interval_seconds relaxes
pull to a safety net while nothing goes wrong. A pull run that, with push
active since before it started, still creates new Record Versions reports
missed_deliveries and returns pull to the instance’s interval, so a plugin
holds back items the source may still be delivering. Only a plugin with a push
kind reports a push status (invalid_push_status).
Ingestion Contribution
Segmentation and embedding (since Plugin API 0.6). The plugin cuts one Record Version’s text into segments and embeds each segment in the vector spaces it owns; it also encodes queries into those spaces, so a query vector always comes from the model that made the document vectors. The core keeps everything else: the vector space registry, Weaviate and its named vectors, projection generations and rebuilds, authorization and withdrawal. Write one with the Go SDK and pin it as described in Write an ingestion plugin. Spaces.contributions.ingestion.spaces declares each space by id: the
plugin id or an id starting with <plugin id>. (foreign_space otherwise),
so a space has exactly one owner. A space’s identity is its id and version
together (<id>@<version>, as search hits and the registry report it); bump
the version whenever the vectors change. model, dimensions, metric
(cosine, dot or l2) and the modalities it indexes and accepts as
query_modalities (only text in 0.6) describe it. At startup the core
registers the enabled spaces and refuses one registered under another owner
(space_owner_conflict) or with another model, dimensions or metric under the
same version (space_changed).
segment_and_embed (ingestion-segment-and-embed-request.schema.json)
carries the usual envelope and idempotency key, the Record Version identity,
an optional language hint, the Version’s text parts (key, role,
text, in Manifest order) and the spaces to embed, the ones the deployment
enables. The answer (ingestion-segment-and-embed-response.schema.json) lists
segments in reading order: part_key, start and end in Unicode code
points, vectors with exactly one vector per requested space, and optionally
lexical_text (text the core indexes in a separate keyword field, while
excerpts keep the source text) and provenance (stored, never interpreted).
The same idempotency key must yield the same answer: rebuilds reuse stored
segments and vectors, and a different answer for the same Version is refused.
Since 0.8, spaces may be empty: the answer then carries the segments with
vectors: {}. The core asks a plugin whose plugin_api range admits 0.8 for
the segments alone when a Version arrives, so it is searchable by keyword
whatever the embedding backend’s state, and for the vectors at enrichment; the
segments, lexical text and provenance must be the same both times, or the
Version is blocked (derivation_conflict). A plugin limited to 0.6 is asked
once, with the spaces.
embed_query (ingestion-embed-query-request.schema.json) carries the
space and a query (modality: text, text) and answers {"vector"}
within query_timeout_ms, which a search waits for.
Errors. Unavailability or a retryable: true envelope delays the Version,
retried with backoff; a terminal envelope, or an answer the checks below
refuse, blocks it as ingestion_refused. For embed_query, a terminal
envelope refuses the search (422) and anything else makes it unavailable
(503). A query longer than the space accepts is refused with code
query_too_long and a message naming the limit, such as query exceeds 256 tokens: the search fails with 422 query_too_long and that message (at most
256 code points). Any other terminal code is 422 unsupported_search.
CheckSegmentAndEmbedOutput and CheckEmbedQueryOutput in internal/plugins
judge every answer for the engine and the Contract Runner:
Deployment. A pin enables spaces with
spaces: {"<id>": "served" | "evaluation"} (absent: the only declared space, served); exactly one is
served, and a deployment pins one ingestion plugin (ingestion_conflict). A
projection generation carries the spaces enabled when it was built, served
first, each as a named vector. A Corpus goes through the plugin once its
routed generation is served by one of the plugin’s spaces; a Corpus built
before keeps its path until it is rebuilt, and the rebuild calls the plugin
only for Versions without stored vectors in the new spaces.
GET /v0/corpora/{corpus_id}/vector-spaces lists a Corpus’s spaces with their
owner, role and coverage.
Ingestion fixtures (ingestion-fixture.schema.json, a file with a
top-level ingestion property) hold the parts, an optional language,
configuration, spaces (default: every declared space) and queries
(default: the first 200 code points of the first non-empty Part), and what to
expect: the exact segments and whether every segment carries
lexical_text. Ids derive from the first 16 hex digits of the SHA-256 of the
fixture bytes (dev-record-…, dev-version-…), with Corpus dev-corpus,
Organization dev-organization and idempotency key dev:<sha256>.
fixtures/ingestion/article.json is the normative fixture every ingestion
plugin is certified with.
Retrieval Contribution
Search (since Plugin API 0.7). The plugin answers a search in rounds; each round it either asks the core for candidates or returns the final ranking. It never queries the index itself and never sees a model: it names a vector space and the core encodes the query with that space’s owner. The core keeps the index, authorization, withdrawal fences and generation routing, and applies them before any candidate reaches the plugin. Write one with the Go SDK and pin it as described in Write a retrieval plugin. Profiles.contributions.retrieval.profiles declares the search profiles
the plugin answers, by name (^[a-z][a-z0-9_]{0,31}$), default required.
Each has a max_latency_ms (50 to 10000), the deadline of a whole search
under it, and a max_cost_cents, the most one search may report spending in
usage. limits bound a search: max_rounds (1 to 3, default 3),
max_requests per round (1 to 8, default 4), max_candidates, the largest
k (1 to 100, default 50), and max_response_bytes (default 1 MiB, cap
4 MiB).
search (retrieval-search-request.schema.json) carries the envelope,
the profile, the round (1 first), the query (text and the client’s
mode, a hint), the limit, the scope (corpus_ids and, when the client
filters, source_namespaces), the spaces every requested Corpus’s routed
generation carries (id, owner, model, dimensions, metric, modalities, role,
coverage), and served: every request of earlier rounds with the
candidates the core served for it. A candidate is an authorized, current
segment: segment_id, record_id, version_id, part_key, its text,
start and end in code points, and the index score (higher is better).
The answer (retrieval-search-response.schema.json) is exactly one of:
requests: candidate requests, eachbm25(fieldsourceorlexical,query_text),near_vector(space, andquery_textor avector) orhybrid(space,query_text,field,alpha,fusionrelative_scoreorranked), withk, an optionalfilter(source_namespaces, inside the scope) andgroup_by: record;ranking:hits, best first, each a servedsegment_idwith the plugin’sscoreand an optionalexplanationthe API returns with the hit.
usage (paid_calls, cost_cents). The same request must
yield the same answer.
Errors. A terminal envelope refuses the search (422
unsupported_search); unavailability or a retryable envelope makes it
unavailable (503); an answer the checks below refuse fails it with 502
retrieval_plugin_invalid; a search that outruns the profile’s
max_latency_ms fails with 504 search_deadline_exceeded.
CheckSearchOutput and RetrievalSession in internal/plugins judge every
answer for the engine and the Contract Runner:
Deployment. A pin needs no routes; a deployment pins one retrieval
plugin (
retrieval_conflict), which then answers every search.
GET /v0/search/profiles lists its profiles. Without one, the built-in path
answers the default profile.
Retrieval fixtures (retrieval-fixture.schema.json, a file with a
top-level retrieval property) hold a query, mode, limit,
configuration, the profiles to certify (default: all), the spaces
offered (default: fixture.served@1, 8 dimensions) and a catalogue of
candidates. The runner serves each request from the catalogue: the
fixture’s order for the primitive, or else the segments sharing words with
the query text (every segment for near_vector), most shared words first,
then filters, group_by and k. expect.top names the ranking’s first hits.
fixtures/retrieval/newsroom.json is the normative fixture every retrieval
plugin is certified with.
Normative fixtures
fixtures/index.json lists every fixture with its schema and two outcomes.
schema_valid is the result of JSON Schema validation alone. valid is the
result after the semantic rules. For manifests, normalizer responses and
subscription responses, errors lists the exact set of error codes. A
subscription response is judged against the request its request field
names. fixtures/ranges.json binds range parsing and matching.
Go tests (go test ./internal/plugins/...) run all fixtures through the
engine’s validation. checks/validate.py, run by make contracts, checks the
schema outcomes with an independent JSON Schema implementation.
Local development
These conventions are part of the v0 tooling contract. They bind SDKs in every language and the Contract Runner, but not the engine. Run convention.quivr plugin dev starts the manifest’s run.command
(argv, no shell) in the plugin directory, in its own process group, with:
The plugin must serve the routes above on that address.
dev stops it with
SIGTERM, then SIGKILL after five seconds.
Invocation fixtures (plugin-fixture.schema.json) name an input file,
relative to the fixture, with its media type and optional configuration,
source, extensions and provenance. A tool turns one into a normalizer
request:
input.referenceis{"kind": "file", "url": <absolute file:// URL>}, andsize_bytesandsha256are computed from the file;- the ids are development values derived from the first 16 hex digits of the
input SHA-256:
dev-invocation-…,dev-record-…,dev-version-…,dev-blob-…, plusorganization_iddev-organization; idempotency_keyisdev:<input sha256>;sourcedefaults to{"corpus_id": "dev-corpus", "namespace": "dev", "record_key": <input.path>}, andcorpus_idfollowssource.corpus_id;configurationdefaults to{}and is validated against the manifest configuration schema.
fixtures/invocations/markdown.json is a normative example.
Subscription fixtures (subscription-fixture.schema.json) hold the
record’s text Parts (and enriched), its optional metadata (source,
accepted_at, provenance, extensions), optional plugin configuration, and the
evaluations, each with an expression, an optional configuration and an
optional expected decision. A file is a subscription fixture when it has a
top-level evaluations property. A tool turns one into requests:
- evaluations are numbered
e1,e2, … in order; evaluation n stands for Subscriptiondev-subscription-n(Versiondev-subscription-version-n) of Saved Querydev-saved-query-n(Versiondev-saved-query-version-n); - the Record Version ids derive from the first 16 hex digits of the SHA-256
of the fixture bytes:
dev-record-…,dev-version-…, Corpusdev-corpus, Organizationdev-organization; - metadata the fixture omits defaults to
sourcedev-namespace/dev-record-…,provenance{"origin": "client"}andaccepted_at2026-01-01T00:00:00Z; - evaluations are split into batches of
max_batch_size; batch i has invocation iddev-invocation-…-iand idempotency keydev:<sha256>:i; - the configuration, every expression and every evaluation configuration are validated against the manifest schemas, and Part keys must be unique.
fixtures/subscriptions/strike.json and fixtures/subscriptions/metadata.json
are normative examples. quivr plugin dev does not replay connector fixtures;
quivr plugin test runs them.
Try it
0 valid, 1 invalid or incompatible, 2 usage error.
Scaffold and run a Python normalizer with the SDK in sdks/python:
quivr plugin init alerts --kind subscription scaffolds an alert rule
instead: a case-insensitive phrase rule with a kind-discriminated
expression_schema, a subscription fixture with expected decisions, and tests.
quivr plugin dev [--fixture <file>] [--watch] [--port <n>] [--startup-timeout <duration>] [<plugin-dir>]
runs these steps:
- inspects the manifest;
- starts
run.command, waits forGET /v0/healthand checks thatGET /v0/discoverymatches the manifest: digest, id, version, Plugin API range and Contributions; - with
--fixture, sends the fixture’s request, validates the answer with the same output checks as the Contract Runner (engine Manifest rules, response size, Blob Parts, declared namespaces), and prints the response on stdout. With a subscription fixture, it sends every batch, validates each answer withCheckSubscriptionOutputand the expected decisions, and prints the decisions of all batches.
0 when every check passes and 1 otherwise. Without --fixture,
or with --watch, it keeps running and restarts the plugin when a file in the
plugin directory changes; hidden directories, __pycache__, virtual
environments and build outputs are ignored.
Contract Runner
quivr plugin test [--endpoint <url>] [--report <file>] [--fixture <file>]... [--startup-timeout <duration>] [<plugin-dir>]
certifies that the engine can safely invoke every Contribution a plugin
declares. It talks
only the public protocol, so it applies to a plugin in any language, and it
judges answers with the engine’s own validation, never a copy. It starts
run.command like dev. With --endpoint, it targets a running plugin
instead and still reads quivr-plugin.yaml from <plugin-dir>, because
discovery carries only the manifest digest.
These normalizer checks carry
"contribution": "normalizer" in the JSON
report. When the manifest declares subscription, the runner adds checks with
"contribution": "subscription":
When the manifest declares
connector, it adds checks with
"contribution": "connector", from the plugin’s own connector fixtures:
When the manifest declares
ingestion, it adds checks with
"contribution": "ingestion":
When the manifest declares
retrieval, it adds checks with
"contribution": "retrieval":
The human report goes to stdout; the plugin’s own output goes to stderr.
--report <file> writes the JSON report described by
reports/contract-report.schema.json. Exit codes: 0 certified, 1 not
certified, 2 usage error.
The deliberately broken plugins in tests/plugin-contract/ show one failure
per rule, for every Contribution; the connector rules run one fake plugin in a
broken mode each. CI certifies both quivr plugin init templates and the Go
SDK’s sample connector, and publishes their reports as the
plugin-contract-report, subscription-plugin-contract-report,
go-connector-contract-report and, for the Go SDK’s sample ingestion plugin,
go-ingestion-contract-report and, for its sample retrieval plugin,
go-retrieval-contract-report workflow artifacts. Write a connector with the
Go SDK.