Known gaps
Known gaps
Section titled “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.
Where things stand (2026-09-27)
Section titled “Where things stand (2026-09-27)”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. RulesetKazmaLatestRulerequires 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 tomain; 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 FULLwould 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 shipped | The 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 warning | nothing; 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 one | test_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 approval | TOOL_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 endpoint | test_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 row | none |
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 forever | the 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 figure | the 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.pyfails 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 (nowTaskStore.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 plainpostgres: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 thatTaskStore.prune_tasksreported 0 deletions on Postgres whatever it deleted (itsDELETEhad noRETURNING; 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-ksweep 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.pyandtest_shared_store_peers.pypass 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, rawsqlite3reads). 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 patterntest_swarm_paused_task_endings.pynow uses.test_swarm_task_store.pyis also where thetask-hitl-1/task-paused-1rows in the live database came from (2026-08-14, beforeconftest.pystripped DSNs). -
By design: the suite can only reach a real Postgres through one deliberate switch.
conftest.pyforce-pinsKAZMA_DB_BACKEND=sqliteand strips every DSN at import, with a guard that removes the DSN again if anything re-adds it — so a developer’s.envcan never point the suite at a live database. OnlyKAZMA_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 whytest_conftest_db_guard_is_failsafe_by_defaultexists. -
Accepted:
KAZMA_DATA_DIRdoes not isolate a Postgres-backed ConfigStore — by design, now said out loud twice._use_postgres()keys offKAZMA_DB_BACKEND/KAZMA_DATABASE_URLonly, so a script that sets only the data dir on a box with.envloaded 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 doctorshows the same. -
148 of 262Closed 2026-09-25: every variable the code reads is described on the curated page, each written from its call site, and the ratchet inKAZMA_*variables are not yet describedtests/test_env_reference.pyis 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_symbolsratchet intests/test_debt_ratchet.pyand 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 --fixremoving “unused” imports silently broke every native skill via a re-export contract no linter could see (caught bytests/test_imports.py). -
Three bandit findings are reported but not gated.Closed 2026-09-25:tests/andscripts/joined the HIGH gate. The findings were fixed, not waived —vendor_codemirror.pyresolves npm/npx withshutil.whichinstead ofshell=True, the template-compile test builds its Jinja environment with autoescape on, and the FTP backup tests carry the same justified# nosec B402as the backend they test.tests/test_security_scan_scope.pykeeps both directories in the gate and every# nosecnaming 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.retrievefalls 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 readsNonefor every secret the UI holds — andNoneis 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:
Where Symptom Fixed cron scheduler two 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 doctorreported 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 registry under KAZMA_PRODUCTION=1thedefaultrung 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 announcedThe 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.hitlre-exportskazma_core.tenant_context._current_tenant_idinstead 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.seton 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, inhitl.get_current_tenant_id, never on store:retrieve_scopedneedsNoneto tell an absent tenant from an explicit one. Locked bytests/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_retrieveand the Settings “is this configured?” probe both calledvault.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_scopedortenant_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: aretrievewith 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
retrievelog, once per name, when it returnsNonefor 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.pybegan 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_phase8timing out inwait_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 callstask.cancel()and thenawait asyncio.gather(*tasks)with no timeout. Cancellation cannot reach those tasks —_worker_loopparks inasyncio.to_thread(claim_next), and a thread is not cancellable, so the exception is only delivered once the thread returns. Ifclaim_nextis stuck on a lock, the gather waits forever. Both waits are bounded now, and the app-levelstop_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-
retrieveprobe, 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 withlibreoffice-writer,tesseract-ocrandfonts-noto-coregets 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. -
(closed 2026-09-20). A fossil in/api/telemetryopen with no route behind itALWAYS_OPEN_PATHSfrom a mock endpoint removed years ago. It never opened the two real telemetry routes:is_always_openmatches that set EXACTLY and onlyALWAYS_OPEN_PREFIXESby prefix, so/streamand/snapshotwere 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. -
Closed 2026-09-27, and the breakage was not always loud: a fixture inkazma_core/tools/__init__.pyshadows its own submodules.tests/test_file_read_cache_invalidation.pydidfrom kazma_core.tools import file_write as fwand patchedcheck_path_accessonto the FUNCTION — nothing at all — and passed. Every name in the package is its submodule now (nine were rebound), andkazma_core.web_acquire’ssearch.pybecameserp.pyso itssearchis one thing, the function.tests/test_package_namespaces.pychecks every product package, with a negative control. The record: it exported a function namedread_url, soimport kazma_core.tools.read_url as rubound the function, not the module (Python resolvesimport a.b as cby attribute since 3.7); pytest’smonkeypatch.setattr("kazma_core.tools.read_url.X", …)raised AttributeError on the function, whilemock.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.
| Closed | Gate 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 too | test_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 .env | test_serve_env.py |
Boot errors hidden behind a DEBUG-only try | test_app_bootstrap_errors.py |
Replay reads/deletes, chat stop/steer/abort, /api/sessions acted on any thread | test_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 error | test_the_admin_decision_lives_in_one_place; test_admin_decision.py |
| Web sessions never carried a tenant | test_session_tenant_binding.py |
| 43 UI writes discarded the response; 24 toasted success on failure | test_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 loop | test_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 ContextVars | test_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_exec | test_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.cmd | test_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 pages | test_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 handlers | test_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 string | test_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”| Closed | Gate |
|---|---|
| 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 failed | DrillResult.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, GC | test_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 ports | the 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:
| Closed | Gate / evidence |
|---|---|
131 httpx.AsyncClients built in async code loaded the CA bundle on the loop, per client | kazma_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 diagnosed | Re-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”| Closed | Gate |
|---|---|
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.db | list_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 used | test_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 refusal | test_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 checkpoints | test_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 closes | test_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 CWD | test_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 returndb.sqlite_session.committed_and_closed(conn), andtest_no_raw_connection_opener_is_used_as_a_contextfails on any helper that hands a raw connection to awithblock.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 no4266(UDP port space full) since 2026-09-25 13:30 and no4231(TCP) since 2026-09-22 23:31. The culprit was never named; the guard’shealth.port_exhaustionline 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) and4266(UDP port space full) fired on 2026-09-23 17:23 and 2026-09-24 17:35; the samenetshforudp(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’shealth.port_exhaustionline 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”| Closed | Gate |
|---|---|
| Saved drafts could not be retired without deleting; an English language lock hid 11 Arabic drafts | tests/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 allowlist | gate 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 denied | tests/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.yml | tests/test_postgres_suite.py; the job runs @pytest.mark.postgres |
| The Postgres lazy plaintext→vault migration raised inside a debug-logged except and never landed | tests/test_diagnostics_are_read_only.py (runs in the Postgres job) |
| Nothing restored a dump to prove it restores | opt-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
KazmaLatestRuleonmain: 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), sosync-metrics.ymlstopped committingMETRICS.mdtomainand only reads the framework repo now (docs/docs/ops/branch-protection.mdsection 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.jsonwith a frozen layout for the site, andscripts/sync_site_metrics.pyreads 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
truefrom before was still honoured as active, with no end. One was on the live install (generalcategory, 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_statusnow removes such a flag instead, andpurge_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.jsongives every page its place (tests/test_website_pages.py: a page added here without one fails),scripts/website_sync_plan.pylists the work from recorded content ids (tests/test_website_sync_plan.py), anddocs/docs/ops/website-sync.mdis 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 (KazmaAIe3f6165, 87 sources recorded at93eecd1d; the plan’s--checkexits 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=falseorKAZMA_PG_RESTORE_REHEARSAL=0turns it off), so the weekly deep drill proves the newest dump restores into a scratch database and drops it. It needsCREATEDB, 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 --deepruns 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_execdenylist 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”| Closed | Gate |
|---|---|
| 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 reason | tests/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 hours | the 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 existed | tests/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 WARNING | tests/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 IP | tests/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 checks | the 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 only | tests/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 only | tests/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 stack | tests/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:
| Closed | Gate |
|---|---|
KAZMA_CALENDAR_PROVIDER=google with no Google token was answered from the sandbox: “explicit” was judged from the call argument only — the §34 incident shape | test_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. Removed | test_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 / 200000 | test_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 gate | test_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 it | tests/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 byEMAIL_DEFAULT_PROVIDER, or an account alias, that is not connected raisesEmailNotConnectedError, 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).Closed 2026-09-27: it is the number of days (0 keeps everything) and wins over the new settingKAZMA_CHECKPOINT_RETENTION_DAYSis only an on/off switch.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 readmetadata.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).Closed 2026-09-27:KAZMA_MEMORY_CONFLICT_POLICY=origin_winsandfail_closedbehave the same.origin_winskeeps the other region’s row quietly (memory health countskept_by_origin);fail_closedrefuses the same write and reports it (region_conflicts, a WARNING each sync pass and the ops alertmemory.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.pychecks 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.
| Closed | Gate |
|---|---|
| The probe tested connectivity, not the extension; search, upsert and delete ran against a server that could not hold vectors | tests/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 alone | the 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 call | test_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 write | test_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 call | test_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 unorderedLIMITslice, 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 intests/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_queryORed 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.
| Closed | Gate |
|---|---|
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 only | tests/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 password | is_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 it | test_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_PROBEDis 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”| Closed | Gate |
|---|---|
| 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 writes | tests/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 open | tests/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 either | tests/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 open | tests/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 global | the 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 budget | tests/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 build | the 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 job | tests/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 untouched | tests/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-click | the 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 order | the 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 lock | tests/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) |
Prompt injection
Section titled “Prompt injection”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:
| arm | banner | ASR |
|---|---|---|
| undefended | — | 22/144 (15.3%) |
| shipped fence | 781 chars | 10/144 (6.9%) |
| neutral filler, same length | 782 chars | 12/144 (8.3%) |
| clause deleted | 328 chars | 14/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.
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.banking is still a single run per condition.
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.
The MCP bridge
Section titled “The MCP bridge”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.
The commitment / date guard
Section titled “The commitment / date guard”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.
Test baseline
Section titled “Test baseline”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 firstasyncone. - 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, andget_app_installation_tokenis now mocked too (it was not, and does make an httpx POST when a GitHub App is configured). Withhttpx.Clientblocked 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.
| Path | Result |
|---|---|
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/tests | 398 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:
| Bug | Consequence |
|---|---|
kazma mcp resolved its data dir from the client’s CWD | the 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 cooldown | a tripped breaker could never reach half-open, so it never recovered |
| the cron scheduler never installed the job’s tenant | every 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 polling | one 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.
Operational tripwires
Section titled “Operational tripwires”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 keysdeepseek 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:
| Check | Result |
|---|---|
postgres:data — whole archive streamed through pg_restore | PASS, 1,891 MB in 142s |
restic:local / offsite:object | PASS (5% of packs re-read; 271 MB offsite) |
| Restore rehearsal into a throwaway database | 20 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).
— 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 snapshots.db will prune but not shrinkVACUUM 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):
| store | size | free pages | reclaimable |
|---|---|---|---|
snapshots.db | 288.1 MB | 0 | 0.0% |
checkpoints.db | 232.6 MB | 0 | 0.0% |
settings.db | 29.4 MB | ~0 | 0.1% |
memory_state.db | 12.9 MB | 4.3 MB | 33.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
Five of the six are fixed (2026-09-21). fast_test.py partitions the tree.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.pythen the WHOLE oftest_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_urlresolves the guard,kazma_core.security.ssrfis absent fromsys.modules— measured, while nine otherkazma_core.security.*modules are loaded. So the test’smonkeypatch.setattr("kazma_core.security.ssrf.validate_url", …)patches one module object, andread_url’s lazyfrom … import validate_urlthen 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):
| Run | Files | Result |
|---|---|---|
c11bdf8b, unchanged tree | 648 | 9619 passed, 0 failed |
| HEAD with two new test files removed | 648 | 9620 passed, 0 failed |
| HEAD, full | 650 | 9638 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.environwrites in 20 test files —test_knowledge_index’sKAZMA_PROJECT_ROOTonce senttest_portabilityto 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 namecopies the object; undoing the patch restores onlyowner.name) — the_streamingfailure — 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_poolheld two MagicMocks, sois_postgres()answered truthy for the rest of that process, andstores.knowledge_ingestheld a test’s knowledge store — and none after. The 82patched_value_importsthe 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 = valuein tests; most restored by hand infinally, 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 aftertests/). All aremonkeypatch.setattrnow, 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.
Adding to this page
Section titled “Adding to this page”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.