fix(memory): honour project:<id> isolation + make the degrade ladder real (#1300, #1301) - #1360
Conversation
|
This PR is being reconciled against it. It will either be closed as superseded or reduced to the genuinely unique delta (e.g. the |
e80fb80 to
a02db0a
Compare
…1301) Reduced onto main after 610150d (#1357) landed the boot half of #1301 and all of #1300 independently. Dropped from the original PR #1360 as duplicate: `probe_hindsight` (main has `probe_health`, which additionally sends the Authorization header and takes an injectable transport), the `_build_hindsight_client` rewrite, and the whole `project:<id>` unified-bank scoping — main's version of that is the better one (it treats a doc's `project:` tags as a set with OR semantics, and only stamps the tag in unified mode, so pre-unified multi-bank deployments' tag sets are untouched). What survives is the half 610150d does not implement at all: it adds no `degraded` on HindsightProvider, so `getattr(provider, "degraded", False)` stays False for the entire life of the process. A boot probe answers one question, once. A daemon that dies after boot leaves the HindsightProvider in place and the flag starts lying again — `/api/status.memory_degraded` and `hal0 memory status` report healthy while recalls come back empty and retains raise. That is the same false-healthy report #1301 was filed about, just displaced in time by one boot. - `_call` wraps every engine round-trip (retain, recall, list_memories x2, delete_document) and observes reachability instead of assuming it. Exceptions propagate untouched, so the existing fail-soft / 404-sweep handling is unchanged. - 4xx does NOT degrade — the daemon answered, and the delete sweep's routine per-bank 404s would otherwise flap the flag on every scoped delete. 5xx does, matching probe_health so boot and runtime agree on "up". - Not a one-way latch: a restarted daemon clears it. A boot probe structurally cannot do this. - `_mark_engine` is edge-triggered so an outage logs once, not once per call. - health.py + `hal0 memory status` wording corrected: degraded now covers two shapes (boot fallback, post-boot outage) and the old line asserted only the first. tests/memory/test_degrade_live.py covers the runtime half plus one anti-vacuity guard: main's boot tests monkeypatch `probe_health` itself, so they cannot distinguish "the probe ran and failed" from "the probe would never have reached the network" — the exact shape of the original bug. The added test points the factory at a real closed loopback port. Known remaining gap, deliberately not addressed here: the boot decision is still permanent in the other direction. A deployment that starts hal0-api before hindsight-api degrades to the volatile PgVectorProvider at boot and never re-promotes, however healthy the daemon later becomes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reduced after reconciliation with
|
…1301) Reduced onto main after 610150d (#1357) landed the boot half of #1301 and all of #1300 independently. Dropped from the original PR #1360 as duplicate: `probe_hindsight` (main has `probe_health`, which additionally sends the Authorization header and takes an injectable transport), the `_build_hindsight_client` rewrite, and the whole `project:<id>` unified-bank scoping — main's version of that is the better one (it treats a doc's `project:` tags as a set with OR semantics, and only stamps the tag in unified mode, so pre-unified multi-bank deployments' tag sets are untouched). What survives is the half 610150d does not implement at all: it adds no `degraded` on HindsightProvider, so `getattr(provider, "degraded", False)` stays False for the entire life of the process. A boot probe answers one question, once. A daemon that dies after boot leaves the HindsightProvider in place and the flag starts lying again — `/api/status.memory_degraded` and `hal0 memory status` report healthy while recalls come back empty and retains raise. That is the same false-healthy report #1301 was filed about, just displaced in time by one boot. - `_call` wraps every engine round-trip (retain, recall, list_memories x2, delete_document) and observes reachability instead of assuming it. Exceptions propagate untouched, so the existing fail-soft / 404-sweep handling is unchanged. - 4xx does NOT degrade — the daemon answered, and the delete sweep's routine per-bank 404s would otherwise flap the flag on every scoped delete. 5xx does, matching probe_health so boot and runtime agree on "up". - Not a one-way latch: a restarted daemon clears it. A boot probe structurally cannot do this. - `_mark_engine` is edge-triggered so an outage logs once, not once per call. - health.py + `hal0 memory status` wording corrected: degraded now covers two shapes (boot fallback, post-boot outage) and the old line asserted only the first. tests/memory/test_degrade_live.py covers the runtime half plus one anti-vacuity guard: main's boot tests monkeypatch `probe_health` itself, so they cannot distinguish "the probe ran and failed" from "the probe would never have reached the network" — the exact shape of the original bug. The added test points the factory at a real closed loopback port. Known remaining gap, deliberately not addressed here: the boot decision is still permanent in the other direction. A deployment that starts hal0-api before hindsight-api degrades to the volatile PgVectorProvider at boot and never re-promotes, however healthy the daemon later becomes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a02db0a to
8b80bf5
Compare
Closes #1300. Closes #1301.
#1300 — cross-project memory bleed on every default install
In
unified_bankmode (the shipped default)_write_namespacecollapsed everything exceptagentsonto the singlesharedbank. Unlikeprivate:, which survives the collapse as avisibility:private+agent:<id>tag pair enforced on read,project:got no compensating marker at all. Aproject:foowrite was literally an ordinary shared write; aproject:barrecall returned it.Chose to honour the isolation, not reject it.
project:<id>is in the closed spec §3 namespace table accepted by both REST and MCP front doors,unified_bank=Trueis the default, andmemory_deletealready documents sweeping by it — refusing it in unified mode would delete a documented, front-door-reachable feature on every default install.The namespace now survives as a server-owned
project:<id>tag, enforced by the same mechanismvisibility:privatealready uses (PR #1244) — one pattern in the file, not two.addstamps the resolved scope and strips caller-suppliedproject:tags (otherwise a shared write can forge its way into another projects recalls)recall/search/list_itemsfilter pre-rerank and pre-budget, so out-of-scope docs never consume the callers tokensdeletegates on the same predicateagentsstays exempt;unified_bank=FalseuntouchedMigration: docs written as
project:foobefore this were actually shared writes — that was the bug — and nothing can distinguish them, so there is nothing to back-fill.#1301 — the ladder never engaged and the flag lied
_build_hindsight_clientonly calledHindsightRestClient.from_env(), which constructs an httpx client and does zero I/O. A down daemon never raised, so theexceptinprovider_from_configwas dead code. Proven on base:Post-fix:
PgVectorProvider/True/True.Fixed both halves — a boot-only probe would start lying again the moment the daemon died:
probe_hindsight()does a bounded synchronousGET /health. "Reachable" = HTTP response below 500, so a 401/404 proves something is listening and must not trigger a data-losing degrade. Escape hatches:HAL0_HINDSIGHT_PROBE_TIMEOUT_S,HAL0_HINDSIGHT_SKIP_PROBEdegradedis a live property driven by observed transport failures, clearing on recoveryVerification
tests/memory→ 144 passed; combined with api/dispatcher → 419 vs base 393 (exactly +26)tests/cli→ 629, identical to baseThe ladder test uses a real closed loopback port and a real throwaway HTTP daemon rather than patching httpx — "no I/O happened" was the bug, so a test that stubs the I/O could not have caught it.
🤖 Generated with Claude Code