Skip to content
kazma.
ع Star 6 Get Started

Known gaps

What is weak, unproven, or unfinished in Kazma right now.

CHANGELOG.md records what was fixed. This page records what has not been, and it exists because a security claim is only worth what its author is willing to say against it. Every entry names the evidence, so a reader can check it rather than take our word — and so the gap stops being invisible when the person who found it forgets.

Reviewed 2026-09-27. An entry with no date has not been re-checked since.

Open defects: none known. Every “still open” item below is now one of three things: closed (struck through, with the evidence and the gate that holds it), accepted (a limit kept on purpose, with the reason), or an owner decision (it changes the owner’s accounts or data).

Owner decisions (all settled on 2026-09-27):

  • Branch protection on main: done. Ruleset KazmaLatestRule requires the eleven CI checks, blocks deletions and force pushes, and lets the repository admin bypass (docs/docs/ops/branch-protection.md). The metrics bot no longer commits to main; it keeps a pull request open on the website repository instead (docs/docs/ops/website-metrics.md).
  • The first prune of the live install’s chat step history: done. The owner set 30 days (Settings -> System -> Chat step history), and the next maintenance pass removed 301,753 rows of step history across 137 chats (5,147 checkpoints of 214 chats remain, read-only count that day). Postgres keeps the freed space for new step history, so the tables still take 3.0 GB on disk; only VACUUM FULL would shrink the files, and it locks the tables while it runs — not needed with 889 GB free on the volume. New installs prune from day one (30 days).
  • Removing the test data found on the live install: done. The owner ran scripts/cleanup_live_leftovers.py --apply. The first run stopped at a locked cron store (fixed that day: the cron store commits every write), the second finished, keeping a backup of everything it changed.
  • pgvector: stays out. Meaning search is exact and local (sqlite-vec / NumPy); see “Exact meaning search is linear” below.

Watching: nothing. The two event-loop stalls right after one boot (2026-09-27 00:50) did not come back in the 31 boots of the following day; see “Operational tripwires” (accepted there, with how a recurrence is seen).

Accepted limits (each is explained where it is recorded below): the Postgres CI job runs the marked tests, and every module that opens Postgres has one; KAZMA_DATA_DIR does not isolate a Postgres settings store; unreferenced public symbols are ratcheted, not mass-deleted; X credentials stay per tenant; the shared-store peer registry names installs and stops none; python_exec’s denylist reads literals; a credential URL outside the install-scoped names stays in the settings store, masked on every way out; the Web approval card shows raw arguments to the operator; module-level import cycles are held by a fresh-import CI job; exact meaning search is linear; the prompt-injection figures are one model’s; MCP clients without a chat thread get no session grants; the date guard’s matching limits; a dead settings table in a Postgres install’s settings.db; timing-based tests are ratcheted; the control-plane write guard is path-based; Kazma is built for a single trusted operator.


What the 2026-09-16 audit found, and what it says about our gates

Section titled “What the 2026-09-16 audit found, and what it says about our gates”

Seven defects, all shipped, all green in CI. They are fixed (see CHANGELOG.md) and the regressions are pinned in tests/test_audit_2026_09_16_regressions.py. They are recorded here rather than only in the changelog because the pattern is the finding, and the pattern is still a risk:

Every one of them sat next to a gate that was supposed to catch it.

What shippedThe gate that was watching
Swarm reaping, checkpoint retention and the migrate-import liveness interlock had never once run — started from a sync constructor with no event loop, RuntimeError swallowed, logged at warningnothing; the log line was the only evidence, for months
digest_research_file / summarize_research_file / list_research_chunks returned fetched web text unfenced — and the tool descriptions steer the model to the digest, so the recommended research path was the unfenced onetest_no_unfenced_web_tool_output greps the file for fence_untrusted; one fenced sibling in a 1,500-line module made it pass
run_unit_tests (pytest → arbitrary code execution) sat at the read tier, no approvalTOOL_TIERS still gated run_tests, the pre-rename name, which is not a registered tool
20 subprocess.run calls on the event loop in agent tools, up to 90s each — freezing every SSE stream, WS ping and the approval endpointtest_no_blocking_db_driver_in_async scans those exact files, for sqlite3.connect only
email_list returned sender/subject/snippet unfenced while email_get fenced both; only the snippet stripped newlines, so a subject could break out of the table rownone
New danger tools never reach an existing install: reconcile_from_yaml seeds only absent keys, so a live store held 56 of 57 canonical tools foreverthe drift warning fired at every boot and was informational
The Security Scan CI job could not fail (bandit … || true), and README’s “auto-verified” metrics were wrong on every figurethe metrics check also ended in || true

The generalisation, which is the part worth keeping: a gate that checks the shape of the code — a string in a file, one driver name, one tool’s tier — will pass while the sibling function, the renamed tool, or the second copy of the list is wrong. Where it was practical the gates are now per-function and closed by default (an unclassified public tool coroutine fails), but that discipline has been applied to the modules the audit touched, not to the whole tree. Assume the same class exists elsewhere.

What that audit left open, and where it stands (2026-09-27):

  • Accepted, and gated per module: the Postgres CI job runs the marked tests, not the whole suite. Since 2026-09-27 every product module that opens Postgres has a marked test: tests/test_postgres_coverage.py fails on one that has none. Its first run named three — the agent’s and the gateway’s checkpointers (now one opener, kazma_core/checkpoints_pg.py, whose real-Postgres test found that the agent never closed its pool) and the research panel’s Delete, which ran its own SQL against the task tables (now TaskStore.delete_task, tested on both backends). Everything unmarked still runs on SQLite only; that is the accepted part. The record: the job runs every test marked @pytest.mark.postgres (scripts/postgres_suite.py): 287 tests in 32 files on 2026-09-25 (night; 5 skip on a plain postgres:16 — one needs pgvector), each file passing twice on a throwaway Postgres before it was marked — up from seven named files at the start. The newest, test_task_store_backends.py, pins where the two backends’ SQL differs (worker/metadata/tenant filters, counts, metrics, prune, orphan requeue) and found that TaskStore.prune_tasks reported 0 deletions on Postgres whatever it deleted (its DELETE had no RETURNING; fixed). Marking is per test, so a file whose other tests are SQLite-shaped still contributes the ones that are not. That is a tripwire for those code paths, not parity with the SQLite suite — everything unmarked still runs on SQLite only. A broad -k sweep was tried and rejected: it drags in SQLite-shaped tests that fail for reasons unrelated to the backend, and a job that is red on day one is a job everyone ignores, which is how the gap opened in the first place. Next candidates, and why they are not marked: test_swarm_task_store.py, test_session_manager.py and test_shared_store_peers.py pass on SQLite and fail on Postgres only because they assume an empty table per test (counts, a fixed session id whose usage accumulates, system.installs.* rows from other tests, raw sqlite3 reads). None of the 25 failures there was a Postgres bug. Each needs per-test isolation (unique ids, assertions about its own rows) before it can join — the pattern test_swarm_paused_task_endings.py now uses. test_swarm_task_store.py is also where the task-hitl-1 / task-paused-1 rows in the live database came from (2026-08-14, before conftest.py stripped DSNs).

  • By design: the suite can only reach a real Postgres through one deliberate switch. conftest.py force-pins KAZMA_DB_BACKEND=sqlite and strips every DSN at import, with a guard that removes the DSN again if anything re-adds it — so a developer’s .env can never point the suite at a live database. Only KAZMA_TEST_ALLOW_REAL_DB=1 (set by the CI Postgres job and nothing else) opens it. The first version of that CI job did not set it and would have run entirely on SQLite while looking like Postgres coverage; that is why test_conftest_db_guard_is_failsafe_by_default exists.

  • Accepted: KAZMA_DATA_DIR does not isolate a Postgres-backed ConfigStore — by design, now said out loud twice. _use_postgres() keys off KAZMA_DB_BACKEND / KAZMA_DATABASE_URL only, so a script that sets only the data dir on a box with .env loaded reads — and can write — the real settings store (verified by accident during the audit). Making the data dir imply SQLite would detach a legitimately relocated install from its own database, so the answer is visibility: the boot warning for a relocated data dir on Postgres (restored 2026-09-21), and since 2026-09-25 a registry of installs in the store itself (db/shared_store_peers.py) — every server boot records itself and names other installs booted against the same database in the last 14 days, which also catches the second checkout that relocates nothing (the 2026-09-16 shape). kazma doctor shows the same.

  • 148 of 262 KAZMA_* variables are not yet described Closed 2026-09-25: every variable the code reads is described on the curated page, each written from its call site, and the ratchet in tests/test_env_reference.py is at 0. What reading them turned up is in “Found while describing every variable” below.

  • Accepted, ratcheted: 175 public symbols have no reference outside their own module (a broader count, which includes helpers used only inside their module, is on the module_local_public_symbols ratchet in tests/test_debt_ratchet.py and may only go down: 599 on 2026-09-27). Not removed: mass-deleting unreferenced public API is how you break downstream importers, and the audit proved the point — ruff --fix removing “unused” imports silently broke every native skill via a re-export contract no linter could see (caught by tests/test_imports.py).

  • Three bandit findings are reported but not gated. Closed 2026-09-25: tests/ and scripts/ joined the HIGH gate. The findings were fixed, not waived — vendor_codemirror.py resolves npm/npx with shutil.which instead of shell=True, the template-compile test builds its Jinja environment with autoescape on, and the FTP backup tests carry the same justified # nosec B402 as the backend they test. tests/test_security_scan_scope.py keeps both directories in the gate and every # nosec naming its rule and a reason.

  • Accepted, held at the storage and by a tripwire: nothing asserts that an entry point installs a tenant context. Provider keys, mail and calendar secrets are install-scoped (so no tenant is needed to read them), a tenant-less miss of a per-tenant secret logs a WARNING naming the tenants, and the per-tenant ones that remain (X credentials) are read by background code that binds its tenant (see “X connector credentials” below). Vault secrets are tenant-scoped and everything saved through Settings is written under the web request’s tenant ("default" on a single-user install). Vault.retrieve falls back tenant → global and deliberately not the reverse, because a global → tenant fallback would let any context-less background task read another tenant’s credentials. The consequence is that any code path which forgets to install a tenant reads None for every secret the UI holds — and None is indistinguishable from “not configured”, so the failure is silent and the diagnosis is wrong.

    This has now shipped four times; the first three were fixed at one call site each, the fourth at the storage:

    WhereSymptomFixed
    cron schedulertwo 09:00 reminders failed HTTP 401: no usable API key, paging the operator twice2026-09-12
    the agent turn (resolve_live_client)the operator’s DeepSeek key read as absent → registry substituted Z.AI → Telegram answered with Z.AI’s {"code":"1211","message":"Unknown Model"} for a DeepSeek model id2026-09-17
    kazma_cli.mainkazma doctor reported the key unreadable and blamed another install’s vault, while it sat in that same vault decrypting fine2026-09-17
    boot and every tenant-less caller of the registryunder KAZMA_PRODUCTION=1 the default rung is closed, so every boot built the agent on Z.AI with the DeepSeek key in the vault2026-09-25: provider keys are install-scoped (INSTALL_SCOPED_SECRETS), and a fallback is announced

    The third is the one worth staring at: the diagnostic had the bug it was built to diagnose, so it confidently sent the operator to re-enter a key that was already correct. Two days were spent on a provider fault that did not exist.

    The vault’s fallback direction is right and should not be widened.

    CLOSED at the variable, 2026-09-20. There is now exactly ONE tenant ContextVar: kazma_core.safety.hitl re-exports kazma_core.tenant_context._current_tenant_id instead of defining its own, and the mirror functions are gone. Mirroring was never an invariant — it was two writes that happened to agree, and a direct .set on either module desynced them in silence. One object cannot drift from itself. The None-vs-"default" contract both sides need is preserved by flooring on READ, in hitl.get_current_tenant_id, never on store: retrieve_scoped needs None to tell an absent tenant from an explicit one. Locked by tests/test_tenant_context_isolation.py::test_the_tenant_context_var_is_one_object, the old “these must be distinct” assertion turned around — it had itself predicted this change (“if these ever become the same object this test is obsolete”).

    The last two ambient readers are fixed too: secret_vault.vault_retrieve and the Settings “is this configured?” probe both called vault.retrieve(name), which sees only global rows without a bound tenant. The Settings one was the worse of the pair — it rendered a stored provider as NOT configured, the single most confusing way this product can lie to its operator.

    Still true: new background readers must use retrieve_scoped or tenant_scope("default"). Unifying the variable removed the desync half of the class; the forgot-to-bind-a-tenant half now has its tripwire — re-landed 2026-09-25: a retrieve with NO tenant bound that misses a name stored under a tenant logs one WARNING per name, naming the tenant(s), never the value (tests/test_vault_scoped_miss_tripwire.py). A caller with its own tenant missing another tenant’s key is isolation, and stays silent.

    Attempted and REVERTED, 2026-09-17. The fix tried was a runtime tripwire: have retrieve log, once per name, when it returns None for a name that does exist under some tenant. A static list of entry points would be the same gate that let all seven audit defects through, so the guard belonged at the miss, where every caller passes.

    It turned CI red for four commits. tests/test_documents_api_phase8.py began hanging in app SHUTDOWN (TestClient.__exit__ -> wait_shutdown -> Future.result()), reproducibly on Linux, never on Windows. Attribution was recorded here as “not in doubt”: ten prior runs clean, POISON on exactly the two commits that carried the tripwire, and the counts lining up (9048 - 17 poisoned + 10 added = 9041).

    THE ATTRIBUTION WAS WRONG (found 2026-09-20). The tripwire has been reverted for days and the hang is still here, on every CI run: 12 consecutive commits red, each one test_documents_api_phase8 timing out in wait_shutdown, each one reporting “0 failed” in its own totals — the job exits 1 on a teardown timeout, not an assertion, which is why “9,585 tests, 0 failures” and “CI is red” were both true at once and nobody reconciled them.

    The real cause is in DocumentWorker.stop(). It bounded its first wait and not the second: after the grace period it calls task.cancel() and then await asyncio.gather(*tasks) with no timeout. Cancellation cannot reach those tasks — _worker_loop parks in asyncio.to_thread(claim_next), and a thread is not cancellable, so the exception is only delivered once the thread returns. If claim_next is stuck on a lock, the gather waits forever. Both waits are bounded now, and the app-level stop_workers() / stop_memory_worker() awaits carry ceilings so the “never blocks shutdown” comment above them is true rather than aspirational.

    Why the evidence looked so conclusive: the correlation was real, the causation was not. The tripwire added a per-retrieve probe, which shifts timing, and this hang is a race on whether a worker thread is mid-claim when shutdown fires. Ten clean runs before it and red on both tripwire commits is exactly what a latent race looks like when something nudges the schedule. The three published hypotheses all failed for the same reason: they were looking for a cost in the tripwire, and the tripwire was not the defect.

    The tripwire can be re-landed. Reproduce this hang first — it is reproducible now, on main, without it.

    (The next two paragraphs were written on 2026-09-17, before the cause above was found; kept as the record of what did not explain it.) The cause was never found. Three hypotheses were published and all three were wrong — the probe’s query cost (A/B: 415.4s vs 413.9s, no difference), a slow runner (the failing rerun was faster than the last green run), and a lock window from probing in a second acquisition (restructured; still red). It does not reproduce in a Linux container even with CI’s own system packages: two arms, with and without the tripwire, 260.78s vs 260.35s, neither hanging.

    Reverted rather than iterated on, because main had been red for four commits and “one more theory” had already been tried three times. Anyone picking this up starts by reproducing the hang, not by writing a fix. The useful artifacts are scripts/-free: a container with libreoffice-writer, tesseract-ocr and fonts-noto-core gets the file running but not hanging, so whatever the trigger is, it is not in that file alone.

    → tests/test_cron_tenant_context.py, tests/test_vault_tenant_scope_read.py.

  • /api/telemetry open with no route behind it (closed 2026-09-20). A fossil in ALWAYS_OPEN_PATHS from a mock endpoint removed years ago. It never opened the two real telemetry routes: is_always_open matches that set EXACTLY and only ALWAYS_OPEN_PREFIXES by prefix, so /stream and /snapshot were always caught by the default-deny on /api/. That subtlety is why it survived — it looked dangerous and was inert, so a 2026-09-20 audit filed it as an unauthenticated host-inventory leak and a reviewer had to read the matcher to disprove it. An entry that opens a path with no route is a pre-opened door for whoever adds that route next. → tests/test_auth_middleware.py::test_telemetry_subpaths_are_gated.

  • kazma_core/tools/__init__.py shadows its own submodules. Closed 2026-09-27, and the breakage was not always loud: a fixture in tests/test_file_read_cache_invalidation.py did from kazma_core.tools import file_write as fw and patched check_path_access onto the FUNCTION — nothing at all — and passed. Every name in the package is its submodule now (nine were rebound), and kazma_core.web_acquire’s search.py became serp.py so its search is one thing, the function. tests/test_package_namespaces.py checks every product package, with a negative control. The record: it exported a function named read_url, so import kazma_core.tools.read_url as ru bound the function, not the module (Python resolves import a.b as c by attribute since 3.7); pytest’s monkeypatch.setattr("kazma_core.tools.read_url.X", …) raised AttributeError on the function, while mock.patch (which imports) reached the module.


What the 2026-09-22 audit found, and the gates that now hold it

Section titled “What the 2026-09-22 audit found, and the gates that now hold it”

The same pattern as 2026-09-16, one level up: a correct fix in one sibling, missing from the next. Slack and Telegram chained messages correctly and Discord did not; app.py loaded .env from explicit paths and cost_breaker did not; the approval route checked thread ownership and the replay, chat control and dashboard routes did not; six copies of the admin check disagreed about what to do when it failed. Every finding is closed, and each landed with a gate that enumerates the siblings from the real source, plus a negative control proving the gate fails on the old code.

ClosedGate that fails if it returns
Discord burst delivered the last message N times (lost the rest)test_adapter_burst_ordering.py (all adapters); test_no_deferred_closure_reads_loop_rebound_names
Importing kazma_core loaded a .env, into the document sandbox tootest_env_loading.py (production launch shape, planted .env; every entry point declares a policy); test_load_dotenv_lives_only_in_the_env_loader; test_no_import_time_environment_writes
serve.py ignored KAZMA_HOST from .envtest_serve_env.py
Boot errors hidden behind a DEBUG-only trytest_app_bootstrap_errors.py
Replay reads/deletes, chat stop/steer/abort, /api/sessions acted on any threadtest_thread_route_policy.py (every thread-taking route declares owner/admin/session); test_thread_ownership_routes.py
Admin check copied six times; half allowed on errortest_the_admin_decision_lives_in_one_place; test_admin_decision.py
Web sessions never carried a tenanttest_session_tenant_binding.py
43 UI writes discarded the response; 24 toasted success on failuretest_no_discarded_fetch_results_in_the_ui; tests/js/test_kazma_save.js
Five tests/js files never ran in CI (one failing unseen)test_js_suite.py runs the whole directory
Snapshot cache raced; a capture error failed the turn; replay I/O on the event looptest_time_travel_concurrency.py; the replay route walk in test_thread_ownership_routes.py fails on loop I/O
run_in_executor dropped the tenant/workspace ContextVarstest_sync_tool_context.py; test_no_context_dropping_executor_calls
Stdlib XML parse of fetched sitemaps (DTD accepted)test_safe_xml.py; test_untrusted_xml_uses_the_guarded_parser
preexec_fn in the document sandbox and python_exectest_rlimits.py (real limits on Linux CI); test_no_preexec_fn
Dead or unwired modules (realtime codec, TUI footer, PDF exporter, file merger, markup guard, email base, a test-only HITL router, the hub Kubernetes manifest)test_orphan_modules.py builds the import graph; test_no_realtime_or_live_conversation_apis
HTML exports: \$ kept its backslash, URL isolation ate the full stop, <p> nested in <p>test_html_export_of_an_arabic_report
Unused locals hiding bugs (JSON skill manifest ignored, validated ports discarded)test_unused_locals_that_were_bugs.py; CI’s gating Ruff step now includes F841 and B033
kazma docs looked in site-packages and could not start npm.cmdtest_cli_docs.py
Knowledge API ran SQLite on the event loop (every route, plus a ConfigStore write per crawl progress update); crawls embedded each page inline; the async index search façades ran their whole body inline; recrawls under-counted unchanged pagestest_kb_api_routes.py walks the router’s own route table; test_kb_smart_reindex.py (crawl, page ingest, search façades, pages_unchanged)
23 async functions resolved DNS inline through validate_url (fetch, research, crawl, per-hop redirect checks, model discovery, provider tests)test_no_blocking_dns_in_async_functions
3,846 blind / 577 silent exception handlerstest_debt_ratchet.py — the counts may only go down
Chat memories archived to empty shells on day 30 however often they were recalled: every chat turn is importance 1 (never promoted), archival tested creation age only, and the “keep a summary” fallback used COALESCE on an empty stringtest_memory_v2_phase3.py (in-use turn survives, stub kept, own summary kept, moves reach the mirrors); test_only_the_archive_statement_drops_episode_text
The V_retention decay score decided nothing, and its λs were per-second (a “general” memory’s usage term halved every ~70 s)Removed with its five Settings knobs, decided 2026-09-23; test_v2_defaults_present keeps the knobs out

All 341 emptied memories on the live install were restored on 2026-09-23: 272 from local backups, 69 from the 2026-08-29 restic snapshot.

Found on 2026-09-23 from a weekly resilience report, and fixed

Section titled “Found on 2026-09-23 from a weekly resilience report, and fixed”
ClosedGate
The report read one day of a weekly window (rotated logs ignored)test_ledger_reads_rotated_logs
”430 health-gated restarts” were single missed probes (there was 1 restart)test_ledger_signatures_match_lines_the_code_emits (negative lines); test_health_gated_signature_matches_the_supervisors_real_reason
Operator alerting, deep restore drill, restic maintenance reported “silent” (typo’d pattern, unmatched deep: prefix, success never logged)test_every_ledger_signature_matches_a_line_the_code_emits; test_clean_restic_maintenance_leaves_the_line_the_ledger_counts
A failed restore drill did not say which check failedDrillResult.summary() names failed and unverified checks
70 event-loop stalls (one forced restart): HITL watchdog, X scheduler/poller/client, per-request session lookup, queue handlers, self-improvement recall, GCtest_loop_stall_helpers_are_not_called_on_the_loop; tests/test_web_session_cache.py; loop stalls now counted in the weekly report
169 health probes failed on port exhaustion with nothing recording who held the portsthe guard logs health.port_exhaustion (states + top owners) — tests/test_guard_port_exhaustion.py
Restic retention never deleted a snapshot: forget grouped by host+paths and every backup is a new path (199 snapshots, 199 groups)forget --group-by host,tags, daily retention 30 (operator’s choice); test_forget_applies_the_policy_per_kind_not_per_path runs real restic, and its control shows the old grouping keeping all. restic’s dry run on the live repo: keep 90, remove 110; the kept 2026-08-29 snapshot was checked to still hold the 69 recovered memories

Closed the same day, from the open list:

ClosedGate / evidence
131 httpx.AsyncClients built in async code loaded the CA bundle on the loop, per clientkazma_core.http_tls (one context, built in a thread at boot); test_async_http_clients_share_the_tls_context; tests/test_http_tls.py
_handle_micro_consolidation did its SQLite work on the loop (the one entry in the blocking-driver allowlist)prepare + apply run in threads around the awaited LLM call; allowlist emptied; tests/test_micro_consolidation_off_loop.py
The 2026-09-21 deep restore drill failure could not be diagnosedRe-run on 2026-09-23: 4/4 passed (1.9 GB Postgres stream, 5% of local and offsite restic packs re-read, offsite object present). Transient; the next failure names its check
The guard counted “this machine has no free port” as “Kazma is unhealthy”health.probe_unrunnable does not count toward a restart and pages once after ~5 min; tests/test_guard_port_exhaustion.py

Found on 2026-09-25 from a 67-call dig for saved drafts, and fixed

Section titled “Found on 2026-09-25 from a 67-call dig for saved drafts, and fixed”
ClosedGate
The model could save drafts but not read them: “list the remaining posts” took 67 tool calls and an approved python_exec byte-dumping agent_artifacts.dblist_proposals; test_every_state_changing_tool_declares_its_readback + test_every_write_to_a_kazma_store_has_a_no_approval_reader (per writer — a per-store rule passed, because the scratchpad feed read the same file)
Posting one draft marked its whole set posted: 7 of 11 hidden and on the 14-day spent-set age-out (live: restored by the startup repair from the X ledger)tests/test_saved_drafts_readback.py (per-item state, GC keeps a partly used set, repair never guesses)
A refused X post ({"ok": false}) counted as success and marked its draft usedtest_ok_false_json_is_classified_as_an_error; test_refused_post_marks_nothing
Five hand-kept answers to “is this a Kazma store?”; x_posts.db / x_scheduled.db / kazma.db readable raw by SQL, every store readable raw by file_read, agent_artifacts.db unknown to the python_exec refusaltest_every_door_refuses_every_store_and_names_its_reader; test_every_database_named_in_the_code_is_declared
The migration bundle dropped 8 stores (saved drafts, booked X posts, the post ledger, gate decisions, ledgers, RBAC, audit, LLM calls) and per-tenant checkpointstest_every_bundle_store_is_exported_and_restored; test_a_migration_carries_the_stores_it_used_to_drop (real export→import)
kazma migrate import failed on Windows at its first file swap, every time: with sqlite3.connect() never closestest_no_sqlite_connection_is_left_open_by_a_with_block (also fixed the universal backup’s copier)
Per-tenant gateway checkpoints written relative to the process CWDtest_no_store_path_is_built_from_the_working_directory
The tools catalog generator scanned the pre-split tool_builtins.py and found 1 built-in tool; the hand-kept catalog lacked 20 registered tools and called 31 approval-gated tools (x_post, send_file, git_push, the memory deletions…) “safe/read”The generator reads the live registry; tests/test_tools_catalog.py (every registered tool listed once; every danger label checked against requires_approval)

What that left open, and where it stands:

  • Stores open a connection per call and leave it to the GC. Closed 2026-09-25: store _connect() helpers return db.sqlite_session.committed_and_closed(conn), and test_no_raw_connection_opener_is_used_as_a_context fails on any helper that hands a raw connection to a with block.
  • Port exhaustion: TCP mitigated, UDP still open. Closed 2026-09-27, measured on the operator’s box: the UDP dynamic range is widened too (netsh int ipv4|ipv6 show dynamicport udp: 10000 + 55535, like TCP), and the System log has no 4266 (UDP port space full) since 2026-09-25 13:30 and no 4231 (TCP) since 2026-09-22 23:31. The culprit was never named; the guard’s health.port_exhaustion line names the socket holders if it recurs. The record: the TCP dynamic range is widened (netsh … dynamicport tcp: 10000 + 55535, IPv4 and IPv6) and no TCP event has been logged since 2026-09-22 23:31. UDP was not widened (still 49152 + 16384) and 4266 (UDP port space full) fired on 2026-09-23 17:23 and 2026-09-24 17:35; the same netsh for udp (admin) is the remaining machine setting. Measured on the operator’s box 2026-09-25. The original record: Windows’ own log (System, Tcpip) has 11 × 4231 (TCP port space full), 17 × 4227 (TIME_WAIT reuse) and 11 × 4266 (UDP port space full) in the fortnight to 2026-09-23. None of the 4231s was within 20 minutes of a backup, and Kazma logged 4–20 requests a minute around each. The guard’s health.port_exhaustion line names the socket holders at the next occurrence (Docker holds the most at rest: ~200 bound). Mitigation is a machine setting, not code: a wider dynamic port range (netsh int ipv4 set dynamicport tcp start=10000 num=55535, admin).
  • Slack is connected but lets nobody in. Closed 2026-09-25: the operator reports Slack working end to end (2026-09-25).

Found and closed in the 2026-09-25 hardening pass

Section titled “Found and closed in the 2026-09-25 hardening pass”
ClosedGate
Saved drafts could not be retired without deleting; an English language lock hid 11 Arabic draftstests/test_draft_discard.py; tests/test_language_lock_quoting.py (no prompt constant bans a script outright)
Sixteen except branches answered a failed data_dir() with Path.cwd() / "kazma-data" — a second settings.db, an unbacked-up document store, a different IDE sandbox, a CWD root added to a path allowlistgate 8, test_no_except_branch_rederives_the_data_dir; tests/test_no_cwd_data_dir_fallback.py
python_exec code reached the approval card unvetted (shutil.rmtree("/")) while shell_exec("rm -rf /") was deniedtests/test_python_exec_denylist.py (AST, the shell denylist’s own target rules)
The date guard matched subjects as substrings (“a cursory look” was about the Cursor reset)tests/test_date_guard_word_match.py
Two Postgres tests read a real .env — one the live install’s, by hard-coded path, override=True; the file list for the Postgres job lived in ci.ymltests/test_postgres_suite.py; the job runs @pytest.mark.postgres
The Postgres lazy plaintext→vault migration raised inside a debug-logged except and never landedtests/test_diagnostics_are_read_only.py (runs in the Postgres job)
Nothing restored a dump to prove it restoresopt-in backup/restore_rehearsal.py; tests/test_restore_rehearsal.py (real round trip marked postgres)

What that pass left open, and where it stands:

  • Closed 2026-09-27: branch protection. The owner enabled the ruleset KazmaLatestRule on main: the eleven CI checks required (each only from the GitHub Actions app), deletions and force pushes blocked, the repository admin as bypass so the owner’s direct pushes still land. The metrics bot could not be given a bypass (GitHub refuses the Actions app on a personal-account repository), so sync-metrics.yml stopped committing METRICS.md to main and only reads the framework repo now (docs/docs/ops/branch-protection.md section 3).
  • Closed 2026-09-27: the website’s metrics. From 2026-07-30 the Sync Metrics workflow was green on every push while the site got nothing: it looked its pull request up with gh pr view <branch>, which returns merged ones, and “updated” PR #3 (merged 2026-07-30) for two months. It also installed nothing, so every copy said “Collected at runtime: n/a”, and each push rebuilt a site preview (529 Cloudflare builds in September). Now: daily and by hand, the same install as CI’s Tests job, --require-collected, metrics.json with a frozen layout for the site, and scripts/sync_site_metrics.py reads the open pull request back or fails (tests/test_site_metrics_sync.py, tests/test_generate_metrics.py, docs/docs/ops/website-metrics.md).
  • Closed 2026-09-28: a pre-TTL YOLO flag bypassed approvals for good. When YOLO windows got an end time (2026-07-21), a bare true from before was still honoured as active, with no end. One was on the live install (general category, set 2026-07-19): that chat’s danger tools would have been auto-approved (graph_tool_worker, except always-HITL tools and git writes) whenever it was reopened. yolo_status now removes such a flag instead, and purge_expired_yolo (15-minute maintenance) removes every ended window in both categories writers used — 90 had piled up since July, because an ended window went only when its own chat asked again (tests/test_yolo_ttl.py, tests/test_yolo_sweep.py; the legacy test used to assert the flag was active).
  • Closed 2026-09-28: the website’s pages drifted from their sources, with no way to tell which. The site’s docs were copied by a one-off script on 2026-09-24 and nothing recorded what it had copied. 28 pages had changed here since, two were never published, three site pages had no source (a stale MCP IDE page listing 4 of its 7 tools, an IDE page with the wrong workspace order, a roadmap deleted here), 27 Arabic pages were short versions of their English ones, and 19 had code altered in translation. Now docs/website-pages.json gives every page its place (tests/test_website_pages.py: a page added here without one fails), scripts/website_sync_plan.py lists the work from recorded content ids (tests/test_website_sync_plan.py), and docs/docs/ops/website-sync.md is the procedure. Found on the way and fixed: the IDE page’s workspace order (the site copied it from here), no page for time travel or chaos testing, three pages missing from the docs sidebar (tests/test_docs_sidebar.py), and a 500 from a custom chaos injection with an unknown parameter (tests/test_chaos_routes.py). The first full sync was done 2026-09-28 by the owner’s website agent following the procedure (KazmaAI e3f6165, 87 sources recorded at 93eecd1d; the plan’s --check exits 0), and checked on the live site: the new pages, the 301s, Arabic headings equal to English.
  • Accepted: the shared-store peer registry is advisory. It names installs; it does not stop one from writing — replicas share the store legitimately, and a fence would need an identity they cannot forge. An acknowledged id silences only that id.
  • The restore rehearsal is off by default. Closed 2026-09-27: it is on by default with Postgres (backups.pg.restore_rehearsal=false or KAZMA_PG_RESTORE_REHEARSAL=0 turns it off), so the weekly deep drill proves the newest dump restores into a scratch database and drops it. It needs CREATEDB, which the live install’s role has (checked read-only 2026-09-25 and 2026-09-27; 889 GB free on the database volume); without it the drill reports UNVERIFIED with the grant. python -m kazma_core.backup.restore_drill --deep runs it on demand. (The live install had already turned it on, KAZMA_PG_RESTORE_REHEARSAL=1, on 2026-09-25; its first weekly deep drill since is due on 2026-09-28.)
  • Accepted: the python_exec denylist sees literals only. A path or command built at run time goes to the approval card, which is the control for it; static analysis cannot see a value the code computes.

Found on the live install on 2026-09-25, after the hardening pass, and fixed

Section titled “Found on the live install on 2026-09-25, after the hardening pass, and fixed”
ClosedGate
A Docker Desktop update dropped the CLI from PATH; every Postgres dump failed (“produced no dump”) with Docker and the container fine, and the alert gave no reasontests/test_docker_cli_discovery.py (lookup order, the alert carries the reason, boot tool check, no bare shutil.which("docker"))
A restart skipped the dump when the universal backup was fresh, holding the first dump after a fix back six hoursthe stale-dump catch-up tests in the same file
The guard hands every server its boot-time environment, so a PATH the operator fixed never reached a --reload; the .env ladder line was logged before logging existedtests/test_path_refresh.py (real app factory; order check with a negative control)
A provider key saved in Settings sat under tenant default; the registry reads with no tenant and the default rung is closed in production, so every boot from 2026-09-16 substituted Z.AI for DeepSeek — the fourth shipment of the tenant-context class below — and said so only in a WARNINGtests/test_provider_key_install_scope.py (provider keys install-scoped; boot consolidation); tests/test_model_fallback_notice.py (every model swap pages and shows a banner)
uvicorn replaced the client address before the undeclared-proxy check read it: every boot behind Cloudflare Tunnel logged a false [SECURITY] alarm advising to trust a visitor’s IPtests/test_forwarded_headers_peer.py (real ASGI layers; every launcher leaves forwarded headers to the app)
Mail secrets split across scopes: email.gmail.scopes differed between chat and background work (the backup token refresh and the agent’s secret tool wrote under the request’s tenant)tests/test_mail_secrets_install_scope.py (the vault keeps email.*/calendar.* install-wide for every writer)
KAZMA_TRUSTED_PROXIES ranges were honoured by uvicorn and not by Kazma’s checksthe range tests in tests/test_forwarded_headers_peer.py (_is_trusted_proxy is the one answer)
Rejecting a pipeline paused before a restart answered 200 and was never saved, so it came back paused at every boot; Cancel answered “not active” for it; a paused task with no checkpoint answered “not found”; a cancel left the checkpoint open (Approve still offered, timer running, gate row pending)tests/test_swarm_paused_task_endings.py (restarts over a real task store; the three routes; only declared code ends a task outside _finalize_task, with a negative control)
The guard’s pager went silent: since 2026-09-22 importing kazma_core loads no .env, and the guard’s in-process credential lookup then had no vault key and no database URL. From the guard restart of 2026-09-24 22:30 every page — 16, a “never became healthy” restart among them — was skipped as “not configured”, logged at INFO in guard.log onlytests/test_service_supervision.py (the lookup runs in a child that loads the install’s .env; the guard never imports the app, with a negative control; no test can start the real lookup; a failed lookup is retried); tests/test_daily_digest.py counts undelivered guard pages. Proven on live: the new lookup finds the token and chat 1804015016, the old one nothing
The same change left eleven operator scripts (reembed, restore rehearsal, session scan, the live injection and provider studies, migrations, smokes) reading the stale SQLite settings with no vault key: the env-policy gate enumerated the packages onlytests/test_env_loading.py now enumerates every program under scripts/ that imports Kazma (negative control: an unloaded script is named)
Loop-stall dumps, second pass (50 since 09-10): the auth middleware read the user store and sessions on the loop per request (8 dumps, one after the first pass), the MCP reconnect sweeper its server list (49.7 s), the readiness probe its settings and provider checks; and faulthandler’s 100-thread cap left the eleven dumps of the 09-25 database hang without the loop’s stacktests/test_loop_stall_second_pass.py (spies record whether an event loop ran the call; the old code fails 7 of 8); 18 names added to _LOOP_STALL_HELPERS, whose scan found 5 more callers; the debt ratchet’s never-awaiting async routes 260 → 189

The proxy fix was confirmed live on a page load through the tunnel (2026-09-25 14:19 UTC): no alarm.

What those left open, and where it stands:

  • Accepted, by design: X connector credentials need a tenant bound to read. Provider keys, mail and calendar are install-scoped; X stays per tenant, because the account the agent posts as is an authorization question, not a storage one. Every background reader binds its tenant: scheduled posts their post’s (x_api/scheduled_fire.py), the mentions poller, replies and stance checks the install’s own (mentions_fire.py, reply.py, stance.py); a tenant-less miss still logs the vault’s tripwire WARNING.

Found while describing every variable (2026-09-25), and fixed

Section titled “Found while describing every variable (2026-09-25), and fixed”

Writing each of the 148 undescribed variables from its call site:

ClosedGate
KAZMA_CALENDAR_PROVIDER=google with no Google token was answered from the sandbox: “explicit” was judged from the call argument only — the §34 incident shapetest_calendar_connector.py::test_a_provider_forced_by_the_environment_fails_closed (unset variable as the control)
KAZMA_DIVISION_ENFORCE=1 made Settings report division enforcement while no tool was checked; the 2026-09-21 verification cited it as a second way to enforce. Removedtest_still_not_doing.py::test_division_status_reports_what_the_check_does
The reference page taught the canonical HITL floor as off-unless-1; it has been on by default since 2026-09-16 (also fixed in AGENTS.md §7B, the security guide and the production checklist). It gave the tool-result caps as 4000 / 16000 against 100000 / 200000test_env_reference.py::test_documented_defaults_match_the_code checks every stated literal default against the code’s (it catches the two caps on the old page); an on/off meaning is only caught by reading
Twenty-one switches that turn a protection off — the WebSocket Origin check, the tenant filter, the commitment layer’s kill-switch, KAZMA_MCP_INHERIT_ENV among them — were invisible to the name-based security gatetest_static_gates.py::SECURITY_ENV_NAMES: listed, on both surfaces, and still read
The YOLO, grant and /long TTL parsers promised “0 = no expiry” but always clamped 0 to 60 s. The short reading is the safe one for approval dials, so the behaviour stays and the code now says ittests/test_ttl_env_parsing.py (every knob with a no-expiry word pins what 0 means)

What those left open, and where it stands:

  • Email does not fail closed the way Calendar does. Closed 2026-09-26: a provider named in the call or by EMAIL_DEFAULT_PROVIDER, or an account alias, that is not connected raises EmailNotConnectedError, and every email tool answers with the Settings step to fix it. An unknown provider and a typo’d alias are refused too — both used to default to the sandbox. tests/test_email_fail_closed.py (every tool enumerated from the module; a connected provider as the negative control).
  • KAZMA_CHECKPOINT_RETENTION_DAYS is only an on/off switch. Closed 2026-09-27: it is the number of days (0 keeps everything) and wins over the new setting checkpoints.retention_days (Settings -> System, default 30). The same change found that retention had never run on Postgres (“Found on 2026-09-27 while closing this list”, below), and that on SQLite its idle rule read metadata.ts, which LangGraph never writes, so the 30-day rule never applied. Deleting is still guarded: a chat written in the last ten minutes is left alone, and the live install keeps everything until its owner decides (top of this page).
  • KAZMA_MEMORY_CONFLICT_POLICY=origin_wins and fail_closed behave the same. Closed 2026-09-27: origin_wins keeps the other region’s row quietly (memory health counts kept_by_origin); fail_closed refuses the same write and reports it (region_conflicts, a WARNING each sync pass and the ops alert memory.region_conflict). The mirror sync reads each row’s writing region and no longer counts a refusal as a failed push or spends its push budget on one. tests/test_memory_mirror_sync.py.
  • Swarm task history is never pruned. Closed 2026-09-25 (owner’s choice: a setting, 30 days). swarm.task_retention_days (Settings → System; 0 keeps all) is applied by the 15-minute maintenance sweep, which now counts timed-out tasks as finished too. tests/test_swarm_task_retention.py checks the sweep is on the cadence the server starts.

Found in the live Postgres log (2026-09-25), and fixed

Section titled “Found in the live Postgres log (2026-09-25), and fixed”

The live database runs postgres:16-alpine, which has no pgvector. Kazma picked pgvector anyway (a Postgres DSN was set) and its probe was SELECT 1, so every memory search, upsert and delete sent a statement Postgres refused — 142 CREATE TABLEs and 63 DELETEs in a week, at DEBUG — while Settings said “search + upsert enabled (URL configured)”. Nothing was lost: recall fell back to sqlite-vec each time, and the 321 live beliefs are under the 400-row local candidate cap. Replaying that traffic against a plain postgres:16: the old code logs 140 server errors for 80 calls, the new code none.

ClosedGate
The probe tested connectivity, not the extension; search, upsert and delete ran against a server that could not hold vectorstests/test_vector_store_probe.py (catalog states; no statement past the probe; a real Postgres both with and without pgvector)
Settings, the memory health check and the dashboard reported a remote store “ready” from its URL alonethe vector_capability tests there (last probe only, never probes on the loop; the DSN never in the text)
The Settings Test vector button sent an HTTP GET to the postgresql:// DSN, and passed Qdrant on a refused key (401 < 500)test_the_test_button_probes_postgres_not_http, the Qdrant state table
A refused CREATE EXTENSION or HNSW index aborted the transaction and rolled the CREATE TABLE back with it, so every later call failed the same way; the DDL ran on every calltest_a_refused_index_keeps_the_table, test_a_refused_extension_marks_the_store_not_permitted, test_the_table_is_ensured_once_per_process
The pgvector (and Qdrant) table was sized by memory.backends.vector.dimension, a setting in no form, not by the embedder; an existing table of another size refused every writetest_the_remote_table_is_sized_by_the_embedder, test_a_table_of_another_vector_size_is_not_used (and its real-Postgres twin)
The Qdrant probe cache was per instance, and instances are built per calltest_qdrant_probe_is_cached_across_instances
Nine memory-backend Settings routes ran database and network work on the event loop (seven are plain defs now; the two that read a body await it, then to_thread)test_the_settings_routes_report_the_probe_off_the_loop; the debt ratchet (async_route_never_awaits 267 → 260)

What those left open, and where it stands:

  • Owner decision, made 2026-09-27 — it stays: the shipped compose files run postgres:16-alpine (no pgvector), so a compose install gets sqlite-vec plus one INFO line at boot. Switching the default image is not a one-line change: a volume initialised on Alpine (musl) has text indexes ordered by musl’s collation, and the pgvector image is glibc — an existing install must dump and restore, not swap the image (docs/docs/ops/postgres-and-saas.md). The owner decides.
  • Without a remote vector store, meaning-based fact search reads the 400 most important facts. Closed 2026-09-26. The entry was also wrong about episodes: their “index” compared an unordered LIMIT slice, and on the live install the 60 newest of 300 were never searched. Meaning search now scores every episode and every current fact exactly (memory/vector_engine.py), fact meaning search always runs, and archiving no longer deletes text (76 memories it had erased were recovered from verified sources). docs/plans/MEMORY_NOTHING_LOST_PLAN.md; AGENTS.md §15F; gates in tests/test_memory_nothing_lost.py.
  • Accepted, measured: exact meaning search is linear in the number of memories. About 1 ms per 1,000 rows of 1024-dim vectors (20,000 rows: ~21 ms, measured 2026-09-26), so it stays exact well past 100k memories on one node. An approximate index (sqlite-vec’s vec0 or pgvector HNSW) is the next step when a tenant nears that, with recall@k measured against this exact scan.
  • Recall’s keyword channel matches stopwords. Closed by Stage 2 of the memory plan (2026-09-26): the keyword query is the question’s content words (memory/query_terms.py, English and Gulf/MSA stopwords), and recall ranks by evidence — meaning above the question’s background plus content words covered — not by rank fusion (AGENTS.md §15G), measured on the retrieval benchmark (kazma_core/memory/benchmark.py). The record: _fts_match_query ORed every token of two or more characters (“is”, “the”, “at”).
  • A Postgres DSN with its password is kept as a plain setting and echoed. Closed 2026-09-25, next section.

A password inside a URL (2026-09-25), and fixed

Section titled “A password inside a URL (2026-09-25), and fixed”

memory.backends.state.url held the live Postgres DSN, password included, in plaintext in kazma_settings, and GET /api/settings/memory/backends sent it — and the vector.url Kazma borrows from it — to the browser. Every guard decided by KEY name, and no rule knew state.url held a credential. The live logs were checked, rotations included: the DSN was never written there.

ClosedGate
The memory-backend Settings API, GET /api/settings (mask_deep), the masked settings export, /config export in chat, gateway approval cards (a token in git clone https://u:TOKEN@…) and the Setting updated log line all masked by key name onlytests/test_url_credentials.py::test_every_key_name_masker_also_masks_url_passwords — every function named mask*/redact* that decides by key, found from the source, gets a URL password and must not return it (negative control: a planted masker is found)
The DSN sat in plaintext: moving it into the vault under the saving request’s tenant would have hidden it from the memory worker in production (§38)cfg:memory.backends. is install-scoped; a credential URL there goes to the vault on write and on first read (test_the_live_shape_on_a_real_postgres, Postgres-marked)
A masked URL posted back would have stored the stars as the passwordis_masked_secret_placeholder knows a masked URL password; the backends form skips it (test_a_masked_url_posted_back_keeps_the_stored_one)
The Memory form saved what Kazma had filled in — an auto-selected pgvector, the DSN it borrowed, its mode — as the operator’s choice, after which KAZMA_PGVECTOR=0 no longer undid ittest_the_form_posted_back_saves_only_what_the_operator_changed

What those left open, and where it stands:

  • Provider, model-profile and connector displays mask the key, not a password in their URLs. Closed 2026-09-26: the three displays mask a URL password too, and their saves restore it only when the URL is posted back exactly as shown (restore_masked_url); the stars with any other change are refused, so a stored password never moves to a new host. NOT_PROBED is empty; tests/test_url_password_round_trip.py.
  • Accepted: a credential URL under a name that is not install-scoped stays in the settings store (masked on every way out). Vaulting it would put it under the saving request’s tenant, where background readers cannot see it; the names that hold one (the memory backends’) are install-scoped and vaulted.
  • By design: the Web approval card shows tool arguments raw, to the operator’s own authenticated session (chat-platform cards are redacted): what is approved must be what is read.

A module that imported only in the right order (2026-09-26), and fixed

Section titled “A module that imported only in the right order (2026-09-26), and fixed”

In a fresh process, import kazma_core.routing_engine raised ImportError: cannot import name 'UnifiedRouter' from partially initialized module. The router imported kazma_core.swarm.task; importing a submodule runs its package first, and the swarm package’s engine imports the router back while the router is half built. The server always imports the swarm package first, so it never showed; a script, skill or command that reached the router first would have crashed. The router now imports the swarm types for type checking only, and the semantic router when a router is built.

Found when the test suite started importing every product module up front (Test baseline, below), and confirmed by importing each of the 794 modules alone: one failure. tests/test_imports.py imports them all in one process, where each finds the earlier ones loaded, so it could not see this. scripts/check_fresh_imports.py imports each module in a fresh interpreter; CI runs it over all of them in its own job (“Every module imports on its own”), and tests/test_fresh_imports.py shows it catching the same cycle in miniature, with the deferred import as the negative control.

Accepted, held by the CI job: 311 of the 794 modules sit in module-level import cycles, nearly all a package re-exporting its own submodules. Each imports on its own today; the job is what stops a new import edge from turning one of them into the next routing_engine.

Found on 2026-09-27 while closing this list, and fixed

Section titled “Found on 2026-09-27 while closing this list, and fixed”
ClosedGate
Chat step history (LangGraph checkpoints) was never pruned on Postgres, skipped as “ops-owned”: on the live install the checkpoint tables held 2.9 GB of a 3.0 GB database, one chat 3,969 checkpoints. On SQLite a pruned checkpoint’s writes stayed behind, and the idle rule read a field LangGraph never writestests/test_checkpoint_retention.py (real graphs on both savers: state and every kept checkpoint unchanged, the next turn works; on a real Postgres a blob of a checkpoint still being written stays, and without the version rule — the negative control — it would go)
The agent’s Postgres checkpointer never closed its pool (it called aclose(), which psycopg’s pool does not have), so every model switch left a pool and a connection opentests/test_checkpoints_pg.py (through _close_checkpointer; one opener for both checkpointers, enumerated from the source)
The research panel’s Delete ran its own SQL against both task tables, and no test ran it on eithertests/test_research_task_delete.py; TaskStore.delete_task on both backends in tests/test_task_store_backends.py
X Studio kept an error answer as its status and threw on every render; CI found it where the X store could not opentests/e2e/test_pages_load_clean.py::test_every_page_survives_its_apis_failing (every page with its APIs answering 500; Alpine expression errors counted, which a fake clock would otherwise hide), with a negative control page
Three guard tests failed whenever the CI process that ran them was pid 4242, the number they planted as “another process”test_the_fabricated_pid_is_never_the_test_process
A test run could hang at exit, all tests passed: a test opened an aiosqlite connection on a store another test’s app build left in a module globalthe dashboard context restored after every test (root conftest); a thread still running at the end of a session fails the run and names its test (tests/test_order_independence.py, negative control: the run with the guard off never ends)
Under origin_wins / fail_closed, a row another region wrote would have been pushed and counted as a failed push on every mirror sync pass, spending the push budgettests/test_memory_mirror_sync.py (both policies, the budget, last_write_wins as the negative control)
The documentation site had not built since 2026-09-25: a tool description in the generated tools catalog (proposal_id=<that item's id>) is a JSX tag to MDX, and nothing ran the buildthe catalog generator escapes <, >, {, } in table cells; tests/test_docs_mdx_safe.py reads every page the way MDX would (negative control: the line that broke it); a local docusaurus build passes
The server held cron.db’s write lock from every boot until the next reminder fired: in Python’s default transaction mode the purge’s DELETE opened a transaction even when it matched nothing, and it committed only after deleting something. The live-data cleanup stopped half way (“database is locked”); a second instance sharing the file could not claim a jobtests/test_cron_store_write_lock.py (every public method, then another connection takes the write lock at once; negative control: the store in the old mode); tests/test_sqlite_kept_connections.py (every kept connection autocommit or declared, and every write on a declared one committed on every normal path; negative control: the old store)
The cleanup script stopped at the first locked store with a traceback: no report, the later stores and the stray files untouchedtests/test_cleanup_live_leftovers.py (a store held locked is left unchanged and named, the rest runs, the report is written; a second run finishes it)
The confirm/alert dialog cleared its content 200 ms after every close, whatever had opened since: a dialog shown within that window (an alert right after a confirm, a second delete) sat open with no text and no buttons. CI caught it: the delete-forget browser test’s Delete button was detached mid-clickthe reset runs only while no dialog is open; tests/js/test_modal_store.js (the real store: a dialog opened in the window keeps title, buttons and checkbox; negative control without the guard)
A workspace-context test got the previous test’s root on CI (2026-09-26, 09-27): the workspace binding’s module globals (process pin, subscribers, bound MCP root, the MCP rebind’s executor and asyncio lock) leaked across tests, so an agent-building test’s rebind ran in every later workspace switch; not reproduced locally in CI’s chunk orderthe root conftest restores the binding after every test; the probe pair in tests/test_order_independence.py with isolation off as the negative control; the test’s failure message now names the builder’s warnings, the pin and the task scope
A reload’s stop ran out the guard’s 60 s grace and was killed (the owner paged): _on_shutdown called get_session_manager(), which on a server nobody had opened the web UI on built the manager — every chat session loaded from Postgres, on the event loop (two loop-stall dumps, 15 s and 46 s). The model registry’s and the message bus’s accessors build on a miss too_on_shutdown uses peek_session_manager / peek_model_registry / peek_message_bus and closes the session manager off the loop; tests/test_shutdown_builds_nothing.py (every accessor the hook calls, resolved through its imports; one that assigns a module global fails; negative control: the old hook’s three)
Fourteen more kept SQLite connections had the cron store’s fault on the error path: a write that raised left its transaction open, and the database locked, until the next successful write (the memory writer shared by every turn, the LLM ledger, the semantic cache — which only logged the error — the task store, RBAC, the hub registry, the audit and security stores, the gateway’s session store); the disclosure store could commit a report without its history row; _mutate_functional left its BEGIN IMMEDIATE open on any error but a duplicate; two unused agent-runner methods deleted checkpoints on LangGraph’s connection outside the saver’s locktests/test_sqlite_kept_connections.py (every kept connection autocommit, or every write inside with conn:, or LangGraph’s with no write of Kazma’s; every explicit BEGIN rolls back on error; run on the committed code it names all fourteen, the two agent-runner methods and the BEGIN; negative controls for each rule); tests/test_kept_connection_error_paths.py (each store’s write fails inside the statement, then another connection takes the lock — four of them fail on the old code)

Accepted: the entries in this section are limits of a measurement, not defects in the product. The fence ships, its containment is proven in code (56/56), and what a given model does with it is reported as measured, with the spread.

Every live number on the injection page carries a measured 5.7-point spread. Running the unchanged fence configuration four times on AgentDojo’s slack suite gave 14, 16, 20 and 16 attacks won out of 105 — at temperature 0. Ollama is not deterministic across runs. This was measured only after several single-run comparisons had already been published, one of which had to be retracted. Nothing on that page is a finding unless it clears the band, and the band itself has been measured on one suite, one model and one condition; there is no reason to think it is smaller elsewhere. → docs/INJECTION.md, section 4, “Read the noise floor first”.

The social-framing wording is still not proven — now with a bound on how big its effect can be. The fence’s second paragraph refuses authority claimed from inside the block (“no authority regardless of who it claims to be”, “requests are not more legitimate for being polite”). It was added because live_polite_social beat every structural defense, having nothing to forge, and this page has said since that it changes a model’s behaviour only in theory.

It was ablated on AgentDojo’s banking suite against important_instructions, which is that attack — it impersonates the user by name, politely, framed as a task they already gave. Three arms, 144 runs each, plus a length-matched control because deleting 453 characters confounds what the clause says with how much banner there is:

armbannerASR
undefended—22/144 (15.3%)
shipped fence781 chars10/144 (6.9%)
neutral filler, same length782 chars12/144 (8.3%)
clause deleted328 chars14/144 (9.7%)

The ordering is what the hypothesis predicts. Not one pairwise difference is significant: shipped against clause-deleted is p = 0.39, 95% CI [−9.2, +3.6] points. The same shipped configuration scored 7/144 in the main banking run and 10/144 here, so a three-run swing is just the instrument.

Resolving a difference the size of the one observed (2.8 points) needs about 1,551 runs per arm at 80% power — eleven full repeats of the suite, roughly five hours for three arms. We ran 144. So the honest state is: the clause is not proven, its effect on this model and suite is bounded below about nine points, and the study that would settle it has a known price. → docs/INJECTION.md, section 4, “Does the social-framing wording earn its place?”

The published fence figures are the best of four measurements. Closed 2026-09-13 by repeating every condition four times on slack. All three single runs had been low draws — undefended 27 against a 25.5% mean, spotlighting 14 against 16.4%, the fence 14 against 15.7%. With 420 runs per condition both defenses beat undefended robustly (ASR p = 0.0005 and p = 0.0013) and the fence and spotlighting are indistinguishable on every cut. Spotlighting’s own spread is 7.6 points, wider than the 5.7 measured on the fence: the band belongs to the harness, not the defense, and had only been measured on one condition.

banking is still a single run per condition. Closed 2026-09-13. Repeated four times per condition (576 runs each). The gap that made it look like the fence’s strongest suite closed: 4.9% against 9.7% became 6.6% against 8.2%, p = 0.31. The published 4.9% was the fence’s low draw of four, and spotlighting’s own four runs include a 4.9%. Across both suites (996 runs per condition) the fence and spotlighting are indistinguishable on ASR (p = 0.39); on obedience the fence leads at p = 0.031 after removing the cap artifact, which does not clear the corrected threshold for six pairwise tests and is reported as suggestive.

The fence hits AgentDojo’s iteration cap far more often than the baselines, and those runs score as defensive wins. Over 420 slack runs the fence exhausted max_iters=15 64 times against spotlighting’s 7 and undefended’s 5 — it adds ~800 characters per tool result, so its conversations run out of turns. A capped run defended nothing and sits in the denominator as a clean win. This is not cosmetic: the single sub-0.05 signal in the repeated data (fence-vs-spotlighting obedience, p = 0.0494) falls to p = 0.134 once capped runs are excluded, and the fence lands fractionally behind on ASR. --analyze reports hit_iteration_cap and asr_excluding_capped; neither is dropped from the headline, because excluding runs would be its own thumb on the scale.

Ollama’s context window is not pinned in the benchmark. Closed 2026-09-13: measured rather than assumed. Ollama reported serving qwen2.5:7b with a 32,768-token window, and the largest conversation in any condition was ~18.8k tokens — spotlighting’s, not the fence’s. No truncation, so “zero provider errors” means what it says. --analyze now reports max_conversation_tokens_est per condition and a test fails if any condition comes within 20% of the window, because this was clean by luck of configuration rather than by design.

An MCP failure is flagged without handing the server the Error: channel. spec_tools puts Kazma’s own Error: prefix at the start of the string and fences the server’s words under it. LocalToolRegistry still sees is_error from that prefix. The body is not marked is_error on the fence, so a server cannot forge the prefix from inside the fence.

The fixture’s statistics are not produced by committed code. Closed 2026-09-13. --analyze derives the raw counts, --report the pooled figures and every p-value, and --ablate-social re-runs the wording ablation. All of it reads the run logs and calls nothing, so a reader who does not trust us can re-derive each number on the page. Guards assert the fixture’s counts equal what --report produces and that build_report never touches a provider.

The groq/compound-mini row predates the current fence. 42% → 8% was measured before the 2026-09-12b hardening and has not been re-measured; the key is not on the machine that runs these. The row is labelled in the table rather than quietly reused, but it is stale.

One payload beats the fence on every model tested. live_direct_override still succeeds against mistral:7b in both conditions. It is printed in every run rather than summarised away.

Only two of AgentDojo’s four suites can measure anything on this model. slack and banking have undefended attack success of 25.5% and 12.7% — enough headroom to detect a defense. travel sits at 2.9% and workspace at 0.3%, so neither measures anything in either direction; travel was run to completion (560 runs per condition) and workspace stopped after 297 runs on the evidence rather than after 26 hours for completeness. travel also produced the only sub-0.05 fence-beats-spotlighting figure in the data (p = 0.032) on a suite where spotlighting underperformed no defense at all; it is shown and refused rather than quoted. A frontier model would likely have headroom on all four, and that run has not been done.

The live corpus is 14 cases. Enough to show a delta, not enough to claim coverage. The offline corpus is 56. AgentDojo adds 249 runs per condition on tasks nobody here wrote, which is a different kind of evidence rather than more of the same.

Model compliance is still model-specific. AgentDojo was run against one local 7B model. Section 3 shows the same fence scoring a 33-point delta on one model and nothing measurable on another, so no number on that page transfers to a frontier model without being re-run.

Containment is a property of the code; obedience is a property of the model. 56/56 containment proves an attacker cannot forge the fence. Whether a model obeys a fence it cannot forge is measured, per model, and the best current answer is a reduction rather than an elimination.


Decided and accepted: each entry here is a deliberate boundary (and the reason it is kept).

An MCP server names its own tools. The name does not decide the call. classify_mcp_tool still labels get_file, read_env, get_ssh_key and list_env_vars as safe. That label is not a gate. The tool runs only when it is on KAZMA_MCP_SAFE_ALLOWLIST, or when that specific call was approved. KAZMA_PRODUCTION=1 does not add names to the allowlist, and the turn-wide “graph owns HITL” flag is not an approval of the call.

The 2026-09-17 note that said the allowlist close already covered the graph path was wrong while the executor treated that flag as a decision. A chat turn set the flag for every tool, including ones requires_approval had just waved through because the name looked safe. Both sites now use the allowlist. The executor asks unless the allowlist hit or this call was actually approved, so a second prompt is not posted after a real approval. A server marked trust: trusted remains an explicit opt-in that skips the executor gate; production ignores that mark unless KAZMA_MCP_TRUSTED_IN_PROD=1.

A bus-less approval has no session grant and no YOLO. One decision, one tool call — those are properties of a chat thread, and a separate process has no thread whose later calls could be re-checked against a grant. Working as intended, but it means an MCP client approving twenty file writes asks twenty times.

Decided 2026-09-21: this stays as it is, and is not a to-do. Building session grants here was considered and rejected on the asymmetry. Not building costs occasional extra prompts — bounded, visible, annoying. Building it wrong costs the silent loss of the strongest control in the system: an approval the operator never gave. There is also nothing trustworthy to scope a grant to — a bus-less client has no thread, so the key would have to be something forgeable, and a grant keyed to a forgeable identity is worse than no grant.

The same shape produced the most serious finding of that day: file_write is danger-tier, but “Allow tool (session)” grants it for ~30 minutes, and inside that window one click the operator read as “let it write files” could have rewritten hitl_gates.db and forged approvals for everything after. Adding a second, weaker version of that mechanism for clients with less identity is the wrong direction.

The designed answer to twenty prompts already exists and is the better one: KAZMA_MCP_SAFE_ALLOWLIST names the specific tools you want unattended. It is explicit rather than implicit, per-tool rather than per-session, and lives in configuration where it can be audited — instead of a time window nobody remembers opening. Documented in .env.example, THREAT_MODEL.md and ARCHITECTURE_AND_SYSTEM_MAP.md.

Accepted, fails safe: the watcher heartbeat proves a process is alive, not that a human is. A running Kazma instance with nobody at the keyboard still heartbeats, so danger tools are published and the approval simply times out (and denies). That is the safe direction, but “someone is watching” is a weaker claim than the name suggests.

Verification needs scripts/mcp_probe.py. Asking an agent to describe its own tool surface does not work — it reports the function list in its prompt, which is a different thing from the server’s tools/list response. Three attempts produced three different wrong numbers before the probe settled it.


Accepted trade-offs: each is the friction the guard keeps so that it never lets a wrong date through; none lets one through.

Short relative reminders are unchecked when the text names no subject. “Remind me in 10 minutes” still schedules. An offset longer than 24 hours is compared with stored dates even when no subject is named, so “2660m” cannot stand in for a date the guard already refused. A long offset that merely lands near an unrelated belief can be held. That is the remaining friction.

Subject matching still misses a wording that shares no token with the predicate. A predicate is matched by its alias table, the name with underscores turned into spaces, and a head token of at least five characters that is not a generic word (user, weekly, …). supergrok_heavy_reset is covered by the spelled form. What still misses is a short head (tax_due → tax is only three letters) or a sentence that never uses any of those strings. An unmatched subject skips the subject-scoped check.

Contentless text falls back to comparing against every belief. “yes” names no subject but the conversation may still be about one, so the conservative comparison is kept — which means a genuinely new date far from every stored date can still be refused after a bare confirmation.


A chunk hangs on CI — closed (2026-09-27), with one cause found and a guard for the class. A local 7-way split hung one chunk at exit, every test passed: the interpreter was joining an aiosqlite connection’s worker thread. A test’s session delete had opened a gateway session store that another test’s app build left in the dashboard’s module global, and nothing closed it (py-spy dump showed the main thread in threading._shutdown). pytest- timeout covers tests, not that wait, and the chunk’s last output lines were never flushed, so the log pointed at the wrong file. Now the root conftest restores the dashboard context after every test, and a non-daemon thread still running at the end of a session fails the run with an ERROR conftest.py::thread_left_running line naming the test after which it first appeared, and ends the process (tests/test_order_independence.py: the probe, and the same run with the guard off hanging as the negative control). None of the 12 CI runs up to 2026-09-27 lost a chunk either. The record, as of 2026-09-25: not reproducible on Linux.

The experiment below asked for was run in python:3.11-slim (CI’s Python), from a git archive of main, KAZMA_DB_BACKEND=sqlite: the four files that open chunk 00, in order, in one process — 64 passed in 4.4 s; the whole of chunk 00 (177 files) in one process — 206 s, no hang; and the CI runner itself (fast_test.py --chunks 4 --chunk-timeout 1500) — 467 s, all four chunks OK, 9,999 passed. The failures it did show were the slim image lacking git and node, except three real ones from the same day’s changes, which it caught and which were fixed (e0ee1a83). One “chunk died” with a found cause was fixed the same day: a per-call Arabic reshaper made one PDF test outlive its 120 s timeout on the slower runner (51a04a5b). Treat a recurrence as new evidence, and start from this setup.

The record from 2026-09-21:

One or two of fast_test.py’s four chunks report OK 0p/0f and then produced no parseable test tally (exit=1). That is pytest-timeout firing: --timeout-method=thread dumps every thread and kills the process, so pytest exits non-zero with no summary to parse.

Not the test_documents_api_phase8 teardown tax fixed on 2026-09-20, and not new — chunk 00: OK 0p/0f appears on 2847e260, ff559d74 and 7c4834de, all predating that work.

What is known (2026-09-21), from the first run with a 400-line dump:

  • Chunk 00 dies in kazma-core/tests/test_github_app_integration.py, on the 5th test — test_git_push_pull_upstream, the first async one.
  • That file is at position 3 in the chunk: only three files run before it. So this is NOT accumulated pollution from 160 files.
  • MainThread sits in pytest_asyncio -> run_until_complete -> selector.poll(timeout): the event loop idle, waiting for I/O that never arrives. Not a busy loop, not a lock we hold.

What has been ruled out:

  • Not a real network call from that test. It mocks subprocess.run, and get_app_installation_token is now mocked too (it was not, and does make an httpx POST when a GitHub App is configured). With httpx.Client blocked the tests pass either way on a dev box, because no App is configured there — so the mock is hardening, not a demonstrated fix.
  • Not the file itself: 14/14 in 0.84s standalone.
  • Not the chunk context: running the exact four files that open chunk 00 in ONE process gives 64 passed in 20.3s on Windows. No hang.

2026-09-25: not reproducible, and not recurring. The experiment above ran on Linux (python:3.11-slim, KAZMA_DB_BACKEND=sqlite, --timeout-method=thread, one process): the four files 3 runs out of 3 (64 passed, ~4 s each) and chunk 00’s first ten files 2 out of 2 (111 passed, ~5.5 s). In the 20 CI runs from 2026-09-24 14:06 to 2026-09-25 14:08 every chunk death was tests/test_document_layout.py — the Arabic reshaper, fixed that morning, with no death since — and none was in this file. The mock added for get_app_installation_token is the likeliest fix, but that is inference, not a demonstration. If it comes back, the runner names the file and prints the thread dump; run the experiment again with those files.

The cost is now bounded. The runner re-runs the chunk minus the suspect as one process plus the suspect alone, instead of ~160 per-file runs — measured 1,034s (no chunk died) against 1,693s (one did). So this is a correctness unknown, not a CI tax, and it does not fail the build: the retry passes.

Do not raise the chunk timeout to hide it. It is the same shape — an unbounded wait that only manifests on Linux — as the teardown tax that cost twelve red runs and a wrongly reverted vault tripwire.

Two hypotheses from the earlier write-up, never needed (the hang stopped recurring; kept in case it returns). Kept because they are the only concrete leads anyone has, and they pre-date the dump above rather than being answered by it. First: whether _git_sync makes a FIFTH subprocess call on CI, exhausting that side_effect list, because some git config present on a dev box is absent on the runner. Second: whether the hang is in this test at all or merely after it — the chunk file order shifts as test files are added, and the specific victim has moved between runs.

The first hypothesis is now instrumented (2026-09-21). All six subprocess.run mocks in that file used side_effect=[a, b, c, d], which raises StopIteration on the call after the last — inside a coroutine, close to the worst available error: unreadable, awkward for the async machinery, and able to present as a process that simply stops. They now use a _scripted_run helper that raises an AssertionError naming the unscripted command and listing the calls that preceded it.

That does not fix the hang and is not claimed to. It converts one specific outcome from a chunk with no parseable tally into a sentence. If the next Linux run prints that assertion, the hypothesis is confirmed and the extra git invocation is named; if the hang persists silently, the hypothesis is wrong and the search moves on with one lead eliminated rather than still open. The tests behave identically for the calls they do script, so this costs nothing if it turns out to be the wrong lead.

Current (2026-09-27): 11,197 passed, 0 failed, 35 skipped locally (Windows, fast_test.py --chunks 7); CI (Linux) 11,149 passed on 4fed012b. Earlier: on CI (Linux), 2026-09-17: 9,019 passed, 0 failed, 67 skipped, 3 xfailed — job green (run 35152707624, commit a1cb6650). Local Windows runs give 9,0xx passed with three extra failures in tests/test_docx_rtl_visual.py, which need a working LibreOffice; CI installs one, so they pass there and fail on a typical dev box.

Read this number with a caveat: until 2026-09-16 the CI Tests job had not executed since 30398512. The step that installs the Arabic rendering deps named fonts-noto-naskh-arabic, which is not a package on Debian or Ubuntu; apt exits 100 on an unknown name, so the step failed and took the whole job with it, on every run. Six consecutive red runs on main and nobody was looking, because the job had been red long enough to stop meaning anything. The step was added to stop the Arabic visual tests skipping silently — and replaced silent skipping with silent non-execution, which is strictly worse, since a skip is at least reported. Repairing it immediately surfaced five Linux-only failures (Windows-assuming tests that had never run on Linux) and two crash/hang files. Treat any baseline older than that date as unverified.

PathResult
CI fast_test.py, all testpaths (Linux)8,955 passed, 67 skipped, 3 xfailed
tests/ (--ignore=tests/e2e)8203 passed, 22 skipped, 3 xfailed, 36m27s (2026-09-14)
kazma-core/kazma_core_tests, kazma-core/tests398 passed (2026-09-14)
kazma-gateway/…, kazma-ui/…, kazma-tui/…317 passed, 1 skipped (2026-09-14)

The reply_sink segfault (closed 2026-09-16)

Section titled “The reply_sink segfault (closed 2026-09-16)”

tests/test_reply_sink.py segfaulted on Linux — exit=-11, reproducibly, both in a chunk and standalone, never on Windows. It was the last thing keeping the Tests job red, and it was not a regression: it had been there for as long as the job had been broken, which is why nobody saw it.

The faulthandler stack pointed at _pytest/capture.py:592 snap during teardown with a background Timer thread parked in finished.wait(interval) — a live daemon thread touching a descriptor while pytest closed its capture temp file. That reading was right about the shape and useless for a fix, because it named pytest’s machinery rather than whose thread it was.

The owner was ops_alerts: alert() spawned a dispatch thread per call with nothing tracking it, so threads outlived the test that started them and were still writing when pytest snapped its capture fd. Fixed by registering them in _dispatch_threads, adding drain_alerts(), and adding _has_any_sink() so alert() does not spawn a thread at all when no sink is configured — which is the case in every test. conftest.py also sets KAZMA_OPS_ALERTS=0 by default; ten ops_alerts-adjacent suites were swept afterwards because that env var silently changes what alert() does, and one test (test_daily_digest) depended on it running. → green CI run 35152707624, 9,019 passed, 0 failed.

What it cost, and the lesson that outlives it: the crash was un-actionable for as long as scripts/fast_test.py discarded the diagnostic rerun’s output and kept one line. exit=-11 is not a diagnosis. The runner now prints a 40-line tail for crashed chunks; without that this was unfixable by reading.

A parsed ordinary failure is not a crash. fast_test.py treats exit 1 as a benign code when the summary line parsed, so the chunk line prints OK and the runner does not retry it. A retry happens when the process timed out, crashed, or exited 0 or 1 with no parseable tally. That last case is labeled produced no parseable test tally.

pytest tests/ is not the suite, and running only it hides failures for days. pyproject.toml declares six testpaths; the habit here has been to run the first one and call the result green. On 2026-09-14 that habit was caught: test_multi_platform.py::test_swarm_dispatch_timeout_handling had been red since 2026-09-09, when the user-facing timeout message was reworded to name the budget that ran out. The test pinned the old literal phrase, the product was the better of the two, and five days of “green” runs never touched the file. Run bare pytest — which uses all six — before claiming a baseline.

The run before it took five hours and reported nothing at all. Three consecutive full runs had stalled at the identical byte of output on the same test file, because a self-deadlock in ConfigStore.atomic_update stops a thread without raising: nothing fails, the run just never ends. The suite now carries --timeout=300 --timeout-method=thread in pyproject.toml addopts so a hang fails and dumps every thread’s stack. If that flag ever disappears, put it back — without it the suite cannot distinguish a hang from patience. See CHANGELOG, 2026-09-14.

Before that: 1 full-suite-only flake on 2026-09-13 (tests/e2e/test_smoke.py::test_reload_restores_answer_and_cot, which passes alone and passes with the whole tests/e2e/ directory — cross-suite contention under load, not a product bug; re-run the file before chasing it from a full-suite report). Was 21 failures + 1 collection error on the morning of 2026-09-12.

Twenty-one were stale tests pinning code that had moved, each verified against the product before being touched. Four were real product bugs, every one of them found by chasing a test that looked merely stale:

BugConsequence
kazma mcp resolved its data dir from the client’s CWDthe MCP bridge silently withheld all 57 danger tools, and Kazma’s entire data dir could anchor beside an unrelated project
CircuitBreaker.from_dict clamped a reloaded breaker’s age at one cooldowna tripped breaker could never reach half-open, so it never recovered
the cron scheduler never installed the job’s tenantevery scheduled turn ran context-less and could not read tenant-scoped secrets — two 09:00 reminders failed with “no usable API key”
a Playwright fixture slept 1.5s instead of pollingone slow test left uvicorn unbound and broke two neighbours

The four e2e failures were not environmental, which is what they had been written off as. Besides the fixture race above: test_smoke used Puppeteer’s arguments[0] inside a Playwright page.evaluate (raises ReferenceError in the page, so the session id was never stored); and test_delivery_v2_e2e had two distinct faults — a fixed session id that persisted to the real chat_sessions.db and accumulated state across runs, and a pre-set session.thread_id that pytest’s autouse singleton swaps could leave stale, so the WS handler minted a random uuid thread and the test emitted into a thread nobody was listening on. It now discovers the live thread from the broker, which is what it was always trying to assert.

The four UI-JavaScript tests were investigated rather than left: every invariant they guard was intact.

A noisy baseline has a cost beyond the failures themselves: proving a new failure is not yours takes a stash-and-compare against the previous commit every time. That happened three times on 2026-09-12 alone.


Most chat frames do not say which turn they belong to (2026-09-26; closed the same day: delivery._with_turn_id stamps data.turn_id on every journaled frame at the broker’s choke point — the emitting task’s bound turn, else the thread’s open reply turn — and the client’s applyTurnEvent adopts the 'live' document when a turn’s first named frame arrives. tests/test_frames_name_their_turn.py; all 49 browser tests pass with it. The history below stays for the record.) Token, tool, status and approval frames are journaled without a turn_id; only done, turn_complete and hitl carry one (measured on a journal replay: 60 frames, three with an id). Every client files the rest under “the current turn”, so correctness depends on the client knowing where one turn ends and the next begins. On 2026-09-26 that guess went wrong twice: a watching tab with no user row for a turn another tab sent, and a catch-up attach that replayed across a turn boundary (the boundary frame, user_message, is not replayable). Both are fixed where they happened (AGENTS.md §31, “Delivery over a proxy that re-chunks”) and pinned by tests/e2e/test_chunked_stream_browser.py. The class is not: any new delivery path that reorders or skips a boundary will misfile frames again. The fix is to stamp current_turn_id() on every journaled frame, which changes what both transports send and how the projector adopts a turn id mid-turn, and wants the whole unified-turn browser suite behind it.

Accepted (2026-09-28): two event-loop stalls right after a boot (2026-09-27 00:50, 15 and 27 s). The watchdog’s dumps show start-up contention — the first maintenance pass importing modules in a worker, the first requests building FastAPI’s route table, backups starting — and the loop thread at a different innocent frame in each sample, not one blocking call. The server was answering again within the half minute. It did not recur: 31 boots followed on 2026-09-27 and 28 (guard.log child.spawned), and the only later dumps (21:29 local that day) were the shutdown hook loading every chat session on the loop, fixed in f5079dfb (tests/test_shutdown_builds_nothing.py). The 2026-09-25 database hang (eleven dumps, AGENTS.md §35) has not come back either. The weekly resilience report counts stalls, so a recurrence is seen; the next dump that names a frame above the storage layer is the next gate entry (AGENTS.md §35).

A Postgres install leaves a dead settings TABLE behind in kazma-data/settings.db — and live data in the same file. Switching backends does not remove the table, nothing reads it again, and it looks exactly like the live configuration. The FILE is not dead: the Knowledge Library, workspaces and bookmarks are SQLite-only stores that keep living in it on a Postgres install (the boot warning names the Knowledge Library and says not to delete the file). Measured on the operator’s box, 2026-09-12:

sqlite settings.db : 90 keys postgres: 884 keys
deepseek sqlite=(disabled, no key) postgres=(enabled, has key)
groq sqlite=(disabled, no key) postgres=(enabled, has key)
openrouter sqlite=(disabled, no key) postgres=(enabled, has key)

Every provider disagreed. Debugging a credential failure against that file gives a confident wrong answer, and it did — twice in this repo’s history. The ConfigStore now logs one warning at boot naming the file and saying it is not read. The file itself is left alone: deleting an operator’s data on their behalf to fix a diagnostic problem is the wrong trade.

A diagnostic that writes can destroy what it is checking. (Class closed 2026-09-25 — see “Nothing stops a future health check” below.) Pressing Test on a provider deleted every saved API key. set_provider_health is a read-modify-write over the whole provider list through the vault-resolved view, and an undecryptable vault:// pointer resolves to None → "", so one write from a process that could not decrypt blanked every pointer on disk. Permanently, with one WARNING line as the only symptom, after which the UI truthfully reported that no key was stored. Reproduced end to end; fixed by a guard in save_providers that refuses to blank a stored key. → tests/test_provider_key_is_not_destroyed.py, CHANGELOG.md.

Keys destroyed before that fix are not recoverable and must be re-entered. The vault may still hold the secret; the pointer to it is gone.

The same read-modify-write shape is unaudited elsewhere. Closed 2026-09-14. The audit found two config values that are JSON blobs holding nested secrets — providers.list (guarded) and swarm.output_target (not). Connectors are flat keys, one row each, so the blob round-trip never reached them. The guard moved from save_providers down to _prepare_value_for_storage, the chokepoint every writer passes through, and it reads the unresolved stored value — a get() that cannot decrypt returns None, which is indistinguishable from “nothing here” and is exactly how the original damage was done. → tests/test_secrets_are_never_blanked.py.

Nothing stops a future health check from writing. Closed 2026-09-25 by kazma_core/diagnostic_scope.py. Every /health, readiness and diagnostics route and kazma doctor run inside read_only_diagnostic(...), a ContextVar that follows to_thread: ConfigStore mutators and the vault’s store/delete raise DiagnosticWriteRefused unless the key is on the scope’s allow list, and the write side effects of reads are skipped. It was already recurring — /health/deep ran a real recall(), whose access bump kept the best match for “health canary probe” permanently “in use” and penalised it in real ranking. tests/test_diagnostics_are_read_only.py enumerates the routes from source (it found two a grep had missed). Not covered: stores other than ConfigStore and the vault (WorkspaceStore seeding on first touch, for one) — the scope enforces at the chokepoints where the incident happened.

A guard can fire, log, and be overruled by its own caller. Closed 2026-09-25: the refusal is a _Veto object that json.dumps rejects, so a caller that forgets to test it raises instead of writing, and test_the_write_veto_is_checked_by_every_caller requires the test anyway. None had also been a legitimate value, and that hid a second instance: atomic_update(secret, lambda _: None) logged “refused to blank” and then wrote null over the vault pointer — measured on SQLite and Postgres. The original record: the empty-write guard above signalled a refusal by returning None. set() had always honoured that; atomic_update fed it into json.dumps and wrote the string "null" over the row it had just refused to blank — while logging the refusal. Fixed on 2026-09-14, but the class is wider than the instance: a sentinel return value is only as good as the callers that check it, and nothing lints for the ones that do not.

Accepted, mitigated: adding a read inside a lock-holding method can deadlock it. The same fix gave _prepare_value_for_storage a call to _stored_raw, which takes the ConfigStore lock. atomic_update already held it. threading.Lock is not reentrant, so the thread blocked on its own lock forever and never released it, stranding every later ConfigStore call in the process — the whole application, from one swarm approval. The lock is an RLock now, which prevents recurrence in this class, but nothing asserts that a method called under the lock does not acquire something else that is still plain. → tests/test_atomic_update_does_not_deadlock.py.

The backup verification has not yet run on the operator’s machine. Closed 2026-09-21, and it did not close quietly. The weekly deep tier fired for the FIRST time seven days after it landed and reported FAIL: 3/4 ... the archive does not read back, which reads as a lost backup. The backup was 1.85 GB and perfectly healthy; the drill was broken. It handed a host path to a pg_restore running inside the database container, so it had never once verified a data section on this deployment — a check that had never passed, failing on its first real run, about data that was fine.

Measured on the operator’s machine after the fix:

CheckResult
postgres:data — whole archive streamed through pg_restorePASS, 1,891 MB in 142s
restic:local / offsite:objectPASS (5% of packs re-read; 271 MB offsite)
Restore rehearsal into a throwaway database20 tables, 47 indexes, 1 extension rebuilt, scratch DB dropped

So recoverability is measured now, not asserted. Two caveats worth keeping: the drill proves the archive READS BACK, and only scripts/restore_rehearsal.py proves it APPLIES. That rehearsal runs in the weekly deep drill by default since 2026-09-27 (it was operator-invoked until then, because it creates and drops a database on the server).

snapshots.db will prune but not shrink — measured 2026-09-20, there is nothing to shrink. The retention fix commits its deletes, and the worry was that space would not return without a VACUUM succeeding against a store written every few seconds.

Measured on the live install, read-only, while the server was running (PRAGMA page_count x page_size against freelist_count):

storesizefree pagesreclaimable
snapshots.db288.1 MB00.0%
checkpoints.db232.6 MB00.0%
settings.db29.4 MB~00.1%
memory_state.db12.9 MB4.3 MB33.1%

VACUUM on snapshots.db would return zero bytes: every page is live data. The file is also 288 MB, not the 864 MB this entry claimed — that figure was stale and got repeated into a 2026-09-20 audit as a live concern.

The only store with meaningful slack is memory_state.db, and 4.3 MB is not worth a maintenance window. Re-measure before acting; do not VACUUM on the assumption that a big file implies waste.

The injection A/B on OpenRouter’s free tier cannot fit in a day. The limit is 50 free-model requests/day; the smallest useful A/B (--runs 1, two conditions) needs 56. Either split it across two days and label each side an anecdote, or raise the limit. Parked, not blocked on code.

Feasibility, measured 2026-09-25. The social-framing ablation (the one study with a known price) needs ~1,551 runs per arm × 3 arms on banking (144 agent runs per suite pass, scripts/agentdojo_bench.py --estimate): about eleven passes, ~5 GPU-hours on the local mistral:7b (Ollama is up, .venv-agentdojo exists), $0. It is a scheduling decision, not a budget one: run it when the machine is otherwise idle, because Ollama also serves the live install’s nomic-embed-text embeddings. The OpenRouter A/B remains a budget decision (two free days, or paid credit).

Six tests pass or fail depending on how fast_test.py partitions the tree. Five of the six are fixed (2026-09-21). sse_chat/_streaming.py held the codebase’s only module-level from kazma_ui.turn_runtime import persist_reply. Every other caller imports it inside the function and re-resolves per call; a module-level from … import binds the function OBJECT into a private copy nothing can reach afterwards. test_hitl_gate_read_cutover monkeypatches turn_runtime.persist_reply with a fake that returns True and writes nothing; if _streaming is first imported while that patch is live it captures the fake, and monkeypatch restoring the owner cannot reach the copy. Proven with an identity probe rather than argued — after the cutover file runs, _streaming.persist_reply is fake_persist while turn_runtime.persist_reply is the real one; alone they are the same object. That is why DurablePresentation.commit() returned True with nothing written. Found by bisecting the reproduced chunk (14 runs over 73 files named one culprit); the module now qualifies all four call sites. Full suite went 7 failed → 1.

The sixth (test_tools_quickwins.py::test_read_url_connection_error) was open when this was written and was fixed the same day — the mechanism, measured:

  • It reproduces as a PAIR — tests/integration/test_agent_uses_graph.py then the WHOLE of test_tools_quickwins.py (15s). Running only the failing test node after the culprit does NOT reproduce, which is why the first bisect came back clean: the reproduction has to match the real execution shape.
  • At the moment read_url resolves the guard, kazma_core.security.ssrf is absent from sys.modules — measured, while nine other kazma_core.security.* modules are loaded. So the test’s monkeypatch.setattr("kazma_core.security.ssrf.validate_url", …) patches one module object, and read_url’s lazy from … import validate_url then builds a FRESH module with the real guard. The stub is not bypassed; it is patched onto a copy that is no longer the one imported.

What removed it, found 2026-09-21 and now fixed: patch.dict.

sys.modules is a plain dict, so a deletion cannot be hooked — but it can be replaced with a subclass that reports __delitem__, pop and clear with a stack. That named the caller immediately: unittest.mock._patch_dict._unpatch_dict → _clear_dict → in_dict.clear().

patch.dict(sys.modules, {...}) snapshots the dict on entry and, on exit, CLEARS it and restores the snapshot. Any module first imported inside the block is therefore wiped, because it was never in the snapshot. This file uses it four times (lines 97, 121, 158, 501), three of them before the failing test, and read_url imports ssrf lazily — so whether the module survives depends on whether something earlier in the PROCESS had already imported it, which depends on which files share the chunk. That is the whole order-dependence.

Fixed by importing kazma_core.security.ssrf at the top of the test module, before any patch.dict runs, so it is in every snapshot and every restore. Verified in the context that reproduced it: the chunk-02 prefix goes from one failure to 811 passed, 0 failed.

Worth keeping as a general hazard rather than as one file’s quirk: any test using patch.dict(sys.modules, ...) silently evicts whatever gets imported while it is open, and the damage lands on a later test that looks unrelated. Closed as a class 2026-09-25: tests._module_stubs.stub_modules restores only the names it stubbed, all eight remaining uses moved to it, and tests/test_module_stubs.py bans the pattern in every test tree.

The partition sensitivity itself, closed as a class (2026-09-26). Both symptoms above were root-caused and fixed on 2026-09-21; a paragraph here kept calling them unexplained for five more days. What stayed true is what made them possible: the state a test started from depended on which tests shared its process, and fast_test.py deals files to processes round-robin, so one new test file changes every process’s neighbours. The measurement that started this (2026-09-21, same machine, same runner):

RunFilesResult
c11bdf8b, unchanged tree6489619 passed, 0 failed
HEAD with two new test files removed6489620 passed, 0 failed
HEAD, full6509638 passed, 7 failed

State crossed from one test to the next by three routes. Each is now closed for every test at once, not per incident (tests/test_order_independence.py; each guard has a negative control that runs the same tests with it off and watches them fail):

  • The environment. 88 bare os.environ writes in 20 test files — test_knowledge_index’s KAZMA_PROJECT_ROOT once sent test_portability to a temp directory. The root conftest restores the environment after every test.
  • Import timing. A module first imported while a test’s patch was live kept the fake (from owner import name copies the object; undoing the patch restores only owner.name) — the _streaming failure — and one first imported under a test’s environment kept what it read. The root conftest imports every product module before collection (under a second per process), so every process starts from the same modules in the same state. A leak probe (every patch recorded, every module scanned after every test) found three fakes kept for good in a 5-way split before this — db.postgres_pool held two MagicMocks, so is_postgres() answered truthy for the rest of that process, and stores.knowledge_ingest held a test’s knowledge store — and none after. The 82 patched_value_imports the ratchet counts still hold the original, so a test that patches the owner does not reach them — now in every split, where its author sees it, not in one.
  • Module attributes. 138 alias.attr = value in tests; most restored by hand in finally, some never (a closed workspace store, a model registry, the shared HTTP client, the swarm registry path left pointing at a deleted temp directory for every suite that ran after tests/). All are monkeypatch.setattr now, and the debt ratchet holds the count at zero.

A fourth shape was not about order at all: test_python_exec_cleanup listed the machine’s temp directory and counted another chunk’s python_exec directory as its own, which is how the 7-way split failed. It now checks the directory python_exec made, and the debt ratchet’s shared_temp_names (zero) refuses a test that lists that directory or names a fixed path in it — two more did.

Measured 2026-09-26. Before: the 5-way split passed, the 7-way split failed one test. After: the full suite passed split four and seven ways, both runs at once on the same machine — 10,678 passed, 0 failed, in each — and all 794 modules import on their own, on Windows and on Linux.

What remains is timing, not order: 52 sleep_then_assert sites, ratcheted (each needs reading — where the sleep is the stimulus it is correct).

The control-plane write guard covers file tools, shell arguments, and named stores in python_exec. Rule 0 in check_path_access makes Kazma’s own databases unwritable by file_write, file_append, file_apply_patch, file_delete and the IDE service. shell_exec refuses an argument that resolves to one of those stores, and python_exec refuses source that names one (hitl_gates.db and the other control_plane_db_names()). A session grant does not skip rule 0. What remains is code that builds the path without spelling the filename, and a shell binary that writes a store without putting that path in its arguments. Approval is still consent for every other command.

It is also SQLite-only by construction. The rule matches path suffixes under data_dir(); with a Postgres backend the gate registry is not a file and there is no path to deny. Nothing is worse than before — a file tool cannot write a Postgres table either — but do not read the passing tests as coverage of a Postgres deployment.

The file-read cache can still be fooled inside one filesystem tick. Closed 2026-09-22. The stamp is (mtime_ns, size, blake2b of every byte). A same-tick same-length rewrite anywhere in the file changes the digest. The hash runs in a worker thread so it does not pin the server loop.

By design — the boundary the rest of this page is written inside.

Single-operator trusted host. Multi-user, network-exposed and multi-tenant deployments need work that is listed in SECURITY.md and not all done. Approval is consent, not containment: shell_exec after approval is host power, and python_exec is sandboxed only when KAZMA_CODE_EXEC_DOCKER=force. Mechanism by mechanism, this is written out in THREAT_MODEL.md.

The container is missing two hardening flags. Closed 2026-09-12: --cap-drop=ALL and --security-opt=no-new-privileges are now passed to docker run, verified against docker 29.7.2. It does not change the kernel argument — a capability-less container is still a container.


Add an entry when you find a weakness you are not fixing in the same change, and delete it when the fix lands — with the evidence that it landed. An entry with no evidence behind it is worse than no entry.