Kairos — Integration Test Suite

How to run

All tests run inside Docker. There is no host shortcut: host package resolution differs from the pinned images and produces false results — auth.test.ts and api.test.ts fail on the host and pass in the container. A host run will lie to you.

Flow checks — the stack, driven through the UI (tools/e2e_flows.sh)

Walks what a reviewer clicks, as each persona, against a loaded stack: pages render with no error screen or console error, role redirects, API refusals (field worker → quarantine 403, engineer → PTW countersign 403), a sourced Copilot answer and a safety refusal, an RCA pack with no duplicated events, and an audit pack that lists each document once. Uses host-installed agent-browser, so it runs on a developer machine, not in CI.

./tools/e2e_flows.sh            # changes no workflow state — safe on demo data
./tools/e2e_flows.sh --mutate   # also PTW dual sign-off, deviation raise/resolve, supersede via the UI

--mutate leaves signed briefs, a resolved deviation and a superseded document behind: run it on a stack you will reset, never on the dataset you are about to demo or benchmark.

Tier 1 — service-free (969 tests, no stack, no secrets, no network)

These need nothing running. This is what CI's unit job executes on every push. The container run is 786 passed and 3 skipped (2026-10-01): the Docker test image mounts only ./backend, ./tests and ./db, so the checks in test_sec_infra.py that read infra/, docker-compose*.yml and .github/ skip there (they run on a full checkout, and in CI, which has the whole repo).

docker compose run --rm --no-deps -e KAIROS_SKIP_TEST_CLEANUP=1 kairos-backend-api \
  pytest -q tests/test_{pii,query_category,search_fusion,ingestion_formats,http_pool,model_validation,pid,auth_cache,\
config_guardrail,briefs_countersign,topology_verify,ot_coverage,phase_gate,extraction_path,\
timestamp_alignment,model_gate_classes,ner_parse,superseded_filter,brief_signing,\
attribution_evidence,authz_boundary,brief_paging,asset_bulk_import,asset_counts,\
quarantine_item_id,purge_safety,synthesis_stream,graph_query_policy,event_reorder,supply_chain,\
form_extraction,cross_functional,offboarding_session_id,corpus_filter,alias_expansion,\
ner_fallback,asset_tag_filter,linked_document_scope,nim_retry,rca_timeline,audit_evidence,\
document_extraction_view,image_utils,ocr_review_release,supabase_http,redate_demo,sec_auth,sec_authz,sec_documents,sec_infra,sec_llm,cr_fixes,tenant_isolation,showcase_dataset}.py

All 55 files, 969 tests (counted 2026-10-09; matches the 55 tests/test_*.py entries in .github/workflows/tests.yml). Recent additions:

Security pass, 2026-10-01: the five test_sec_*.py files. Each is named for the review group it covers; all run against in-memory fakes, with nothing written to any store. Finding ids (H1, M4, ...) are the 2026-09-30 security review's, listed one per line in implementation/status.md § Accepted risks and deploy checklist.

FileCovers
test_sec_auth.pyRole and site_id come from app_metadata only and a self-edited user_metadata grants nothing (H1); the roles migration is dry-run by default and never overwrites (scripts/migrate_roles_to_app_metadata.py); constant-time internal key compare (L4); APP_ENV is normalised and only development is development (L5); the boot guard needs MOC_WEBHOOK_SECRET (H7); login, refresh and DB errors do not echo upstream text (L6); POST /auth/logout revokes and drops the cache (L1, server-side half); /health/detailed is gated while /health/, /docs and /openapi.json stay public (L7); the seeder refuses blank passwords and no seeded password is committed; display names ignore user_metadata
test_sec_authz.pyingest_event mapping, OPA grants and _sensitive_actions (H2); site scope on event reports, plant state, asset reads and create, events reads, audit pack (H3 route gate, M8); inspection document_id must be an inspection_report and the evidence confidence is capped (H3); acknowledgements use the token actor, are idempotent and 404/403 correctly, deviation-flag, tag-out and elicitation actors come from the token (M6); annotation corpus roles, dedup and rate limit (M7); off-boarding programmes limited to staff and the subject (M9); voice-upload size and path sanitising (M11, M12); inbox .in_() filter (M2); final acks and feedback visibility (L11); MoC webhook HMAC with timestamp, skew, raw body, fail-closed and replay (H7); OPA path and rate-limit exemption cannot be forged through Host
test_sec_documents.pyDocument type allow-list, safe filenames, authority_capped with the audit record, unknown asset, occurred_at bounds (H5, M12); normalised content type and forced-download signed URLs (L10); storage errors leak no text (L6); supersede gate: self, inactive replacement, role, MoC created once, approved applies, rejected blocks, operational documents immediate (H6); PDF page, DPI, pixel and spreadsheet ceilings and non-retryable BadFile (M11); confirmed-aliases-only pipeline (M13); per-document topology node ids (L12); broader PII patterns and late names (L9)
test_sec_infra.pyRLS on every schema table and migration 017 covering the 14 late ones (M1); Caddy body cap and headers (M11); dev connector port bound to loopback and callers sending X-Connector-Secret (M14); third-party Actions pinned to commit SHAs (L15). Skips for the files the container does not mount
test_sec_llm.pyForged client context and query_category cannot clear the gate, the server retrieves its own evidence, bad as_of is 422, request bounds (H4, M11); the stream writes the audit row and pending_moc (M3); escaped document text and system-message rules, lowest CONFIDENCE wins, last SOURCES_USED wins, phantom citations and missing confidence refuse, RCA prompt escaping and citation filtering, NER default confidence below 0.7 (M4); formerly ungated wordings are classified, every named asset must be anchored, aliases resolve (M5); RCA pack site boundary (M8); NER honours the configured base URL (L8)

Existing files changed with it: test_event_reorder.py (a duplicate check never records the event, only mark_seen after a successful ingest does; _store_event inserts, accepts an identical retry, never overwrites another event; the pending-brief slot is per work order; a PTW never cancels another brief; the 4-hour cool-down is per trigger type), test_authz_boundary.py, test_config_guardrail.py, test_query_category.py and test_synthesis_stream.py (server-side evidence), plus the stack-tier test_auth.py, test_health.py, test_ot_connector.py and conftest.py (seed passwords from .env, connector secret). test_alias_expansion.py — a query naming a confirmed alias (P-101) also searches its canonical asset. test_ner_fallback.py — NIM NER calls are capped at 4 concurrent; a timeout or 5xx is retried but a 4xx is not; a long document is extracted in chunks and merged, and a failed chunk keeps the others while flagging recall as a floor; an unset OLLAMA_BASE_URL goes straight to regex. test_asset_tag_filter.py — document references (WO-, PTW-, SOP-, month-year stamps) never become alias candidates or quarantine items, and "HE-301 Shell and Tube Heat Exchanger" resolves to HE-301. test_linked_document_scope.py — an asset-scoped search also matches documents the graph links to the asset, the asset's own documents rank first within an authority level, and a graph outage degrades to the primary-asset scope. test_nim_retry.py — the shared provider call path and the registry behind it: a 502/503/504 is retried once before the cascade hands the answer to a different model, a client error is never retried, each tier posts its own model id and only its own extra_body keys, an unkeyed tier is absent from the cascade entirely, Token Factory takes tier 1 when keyed, and a tier that already failed this request (a broken stream) is skipped rather than retried. Filename kept because CI's service-free job lists test files by name. test_rca_timeline.py — the RCA timeline shows an event recorded in both Supabase and Neo4j once, orders mixed UTC offsets by instant, and reads a naive timestamp as UTC. test_supabase_http.py — Supabase REST reads survive a dropped pooled HTTP/2 connection: a GET/HEAD that hits a dead connection is retried once, a second failure still raises, a write is never replayed, and every PostgREST session in the process goes through the retry transport. test_tenant_isolation.py — showcase data stays out of real accounts (services/tenant.py): the shape of every PostgREST, Cypher and search filter (a nullable column keeps its NULL rows, wildcards are *), a registry of the list and aggregate reads that must be scoped, the demo write fence and its route registry (every allowed route is real, every guarded route calls a guard, the closed set is pinned), the by-id read fence (a real account gets 404 on a showcase record, every by-id GET route names a parameter the fence understands), the target guards, the demo role's role mapping, the hourly demo budget, statistics a showcase action must not feed, and the _demo stores never being created at startup. test_cross_site.py — cross-site failure patterns (services/cross_site.py): a failure family on one equipment class seen at two sites is shared, one recurring at a single site with a sister site running the class is an advisory, one work order is not a pattern, a class on one site has nothing to compare, and the output carries no free text or person.

test_showcase_dataset.py — the showcase plant (scripts/showcase/, scripts/load_showcase.py): the six-folder dataset (written, read back, and bound to load time exactly as a fresh build would be, the loader never rebuilding it), size, unique DEMO- ids, parents before children, no golden name or tag reused, every document names its asset and every conflict is stated by both of its sources, every direct row carries a marker, ids are deterministic and dates move with the anchor, every state the governance pages show exists, the permit waiting for a second signature, recurring failures and cross-system clock drift, live events valid for the real API, the loader's --apply gate and its real-mode count check, the redate shift (whole days, full rows, loader ids only) and the reset (state only, never the vault, never an unfiltered delete). test_redate_demo.py — the demo re-date shift (scripts/redate_demo.py): only events the loader created from the dataset move (a live or QA event newer than the story neither anchors nor moves), the newest lands on "yesterday", spacing and time of day are kept, payload and Neo4j timestamps keep their source offset, and a same-day re-run shifts by 0. test_synthesis_stream.py also pins the streaming route's terminal events: a successful stream ends on done with no error, and a failed one ends on error without exposing the exception text. test_image_utils.py — the one shared NIM image downscale helper (OCR and P&ID): an image that fits after JPEG re-encoding keeps full resolution, one already under the ceiling is sent untouched, one no step can fit returns nothing, and both callers keep their return shapes. test_ocr_review_release.py — releasing a document held by the OCR gate rebuilds ingestion's workflow params from the stored vault URL and names the reviewer; a document whose artifact path cannot be recovered is refused. test_document_extraction_view.py — the document extraction view and redacted export read entities back from the graph edges carrying the document id: the entity is the non-document endpoint, a re-linked entity is listed once at its strongest confidence, and only MENTIONS_PERSON names feed redaction. test_audit_evidence.py — the compliance audit pack lists a document linked by several edges or assets once, verified if any link is, with its highest confidence and every asset it covers. test_attribution_evidence.py (12 tests) covers the pure decision functions _attribute and _classify_attestation in workers/attribution.py, including the brownfield regression that genuine_failure was unreachable on uninstrumented assets. test_authz_boundary.py (41 tests) covers the trust boundary — which routes are policy-enforced, that the middleware carries no second token verifier, fail-closed behaviour when OPA is unreachable, and token-derived site scope. test_brief_paging.py (14 tests) covers routers/briefs.page_inbox — the Layer 8 inbox rules that break silently when reordered: limit applies after the governor/frozen filters rather than in SQL (limiting the query let a full page of critical briefs hide a normal-priority one permanently), the page is trimmed before pushes are recorded so an off-page brief cannot spend EEMUA governor budget, and delivered + frozen_page never exceeds limit. test_quarantine_item_id.py (10 tests) covers dependencies.valid_quarantine_item_id and asserts all four quarantine-by-id routes are wired to it. test_offboarding_session_id.py (14 tests) covers routers/elicitation._session_uuid and pins the fix for the off-boarding 500s: offboarding_sessions.id is a UUID column, so an unparseable path segment reached PostgREST as 22P02 and surfaced as a 500 on a public route. It also asserts no off-boarding handler has gone back to .single() — that raises PGRST116 on zero rows, which made each handler's own 404 unreachable and turned every unknown programme into a 500. test_purge_safety.py (24 tests) asserts no purge prefix or exact id can match a real id — including WO-2026-0714, whose promoted item the compliance caveat cites — that every prefix ends in -, and that nothing sits in both a prefix and an _EXACT list. test_synthesis_stream.py (14 tests) covers the SSE path: every safety-critical category emits zero delta events, the post-gate still retracts a low-confidence answer, the evidence gate refuses before any provider call, and both synthesis paths call the same two gate methods. test_asset_bulk_import.py (15 tests) covers routers/assets.partition_import_rows — existing assets are skipped rather than overwritten (Neo4j uses ON CREATE SET, but the Supabase write is an upsert that would otherwise replace identity_confirmed_by on re-import), duplicates within one payload are reported rather than collapsed, and rows for another site are refused before the existence check so the response cannot leak which asset ids exist in a site the caller cannot read. The seven conformance files (briefs_countersign … model_gate_classes) are part of this tier — an earlier version of this command listed only the first nine and undercounted it.

This list, AGENTS.md's, and CI's unit job must stay identical. They drifted once already: CI ran only the first nine files while the docs claimed the whole tier was gated, so eleven service-free suites — every conformance test among them — never ran on a push. Fixed 2026-08-17.

Frontend and Go tests (outside the pytest list, not run by CI)

Frontend (vitest). docker compose run --rm --no-deps kairos-frontend npx vitest run (see FRONTEND.md §14 for the memory caveat). Three files were added by the security pass:

FileCovers
src/lib/security-headers.test.ts (3)next.config.ts ships CSP, nosniff, referrer and permissions policies on /:path*; the CSP allows off-site only the API origin and the YouTube embed and has no unsafe-eval in production; development adds unsafe-eval, and a bad API URL falls back to localhost (L2)
src/lib/service-worker.test.ts (4)Runs public/sw.js against a fake worker scope: an API read goes to the network first even when a cached copy exists, the cache is used only when the network fails and only for the same user, a request with no user to key on is not touched, and a LOGOUT message deletes every cache (M10)
src/lib/session-security.test.ts (9)Route params are encodeURIComponent-ed and api.ts has no unencoded interpolation left (L3); clearSession revokes server side, wipes caches, the queue DB and Copilot history and tells the service worker, while a forced expiry keeps the offline queue and caches; sign-out never blocks when the backend or browser APIs fail; queued writes record the user's sub, another user's writes are dropped instead of replayed, and the queue is untouched when nobody is signed in (M10, L1)

The existing ingest and supersede tests gained cases for the authority selector (levels 1 to 3 only for reliability and admin, H5), the "Authority lowered" notice, and the pending_moc_approval supersede state.

Go (go test ./..., or make test-connectors against a running stack). backend/connectors/cmd/connector/auth_test.go (TestRequireSecret, TestValidateConfig, TestReceiveWorkOrderBodyLimit, TestReceiveWorkOrderDoesNotEchoUpstream) and backend/connectors/internal/ot/client_test.go (TestPIQueryEscapesTagAndScrubsURL) cover the X-Connector-Secret check, the boot refusal of missing or default secrets, the 1 MiB body cap, no upstream error echo, and PI tag escaping with the historian URL scrubbed from errors (M14, L14).

Tier 2 — full integration suite (needs the stack)

Stack-tier tests not yet updated for the 2026-10-01 rules. test_events.py and test_governance.py (and any test that posts events as field_worker, supersedes a level 1 to 3 document in one call, or acknowledges an event it is not a recipient of) predate ingest_event, the supersede MoC gate, the 409 on a repeated acknowledgement and the event_id conflict rule. They write to cloud stores, so updating and re-running them is a separate decision (status.md § Security pass, step 6). Do not read a red result there as a regression until they are.

# Full suite
docker exec kairos-backend-api python -m pytest tests/ -q --timeout=120

# Single file / single test
docker exec kairos-backend-api python -m pytest tests/test_events.py -v --timeout=120
docker exec kairos-backend-api python -m pytest tests/test_governance.py::test_promote_quarantine_item -v

# DB-write verification tests (slower — wait for Temporal pipeline ~60s)
docker exec kairos-backend-api python -m pytest tests/test_db_writes.py -v --timeout=180

Prerequisites: the stack must be up, and seed users must exist — if login fails, run:

docker exec kairos-backend-api python scripts/seed_users.py

⚠️ Run tier 2 against local stores, never cloud. The suite creates and purges test entities and the teardown purge is unreliable against cloud Supabase, so a cloud run pollutes the golden dataset. Bring up docker compose --profile local-stores up and point .env at the local containers (see INFRA.md §NEO4J_LOCAL_PASSWORD). Set KAIROS_SKIP_TEST_CLEANUP=1 to skip the purge entirely.

CI

.github/workflows/tests.yml mirrors the two tiers:

JobNeedsBehaviour
unitnothingRuns the 969 service-free tests on every push and fork PR, plus the benchmark grader selftest. Must stay green.
integration--profile local-stores + a throwaway CI_SUPABASE_* projectRuns the full suite. Skips with exit 0 when CI_SUPABASE_URL is unset, so a missing optional credential is never a red build.

Neo4j, Qdrant, Elasticsearch and Redis run as local containers in CI, so Aura and Qdrant Cloud are never touched.

To enable tier 2, set the secrets yourself — there is deliberately no script that reads them out of .env:

gh secret set CI_SUPABASE_URL                # from a THROWAWAY Supabase project, not production
gh secret set CI_SUPABASE_ANON_KEY
gh secret set CI_SUPABASE_SERVICE_ROLE_KEY
gh secret set CI_SUPABASE_JWT_SECRET
gh secret set NVIDIA_NIM_API_KEY
gh secret set JINA_API_KEY
gh secret set GROQ_API_KEY

Recommended: leave the integration job disabled. Enabling it costs ~20 provider calls per push — every /search embeds the query via Jina, /search/synthesize walks the Token Factory → NIM → OpenRouter → Gemini cascade, and elicitation calls the LLM. Gemini's free tier is a few hundred requests/day and is shared with the benchmark harnesses, so a busy day of pushes exhausts it; once it 429s, synthesis silently returns no answer and measured answer quality collapses (observed: 24/25 → 13/25). Tier-1 gives 969 service-free tests with zero provider calls, which is the signal CI should be providing. Enable tier 2 only for a deliberate pre-release run, against a throwaway Supabase project.

Never point CI at the production Supabase project — make init-all reinitialises schema and the suite purges entities, so it would corrupt the golden dataset on every push.


Architecture

Tests live at the project root in tests/, mounted into the container via docker-compose.yml:

tests/                        ← project root (NOT inside backend/)
  __init__.py                 ← makes tests/ a proper package (prevents site-packages conflict)
  conftest.py                 ← shared fixtures: auth tokens, HTTP clients, shared_asset_id
  test_annotations.py
  test_assets.py
  test_audit_log.py
  test_auth.py
  test_briefs.py
  test_compliance.py
  test_db_writes.py           ← DB-level verification: Neo4j edges, Qdrant vectors, ES documents
  test_documents.py
  test_elicitation.py
  test_events.py
  test_governance.py
  test_health.py
  test_model_validation.py    ← Layer-0 NER span-overlap matcher (pure logic, no network)
  test_ot_connector.py
  test_pid.py                 ← P&ID topology JSON parsing (pure logic)
  test_search.py
  test_contract.py            ← response-shape contract tests for endpoints that historically drift
  # service-free tier (no stack, no secrets, no network — CI's `unit` job)
  test_pii.py                 ← PII redaction at the export boundary
  test_query_category.py      ← safety classification + both refusal gates + parse regressions
  test_search_fusion.py       ← RRF retrieval fusion
  test_ingestion_formats.py   ← spreadsheet + email ingestion
  test_http_pool.py           ← per-event-loop client + embedding LRU
  test_auth_cache.py          ← verified-token cache
  test_config_guardrail.py    ← fail-closed secret guardrail
  test_briefs_countersign.py  ← PTW dual sign-off
  test_topology_verify.py     ← P&ID element-by-element gate
  test_ot_coverage.py         ← coverage from verified topology only
  test_phase_gate.py          ← deployment phase gating
  test_extraction_path.py     ← extraction_path / handwriting_suspect flags
  test_timestamp_alignment.py ← cross-source drift
  test_model_gate_classes.py  ← per-asset-class model gate
  test_ner_parse.py           ← NER truncation recovery (max_tokens cliff)
  test_superseded_filter.py   ← superseded docs excluded from default retrieval
  test_brief_signing.py       ← HMAC acknowledgment signatures
  test_attribution_evidence.py ← pure attribution decision functions
  test_authz_boundary.py      ← policy enforcement, fail-closed, token-derived site scope
  test_brief_paging.py        ← Layer 8 inbox paging: limit after filtering, trim before push
  test_asset_bulk_import.py   ← Layer 1 golden-record import partitioning + tenancy boundary
  test_asset_counts.py        ← a failed asset issue-count lookup is null ("unknown"), never a clean 0
  test_quarantine_item_id.py  ← Layer 6 quarantine id guard: malformed id 404s, never 500s
  test_purge_safety.py        ← purge matchers can never reach real ids (the DOC-X incident)
  test_synthesis_stream.py    ← SSE synthesis: safety-critical categories never stream text
  test_graph_query_policy.py  ← ARCHITECTURE §7: no unbounded traversal; + what may be called a conflict
  test_event_reorder.py       ← Layer 8 delay compensation; PTW never waits behind the hold
  test_supply_chain.py        ← ARCHITECTURE §8: served model matches the pin; submission outliers
  test_form_extraction.py     ← form fields reach quarantine ONLY; never a KNOWLEDGE_EDGE
  test_cross_functional.py    ← the function mapping is complete; an unmapped type inflates the result
  test_offboarding_session_id.py ← UUID path params 404 rather than 500 (`maybe_single`, not `single`)
  test_corpus_filter.py       ← test-artifact predicate: every real corpus name and golden asset id pinned against over-matching; the guard sits inside the asset-list and compliance Cypher
pytest.ini                    ← project root

Suite size: 1,210 tests collected across 70 files for the full suite (counted 2026-10-09: 1,194 after the 8 legacy-role-fallback tests went and 1 re-date test came, plus the 7 cross-site and 9 form tests (table layout and the extract-form route); 969 are service-free, the rest need the stack). The last full green run was 412 passed · 0 failed (2026-08-22) and is superseded. There is no current full-suite pass count: re-run tier 2 against local stores to get one, and do not quote 412 as a pass figure for the present tree. 1 known transient flake (test_briefs.py::test_attribution_worker_queues_recheck — a work-order POST occasionally 500s under concurrent load; passes deterministically in isolation).

Volume mounts (in docker-compose.override.yml under kairos-backend-api — they are dev-only; the base docker-compose.yml bakes code into the image and mounts no source):

volumes:
  - ./backend:/app
  - ./tests:/app/tests         # test files
  - ./pytest.ini:/app/pytest.ini

Environment variables set on the container for tests:

VariableValuePurpose
PYTHONPATH/appEnsures tests/ resolves to /app/tests/, not the ML library's tests package in site-packages
OT_CONNECTOR_URLhttp://kairos-backend-go:8090Go connector URL (different inside vs. outside Docker)
API_BASE_URL(unset — defaults to http://localhost:8000)FastAPI base URL; localhost works inside the API container because uvicorn binds there

Configuration (pytest.ini)

[pytest]
asyncio_mode = auto     # all async tests run automatically without @pytest.mark.asyncio
testpaths = tests
timeout = 90            # per-test timeout in seconds (overridable per-request with timeout= param)
pythonpath = .          # adds rootdir to sys.path (belt-and-suspenders alongside PYTHONPATH env)

Fixtures (tests/conftest.py)

All fixtures are session-scoped tokens (sync) + function-scoped async clients.

Admin auth — INTERNAL_API_KEY (never expires)

admin_client does not log in via Supabase. It uses the static INTERNAL_API_KEY (<dev-internal-key>) as a Bearer token. FastAPI's get_current_user detects this key and returns role=admin without calling Supabase. This eliminates JWT expiry failures mid-run.

admin_client     # AsyncClient with Bearer <dev-internal-key> (role=admin, never expires)

Tokens — session scope (sync httpx.post)

engineer_token   # logs in as engineer@example.com — role: engineer
field_token      # logs in as field_worker@example.com — role: field_worker

Session scope avoids re-authenticating on every test. Sync httpx.post avoids event-loop scope conflicts with pytest-asyncio.

HTTP Clients — function scope (async)

admin_client     # AsyncClient with INTERNAL_API_KEY (role=admin)
engineer_client  # AsyncClient authenticated as engineer
field_client     # AsyncClient authenticated as field_worker
anon_client      # AsyncClient with no auth header

Each test gets a fresh client, ensuring no state bleeds between tests.

Shared asset — session scope

shared_asset_id  # Creates one PUMP asset once; reused by tests that need an existing asset

Tests that need deduplication isolation must create their own assets with uid().

uid() utility

from tests.conftest import uid
# returns uuid4().hex[:8].upper() — e.g. "A3F2C1B9"
# used to generate unique IDs per test run to avoid collisions

Data hygiene — automatic teardown

Tests run against the live stack and create ephemeral entities with known id prefixes (ASSET-TEST-, ASSET-DEDUP-, ASSET-EV-, ASSET-ACK-, WO-*, DOC-*). A session-scoped autouse fixture in conftest.py (_cleanup_test_data) purges all of them at the end of the run via scripts/purge_test_data.py, so the suite no longer accumulates junk in Neo4j, Supabase, or Elasticsearch.

  • Skip cleanup for a run: set KAIROS_SKIP_TEST_CLEANUP=1.
  • Clean up manually at any time: make purge-test-data.
  • Cleanup never fails the suite — if a store is unreachable it logs and moves on.
  • Do not rely on UI-side filtering of test prefixes to keep demos clean; keep the canonical state (the golden dataset, see DATASET.md) free of test data by rebuilding.

Test files and coverage

Conformance workstreams — service-free (added 2026-08-16)

These run with no stack, no secrets, no network and are part of CI's unit job. They were written service-free deliberately: Supabase has no local counterpart, so a write-heavy test would have to hit the production project.

FileCoversCases
test_briefs_countersign.pyPTW dual sign-off — two distinct signers required, self-countersign rejected, double-countersign rejected. Includes the regression for scoping the read by recipient, which made every real countersign 404.7
test_topology_verify.pyP&ID element-by-element gate — partial confirmation does not promote, one disputed element blocks canonical, confirming promotes the existing edge. Includes the .neq()-vs-NULL regression that emptied the element map.11
test_ot_coverage.pyCoverage from verified topology only; unverified topology is not coverage; no linked drawing → none, never a fabricated tag.6
test_phase_gate.pyDeployment phases — default is 3 (inert), phase 1 disables synthesis, phase < 3 persists a brief but suppresses the push.6
test_extraction_path.pyextraction_path / handwriting_suspect on every OCR envelope; engineering drawings excluded; confidence untouched by the flag.8
test_timestamp_alignment.pyCross-source-system drift only; same-source events are not drift; unparseable timestamps skipped rather than read as epoch-zero.9
test_model_gate_classes.pyPer-asset-class gate — a regressed class is blocked, others unaffected, enforcement off by default, lookup failure fails open.6
test_superseded_filter.pySuperseded documents must not read as current. ES and Qdrant both exclude them by default; the exclusion is must_not superseded, never must active, because kairos_assets docs and pre-existing Qdrant points carry no status at all and would otherwise vanish. Time-travel (as_of) re-includes them. Covers the Filter(must=…) or None regression that dropped the exclusion when no other condition existed.7
test_brief_signing.pyHMAC acknowledgment signatures. Deterministic, and bound to every fact it claims — brief, signer, action, timestamp, key. A signature captured on one brief cannot be replayed onto another, and ack vs countersign never collide.3
test_ner_parse.pyNER truncation recovery. max_tokens: 1024 truncates the JSON on entity-dense documents; json.loads rejected the whole response and the document fell to the regex path (ASSET_TAG only). Salvage keeps the complete objects and flags parse_recovered; unrecoverable garbage still returns None.8

test_query_category.py also gained the post-synthesis safety gate cases and the parse_synthesis_response regressions (missing ANSWER: marker; RCA citing a document called None).

What these cannot catch. Every bug this project's unit suite has missed was a query semantics bug — .neq() against a NULL JSONB key, .in_() against a set the fake ignores. Test doubles implement filters as passthroughs, so a filter whose bug is its filtering always passes. Those need a real database or a real browser; see implementation/e2e-sweep.md.

test_health.py — Stack liveness (3 tests)

TestWhat it verifies
test_health_returns_200GET /health → 200
test_health_response_shapeResponse has status, timestamp fields
test_health_detailedGET /health/detailed as admin_client (the route needs a sign-in) → all 5 service pings succeed

test_auth.py — Authentication (Layer 0, 8 tests)

TestWhat it verifies
test_login_adminAdmin JWT obtained
test_login_engineerEngineer JWT obtained
test_login_fieldField worker JWT obtained
test_login_wrong_password401 on bad credentials
test_login_unknown_email401 on unknown email
test_me_admin/engineer/fieldGET /auth/me returns correct role per token
test_invalid_token_rejectedMalformed Bearer token → 401 in all modes
test_refresh_tokenToken refresh flow works

test_assets.py — Asset MDM (Tasks 1-3, Layer 1, 15 tests)

TestWhat it verifies
test_create_assetPOST /assets/ → 201, asset in Supabase + Neo4j
test_create_asset_is_idempotentDuplicate asset_id → 200 (MERGE, not error)
test_create_asset_auto_idNo asset_id supplied → one is generated
test_get_assetGET /assets/{id} → correct fields
test_get_asset_not_foundUnknown asset_id → 404
test_list_assetsGET /assets/ → items/total envelope
test_list_assets_filter_site?site_id= filter works
test_list_assets_filter_equipment_class?equipment_class= filter works
test_get_asset_aliasesAlias resolution (TAG → canonical ID)
test_get_asset_hierarchyParent-child hierarchy traversal
test_get_asset_knowledgeKnowledge graph facts for an asset
test_get_asset_knowledge_as_ofBitemporal as_of filtering on graph edges
test_get_asset_knowledge_invalid_as_ofMalformed as_of → 422
test_field_worker_cannot_create_assetRole enforcement: field_worker → 403 on POST
test_field_worker_can_list_assetsfield_worker can GET /assets/

test_documents.py — Document Vault + Pipeline (Tasks 4-8, 20, Layers 2-3, 16 tests)

TestWhat it verifies
test_ingest_document_acceptedPOST /documents/ingest → 202, SHA-256 in response
test_ingest_duplicate_is_idempotentSame bytes → status=duplicate, same document_id
test_ingest_document_linked_to_assetasset_id param links doc to asset in Supabase
test_pipeline_advances_beyond_queuedTemporal worker moves stage past "queued" within 60s
test_get_extraction_statusGET /documents/{id}/status → pipeline_stage field
test_extraction_status_not_foundUnknown doc_id → 404
test_get_extraction_results/extraction endpoint returns graph_edges_created
test_get_document_metadataGET /documents/{id} → metadata fields
test_get_document_not_foundUnknown doc → 404
test_list_documentsGET /documents/ → items/total envelope
test_list_documents_by_asset?asset_id= filter works
test_list_documents_by_type?document_type= filter works
test_supersede_documentVault immutability: old doc marked superseded, edges closed
test_supersede_already_superseded_returns_409Double supersede → 409
test_topology_not_found_for_non_pidNon-P&ID doc has no topology → 404
test_topology_endpoint_exists_for_pid_drawingP&ID drawing → topology endpoint responds (200 or 404, never 5xx)

test_db_writes.py — DB-Level Write Verification (Tasks 4-6, Layers 2-3)

Queries Neo4j, Qdrant, and Elasticsearch directly after the document pipeline completes. These tests are slower (~60-90s) because they wait for the full Temporal pipeline.

TestWhat it verifies
test_neo4j_knowledge_edge_writtenAll 6 required KNOWLEDGE_EDGE properties present: valid_from, valid_to (stored as sentinel 9999-12-31 23:59:59 UTC for open-ended edges — Neo4j drops null properties so a real datetime is used), authority_level, document_id, confidence, verification_status
test_neo4j_document_node_writtenDocument node is merged into Neo4j with matching document_id
test_qdrant_vectors_indexedAt least one vector chunk in kairos_documents collection with document_id payload
test_elasticsearch_document_indexedDocument findable in kairos_documents ES index by document_id term query

Timeout: Use --timeout=180 when running test_db_writes.py alone. The shared test suite uses --timeout=120 which may be tight for pipeline-heavy tests.

_ingest_and_wait() helper: Ingests a document then polls the pipeline status until a terminal stage is reached — _TERMINAL = {"complete", "review_required", "failed"}. Intermediate stages such as ner_running are passed through without aborting; only genuinely stuck or unknown states cause a skip.

test_annotations.py — NER Active Learning (Task 21, Layer 3, 8 tests)

TestWhat it verifies
test_annotation_stats_shape/annotations/stats returns total, corrections_this_week, top types
test_create_annotation_correctis_correct=True → 201, feeds validation corpus
test_create_annotation_correctionis_correct=False + corrected_type → 201
test_create_annotation_feeds_corpusCorrect annotation appears in validation corpus stats
test_list_annotations_returns_createdListed annotations match what was submitted
test_list_annotations_requires_document_idMissing document_id param → 422
test_list_annotations_empty_for_unknown_docUnknown doc → empty list, not error
test_annotation_missing_required_fieldsMissing entity_text/type/is_correct → 422

Note: Annotations require a real document_id (FK constraint on ner_annotations). Tests ingest a document first.

test_events.py — Event Ingestion (Tasks 13-16, 33, Layer 8)

TestWhat it verifies
test_ingest_work_order202, status=accepted, brief_task_id present
test_work_order_deduplicationSame payload twice → second is deduplicated
test_work_order_recurring_detectionrecurring_detected field present on second WO
test_get/ack_eventEvent retrieval and acknowledgement
test_ingest_ptwPTW event → priority=critical, brief_id generated
test_ingest_shift_handoverHandover event → brief task queued
test_ingest_alarmAlarm → 202 accepted
test_ingest_tag_out / dedupLOTO tag-out + deduplication
test_ingest_inspection_complete_passedHigh confidence → quarantine_item_id=null
test_ingest_inspection_complete_low_confidence_quarantinedconfidence < 0.7 → quarantine
test_deviation_flag_and_resolveFlag deviation → resolve with disputed resolution
test_deviation_flag_resolve_promotedresolution=promoted + moc_warranted=False → briefs_unfrozen in response, moc_id=null
test_deviation_flag_resolve_moc_warrantedresolution=promoted + moc_warranted=True → moc_id starts with MOC-
test_ingest_inspection_with_document_idInspection with document_id → INSPECTION_RECORD Neo4j edge → edge_id non-null
test_set_and_get_plant_statePlant state gate: normal/turnaround/shutdown/emergency

Dedup isolation: Tests that assert deduplication behavior create fresh unique assets with uid() to avoid bleeding from shared_asset_id.

test_briefs.py — Brief Delivery + EEMUA 191 Governor (Tasks 8, 13, 16, Layer 8)

TestWhat it verifies
test_get_my_briefs_shapeResponse includes briefs, total_pending, governor_state
test_governor_ceiling_is_6Hard ceiling is exactly 6 pushes/operator/hour
test_governor_state_is_valid_valueState is one of: normal / suppressed
test_get_governor_status_endpoint/briefs/governor/status responds with push count + ceiling
test_get_briefs_unacknowledged_only_default?unacknowledged_only=True returns only pending briefs
test_get_briefs_all_including_acknowledged?unacknowledged_only=False includes all briefs
test_brief_not_found_returns_404GET /briefs/{uuid} with non-existent UUID → 404
test_ack_nonexistent_brief_returns_404POST .../ack for non-existent brief → 404
test_brief_feedback_requires_ratingFeedback with valid rating → 200/404/422, never 500
test_ack_brief_via_ptwPTW with issuing_engineer_id=service-kairos-connector → brief_id returned immediately → ack → 200 with status field
test_attribution_worker_queues_recheckrating=incorrect on a real brief → confidence_recheck_queued row in audit_log

UUID requirement: Brief, conflict, and quarantine endpoints use PostgreSQL UUID columns. Tests use str(uuid4()) for fake IDs — plain strings cause Postgres parse errors → 500.

test_search.py — Hybrid Search + Synthesis (Tasks 9-12, Layer 11, 11 tests)

TestWhat it verifies
test_search_returns_response_shapequery, results, total, retrieval_methods present
test_search_empty_query_rejectedMissing q param → 422
test_search_with_asset_scope?asset_id= filter works
test_search_authority_filter?authority_min= param accepted
test_search_with_as_of?as_of= time-travel filter works
test_search_result_fieldsEach result has retrieval_method and relevance_score
test_search_asset_scoped_endpointGET /search/assets/{id} works
test_synthesize_response_shapePOST /search/synthesize → refused, safety_critical, sources
test_synthesize_safety_critical_refusalquery_category=max_allowable_pressure → safety_critical=True
test_rca_pack_response_shape/rca-pack → timeline, hypotheses, supporting_documents
test_rca_pack_refused_on_low_confidence_safetyrefused field present and boolean

Safety categories: Must use exact keys from SAFETY_CRITICAL_CATEGORIES in backend/api/services/llm.py: max_allowable_pressure, isolation_interlock_sequence, torque_specification, electrical_rating, pressure_relief_setting, safety_shutdown_setpoint. Other strings won't trigger the safety gate.

LLM timeouts: Synthesize and RCA pack tests use timeout=120.0 per-request — LLM calls can be slow.

test_governance.py — Governance Layer (Tasks 21-25, 34, Layer 7, 23 tests)

TestWhat it verifies
test_list_conflicts_shapeConflicts list → items/total envelope
test_list_conflicts_filter_track?track=administrative filter works
test_conflict_not_foundUUID that doesn't exist → 404
test_resolve_conflict_not_foundPOST resolve on non-existent UUID → 404
test_resolve_administrative_conflictResolve open admin-track conflict → status=resolved (skipped if none exist)
test_resolve_engineering_track_conflict_rejectedEngineering-track conflicts require MoC webhook → 400 (skipped if none exist)
test_list_quarantine_shapeQuarantine list → items/total/note envelope
test_quarantine_defaults_to_pendingDefault filter returns only review_status=pending items
test_quarantine_filter_by_review_status?review_status=disputed filter works
test_promote_quarantine_itemLow-confidence inspection → quarantine → promote → edge_id returned
test_dispute_quarantine_itemQuarantine item → dispute → status=disputed
test_double_promote_returns_409Promoting already-promoted item → 409
test_sla_report_shapeSLA escalation report fields present
test_circuit_breaker_shapeSPC circuit breaker states per asset class
test_list_moc_shapeGET /governance/moc → items/total envelope
test_get_conflict_detailGET /governance/conflicts/{id} → 200 with conflict + blast_radius fields (skipped if no conflicts)
test_moc_webhook_bad_payloadMalformed webhook payload → 400
test_moc_webhook_valid_payloadValid MoC webhook (created via deviation flag resolve) → 200, moc_id + resolution in response
test_blast_radius_nonexistent_docBlast radius for unknown doc → 200 empty (not 404)
test_validation_corpus_statsCorpus coverage stats by entity type
test_model_gate_historyGET /governance/model-gate/history → items/total
test_model_gate_run_requires_adminfield_worker → 403; admin → 200 with task_id

test_compliance.py — Compliance (Task 26, Layer 7, 9 tests)

TestWhat it verifies
test_list_frameworksGET /compliance/frameworks → list of regulatory frameworks
test_compliance_dashboard_shapeGET /compliance/dashboard → gap counts by framework
test_list_gapsGET /compliance/gaps → items/total envelope
test_list_gaps_filter_framework?framework= filter works
test_audit_pack_shapeGET /compliance/audit-pack → status=draft, note contains sign-off warning
test_audit_pack_oisd_117OISD_117 audit pack generated without error
test_gaps_reduce_after_document_promotionPromoting a procedure clears its compliance gaps

test_elicitation.py — Elicitation (Tasks 19, 29-31, Layer 9, 12 tests)

TestWhat it verifies
test_voice_note_ingest_acceptedAudio file upload → 202, task_id + sha256 in response
test_voice_note_idempotentSame bytes twice → both 202 (accepted or duplicate)
test_trigger_no_conditions_not_triggeredNo trigger conditions met → triggered=False
test_trigger_novel_troubleshooting_triggersnovel_troubleshooting=True → triggered=True, workflow_id
test_elicitation_questions_not_foundGET questions for unknown WO → 404
test_submit_elicitation_responsesTrigger novel → get questions → submit responses → 200
test_create_offboarding_programmeCreates programme, returns session_id, total_sessions
test_offboarding_requires_engineer_or_adminfield_worker → 403
test_submit_offboarding_responsesCreate programme → get items → submit responses for first item
test_list_offboarding_shapeGET /elicitation/offboarding → items with completion_pct
test_get_offboarding_programmeGET /elicitation/offboarding/{id} → session items with statuses
test_get_offboarding_questions_for_sessionGET questions for specific session item

Voice note MIME type: Supabase storage bucket does not allow audio/wav. Tests use application/octet-stream as content type.

Voice dedup is async: The dedup check reads from quarantine_items which is populated by the Celery transcription worker. Both uploads correctly return 202 but the second may not yet show status=duplicate synchronously.

test_audit_log.py — Audit Trail (Task 25, Layer 7, 9 tests)

TestWhat it verifies
test_audit_log_shapeGET /audit-log/ → items/total/limit/offset envelope
test_audit_log_filter_entity_type?entity_type=brief returns only brief entries
test_audit_log_filter_action?action=brief_acknowledged filters correctly
test_audit_log_filter_performed_by?performed_by= filter works
test_audit_log_has_entries_after_asset_writeLog is non-empty after test setup actions
test_audit_log_entry_fieldsEach entry has action, entity_type, performed_by
test_audit_log_filter_combinedCombined entity_type + action filter works
test_audit_log_filter_entity_id?entity_id= filter returns only entries for that entity
test_audit_log_paginationPage 1 and page 2 entries don't overlap

test_ot_connector.py — Go OT Connector (Task 17, Layer 5, 8 tests)

Hits the Go service at port 8090 (http://kairos-backend-go:8090 inside Docker, http://localhost:8090 from host).

TestWhat it verifies
test_ot_connector_health/health → status=ok, service=kairos-connector
test_ot_query_requires_asset_and_tagMissing params → 400
test_ot_query_returns_timeseries/ot/query?asset_id=&tag= → data[], from, to
test_ot_query_mock_flagmock=true when PI_WEBAPI_BASE_URL not set
test_ot_coverage.py (6 cases, service-free)GET /assets/{id}/ot-coverage — verified loops yield real drawing tags; unverified topology is not coverage; no linked drawing → none, never a fabricated tag
test_ot_coverage_unknown_assetUnknown asset → 200 with mock coverage (not 404)
test_eam_sync_returns_completed/eam/sync loads fixture → status=completed, synced>=0
test_eam_work_order_forwardingGo /eam/work-order → proxied to FastAPI → status=accepted/deduplicated

test_pii.py — PII redaction, DPDP export boundary (5 tests, no services)

TestAsserts
test_redacts_structured_identifiersEmail, phone, Aadhaar, PAN, employee ID, shift ID all masked — asserts each literal is absent from the output
test_person_names_masked_with_stable_pseudonymSame name → same [PERSON_1] twice, so cross-references survive
test_equipment_tags_and_part_numbers_are_not_piiEQ-101, MS-4471-B, XV-203, OISD-117 pass through untouched — redaction must not damage operational content
test_clean_text_is_returned_unchangedNo PII → identical text, pii_found=False
test_spans_reference_original_offsetsSpan offsets index the original text, not the masked output

test_query_category.py — Safety-critical classification + refusal gate (32 tests, no services)

TestAsserts
test_safety_critical_queries_are_classified (×10)MAWP, PSV set pressure, isolation boundary, torque, insulation class, ESD setpoint each map to the right category
test_non_safety_queries_are_not_classified (×4)Alias, personnel, failure-count, OEM queries return None
test_relief_setting_wins_over_generic_pressureOrdering guard: "relief valve set pressure" → pressure_relief_setting, not max_allowable_pressure
test_refuses_safety_query_on_unauthoritative_evidenceLevel-5 field observation only → refused=True, answer=None, sources still returned
test_authoritative_evidence_clears_the_gateLevel-3 OEM evidence with no confidence field → not refused (cascade stubbed)

test_search_fusion.py — RRF retrieval fusion (6 tests, no services)

TestAsserts
test_bm25_scale_does_not_beat_cosineA BM25 score of 98.6 does not outrank a cosine 0.91 at equal authority; the doc both sources agree on wins
test_authority_still_outranks_relevanceLevel 1 before level 5 regardless of fused score — the safety property holds
test_merge_keeps_the_longest_snippetDuplicate collapse keeps the richer snippet, so synthesis still sees the fact
test_documents_without_ids_are_droppedEmpty document_id never enters the ranking
test_graph_edge_renders_readable_snippetA graph hit carries text, not ""
test_unverified_graph_edge_is_flagged_quarantineverification_status != verified → is_quarantine

test_ingestion_formats.py — Spreadsheet + email ingestion (4 tests, no services)

TestAsserts
test_spreadsheet_rows_and_sheet_names_extractedBoth sheets with names; every cell value present; blank rows dropped
test_unreadable_spreadsheet_degrades_without_raisingGarbage bytes → empty text + requires_review, no exception
test_email_headers_body_and_attachment_namesHeaders, body and attachment filename extracted; attachment bytes never inlined
test_mbox_archive_yields_every_messageBoth messages in a 2-message mbox are extracted

test_http_pool.py — Pooled HTTP client + embedding cache (8 tests, no services)

TestAsserts
test_same_loop_reuses_one_clientSecond call in one loop returns the same client
test_new_event_loop_gets_a_fresh_clientEach asyncio.run() loop gets its own client — the Celery case a global client would break
test_closed_client_is_replacedA closed client is never handed out again
test_close_is_idempotentDouble close does not raise
test_cache_never_stores_a_failed_embedding[] is never cached — would poison every later search
test_cache_evicts_least_recently_usedLRU order respected at maxsize
test_task_is_part_of_the_cache_keyretrieval.query vs retrieval.passage do not collide

What the tests prove vs. what they don't

Proven

  • All API routes exist and return correct HTTP status codes
  • Request/response contracts (shapes, required fields, error codes) are correct
  • Auth and RBAC: roles enforced correctly (admin vs engineer vs field_worker)
  • Deduplication logic works for work orders, tag-outs, documents, voice notes
  • Quarantine gate: confidence < 0.7 routes to quarantine; promote/dispute flows work
  • Safety-critical refusal: exact SAFETY_CRITICAL_CATEGORIES trigger safety_critical=True
  • EEMUA 191 governor ceiling is 6; governor state is valid
  • Vault immutability: supersede closes edges, double-supersede returns 409
  • Pipeline: Temporal worker advances document past "queued" within 60s
  • Conflict resolution: non-existent UUID → 404; admin-track → resolved; engineering-track → 400
  • Elicitation responses: novel troubleshooting and offboarding response submission work end-to-end
  • Go connector WO forwarding: POST /eam/work-order proxied from Go to FastAPI
  • Attribution worker: rating=incorrect → confidence_recheck_queued row in audit_log
  • Neo4j KNOWLEDGE_EDGE: all 6 required properties present and correctly typed
  • Qdrant vectors: document chunks indexed with document_id payload in kairos_documents
  • Elasticsearch: document indexed in kairos_documents with correct document_id

Not proven (known gaps)

  • Celery worker correctness end-to-end: tasks are queued and accepted; the Neo4j confidence update after recheck is not verified — only that the task was queued
  • Concurrent correctness: dedup under concurrent load, governor under burst
  • Attribution Neo4j edge update: confidence_recheck_queued verifies the task was queued, not that Neo4j confidence values were updated afterwards

Test users

Seeded by docker exec kairos-backend-api python scripts/seed_users.py:

EmailPassword (env var in .env)Role
admin@example.comKAIROS_SEED_PASSWORD_ADMINadmin
engineer@example.comKAIROS_SEED_PASSWORD_ENGINEERengineer
field_worker@example.comKAIROS_SEED_PASSWORD_FIELD_WORKERfield_worker
reliability@example.comKAIROS_SEED_PASSWORD_RELIABILITYreliability
compliance@example.comKAIROS_SEED_PASSWORD_COMPLIANCEcompliance
demo@example.comKAIROS_SEED_PASSWORD_DEMOdemo

Admin client in tests: admin_client does NOT use Supabase JWT. It uses INTERNAL_API_KEY (<dev-internal-key>) which never expires. This eliminates mid-run JWT expiry failures on long test suite runs.


Common pitfalls

SymptomCauseFix
ImportError: cannot import name 'uid' from 'tests.conftest'PYTHONPATH not set; site-packages tests package winsEnsure PYTHONPATH=/app is in container env (set in docker-compose.yml) and tests/__init__.py exists
Admin login fails (401)Test users not seededdocker exec kairos-backend-api python scripts/seed_users.py
422 Unprocessable Entity on asset createcriticality value invalidUse critical / safety_critical / non_critical — not high/medium/low
500 on brief/conflict fake IDUUID expected by PostgresUse str(uuid4()) not plain strings like "BRIEF-FAKE"
500 on voice uploadSupabase bucket rejects audio/wav MIMEUse application/octet-stream in tests
Document ingest returns status=duplicateSame bytes → same SHA-256Append uid() to content bytes to guarantee uniqueness
Work order dedup test fails (first call already deduplicated)shared_asset_id was used by a prior test within the dedup windowCreate a fresh unique asset per dedup test
test_synthesize_safety_critical_refusal → safety_critical=FalseWrong category keyUse exact key: max_allowable_pressure (see backend/api/services/llm.py)
Search result assertion fails on score fieldField is relevance_score, not scoreAssert relevance_score
OT connector tests fail inside Dockerlocalhost:8090 not reachable from API containerOT_CONNECTOR_URL env var routes to http://kairos-backend-go:8090
test_db_writes.py times outTemporal pipeline takes >90sRun with --timeout=180 or check Temporal UI at http://localhost:8088 for stuck workflows
test_neo4j_knowledge_edge_written fails with missing valid_to keyNeo4j silently drops null properties, so a null valid_to never appears on the edgeFixed in GraphService.create_knowledge_edge(): open-ended edges now store sentinel datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc) — test passes reliably
Mid-run admin token expiry (401 in long runs)Supabase JWT TTL < 9-minute suite runtimeadmin_client now uses INTERNAL_API_KEY — no expiry; engineer/field tokens can still expire on very slow stacks