Skip to content

Commit 992a0f0

Browse files
committed
fix(runtime): isolate decision observation failures
Defer terminal decision events until the Atomic Memory transaction commits, preserve shadow writes when audit persistence fails, and disable redirects for custom Anthropic header clients. Constraint: audit writes stay in the owner transaction and malformed audit writes never block the domain write. Scope-risk: changes are limited to decision observation finalization and custom provider redirect transport. Tested: uv run --no-sync pytest tests/builtin/runtime/test_decision_composition.py tests/builtin/runtime/test_memory_window_recovery.py -q; uv run --no-sync pytest tests/builtin/runtime/test_memory_write_gate_paths.py tests/builtin/runtime/test_processing_composition.py tests/builtin/runtime/test_memory_write_gate_contract.py -q; ruff and ty checks passed.
1 parent cb00001 commit 992a0f0

4 files changed

Lines changed: 163 additions & 31 deletions

File tree

‎src/powercontext/builtin/runtime/atomic_memory_processing.py‎

Lines changed: 57 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from __future__ import annotations
1818

19+
import logging
1920
import math
2021
from base64 import urlsafe_b64encode
2122
from contextlib import asynccontextmanager, nullcontext
@@ -25,6 +26,7 @@
2526

2627
from sqlalchemy import update
2728

29+
from powercontext._logging import log_safely
2830
from powercontext.artifacts import ArtifactLineage, ArtifactRef
2931
from powercontext.artifacts.search import ArtifactSearchExecutionContext
3032
from powercontext.builtin.artifacts.atomic_memory.errors import AtomicMemoryConflictError
@@ -77,6 +79,9 @@
7779
from powercontext.builtin.runtime.config import RuntimeConfig
7880

7981

82+
logger = logging.getLogger(__name__)
83+
84+
8085
@dataclass(frozen=True)
8186
class AtomicMemoryProcessingConfig:
8287
related_mode: Literal["auto", "fts", "vector", "hybrid"] = "auto"
@@ -215,22 +220,28 @@ async def _flush_once(self, scope_id, limit, processing, context, span) -> Memor
215220
if reducible and action.through - action.after > 1:
216221
await self._reduce_window(scope_id, context, processing, cursor, state, action, high_watermark)
217222
raise
218-
await self._publish(
219-
scope_id,
220-
context,
221-
processing,
222-
cursor,
223-
transition,
224-
action,
225-
window,
226-
eligible,
227-
consulted_artifacts,
228-
workset,
229-
prepared,
230-
decision,
231-
observation,
232-
high_watermark,
233-
)
223+
try:
224+
finalized = await self._publish(
225+
scope_id,
226+
context,
227+
processing,
228+
cursor,
229+
transition,
230+
action,
231+
window,
232+
eligible,
233+
consulted_artifacts,
234+
workset,
235+
prepared,
236+
decision,
237+
observation,
238+
high_watermark,
239+
)
240+
except Exception:
241+
self._emit_failed_observation(observation)
242+
raise
243+
if finalized is not None:
244+
emit_decision_observation(finalized)
234245
return self._result(
235246
action.after,
236247
action.through,
@@ -310,6 +321,7 @@ async def _publish(
310321
):
311322
application = self.application
312323
writes = tuple(write for item in prepared for write in item.plan.writes)
324+
finalized = None
313325
with self._stage(
314326
"memory.commit",
315327
{
@@ -352,7 +364,7 @@ async def _publish(
352364
results = await application.service.commit_window(
353365
connection, scope_id, prepared, context, read_set=tuple(workset.reads.values())
354366
)
355-
await self._finalize_observation(
367+
finalized = await self._finalize_observation(
356368
connection,
357369
observation,
358370
final_action=(
@@ -372,6 +384,7 @@ async def _publish(
372384
await self.windows.clear_consumed(connection, scope_id, action.through)
373385
if processing is not None:
374386
await processing.complete(connection, remaining_work=action.through < high_watermark)
387+
return finalized
375388

376389
async def _prepare(self, scope_id, eligible, pipeline, context):
377390
workset = AtomicMemoryWindowWorkset()
@@ -638,23 +651,41 @@ async def _assess_write_gate(self, scope_id, workset, prepared):
638651

639652
async def _finalize_observation(self, connection, observation, *, final_action, subject_refs):
640653
if observation is None or self.decision_observations is None:
641-
return
654+
return None
642655
finalized = observation.model_copy(
643656
update={
644657
"final_action": final_action,
645658
"subject_refs": observation.subject_refs if final_action == "memory_write_hold" else subject_refs,
646659
}
647660
)
648-
await self.decision_observations.append(connection, finalized)
649-
if self.decision_observation_retention_days is not None:
650-
from datetime import UTC, datetime, timedelta
661+
try:
662+
async with connection.begin_nested():
663+
await self.decision_observations.append(connection, finalized)
664+
if self.decision_observation_retention_days is not None:
665+
from datetime import UTC, datetime, timedelta
651666

652-
await self.decision_observations.prune_before(
653-
connection,
654-
finalized.scope_id,
655-
datetime.now(UTC) - timedelta(days=self.decision_observation_retention_days),
667+
await self.decision_observations.prune_before(
668+
connection,
669+
finalized.scope_id,
670+
datetime.now(UTC) - timedelta(days=self.decision_observation_retention_days),
671+
)
672+
except Exception as error:
673+
log_safely(
674+
logger,
675+
logging.WARNING,
676+
"Decision observation persistence failed",
677+
exc_info=error,
678+
extra={"event": "decision.observation_sink_failed", "error_type": type(error).__name__},
679+
)
680+
return None
681+
return finalized
682+
683+
@staticmethod
684+
def _emit_failed_observation(observation):
685+
if observation is not None:
686+
emit_decision_observation(
687+
observation.model_copy(update={"final_action": "memory_write_failed", "subject_refs": ()})
656688
)
657-
emit_decision_observation(finalized)
658689

659690
@staticmethod
660691
def _result(after, through, high_watermark, count, *, held=False, hold_codes=()):

‎src/powercontext/builtin/runtime/composition.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1733,7 +1733,7 @@ def create_provider(provider_name: str) -> Provider[Any]:
17331733
if headers:
17341734
from openai import AsyncOpenAI
17351735

1736-
http_client = _openai_http_client(follow_redirects=False) if base_url is not None else None
1736+
http_client = _endpoint_http_client(follow_redirects=False) if base_url is not None else None
17371737
client = AsyncOpenAI(
17381738
base_url=None if base_url is None else str(base_url),
17391739
api_key=os.getenv("OPENAI_API_KEY") or "api-key-not-set",
@@ -1748,7 +1748,7 @@ def create_provider(provider_name: str) -> Provider[Any]:
17481748
client = AsyncOpenAI(
17491749
base_url=str(base_url),
17501750
api_key=os.getenv("OPENAI_API_KEY") or "api-key-not-set",
1751-
http_client=_openai_http_client(follow_redirects=False),
1751+
http_client=_endpoint_http_client(follow_redirects=False),
17521752
)
17531753
resources.push_async_callback(client.close)
17541754
return OpenAIProvider(openai_client=client)
@@ -1764,6 +1764,7 @@ def create_provider(provider_name: str) -> Provider[Any]:
17641764
base_url=None if base_url is None else str(base_url),
17651765
api_key=api_key or os.getenv("ANTHROPIC_API_KEY") or "api-key-not-set",
17661766
default_headers=default_headers,
1767+
http_client=_endpoint_http_client(follow_redirects=False) if base_url is not None else None,
17671768
)
17681769
resources.push_async_callback(client.close)
17691770
return AnthropicProvider(anthropic_client=client)
@@ -1776,8 +1777,8 @@ def create_provider(provider_name: str) -> Provider[Any]:
17761777
return create_provider
17771778

17781779

1779-
def _openai_http_client(*, follow_redirects: bool):
1780-
"""Create the SDK transport with an explicit redirect policy for local-only endpoints."""
1780+
def _endpoint_http_client(*, follow_redirects: bool):
1781+
"""Create the SDK transport with an explicit redirect policy for custom endpoints."""
17811782

17821783
import httpx
17831784

‎tests/builtin/runtime/test_decision_composition.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from typing import Any
2121

2222
import pytest
23-
from pydantic import AnyHttpUrl
23+
from pydantic import AnyHttpUrl, SecretStr
2424

2525
from powercontext.builtin.inference import InferenceUnavailableError, InferenceUsage
2626
from powercontext.builtin.persistence.sqlite import SQLiteConfig
@@ -231,3 +231,20 @@ async def scenario() -> None:
231231
assert provider.client._client.follow_redirects is False # pyright: ignore[reportPrivateUsage]
232232

233233
asyncio.run(scenario())
234+
235+
236+
def test_loopback_anthropic_headers_provider_disables_redirects() -> None:
237+
async def scenario() -> None:
238+
async with AsyncExitStack() as resources:
239+
factory = composition._unbounded_provider_factory(
240+
AnyHttpUrl("http://127.0.0.1:11434"),
241+
{"x-api-key": SecretStr("test-key")},
242+
workload="generation",
243+
resources=resources,
244+
)
245+
246+
provider = factory("anthropic")
247+
248+
assert provider.client._client.follow_redirects is False # pyright: ignore[reportPrivateUsage]
249+
250+
asyncio.run(scenario())

‎tests/builtin/runtime/test_memory_window_recovery.py‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from uuid import uuid4
1919

2020
import pytest
21+
from sqlalchemy import text
22+
from sqlalchemy.exc import IntegrityError
2123

2224
from powercontext.builtin.artifacts.atomic_memory.extraction import AtomicMemoryCandidate, AtomicMemoryExtractionOutput
2325
from powercontext.builtin.decision_observations import DecisionPrivacyBoundary
@@ -165,6 +167,87 @@ async def scenario():
165167
assert [record.__dict__["final_action"] for record in events] == ["memory_write_applied"]
166168

167169

170+
def test_atomic_memory_gate_emits_failed_outcome_after_late_transaction_rollback(
171+
tmp_path, caplog: pytest.LogCaptureFixture
172+
):
173+
async def scenario():
174+
gate = build_memory_write_gate(
175+
None,
176+
enabled=True,
177+
privacy_boundary=DecisionPrivacyBoundary.NO_EXTERNAL_CALL,
178+
)
179+
assert gate is not None
180+
config = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'atomic-gate-rollback.db'}")
181+
async with SQLiteProfile.open(config, tables=BUILTIN_TABLES) as profile:
182+
contexts = await _ready_contexts(
183+
profile,
184+
candidate_pipeline=atomic_memory_pipeline(Pipeline()),
185+
memory_write_gate=gate,
186+
)
187+
scope = await create_scope(contexts, 1)
188+
context = await contexts.get(scope)
189+
async with profile.database.transaction() as connection:
190+
await connection.execute(
191+
text(
192+
"CREATE TRIGGER reject_memory_cursor "
193+
"BEFORE INSERT ON pc_source_cursors "
194+
"BEGIN SELECT RAISE(ABORT, 'cursor rejected'); END"
195+
)
196+
)
197+
198+
with pytest.raises(IntegrityError, match="cursor rejected"):
199+
await context.triggers.flush(limit=1)
200+
201+
assert (await context.triggers.cursor()).sequence == 0
202+
assert not (await contexts.atomic_memory.for_scope(scope).list()).items
203+
async with profile.database.transaction() as connection:
204+
assert not await DecisionObservationRepository().observations(connection, scope)
205+
206+
caplog.set_level("INFO", logger="powercontext.builtin.runtime.decision_policy")
207+
asyncio.run(scenario())
208+
events = [record for record in caplog.records if getattr(record, "event", None) == "decision.observation"]
209+
assert [record.__dict__["final_action"] for record in events] == ["memory_write_failed"]
210+
211+
212+
def test_atomic_memory_audit_failure_does_not_block_shadow_write(tmp_path, caplog: pytest.LogCaptureFixture):
213+
async def scenario():
214+
gate = build_memory_write_gate(
215+
None,
216+
enabled=True,
217+
privacy_boundary=DecisionPrivacyBoundary.NO_EXTERNAL_CALL,
218+
)
219+
assert gate is not None
220+
config = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'atomic-gate-audit.db'}")
221+
async with SQLiteProfile.open(config, tables=BUILTIN_TABLES) as profile:
222+
contexts = await _ready_contexts(
223+
profile,
224+
candidate_pipeline=atomic_memory_pipeline(Pipeline()),
225+
memory_write_gate=gate,
226+
)
227+
scope = await create_scope(contexts, 1)
228+
context = await contexts.get(scope)
229+
async with profile.database.transaction() as connection:
230+
await connection.execute(
231+
text(
232+
"CREATE TRIGGER reject_decision_observation "
233+
"BEFORE INSERT ON pc_decision_observations "
234+
"BEGIN SELECT RAISE(ABORT, 'audit rejected'); END"
235+
)
236+
)
237+
238+
assert (await context.triggers.flush(limit=1)).current_cursor == 1
239+
assert len((await contexts.atomic_memory.for_scope(scope).list()).items) == 1
240+
async with profile.database.transaction() as connection:
241+
assert not await DecisionObservationRepository().observations(connection, scope)
242+
243+
caplog.set_level("WARNING", logger="powercontext.builtin.runtime.atomic_memory_processing")
244+
asyncio.run(scenario())
245+
diagnostics = [
246+
record for record in caplog.records if getattr(record, "event", None) == "decision.observation_sink_failed"
247+
]
248+
assert len(diagnostics) == 1
249+
250+
168251
@pytest.mark.parametrize("error", [InferenceTimeoutError("embed", 60), InferenceUnavailableError("generate")])
169252
def test_non_extraction_failures_do_not_reduce_source_window(error):
170253
async def scenario():

0 commit comments

Comments
 (0)