Scope and inherited decisions
Start the reference stack with one command and verify behavior through public HTTP contracts and captured webhooks. Reuse the accepted module boundaries (docs/dated/design/quivr-v2-module-boundaries.md),
ingestion contract (docs/dated/design/quivr-v2-ingestion-contracts.md) and
monitoring tracer (docs/dated/design/quivr-v2-monitoring-tracer.md).
The THE-549 spike
provides recovery scenarios and operational evidence. Its tests also inspect
PostgreSQL, Temporal and Weaviate, so they are not the public acceptance suite.
Its fake embeddings and external fake normalizer are not the reference baseline.
Use THE-553’s pinned local E5/TEI profile and CC0 fixture.
Monitoring matches through a pinned alert-rule plugin (the subscription
template, see below); the deterministic fixture evaluator stays installed for
the notification-mechanics tests.
Commands and isolation
QUIVR_PROJECT=<name> selects another project for down, reset and migrate,
for example a verification run kept with QUIVR_KEEP_ON_FAILURE=1.
verify uses its own project name, volumes, network, fixture identities and
temporary credentials. It must not reuse or reset the developer’s stack. Publish
development ports on loopback; verification runs its runner inside the project
network and needs no fixed host ports. A run ID distinguishes artifacts and
concurrent projects. On success or failure, collect artifacts before cleaning up
only that run’s containers and volumes. Interrupt handling follows the same rule.
An explicit keep-on-failure option may preserve that isolated run for inspection.
Every loopback port a host service uses (API, probes, worker probe, fake
servers, receivers, normalizer plugins, demo server) comes from one allocator,
scripts/ports.py. It never hands out a port twice in a harness process and
never re-hands a port a reloaded stack already owns. It picks from a band below
the kernel’s ephemeral range and Docker’s published ports (from 15000 up to
32768, or to where the kernel range starts if lower), so bind(0), outgoing
connections and Docker cannot take a chosen port before its service binds it.
Harness processes running side by side on one host lease their ports through
files in a private per-user directory under the system temporary directory
(quivr-harness-ports-<uid>/), so they
skip each other’s ports; a lease whose process has exited is reclaimed. Asking
the kernel for port 0 and releasing it was racy and occasionally gave two
services one port (THE-728).
The initial build may need network access to fetch pinned tools/images/model
weights. Once materialized, the test path uses local services and requires no
hosted model API key. Cache misses fail clearly; never silently replace the real
embedding profile with fake vectors. Record image digests, model revision and
tool versions in the report. THE-550 must measure cold preparation separately
from warm startup; this design makes no startup-time or hardware-capacity claim.
Services, configuration and startup
Keep the accepted dependency versions/profiles, pinned by digest when composing
the implementation. Do not use floating image tags. API and worker do not race
to initialize tables or collections. A failed initializer stops first startup
with its error preserved. The harness waits with bounded deadlines and reports
which check failed; no unbounded sleep loop.
Separate process liveness from dependency readiness. Liveness means the process
can serve its probe; dependency loss must not turn it into an automatic restart
loop. API readiness reflects its ability to accept durable commands through
PostgreSQL, not the health of every asynchronous stage. A Temporal/search/model
outage must remain observable without falsely declaring accepted work lost.
Search or upload operations can still fail according to their own dependencies.
Worker readiness reports disconnected pollers/adapters and does not erase pending
work.
make dev checks the whole initial stack; fault tests do not require every
service to remain ready during the injected outage. Operational probes are private
deployment interfaces, not additions to the public domain API.
Use the existing single typed configuration loader and an .env.example with
local endpoints and variable names. Bootstrap disposable Organization/Corpus
credentials for tests before scenarios begin; scenario data then enters via the
public API. Generate API/signing/storage secrets per verification project and
keep them in ignored local files with restricted access. Do not copy personal
Linear, GitHub or model-provider credentials into the stack or its report.
Bind the configured webhook destination to the fixture receiver and Organization;
its control interface is reachable only by the test runner.
Migrations may run against the live evaluation stack. Breaking requests/workflows
and required restarts are accepted and reported. Keep ordered migrations (UTC-stamped
names; the numbered 0xx_ set is closed) and an explicit initializer; do not add expand/contract, drain gates or a compatibility
matrix. Verification uses a fresh schema; adapter tests cover migration/bootstrap
idempotence. Safe production upgrades remain separate work.
Runner and test boundaries
Use Go’s test runner fortests/acceptance, with a small HTTP client and streaming
SSE helper. The acceptance package imports public transport types where useful,
but no internal application modules, SQL clients or Temporal/Weaviate clients.
Run the same suite against a supplied base URL and scoped credentials. Raw HTTP
assertions complement generated types so serialization helpers cannot hide an
invalid response. The existing Python/TypeScript representation checks remain
contract tests, not an obligation to implement two complete SDKs now.
Control and diagnostic access does not become the acceptance oracle. A runner
may stop a worker through Compose, but must observe eventual recovery through
public resources. A fault that requires an exact internal transaction pause
belongs to adapter integration unless a real public barrier exists.
Use versioned text fixtures with fixed source identities, expected permissions,
ordinary correction and withdrawal. Include the accepted 24-item FR/EN retrieval
fixture and model-specific judgments separately from the deterministic monitoring
predicate. Isolate tests by Organization/Corpus or fresh stack. Poll named public
conditions with a deadline and retain the last response on timeout; do not assert
random server IDs, precise wall-clock durations or private workflow histories.
Retrieve shared journal events using a captured cursor, deduplicate by event ID
and verify SSE/polling resume against the same committed mutations.
make verify
starts a second API over the same database with change_retention: 2s so
pre-stream cursor expiry (HTTP 410) is proven publicly; in-stream stream_error
is covered by transport tests. The worker also physically prunes the change
journal of org_r (key QUIVR_TEST_RETENTION) after 2 s, every second
(change_prune with allow_short_retention). The short retention is confined
to org_r, so org_a/org_b cursors keep the default seven days.
TestChangePruneExpiresCursorsAndResyncConverges proves the sequence: prune,
then 410 on the seven-day API, then catalog resync converging. Verification
records these settings under timing_overrides.change_prune.
The harness configuration provisions one webhook destination per test
Organization (local-receiver-org-a, local-receiver-org-b) with obvious
local test signing secrets. Nothing listens on those URLs, so their Deliveries
retry and end exhausted. A third org_a destination, local-receiver-capture,
points at a harness-allocated port where the delivery acceptance tests run
their own verifying receiver (QUIVR_TEST_RECEIVER_ADDR,
QUIVR_TEST_RECEIVER_SECRET) with scripted per-Subscription replies. Monitoring
acceptance (TestMonitoring*) runs after the timed change-feed and outage
scenarios on its own Corpora; then TestDeliveryRestartBefore records a failed
attempt, the harness kills and restarts the worker, and
TestDeliveryRestartAfter proves the same Delivery converges to delivered.
The local harness (make dev and make verify) shortens the webhook retry
policy through the worker’s delivery block (initial 2 s, cap 2 s, window 20 s
instead of 1 s / 5 min / 24 h); verification records it under
timing_overrides in report.json. Because the test receivers listen on
loopback, the same block sets allow_private_destinations: true, which is also
recorded there; deployments keep the default refusal of private destinations. The worker probe’s
/metrics (QUIVR_TEST_WORKER_PROBE_URL) is scraped by the exhaustion test and
saved as delivery-metrics.txt.
PostgreSQL adapter suite on a bare database
make adapter-postgres gives fast feedback on transactions and guards without
the full stack, and runs on macOS arm64 as well as Linux (only Docker, Go and
Python are needed). It starts only the pinned postgres service of
deploy/compose/compose.yaml in a project named quivr-adapter-pg-<run id>
with its own random password, writes a config.json holding only
database_url, and runs the suite with QUIVR_ADAPTER_CONFIG pointing at it.
Extra go test arguments go in args, for example
make adapter-postgres args='-v -run TestDelivery'.
The suite’s TestMain prepares the database with app.BootstrapDatabase, the
PostgreSQL part of quivr migrate: the embedded migrations, then the default
projection generation. It needs no S3, Weaviate or tokenizer and adds no flag
to any production command. Both steps are idempotent, so inside make verify,
where quivr migrate has already run, the same TestMain changes nothing. Two
tests need more than PostgreSQL (the tokenizer, TEI and S3). They skip only
when QUIVR_ADAPTER_POSTGRES_ONLY=1, which this target sets, and still run in
make verify.
The test output is streamed and saved to .scratch/<project>/adapter-postgres.log,
with postgres.log and services.json. Secrets are redacted from them, and config.json is deleted with the database. On
success, failure, Ctrl+C or SIGTERM the run removes only its own project
(docker compose -p <project> down --volumes). With QUIVR_KEEP_ON_FAILURE=1
a failed run is kept, and the command to remove it is printed. CI runs it as the
separate adapter-postgres job. That job gives a signal within minutes, proves
the Linux path, and fails if an adapter test silently depends on state that
acceptance scenarios leave behind in make verify.
Webhook fixture and bounded recovery scenarios
The receiver stores raw body bytes, headers, arrival time and response outcome, and exposes a private test control/read interface. Support acknowledge, return 503 for a fixed number of attempts, and hold/release one response with a timeout. Verify the selected signing scheme before parsing, including timestamp freshness, tampered payload rejection and the stable event ID across retries. Keep signing keys out of exported captures. Assertions use the configured destination’s key, not an assumption that any correctly shaped payload is authentic.
For the disable case, configure a bounded retry delay long enough to observe the
first failed attempt and complete disable before its next eligibility time. If a
retry was already admitted, fail the test setup rather than claim suppression of
in-flight work. Observe past that eligibility time with margin and check public
attempt history plus receiver captures. No sleeps that assume disable wins a race.
Other timing values can be shortened for verification through configuration, and
the report records overrides. Do not disable signature checks or transaction guards.
Persistent receiver failure should exercise exhaustion with a shortened configured
delivery window. S3 retry and transaction races belong to targeted adapter tests
when Compose-level interruption cannot isolate the required point. Projection
rebuild/recovery uses available public Operations when implemented; direct store
corruption is an integration fixture, not a new public administration endpoint.
THE-550 reports missing seams as gaps rather than claiming unexecuted scenarios.
There is no successive-corrections policy matrix in this tracer.
Diagnostics and CI
Emit structured JSON logs with severity, component, operation, outcome, duration and the available request/Receipt/Record/Match/Delivery identifiers. Preserve correlation through outbox dispatch and Activities. Do not log content bodies, API keys, signatures or full dependency credentials. Capture bounded error details. Start with counters for accepted commands, processing outcomes and delivery attempt outcomes; gauges for pending work and oldest pending age; and duration measurements for acceptance-to-searchable and webhook delivery. Use bounded labels such as component/outcome, never Record IDs as metric labels. A metrics endpoint is sufficient; a collector, dashboard and distributed tracing backend are not required. Diagnostics assist explanation; public assertions determine correctness.verify writes a run manifest and machine-readable test results plus a short
Markdown report under ignored artifacts. Include source revision, pins, fixture
profile, timing overrides, commands, durations, scenario outcomes and unresolved
gaps. On failure, attach redacted service logs, readiness status, last public
responses/cursors and receiver captures from synthetic fixtures. Preserve artifacts
even when startup fails. Separate diagnostic infrastructure details from API output.
CI uses the same commands and pins as local verification:
- Formatting, static checks, unit tests, full OpenAPI validation and the existing transport checks. Regeneration must match committed generated artifacts once those artifacts exist.
- Adapter integration and the isolated Compose acceptance journey, including the real local embedding service and controlled webhook recovery scenarios.
- Upload the report and failure diagnostics before cleanup, even after failure.
make measure is that explicit run for text retrieval (THE-661). It follows the
frozen workload (tests/measurement/workload-v1.json): public search in three
modes over the 24-query CC0 fixture, three load conditions, cold preparation and
start recorded separately from warm start and model readiness, resource peaks and
exact pins. Harness or dependency errors fail it; a missed p95 target or relevance
deficit is a reported finding. It stays outside verify and runs in CI through
the non-required Retrieval baseline workflow, on manual dispatch only.
Implemented verification (THE-662)
make verify runs on linux/amd64 only (ubuntu-24.04 in CI); no other platform is
claimed. It runs the same commands locally and in CI; locally in this order,
in CI as parallel jobs (below):
make check, with no Docker stack:docs,denylistandmigrations;contracts, which regenerates the transport and compares it with the committed code. It also validates the full OpenAPI document, every example (the original 24 are a guarded floor) and at least 31 boundary checks;image-context, thentest.
scripts/local.py verify: the stack verification, in parts. Each part starts its own isolated stack, then runs its steps in order.- The demo UI check, the last part (
demo).
parts() in scripts/local.py names them:
core: core acceptance, adapters, dependency outages, rebuild, operations, the change feed and the CLI;monitoring: monitoring, delivery across a worker restart and the journey;plugins: normalizer and alert plugins, the runnable guides, the keyless core and the lifecycle;connectors: connector acquisition and restart resumption;demo:scripts/demo.py verify.
persistence_across_restart, which grants the
scoped key its Corpus. make verify runs the parts one after another and stops at the
first failed one. make verify part=core (or part=core,demo) runs only those
parts, without make check.
To add a step, add one line to the part whose state it needs, for example
acceptance('mcp','^TestMCP') or step('name',fn). Steps that restart
processes or share captures stay in one part. A new part also goes in the
matrix of .github/workflows/verify.yml; scripts/test_ci_output.py checks
the two lists match.
CI jobs. check runs make check, adapter-postgres the adapter suite,
and verify <part> one part each, all in parallel. verify passes only when
all of them pass; it is the one check to require on main. The parts cache Go
modules and builds, pip, the E5 model and tokenizer, npm and the Playwright
browser. Docker images are not cached; their pull is part of start_stack,
which each report times.
Isolation. Each run gets:
- a unique Compose project
quivr-verify-<run id>, with its own network and volumes; - ports on 127.0.0.1 only;
- a private directory under
.scratch/(mode 0700); - freshly generated keys, database password, S3 and cursor secrets (state file mode 0600).
GO, CONTRACT_PYTHON, QUIVR_PROJECT and QUIVR_KEEP_ON_FAILURE;
scripts/test_local.py enforces this.
Steps and report.
- Every scenario is a named step of one part. The
quivrCLI step (TestCLI*) runs the stack’s built binary asQUIVR_TEST_BINARY. Each stack part runs capture validation last, beforelifecycleinplugins. - On success, failure or interrupt (SIGINT or SIGTERM), the run captures service
logs and
services.json, writesdependency-inventory.json, then removes only its own project. - Before the report is written, every generated secret is replaced by
[REDACTED]in exportable artifacts. Private configuration files are never uploaded. report.jsonandreport.mdrecord:- status, the failed or interrupted step and its bounded error;
- source revision and dirty flag, per-step durations;
timing_overrides;- pins: image digests, model revision, tokenizer, toolchain;
- the unsupported platforms;
- a link to the remaining-limit report.
- CI uploads each part’s artifacts and writes its job summary (see failure output below).
- With
QUIVR_KEEP_ON_FAILURE=1, a failed run keeps its project for inspection. Remove it withQUIVR_PROJECT=<name> make reset.
go test -json
(scripts/gotest.py); acceptance.log and adapters.log keep the -v text.
- The terminal prints one line per finished step with its duration.
- A failed run prints the failed step, then each failed test’s
--- FAILblock with a bounded excerpt of its output. Browser failures come from Playwright’s JSON report (playwright.json). report.jsonkeeps each step’s tests with their durations and failures;report.mdadds the failed tests and the 15 slowest tests.- In CI,
scripts/ci_summary.pywrites the job summary: failed step, failed tests with excerpts, a link to the artifacts, slowest tests and step timings. It also adds one error annotation per failed test to the run page.
- Compose
--waitup to 180 s, with one more bounded attempt when a dependency crashes while starting (recorded asdependency_start_retries); - each process
/readyzup to 20 s.
api, worker, short-api), its last answer and
the startup log to read, and records them in readiness.json.
Readiness separates durable acceptance from downstream outages. During the
Weaviate outage scenario the harness asserts that:
- the API
/readyzstays 204, so commands are still accepted durably; - the worker
/readyzturns 503, but the worker is live and is not restarted.
during_search_outage. The report records cold model
preparation apart from the whole stack start (preparation); it makes no
startup-time claim.
The lifecycle step recreates containers. Before it runs, the service logs of
the whole run are kept as <service>-before-lifecycle.log.
Assembled journey. tests/acceptance/journey_test.go composes the feature
journeys in its own Corpus, over HTTP, SSE and raw signed webhook bytes. The
harness stops the worker between the first and second phases and restarts it
before the third:
- Before restart.
- Inline ingestion and replay.
- Lexical search, then vector search:
record.retrieval_readyprecedesrecord.enrichment_available. - A Match whose signed webhook fails once and is then delivered with identical bytes.
- A batch with a verified upload and a structured Manifest.
- A correction that no longer matches, and a withdrawal notice.
- Worker stopped. New work is accepted and stays pending; reads and search keep working.
- After restart.
- The pending work converges, with exactly one Match and its Delivery.
- SSE replay and
Last-Event-IDresume agree with polling. - The saved cursor expires with 410 on the short-retention API, and catalog resync converges.
- A rebuild activates a new generation without changing results or resurrecting the withdrawn Record.
journey-<phase>.json with per-step timings. Each feature keeps
its own detailed tests. See tests/acceptance/README.md
for ordering rules, including waiting for enrichment before any search assertion.
Lifecycle. The last step, scripts/lifecycle.py, proves on the verification
project that:
migrateis idempotent while the project runs, and refused with guidance once it is stopped;downthendevkeeps a Corpus;resetthendevstarts with no Corpora.
lifecycle.json.
Metrics and the failure drill. Each process serves Prometheus text on its
private probe listener at GET /metrics. There is no client library, and labels
come only from fixed sets: no identifiers or secrets.
Logs are structured JSON with bounded fields:
command accepted:request_id,receipt_id,record_id.processing outcome:stage,outcome,code,receipt_id,record_id,version_id,duration_ms.delivery attempt: now carriesduration_ms.
scripts/failure_drill.py). This drill checks diagnostics only.
It runs around the journey’s real worker outage and asserts:
- during the outage:
- the API
/readyzanswers 204, so durable acceptance is unaffected; - the worker probe does not answer;
- the ingestion backlog gauges show the Receipt pending for at least 1 s;
- the API log correlates its
request_idwith the Receipt.
- the API
- after the restart:
- the API backlog gauge drops, so the Receipt drained;
- the worker’s processing counter records the outcome;
- its acceptance-to-searchable histogram records an observation over 1 s;
- its log names the same Receipt with its Record and Version.
failure-drill.json and the metric snapshots it read. The final
metrics-api.txt and metrics-worker.txt are captured before cleanup.
Dependencies. third_party/README.md indexes the
notices. scripts/inventory.py lists the Go modules linked into the built
binary, the pinned images, the model and tokenizer, and the demo UI’s npm
packages. A licence it cannot identify is written as unclassified.
Plugin substitution and handoff
The core invokes its accepted processing interface. Initially normalization can run locally. Later, select a remote adapter and add its process through a Compose override; keep the public acceptance inputs, assertions and runner unchanged. The Plugin Contract Runner separately checks plugin input/result schemas and timeouts. Do not build a plugin registry or force local calls over HTTP for the harness. Private customer fixtures are not prerequisites for the public CC0 journey. The harness pins one external normalizer and two alert-rule plugins together.scripts/normalizer_plugin.py pins the normalizer in the stack configuration’s
single plugin entry and,
for pdf-text and the template, runs it as its own process with the repository
SDK. QUIVR_NORMALIZER chooses it for make dev:
For example,
QUIVR_NORMALIZER=none make dev disables it. The choice is applied
on every make dev and printed with the API address. The plugin’s log is
.scratch/<project>/normalizer-plugin.log.
Verification starts on the template and runs these steps in order:
- It ingests a Markdown Blob through the public API.
-
It checks that
quivr apiandquivr workerrefuse invalid pins: an incompatible Plugin API or engine range, and a configuration that fails the plugin’s schema. - It stops the plugin, restarts the API and worker, and checks that both stay healthy.
- It rebuilds the Corpus while the plugin is still down.
-
With the plugin still down, it ingests another routed Blob and checks for 40
seconds that the Receipt stays
pendingwithplugin_unavailable, while both/healthzprobes, text ingestion and search keep working. It then restarts the plugin and checks that the pending Version becomes searchable. -
It pins the controllable Go test plugin (
internal/plugins/devhost/fakeplugin, modeby-record-key) on a required and an optionaltext/*route, and restarts the API and worker. It then checks each failure class (terminal error, malformed Part, bad checksum, undeclared namespace, oversized response, timeout, exhausted retries): the Version is quarantined with the right diagnostic on the Receipt and the Version read, and arecord.quarantinedchange event is published. It also checks that the optional route stays searchable through the built-in text path. Finally it restores the stack’s pin. - It switches the pin to pdf-text, restarting the plugin, the API, the worker and the short-retention API.
-
It uploads a three-page PDF and finds a phrase from page 2 on Part
page-2, withnormalizationprovenance namingpdf-text0.1.0. -
It ingests damaged PDF bytes. The Version is quarantined with a structured
normalizer_faileddiagnostic naming pdf-text and arecord.quarantinedchange event, and a Record ingested afterwards is still searchable. -
Next to pdf-text, the alert-rule template (
scripts/subscription_plugin.py) decides alerts: a matching article gives exactly one signed webhook with the plugin’s evidence, a non-matching one gives none, an invalid expression or configuration is 422 at Subscription creation, and a rule on the provenance producer matches only that producer’s article. -
The keyword alerts plugin
plugins/alertsdecides a saved<word> AND (grève OR strike) NOT sportquery:- only the article that satisfies it alerts, although it writes
GREVE; - it gives exactly one signed webhook;
- the Match evidence names the matched terms and their Parts;
- a malformed query tree is 422;
- a
sourcefilter alone matches only that source’s article.
- only the article that satisfies it alerts, although it writes
- With the alert-rule plugins stopped, a matching article becomes searchable and no Match appears; after the restart the delayed evaluation completes with one Match and one acknowledged webhook.
plugins list (scripts/subscription_plugin.py):
QUIVR_ALERTS=off make dev leaves the keyword alerts plugin unpinned, and
Subscriptions pinned to it are then refused with 422 unsupported_evaluator.
make verify always pins it. make dev prints the pinned alert-rule plugins
under the API address.
Described alerts are offered (the pin’s kinds) only when the plugin has a classifier:
make verifystarts the fake System One serveralerts.fake_system_one(log.scratch/<project>/fake-system-one.log), gives the plugin a test key and the fake’s URL, and never calls TypeSafe. Thedescribed_alertsstep checks that rephrased and translated articles alert, that an unrelated one does not, and that each article costs one classifier call. The keyless restart pins"kinds": ["keywords"]and checks that a described alert is refused with 422.make devoffers them whenTYPESAFE_API_KEYis set in its environment; the plugin then sends article text to TypeSafe. Otherwise it pins"kinds": ["keywords"]and clears the key from the plugin’s environment.
make verify also pins the Go SDK sample connector
(sdks/go/examples/static-source, kind static) in plugins
(scripts/connector_plugin.py, log .scratch/<project>/connector-plugin.log);
make dev does not. The connectors part’s collector_plugin step checks that
the kind is published with its schemas, collects searchable Records and resumes
from its checkpoint, reports plugin_unavailable while the plugin is stopped,
collects everything once it is back, and that no log holds the test token.
The PostgreSQL adapter suite also kills the test plugin process in the middle of an
invocation, restarts it, and checks that the Version ends with exactly one published
Manifest.
THE-550 must implement the commands, run the integrated journey and record actual
results against the table above, including failed attempts and missing behavior.
Until that evidence exists, this proposal establishes a reproducible target only.