From b1d544ba8c5049473dffed52baa627ab14349925 Mon Sep 17 00:00:00 2001 From: "Xin.Zh" Date: Sat, 10 Oct 2026 12:44:53 +0800 Subject: [PATCH 01/11] feat(runtime): add decision policy governance types Add the internal DecisionPolicy, DecisionAssessment, and DecisionObservation vocabulary from RFC 1770. Route the Memory write gate through the shared assessment mapping before preserving its existing ACCEPT/FLAG/HOLD contract. Constraint: no public HTTP/OpenAPI surface, persistence schema, or hosted-provider configuration is added. Rejected: copying Hermes implementation code or plugin structure; only the RFC-level governance semantics are mirrored. Scope-risk: limited to runtime decision vocabulary and Memory write gate assessment mapping. Tested: UV_PROJECT_ENVIRONMENT=.venv312 uv run --python 3.12 --no-sync pytest tests/builtin/runtime/test_decision_policy.py tests/builtin/runtime/test_memory_write_gate_contract.py::test_memory_write_decision_policy_keeps_fallback_unadjudicated tests/builtin/runtime/test_memory_write_gate_contract.py::test_memory_write_decision_policy_uses_configured_hold_direction -q Tested: UV_PROJECT_ENVIRONMENT=.venv312 uv run --python 3.12 --no-sync ruff check src/powercontext/builtin/runtime/decision_policy.py src/powercontext/builtin/runtime/memory_write_gate.py src/powercontext/builtin/runtime/__init__.py tests/builtin/runtime/test_decision_policy.py tests/builtin/runtime/test_memory_write_gate_contract.py Tested: UV_PROJECT_ENVIRONMENT=.venv312 uv run --python 3.12 --no-sync ruff format --check src/powercontext/builtin/runtime/decision_policy.py src/powercontext/builtin/runtime/memory_write_gate.py src/powercontext/builtin/runtime/__init__.py tests/builtin/runtime/test_decision_policy.py tests/builtin/runtime/test_memory_write_gate_contract.py Not-tested: full Memory write gate async contract on this Windows host hangs in CPython Proactor event-loop socketpair setup; default uv Python 3.14 currently fails tests/conftest.py typing import; ty check cannot resolve pydantic/pytest in this environment. Co-authored-by: OmX --- src/powercontext/builtin/runtime/__init__.py | 22 ++ .../builtin/runtime/decision_policy.py | 235 ++++++++++++++++++ .../builtin/runtime/memory_write_gate.py | 52 +++- tests/builtin/runtime/test_decision_policy.py | 152 +++++++++++ .../test_memory_write_gate_contract.py | 23 ++ 5 files changed, 481 insertions(+), 3 deletions(-) create mode 100644 src/powercontext/builtin/runtime/decision_policy.py create mode 100644 tests/builtin/runtime/test_decision_policy.py diff --git a/src/powercontext/builtin/runtime/__init__.py b/src/powercontext/builtin/runtime/__init__.py index 6741a76dee..796156e4f2 100644 --- a/src/powercontext/builtin/runtime/__init__.py +++ b/src/powercontext/builtin/runtime/__init__.py @@ -112,6 +112,18 @@ DecisionResult, StructuredDecisionModel, ) +from powercontext.builtin.runtime.decision_policy import ( + DecisionAssessment, + DecisionAssessmentSource, + DecisionCoverage, + DecisionFailurePolicy, + DecisionObservation, + DecisionPolicy, + DecisionPolicyMode, + DecisionPrivacyBoundary, + DecisionVerdict, + assess_decision_result, +) from powercontext.builtin.runtime.errors import InvalidRuntimeRequestError, TopicMemoryProcessingUnavailableError from powercontext.builtin.runtime.memory_write_gate import ( DecisionMemoryWriteGate, @@ -241,15 +253,24 @@ "ContextAssemblySection", "CreateDreamRunRequest", "DatabaseConfig", + "DecisionAssessment", + "DecisionAssessmentSource", + "DecisionCoverage", + "DecisionFailurePolicy", "DecisionKind", "DecisionMemoryWriteGate", "DecisionModel", "DecisionModelOption", "DecisionModelRequest", "DecisionModelResult", + "DecisionObservation", "DecisionOutcome", + "DecisionPolicy", + "DecisionPolicyMode", + "DecisionPrivacyBoundary", "DecisionRequest", "DecisionResult", + "DecisionVerdict", "DreamApplication", "DreamRun", "DreamRunPage", @@ -392,6 +413,7 @@ "TopicMemoryProcessingUnavailableError", "UsageStatistics", "WorkApplication", + "assess_decision_result", "build_memory_write_gate", "dependency_readiness_probe", "open_builtin_contexts", diff --git a/src/powercontext/builtin/runtime/decision_policy.py b/src/powercontext/builtin/runtime/decision_policy.py new file mode 100644 index 0000000000..fc7fea0f60 --- /dev/null +++ b/src/powercontext/builtin/runtime/decision_policy.py @@ -0,0 +1,235 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Shared governance vocabulary for narrow runtime decision policies.""" + +from __future__ import annotations + +from collections.abc import Mapping +from enum import StrEnum +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator + +from powercontext.builtin.inference import InferenceUsage +from powercontext.builtin.runtime.decision_model import DecisionOutcome, DecisionResult + + +class DecisionPolicyMode(StrEnum): + """Runtime mode for one versioned decision policy.""" + + DISABLED = "disabled" + SHADOW = "shadow" + ADVISORY = "advisory" + ENFORCING = "enforcing" + + +class DecisionFailurePolicy(StrEnum): + """How a consumer treats backend failure when it owns a domain action.""" + + FAIL_OPEN = "fail_open" + FAIL_CLOSED = "fail_closed" + + +class DecisionPrivacyBoundary(StrEnum): + """Content boundary declared by a policy before backend calls.""" + + LOCAL_ONLY = "local_only" + HOSTED_REDACTED = "hosted_redacted" + REFERENCES_ONLY = "references_only" + NO_EXTERNAL_CALL = "no_external_call" + + +class DecisionCoverage(StrEnum): + """Whether a policy evaluation actually judged the supplied content.""" + + ADJUDICATED = "adjudicated" + UNADJUDICATED = "unadjudicated" + + +class DecisionVerdict(StrEnum): + """Domain-neutral verdict before an owning service maps it to an action.""" + + ALLOW = "allow" + DENY = "deny" + REVIEW = "review" + UNKNOWN = "unknown" + + +class DecisionAssessmentSource(StrEnum): + """The source that produced the assessment's substantive judgement.""" + + LOCAL_RULE = "local_rule" + DECISION_MODEL = "decision_model" + NONE = "none" + + +class _StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class DecisionPolicy(_StrictModel): + """Versioned, reviewable policy manifest for one bounded runtime question.""" + + policy_id: Annotated[str, Field(min_length=1, max_length=256)] + decision_kind: Annotated[str, Field(min_length=1, max_length=128)] + version: Annotated[str, Field(min_length=1, max_length=64)] + consumer: Annotated[str, Field(min_length=1, max_length=128)] + mode: DecisionPolicyMode + failure_policy: DecisionFailurePolicy + privacy_boundary: DecisionPrivacyBoundary + local_rules: tuple[Annotated[str, Field(min_length=1, max_length=128)], ...] = () + question: Annotated[str, Field(min_length=1, max_length=8192)] + subject_selector: Annotated[str, Field(min_length=1, max_length=512)] + evidence_selector: Annotated[str, Field(min_length=1, max_length=512)] + outcome_mapping: Mapping[str, Annotated[str, Field(min_length=1, max_length=128)]] = Field(default_factory=dict) + promotion_criteria: tuple[Annotated[str, Field(min_length=1, max_length=512)], ...] = () + + +class DecisionAssessment(_StrictModel): + """Policy-level assessment before domain-specific action mapping.""" + + policy_id: Annotated[str, Field(min_length=1, max_length=256)] + policy_version: Annotated[str, Field(min_length=1, max_length=64)] + mode: DecisionPolicyMode + coverage: DecisionCoverage + verdict: DecisionVerdict + source: DecisionAssessmentSource + reason: Annotated[str | None, Field(min_length=1, max_length=4096)] = None + confidence: Annotated[float | None, Field(ge=0.0, le=1.0)] = None + used_fallback: bool = False + usage: InferenceUsage = Field(default_factory=lambda: InferenceUsage(requests=0)) + latency_ms: Annotated[float | None, Field(ge=0.0, allow_inf_nan=False)] = None + + @model_validator(mode="after") + def validate_fallback_coverage(self) -> DecisionAssessment: + if self.used_fallback and self.coverage is not DecisionCoverage.UNADJUDICATED: + raise ValueError("fallback assessments must be unadjudicated") # noqa: TRY003 + return self + + +class DecisionObservation(_StrictModel): + """Audit/replay sidecar for one policy evaluation attempt.""" + + operation_id: Annotated[str, Field(min_length=1, max_length=256)] + scope_id: Annotated[str, Field(min_length=1, max_length=256)] + consumer: Annotated[str, Field(min_length=1, max_length=128)] + policy_id: Annotated[str, Field(min_length=1, max_length=256)] + policy_version: Annotated[str, Field(min_length=1, max_length=64)] + mode: DecisionPolicyMode + subject_refs: tuple[Annotated[str, Field(min_length=1, max_length=512)], ...] = () + evidence_refs: tuple[Annotated[str, Field(min_length=1, max_length=512)], ...] = () + privacy_boundary: DecisionPrivacyBoundary + privacy_outcome: Annotated[str | None, Field(min_length=1, max_length=128)] = None + provider_id: Annotated[str | None, Field(min_length=1, max_length=128)] = None + backend_model_id: Annotated[str | None, Field(min_length=1, max_length=256)] = None + model_policy_id: Annotated[str | None, Field(min_length=1, max_length=256)] = None + assessment: DecisionAssessment + final_action: Annotated[str, Field(min_length=1, max_length=128)] + fallback_reason: Annotated[str | None, Field(min_length=1, max_length=256)] = None + metadata: Mapping[str, JsonValue] = Field(default_factory=dict) + + +_DEFAULT_OUTCOME_MAPPING: Mapping[DecisionOutcome, DecisionVerdict] = { + DecisionOutcome.YES: DecisionVerdict.ALLOW, + DecisionOutcome.NO: DecisionVerdict.DENY, + DecisionOutcome.ABSTAIN: DecisionVerdict.UNKNOWN, +} + + +def assess_decision_result( + policy: DecisionPolicy, + result: DecisionResult, + /, + *, + outcome_mapping: Mapping[DecisionOutcome | str, DecisionVerdict | str] | None = None, + latency_ms: float | None = None, +) -> DecisionAssessment: + """Map a low-level decision result into the RFC 1770 assessment vocabulary.""" + + mapping = _normalized_outcome_mapping(policy, outcome_mapping) + if result.used_fallback: + return DecisionAssessment( + policy_id=policy.policy_id, + policy_version=policy.version, + mode=policy.mode, + coverage=DecisionCoverage.UNADJUDICATED, + verdict=DecisionVerdict.UNKNOWN, + source=DecisionAssessmentSource.NONE, + reason="decision_model_fallback", + confidence=result.confidence, + used_fallback=True, + usage=result.usage, + latency_ms=latency_ms, + ) + if result.outcome is DecisionOutcome.ABSTAIN: + return DecisionAssessment( + policy_id=policy.policy_id, + policy_version=policy.version, + mode=policy.mode, + coverage=DecisionCoverage.UNADJUDICATED, + verdict=DecisionVerdict.UNKNOWN, + source=DecisionAssessmentSource.DECISION_MODEL, + reason=_bounded_reason(result.rationale, default="abstain"), + confidence=result.confidence, + usage=result.usage, + latency_ms=latency_ms, + ) + return DecisionAssessment( + policy_id=policy.policy_id, + policy_version=policy.version, + mode=policy.mode, + coverage=DecisionCoverage.ADJUDICATED, + verdict=mapping[result.outcome], + source=DecisionAssessmentSource.DECISION_MODEL, + reason=_bounded_reason(result.rationale), + confidence=result.confidence, + usage=result.usage, + latency_ms=latency_ms, + ) + + +def _normalized_outcome_mapping( + policy: DecisionPolicy, + override: Mapping[DecisionOutcome | str, DecisionVerdict | str] | None, +) -> dict[DecisionOutcome, DecisionVerdict]: + raw = override if override is not None else policy.outcome_mapping + normalized = dict(_DEFAULT_OUTCOME_MAPPING) + for key, value in raw.items(): + outcome = key if isinstance(key, DecisionOutcome) else DecisionOutcome(str(key)) + verdict = value if isinstance(value, DecisionVerdict) else DecisionVerdict(str(value)) + normalized[outcome] = verdict + normalized[DecisionOutcome.ABSTAIN] = DecisionVerdict.UNKNOWN + return normalized + + +def _bounded_reason(value: str | None, *, default: str | None = None) -> str | None: + if value is None: + return default + normalized = value.strip() + return normalized[:4096] if normalized else default + + +__all__ = [ + "DecisionAssessment", + "DecisionAssessmentSource", + "DecisionCoverage", + "DecisionFailurePolicy", + "DecisionObservation", + "DecisionPolicy", + "DecisionPolicyMode", + "DecisionPrivacyBoundary", + "DecisionVerdict", + "assess_decision_result", +] diff --git a/src/powercontext/builtin/runtime/memory_write_gate.py b/src/powercontext/builtin/runtime/memory_write_gate.py index c3381ebafc..7a6ac13ca0 100644 --- a/src/powercontext/builtin/runtime/memory_write_gate.py +++ b/src/powercontext/builtin/runtime/memory_write_gate.py @@ -42,6 +42,16 @@ DecisionRequest, DecisionResult, ) +from powercontext.builtin.runtime.decision_policy import ( + DecisionAssessment, + DecisionCoverage, + DecisionFailurePolicy, + DecisionPolicy, + DecisionPolicyMode, + DecisionPrivacyBoundary, + DecisionVerdict, + assess_decision_result, +) logger = logging.getLogger(__name__) @@ -55,6 +65,25 @@ _MAX_SUBJECT_LENGTH = 4000 _MAX_REASON_LENGTH = 512 _DEFAULT_REASON = "the cited evidence does not clearly support this memory write" +_MEMORY_WRITE_POLICY = DecisionPolicy( + policy_id="memory.write.evidence_sufficiency.v1", + decision_kind=DecisionKind.MEMORY_WRITE_GATE.value, + version="1", + consumer="memory_write_gate", + mode=DecisionPolicyMode.ENFORCING, + failure_policy=DecisionFailurePolicy.FAIL_OPEN, + privacy_boundary=DecisionPrivacyBoundary.HOSTED_REDACTED, + local_rules=("candidate_batch_budget", "evidence_projection_budget"), + question=_GATE_QUESTION, + subject_selector="candidate_text", + evidence_selector="candidate_citations", + outcome_mapping={ + DecisionOutcome.YES.value: DecisionVerdict.DENY.value, + DecisionOutcome.NO.value: DecisionVerdict.ALLOW.value, + DecisionOutcome.ABSTAIN.value: DecisionVerdict.UNKNOWN.value, + }, + promotion_criteria=("explicit runtime opt-in",), +) def build_memory_write_gate( @@ -125,13 +154,14 @@ async def assess(self, request: MemoryWriteGateRequest, /) -> MemoryWriteAssessm return assessment def _map(self, request: MemoryWriteGateRequest, decision: DecisionResult) -> MemoryWriteAssessment: - if decision.used_fallback or decision.outcome is DecisionOutcome.ABSTAIN: + assessment = _assess_memory_write_decision(decision, hold_on=self._hold_on) + if assessment.coverage is DecisionCoverage.UNADJUDICATED: return MemoryWriteAssessment( verdict=MemoryWriteVerdict.ACCEPT, policy_id=decision.policy_id, - used_fallback=decision.used_fallback, + used_fallback=assessment.used_fallback, ) - if decision.outcome is not self._hold_on: + if assessment.verdict is DecisionVerdict.ALLOW: return MemoryWriteAssessment(verdict=MemoryWriteVerdict.ACCEPT, policy_id=decision.policy_id) reason = _bounded_reason(decision.rationale) if self._threshold is not None and decision.confidence is not None and decision.confidence < self._threshold: @@ -171,6 +201,21 @@ def _rejection_code(request: MemoryWriteGateRequest) -> MemoryWriteRejectionCode return MemoryWriteRejectionCode.INSUFFICIENT_COVERAGE +def _assess_memory_write_decision(decision: DecisionResult, /, *, hold_on: DecisionOutcome) -> DecisionAssessment: + if hold_on is DecisionOutcome.ABSTAIN: + raise ValueError("the hold direction cannot be abstain") # noqa: TRY003 + pass_on = DecisionOutcome.NO if hold_on is DecisionOutcome.YES else DecisionOutcome.YES + return assess_decision_result( + _MEMORY_WRITE_POLICY, + decision, + outcome_mapping={ + hold_on: DecisionVerdict.DENY, + pass_on: DecisionVerdict.ALLOW, + DecisionOutcome.ABSTAIN: DecisionVerdict.UNKNOWN, + }, + ) + + def _bounded_subject(candidates: tuple[str, ...]) -> str: return _candidate_subject(candidates)[:_MAX_SUBJECT_LENGTH] @@ -201,5 +246,6 @@ def _bounded_reason(value: str | None) -> str: "MemoryWriteGateRequest", "MemoryWriteRejectionCode", "MemoryWriteVerdict", + "_assess_memory_write_decision", "build_memory_write_gate", ] diff --git a/tests/builtin/runtime/test_decision_policy.py b/tests/builtin/runtime/test_decision_policy.py new file mode 100644 index 0000000000..837bdcead0 --- /dev/null +++ b/tests/builtin/runtime/test_decision_policy.py @@ -0,0 +1,152 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from powercontext.builtin.inference import InferenceUsage +from powercontext.builtin.runtime.decision_model import DecisionOutcome, DecisionResult +from powercontext.builtin.runtime.decision_policy import ( + DecisionAssessment, + DecisionAssessmentSource, + DecisionCoverage, + DecisionFailurePolicy, + DecisionObservation, + DecisionPolicy, + DecisionPolicyMode, + DecisionPrivacyBoundary, + DecisionVerdict, + assess_decision_result, +) + + +def _policy() -> DecisionPolicy: + return DecisionPolicy( + policy_id="memory.write.evidence_sufficiency.v1", + decision_kind="memory.write-gate", + version="1", + consumer="memory_write_gate", + mode=DecisionPolicyMode.SHADOW, + failure_policy=DecisionFailurePolicy.FAIL_OPEN, + privacy_boundary=DecisionPrivacyBoundary.REFERENCES_ONLY, + question="Does the cited evidence fail to support the candidate?", + subject_selector="candidate_text", + evidence_selector="candidate_citations", + outcome_mapping={ + "yes": "deny", + "no": "allow", + "abstain": "unknown", + }, + promotion_criteria=("replay evidence exists",), + ) + + +def test_fallback_assessment_is_unadjudicated_even_when_domain_will_pass_through() -> None: + assessment = assess_decision_result( + _policy(), + DecisionResult( + DecisionOutcome.ABSTAIN, + "backend.policy.v1", + InferenceUsage(requests=0), + used_fallback=True, + ), + ) + + assert assessment.coverage is DecisionCoverage.UNADJUDICATED + assert assessment.verdict is DecisionVerdict.UNKNOWN + assert assessment.source is DecisionAssessmentSource.NONE + assert assessment.used_fallback is True + assert assessment.reason == "decision_model_fallback" + + +def test_abstention_is_unknown_without_claiming_content_was_judged() -> None: + assessment = assess_decision_result( + _policy(), + DecisionResult( + DecisionOutcome.ABSTAIN, + "backend.policy.v1", + InferenceUsage(requests=1), + rationale="not enough evidence", + ), + ) + + assert assessment.coverage is DecisionCoverage.UNADJUDICATED + assert assessment.verdict is DecisionVerdict.UNKNOWN + assert assessment.source is DecisionAssessmentSource.DECISION_MODEL + assert assessment.used_fallback is False + assert assessment.reason == "not enough evidence" + + +def test_substantive_decision_result_uses_policy_outcome_mapping() -> None: + yes = assess_decision_result( + _policy(), + DecisionResult( + DecisionOutcome.YES, + "backend.policy.v1", + InferenceUsage(requests=1, input_tokens=3, output_tokens=1), + confidence=0.8, + ), + ) + no = assess_decision_result( + _policy(), + DecisionResult(DecisionOutcome.NO, "backend.policy.v1", InferenceUsage(requests=1)), + ) + + assert yes.coverage is DecisionCoverage.ADJUDICATED + assert yes.verdict is DecisionVerdict.DENY + assert yes.confidence == 0.8 + assert yes.usage.input_tokens == 3 + assert no.coverage is DecisionCoverage.ADJUDICATED + assert no.verdict is DecisionVerdict.ALLOW + + +def test_assessment_rejects_fallback_that_claims_adjudicated_coverage() -> None: + with pytest.raises(ValidationError, match="fallback assessments must be unadjudicated"): + DecisionAssessment( + policy_id="memory.write.evidence_sufficiency.v1", + policy_version="1", + mode=DecisionPolicyMode.SHADOW, + coverage=DecisionCoverage.ADJUDICATED, + verdict=DecisionVerdict.ALLOW, + source=DecisionAssessmentSource.NONE, + used_fallback=True, + usage=InferenceUsage(requests=0), + ) + + +def test_observation_keeps_policy_assessment_distinct_from_domain_action() -> None: + assessment = assess_decision_result( + _policy(), + DecisionResult(DecisionOutcome.YES, "backend.policy.v1", InferenceUsage(requests=1)), + ) + + observation = DecisionObservation( + operation_id="remember:scope-a:42", + scope_id="scope-a", + consumer="memory_write_gate", + policy_id=assessment.policy_id, + policy_version=assessment.policy_version, + mode=assessment.mode, + subject_refs=("candidate:1",), + evidence_refs=("source:task:1",), + privacy_boundary=DecisionPrivacyBoundary.REFERENCES_ONLY, + assessment=assessment, + final_action="memory_write_hold", + ) + + assert observation.assessment.verdict is DecisionVerdict.DENY + assert observation.final_action == "memory_write_hold" + assert observation.evidence_refs == ("source:task:1",) diff --git a/tests/builtin/runtime/test_memory_write_gate_contract.py b/tests/builtin/runtime/test_memory_write_gate_contract.py index 04087ab462..f5e4c99eff 100644 --- a/tests/builtin/runtime/test_memory_write_gate_contract.py +++ b/tests/builtin/runtime/test_memory_write_gate_contract.py @@ -26,11 +26,13 @@ DecisionResult, FailOpenDecisionModel, ) +from powercontext.builtin.runtime.decision_policy import DecisionCoverage, DecisionVerdict from powercontext.builtin.runtime.memory_write_gate import ( DecisionMemoryWriteGate, MemoryWriteGateRequest, MemoryWriteRejectionCode, MemoryWriteVerdict, + _assess_memory_write_decision, _candidate_subject, build_memory_write_gate, ) @@ -262,6 +264,27 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_memory_write_decision_policy_keeps_fallback_unadjudicated() -> None: + assessment = _assess_memory_write_decision( + _verdict(DecisionOutcome.ABSTAIN, used_fallback=True), + hold_on=DecisionOutcome.YES, + ) + + assert assessment.coverage is DecisionCoverage.UNADJUDICATED + assert assessment.verdict is DecisionVerdict.UNKNOWN + assert assessment.used_fallback is True + + +def test_memory_write_decision_policy_uses_configured_hold_direction() -> None: + yes_holds = _assess_memory_write_decision(_verdict(DecisionOutcome.YES), hold_on=DecisionOutcome.YES) + no_holds = _assess_memory_write_decision(_verdict(DecisionOutcome.YES), hold_on=DecisionOutcome.NO) + + assert yes_holds.coverage is DecisionCoverage.ADJUDICATED + assert yes_holds.verdict is DecisionVerdict.DENY + assert no_holds.coverage is DecisionCoverage.ADJUDICATED + assert no_holds.verdict is DecisionVerdict.ALLOW + + def test_the_hold_direction_cannot_be_abstain() -> None: with pytest.raises(ValueError, match="hold direction"): DecisionMemoryWriteGate(_StaticDecisionModel(_verdict(DecisionOutcome.NO)), hold_on=DecisionOutcome.ABSTAIN) From ba7c0861e695f33cce03760d9ddacda8e47798c0 Mon Sep 17 00:00:00 2001 From: "Xin.Zh" Date: Sat, 10 Oct 2026 15:00:53 +0800 Subject: [PATCH 02/11] feat(runtime): govern Memory write-gate execution Apply the RFC 1770 policy mode, privacy-boundary, and observation rules to the existing Memory write gate. Default configuration now prevents content egress, while local-only evaluation keeps the established opt-in enforcement path. Constraint: no HTTP/OpenAPI contract, persistence schema, durable observation ledger, hosted sanitizer, or provider integration is added. Scope-risk: limited to Memory write-gate configuration, decisions, privacy-safe observation logs, and request attribution. Tested: uv run --no-sync pytest tests/builtin/runtime/test_memory_write_gate_contract.py -q (26 passed) Tested: uv run --no-sync pytest tests/builtin/runtime/test_memory_write_gate_contract.py tests/builtin/runtime/test_memory_write_gate_paths.py -q (46 passed) Tested: uv run --no-sync ruff check <11 changed files>; uv run --no-sync ruff format --check <11 changed files>; uv run --no-sync ty check --python-version 3.11 <11 changed files> Not-tested: a final Windows full-runtime retry was stopped after exceeding the prior 175s run time. The only earlier full-suite failure was reproduced against the exact b1d544b baseline in the spawned-worker diagnostics path, unrelated to these files. --- .env.example | 8 ++ .../builtin/artifacts/memory/protocols.py | 4 + .../builtin/artifacts/memory/service.py | 25 ++++ .../builtin/runtime/composition.py | 2 + src/powercontext/builtin/runtime/config.py | 5 + .../builtin/runtime/decision_policy.py | 73 ++++++++++ .../builtin/runtime/memory_write_gate.py | 128 ++++++++++++++++-- .../builtin/runtime/relational.py | 1 + tests/builtin/runtime/test_decision_config.py | 12 +- .../test_memory_write_gate_contract.py | 107 ++++++++++++++- .../runtime/test_memory_write_gate_paths.py | 36 ++++- 11 files changed, 386 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index f2766a4848..4c096ca8d3 100644 --- a/.env.example +++ b/.env.example @@ -109,6 +109,14 @@ POWERCONTEXT_SERVER_RUNTIME_DREAM_MAX_PENDING_PER_SCOPE=32 # Memory write gate: opt-in evidence-sufficiency check before a write commits. Disabled by # default; when unset no gate runs and no extra model call is made. # POWERCONTEXT_SERVER_RUNTIME_MEMORY_WRITE_GATE_ENABLED=true +# `disabled` does not construct a gate. `shadow` records an internal observation but always lets +# the Memory write proceed. `advisory` turns an insufficient-evidence result into FLAG, while +# `enforcing` preserves the visible ACCEPT/FLAG/HOLD outcomes. +# POWERCONTEXT_SERVER_RUNTIME_MEMORY_WRITE_GATE_MODE=shadow +# The default makes no decision-model call. Set `local_only` only for an in-process or loopback +# decision backend controlled by this deployment. `hosted_redacted` is rejected until a shared +# PowerContext content sanitizer is separately reviewed. +# POWERCONTEXT_SERVER_RUNTIME_MEMORY_WRITE_GATE_PRIVACY_BOUNDARY=local_only # Direction only (which verdict means the cited evidence is insufficient): "yes" or "no". # POWERCONTEXT_SERVER_RUNTIME_MEMORY_WRITE_GATE_HOLD_ON=yes # Optional confidence floor below which a hold becomes a written-but-annotated change. diff --git a/src/powercontext/builtin/artifacts/memory/protocols.py b/src/powercontext/builtin/artifacts/memory/protocols.py index 933425d961..5a2cd3d950 100644 --- a/src/powercontext/builtin/artifacts/memory/protocols.py +++ b/src/powercontext/builtin/artifacts/memory/protocols.py @@ -110,6 +110,10 @@ class MemoryWriteGateRequest: candidates: tuple[str, ...] evidence: tuple[str, ...] expected_revision: int | None = None + scope_id: str = "unscoped" + operation_id: str | None = None + subject_refs: tuple[str, ...] = () + evidence_refs: tuple[str, ...] = () class MemoryWriteGate(Protocol): diff --git a/src/powercontext/builtin/artifacts/memory/service.py b/src/powercontext/builtin/artifacts/memory/service.py index c77af62782..e058e1ff84 100644 --- a/src/powercontext/builtin/artifacts/memory/service.py +++ b/src/powercontext/builtin/artifacts/memory/service.py @@ -259,6 +259,7 @@ def __init__( artifact_resolver: _ArtifactResolver | None = None, id_factory: IdFactory | None = None, prompt_context: ScopedPrompts | None = None, + scope_id: str = "unscoped", write_gate: MemoryWriteGate | None = None, capacity_budget: MemoryCapacityBudget | None = None, compaction: MemoryCompactionPolicy | None = None, @@ -268,6 +269,7 @@ def __init__( self._prompt_context = prompt_context self._candidate_pipeline = candidate_pipeline self._write_gate = write_gate + self._scope_id = scope_id self._embedding_model = embedding_model if rerank_candidate_limit < 1: raise _InvalidMemoryOperationError("search-limit") @@ -1384,6 +1386,10 @@ async def _assess_write( candidates=tuple(candidate.text for candidate in candidates), evidence=projection.entries, expected_revision=None if base is None else base.revision, + scope_id=self._scope_id, + operation_id=_gate_operation_id(base), + subject_refs=_gate_subject_refs(candidates), + evidence_refs=tuple(_gate_evidence_ref(entry) for entry in projection.entries), ) ) except Exception: @@ -1839,6 +1845,25 @@ def _candidate_gate_identity(candidate_index: int, identity: str) -> str: return f"candidate:{candidate_index} {identity}" +def _gate_operation_id(base: Memory | None) -> str: + if base is None: + return "memory-write:new" + return f"memory-write:{base.artifact_id}@{base.revision + 1}" + + +def _gate_subject_refs(candidates: tuple[MemoryEntryInput, ...]) -> tuple[str, ...]: + return tuple( + f"candidate:{index}" + if candidate.entry is None + else f"entry:{candidate.entry.entry_id}@{candidate.entry.entry_version_id}" + for index, candidate in enumerate(candidates, start=1) + ) + + +def _gate_evidence_ref(value: str) -> str: + return value.partition("\n")[0] + + def _source_gate_content(source: Source, resolver: _SourceResolver | None) -> str | None: content = getattr(source, "content", None) if isinstance(content, str): diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 7b716e7e4f..2fadef001f 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -349,6 +349,8 @@ def _configured_memory_write_gate( enabled=True, hold_on=runtime.memory_write_gate_hold_on, threshold=runtime.memory_write_gate_threshold, + mode=runtime.memory_write_gate_mode, + privacy_boundary=runtime.memory_write_gate_privacy_boundary, ) if gate is None: log_safely( diff --git a/src/powercontext/builtin/runtime/config.py b/src/powercontext/builtin/runtime/config.py index 049057e7c2..7d69043c66 100644 --- a/src/powercontext/builtin/runtime/config.py +++ b/src/powercontext/builtin/runtime/config.py @@ -47,6 +47,7 @@ from powercontext.builtin.persistence.seekdb import SeekDBConfig from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime._scope_cache import DEFAULT_SCOPE_CACHE_SIZE +from powercontext.builtin.runtime.decision_policy import DecisionPolicyMode, DecisionPrivacyBoundary _HTTP_FIELD_NAME_PATTERN = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+") _RECALL_GATE_BASE_MIN_SEMANTIC_SIMILARITY = 0.3 @@ -143,6 +144,8 @@ def reject_boolean_worker_quota(cls, value: Any) -> Any: memory_rerank_candidate_limit: int = Field(default=30, ge=1, le=100) decision_assistance_enabled: bool = False memory_write_gate_enabled: bool = False + memory_write_gate_mode: DecisionPolicyMode = DecisionPolicyMode.ENFORCING + memory_write_gate_privacy_boundary: DecisionPrivacyBoundary = DecisionPrivacyBoundary.NO_EXTERNAL_CALL # Direction only: which verdict means "evidence is insufficient". The strength threshold # stays unset until a calibration probe establishes it, so a hold never depends on a made-up # number. @@ -159,6 +162,8 @@ def reject_boolean_worker_quota(cls, value: Any) -> Any: def validate_memory_capacity_order(self) -> RuntimeConfig: if self.memory_max_active_entries > self.memory_max_manifest_entries: raise ValueError("memory_max_active_entries cannot exceed memory_max_manifest_entries") # noqa: TRY003 + if self.memory_write_gate_privacy_boundary is DecisionPrivacyBoundary.HOSTED_REDACTED: + raise ValueError("memory_write_gate_privacy_boundary does not support hosted_redacted without a sanitizer") # noqa: TRY003 return self recall_gate_enabled: bool = False diff --git a/src/powercontext/builtin/runtime/decision_policy.py b/src/powercontext/builtin/runtime/decision_policy.py index fc7fea0f60..80ee02c818 100644 --- a/src/powercontext/builtin/runtime/decision_policy.py +++ b/src/powercontext/builtin/runtime/decision_policy.py @@ -16,15 +16,19 @@ from __future__ import annotations +import logging from collections.abc import Mapping from enum import StrEnum from typing import Annotated from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator +from powercontext._logging import log_safely from powercontext.builtin.inference import InferenceUsage from powercontext.builtin.runtime.decision_model import DecisionOutcome, DecisionResult +logger = logging.getLogger(__name__) + class DecisionPolicyMode(StrEnum): """Runtime mode for one versioned decision policy.""" @@ -200,6 +204,72 @@ def assess_decision_result( ) +def unadjudicated_decision_assessment( + policy: DecisionPolicy, + /, + *, + reason: str, + used_fallback: bool = True, +) -> DecisionAssessment: + """Record a no-call policy outcome without claiming the content was judged.""" + + return DecisionAssessment( + policy_id=policy.policy_id, + policy_version=policy.version, + mode=policy.mode, + coverage=DecisionCoverage.UNADJUDICATED, + verdict=DecisionVerdict.UNKNOWN, + source=DecisionAssessmentSource.NONE, + reason=reason, + used_fallback=used_fallback, + usage=InferenceUsage(requests=0), + ) + + +def assess_local_rule( + policy: DecisionPolicy, + /, + *, + verdict: DecisionVerdict, + reason: str, +) -> DecisionAssessment: + """Record a deterministic policy rule that substantively judged the content.""" + + return DecisionAssessment( + policy_id=policy.policy_id, + policy_version=policy.version, + mode=policy.mode, + coverage=DecisionCoverage.ADJUDICATED, + verdict=verdict, + source=DecisionAssessmentSource.LOCAL_RULE, + reason=reason, + usage=InferenceUsage(requests=0), + ) + + +def emit_decision_observation(observation: DecisionObservation, /) -> None: + """Log bounded policy outcome fields without turning logs into a content ledger.""" + + log_safely( + logger, + logging.INFO, + "Decision policy evaluated", + extra={ + "event": "decision.observation", + "consumer": observation.consumer, + "policy_id": observation.policy_id, + "policy_version": observation.policy_version, + "mode": observation.mode.value, + "coverage": observation.assessment.coverage.value, + "verdict": observation.assessment.verdict.value, + "final_action": observation.final_action, + "fallback_reason": observation.fallback_reason, + "candidate_count": observation.metadata.get("candidate_count", 0), + "evidence_count": observation.metadata.get("evidence_count", 0), + }, + ) + + def _normalized_outcome_mapping( policy: DecisionPolicy, override: Mapping[DecisionOutcome | str, DecisionVerdict | str] | None, @@ -232,4 +302,7 @@ def _bounded_reason(value: str | None, *, default: str | None = None) -> str | N "DecisionPrivacyBoundary", "DecisionVerdict", "assess_decision_result", + "assess_local_rule", + "emit_decision_observation", + "unadjudicated_decision_assessment", ] diff --git a/src/powercontext/builtin/runtime/memory_write_gate.py b/src/powercontext/builtin/runtime/memory_write_gate.py index 7a6ac13ca0..b2b05febcd 100644 --- a/src/powercontext/builtin/runtime/memory_write_gate.py +++ b/src/powercontext/builtin/runtime/memory_write_gate.py @@ -46,11 +46,15 @@ DecisionAssessment, DecisionCoverage, DecisionFailurePolicy, + DecisionObservation, DecisionPolicy, DecisionPolicyMode, DecisionPrivacyBoundary, DecisionVerdict, assess_decision_result, + assess_local_rule, + emit_decision_observation, + unadjudicated_decision_assessment, ) logger = logging.getLogger(__name__) @@ -72,7 +76,7 @@ consumer="memory_write_gate", mode=DecisionPolicyMode.ENFORCING, failure_policy=DecisionFailurePolicy.FAIL_OPEN, - privacy_boundary=DecisionPrivacyBoundary.HOSTED_REDACTED, + privacy_boundary=DecisionPrivacyBoundary.LOCAL_ONLY, local_rules=("candidate_batch_budget", "evidence_projection_budget"), question=_GATE_QUESTION, subject_selector="candidate_text", @@ -86,12 +90,24 @@ ) +def memory_write_policy( + *, + mode: DecisionPolicyMode = DecisionPolicyMode.ENFORCING, + privacy_boundary: DecisionPrivacyBoundary = DecisionPrivacyBoundary.LOCAL_ONLY, +) -> DecisionPolicy: + """Return the immutable Memory write policy with its configured runtime boundaries.""" + + return _MEMORY_WRITE_POLICY.model_copy(update={"mode": mode, "privacy_boundary": privacy_boundary}) + + def build_memory_write_gate( decision_model: DecisionModel | None, *, enabled: bool, hold_on: Literal["yes", "no"] = "yes", threshold: float | None = None, + mode: DecisionPolicyMode = DecisionPolicyMode.ENFORCING, + privacy_boundary: DecisionPrivacyBoundary = DecisionPrivacyBoundary.LOCAL_ONLY, ) -> MemoryWriteGate | None: """Build the opt-in gate, or return ``None`` while it stays disabled. @@ -99,9 +115,15 @@ def build_memory_write_gate( enabled; a disabled gate and a missing backend both resolve to ``None``. """ - if not enabled or decision_model is None: + if not enabled or mode is DecisionPolicyMode.DISABLED or decision_model is None: return None - return DecisionMemoryWriteGate(decision_model, hold_on=DecisionOutcome(hold_on), threshold=threshold) + return DecisionMemoryWriteGate( + decision_model, + hold_on=DecisionOutcome(hold_on), + threshold=threshold, + policy=memory_write_policy(mode=mode, privacy_boundary=privacy_boundary), + allow_model_evaluation=privacy_boundary is DecisionPrivacyBoundary.LOCAL_ONLY, + ) class DecisionMemoryWriteGate: @@ -121,24 +143,51 @@ def __init__( *, hold_on: DecisionOutcome = DecisionOutcome.YES, threshold: float | None = None, + policy: DecisionPolicy | None = None, + allow_model_evaluation: bool = True, ) -> None: if hold_on is DecisionOutcome.ABSTAIN: raise ValueError("the hold direction cannot be abstain") # noqa: TRY003 self._decision_model = decision_model self._hold_on = hold_on self._threshold = threshold + self._policy = memory_write_policy() if policy is None else policy + self._allow_model_evaluation = allow_model_evaluation self.policy_id = decision_model.policy_id + @property + def mode(self) -> DecisionPolicyMode: + """Expose the configured mode for composition and focused diagnostics.""" + + return self._policy.mode + async def assess(self, request: MemoryWriteGateRequest, /) -> MemoryWriteAssessment: """Judge one pending write and return a caller-visible verdict.""" if _subject_exceeds_limit(request.candidates): - assessment = MemoryWriteAssessment( + policy_assessment = assess_local_rule( + self._policy, + verdict=DecisionVerdict.DENY, + reason="candidate_batch_budget", + ) + held = MemoryWriteAssessment( verdict=MemoryWriteVerdict.HOLD, policy_id=self.policy_id, code=_rejection_code(request), reason="the candidate batch exceeds the gate assessment budget", ) + assessment = self._mode_assessment(held) + self._observe(request, policy_assessment, assessment) + self._log(assessment) + return assessment + if not self._allow_model_evaluation: + policy_assessment = unadjudicated_decision_assessment(self._policy, reason="privacy_boundary") + assessment = MemoryWriteAssessment( + verdict=MemoryWriteVerdict.ACCEPT, + policy_id=self.policy_id, + used_fallback=True, + ) + self._observe(request, policy_assessment, assessment) self._log(assessment) return assessment decision = await self._decision_model.evaluate( @@ -150,29 +199,77 @@ async def assess(self, request: MemoryWriteGateRequest, /) -> MemoryWriteAssessm ) ) assessment = self._map(request, decision) + self._observe( + request, _assess_memory_write_decision(decision, hold_on=self._hold_on, policy=self._policy), assessment + ) self._log(assessment) return assessment def _map(self, request: MemoryWriteGateRequest, decision: DecisionResult) -> MemoryWriteAssessment: - assessment = _assess_memory_write_decision(decision, hold_on=self._hold_on) + assessment = _assess_memory_write_decision(decision, hold_on=self._hold_on, policy=self._policy) if assessment.coverage is DecisionCoverage.UNADJUDICATED: return MemoryWriteAssessment( verdict=MemoryWriteVerdict.ACCEPT, policy_id=decision.policy_id, used_fallback=assessment.used_fallback, ) + if self._policy.mode is DecisionPolicyMode.SHADOW: + return MemoryWriteAssessment( + verdict=MemoryWriteVerdict.ACCEPT, + policy_id=decision.policy_id, + used_fallback=assessment.used_fallback, + ) if assessment.verdict is DecisionVerdict.ALLOW: return MemoryWriteAssessment(verdict=MemoryWriteVerdict.ACCEPT, policy_id=decision.policy_id) reason = _bounded_reason(decision.rationale) if self._threshold is not None and decision.confidence is not None and decision.confidence < self._threshold: return MemoryWriteAssessment(verdict=MemoryWriteVerdict.FLAG, policy_id=decision.policy_id, reason=reason) - return MemoryWriteAssessment( - verdict=MemoryWriteVerdict.HOLD, - policy_id=decision.policy_id, - code=_rejection_code(request), - reason=reason, + return self._mode_assessment( + MemoryWriteAssessment( + verdict=MemoryWriteVerdict.HOLD, + policy_id=decision.policy_id, + code=_rejection_code(request), + reason=reason, + ) ) + def _mode_assessment(self, assessment: MemoryWriteAssessment) -> MemoryWriteAssessment: + if self._policy.mode is DecisionPolicyMode.SHADOW: + return MemoryWriteAssessment(verdict=MemoryWriteVerdict.ACCEPT, policy_id=assessment.policy_id) + if self._policy.mode is DecisionPolicyMode.ADVISORY and assessment.verdict is MemoryWriteVerdict.HOLD: + return MemoryWriteAssessment( + verdict=MemoryWriteVerdict.FLAG, + policy_id=assessment.policy_id, + reason=assessment.reason, + ) + return assessment + + def _observe( + self, + request: MemoryWriteGateRequest, + policy_assessment: DecisionAssessment, + assessment: MemoryWriteAssessment, + ) -> None: + observation = DecisionObservation( + operation_id=request.operation_id or f"memory-write:{request.expected_revision or 'new'}", + scope_id=request.scope_id, + consumer=self._policy.consumer, + policy_id=self._policy.policy_id, + policy_version=self._policy.version, + mode=self._policy.mode, + subject_refs=request.subject_refs + or tuple(f"candidate:{index}" for index in range(1, len(request.candidates) + 1)), + evidence_refs=request.evidence_refs + or tuple(f"evidence:{index}" for index in range(1, len(request.evidence) + 1)), + privacy_boundary=self._policy.privacy_boundary, + model_policy_id=None if policy_assessment.source.value == "none" else self.policy_id, + assessment=policy_assessment, + final_action=f"memory_write_{assessment.verdict.value}", + fallback_reason=policy_assessment.reason if policy_assessment.used_fallback else None, + metadata={"candidate_count": len(request.candidates), "evidence_count": len(request.evidence)}, + ) + emit_decision_observation(observation) + def _log(self, assessment: MemoryWriteAssessment) -> None: event = { MemoryWriteVerdict.HOLD: "memory.write-gate.hold", @@ -201,12 +298,18 @@ def _rejection_code(request: MemoryWriteGateRequest) -> MemoryWriteRejectionCode return MemoryWriteRejectionCode.INSUFFICIENT_COVERAGE -def _assess_memory_write_decision(decision: DecisionResult, /, *, hold_on: DecisionOutcome) -> DecisionAssessment: +def _assess_memory_write_decision( + decision: DecisionResult, + /, + *, + hold_on: DecisionOutcome, + policy: DecisionPolicy = _MEMORY_WRITE_POLICY, +) -> DecisionAssessment: if hold_on is DecisionOutcome.ABSTAIN: raise ValueError("the hold direction cannot be abstain") # noqa: TRY003 pass_on = DecisionOutcome.NO if hold_on is DecisionOutcome.YES else DecisionOutcome.YES return assess_decision_result( - _MEMORY_WRITE_POLICY, + policy, decision, outcome_mapping={ hold_on: DecisionVerdict.DENY, @@ -248,4 +351,5 @@ def _bounded_reason(value: str | None) -> str: "MemoryWriteVerdict", "_assess_memory_write_decision", "build_memory_write_gate", + "memory_write_policy", ] diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 0ad5fb2fe7..9df126df31 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -353,6 +353,7 @@ def memory( ) -> MemoryService: return MemoryService( prompt_context=ScopedPrompts(self.prompts, self.scope_id), + scope_id=self.scope_id, backend=RelationalMemoryBackend( database=self.database, scope_id=self.scope_id, diff --git a/tests/builtin/runtime/test_decision_config.py b/tests/builtin/runtime/test_decision_config.py index fd15467073..6bb04a5dd9 100644 --- a/tests/builtin/runtime/test_decision_config.py +++ b/tests/builtin/runtime/test_decision_config.py @@ -14,19 +14,29 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast import pytest from pydantic import AnyHttpUrl, SecretStr, ValidationError from powercontext.builtin.runtime import RuntimeConfig from powercontext.builtin.runtime.config import InferenceConfig +from powercontext.builtin.runtime.decision_policy import DecisionPrivacyBoundary def test_decision_assistance_is_disabled_by_default() -> None: assert RuntimeConfig().decision_assistance_enabled is False +def test_memory_write_gate_defaults_to_no_external_call() -> None: + assert RuntimeConfig().memory_write_gate_privacy_boundary is DecisionPrivacyBoundary.NO_EXTERNAL_CALL + + +def test_memory_write_gate_rejects_hosted_redaction_without_a_sanitizer() -> None: + with pytest.raises(ValidationError, match="hosted_redacted"): + RuntimeConfig(memory_write_gate_privacy_boundary=cast(DecisionPrivacyBoundary, "hosted_redacted")) + + def test_decision_inference_defaults_are_unset() -> None: config = InferenceConfig() diff --git a/tests/builtin/runtime/test_memory_write_gate_contract.py b/tests/builtin/runtime/test_memory_write_gate_contract.py index f5e4c99eff..60c57f0491 100644 --- a/tests/builtin/runtime/test_memory_write_gate_contract.py +++ b/tests/builtin/runtime/test_memory_write_gate_contract.py @@ -15,6 +15,7 @@ from __future__ import annotations import asyncio +import logging import pytest @@ -26,7 +27,12 @@ DecisionResult, FailOpenDecisionModel, ) -from powercontext.builtin.runtime.decision_policy import DecisionCoverage, DecisionVerdict +from powercontext.builtin.runtime.decision_policy import ( + DecisionCoverage, + DecisionPolicyMode, + DecisionPrivacyBoundary, + DecisionVerdict, +) from powercontext.builtin.runtime.memory_write_gate import ( DecisionMemoryWriteGate, MemoryWriteGateRequest, @@ -35,6 +41,7 @@ _assess_memory_write_decision, _candidate_subject, build_memory_write_gate, + memory_write_policy, ) # A known-answer pair used to orient the hold direction. A durable preference should pass an @@ -204,6 +211,41 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_shadow_mode_passes_an_oversized_candidate_batch_without_calling_the_backend() -> None: + async def scenario() -> None: + backend = _RecordingDecisionModel(_verdict(DecisionOutcome.YES)) + gate = DecisionMemoryWriteGate( + backend, + policy=memory_write_policy(mode=DecisionPolicyMode.SHADOW), + ) + + assessment = await gate.assess( + _request(candidates=("x" * 4000, "UNASSESSED_TAIL"), evidence=("source:task:1",)) + ) + + assert assessment.verdict is MemoryWriteVerdict.ACCEPT + assert assessment.used_fallback is False + assert backend.requests == [] + + asyncio.run(scenario()) + + +def test_advisory_mode_flags_an_insufficient_write_without_holding_it() -> None: + async def scenario() -> None: + gate = DecisionMemoryWriteGate( + _StaticDecisionModel(_verdict(DecisionOutcome.YES, rationale="evidence is insufficient")), + policy=memory_write_policy(mode=DecisionPolicyMode.ADVISORY), + ) + + assessment = await gate.assess(_request(evidence=("source:task:1",))) + + assert assessment.verdict is MemoryWriteVerdict.FLAG + assert assessment.reason == "evidence is insufficient" + assert assessment.code is None + + asyncio.run(scenario()) + + def test_a_low_confidence_hold_is_written_but_flagged() -> None: async def scenario() -> None: gate = DecisionMemoryWriteGate( @@ -285,6 +327,28 @@ def test_memory_write_decision_policy_uses_configured_hold_direction() -> None: assert no_holds.verdict is DecisionVerdict.ALLOW +def test_gate_emits_policy_observation_without_raw_subject_or_evidence(caplog: pytest.LogCaptureFixture) -> None: + async def scenario() -> None: + gate = DecisionMemoryWriteGate( + _StaticDecisionModel(_verdict(DecisionOutcome.YES)), + policy=memory_write_policy(mode=DecisionPolicyMode.SHADOW), + ) + + assessment = await gate.assess(_request(candidates=("secret claim",), evidence=("secret evidence",))) + + assert assessment.verdict is MemoryWriteVerdict.ACCEPT + + caplog.set_level(logging.INFO, logger="powercontext.builtin.runtime.decision_policy") + asyncio.run(scenario()) + + record = next(record for record in caplog.records if getattr(record, "event", None) == "decision.observation") + assert record.__dict__["policy_id"] == "memory.write.evidence_sufficiency.v1" + assert record.__dict__["mode"] == "shadow" + assert record.__dict__["coverage"] == "adjudicated" + assert "secret claim" not in str(record.__dict__) + assert "secret evidence" not in str(record.__dict__) + + def test_the_hold_direction_cannot_be_abstain() -> None: with pytest.raises(ValueError, match="hold direction"): DecisionMemoryWriteGate(_StaticDecisionModel(_verdict(DecisionOutcome.NO)), hold_on=DecisionOutcome.ABSTAIN) @@ -341,10 +405,51 @@ def test_the_gate_is_built_from_configuration() -> None: enabled=True, hold_on="no", threshold=0.4, + mode=DecisionPolicyMode.SHADOW, + privacy_boundary=DecisionPrivacyBoundary.LOCAL_ONLY, ) assert isinstance(gate, DecisionMemoryWriteGate) assert gate.policy_id == "powercontext.decision.static.v1" + assert gate.mode is DecisionPolicyMode.SHADOW + + +def test_disabled_mode_does_not_construct_a_memory_write_gate() -> None: + gate = build_memory_write_gate( + _StaticDecisionModel(_verdict(DecisionOutcome.YES)), + enabled=True, + mode=DecisionPolicyMode.DISABLED, + privacy_boundary=DecisionPrivacyBoundary.LOCAL_ONLY, + ) + + assert gate is None + + +def test_no_external_call_boundary_skips_the_backend_and_emits_an_unadjudicated_observation( + caplog: pytest.LogCaptureFixture, +) -> None: + async def scenario() -> None: + backend = _RecordingDecisionModel(_verdict(DecisionOutcome.YES)) + gate = build_memory_write_gate( + backend, + enabled=True, + privacy_boundary=DecisionPrivacyBoundary.NO_EXTERNAL_CALL, + ) + + assert isinstance(gate, DecisionMemoryWriteGate) + assessment = await gate.assess(_request(evidence=("source:task:1",))) + + assert assessment.verdict is MemoryWriteVerdict.ACCEPT + assert assessment.used_fallback is True + assert backend.requests == [] + + caplog.set_level(logging.INFO, logger="powercontext.builtin.runtime.decision_policy") + asyncio.run(scenario()) + + record = next(record for record in caplog.records if getattr(record, "event", None) == "decision.observation") + assert record.__dict__["coverage"] == "unadjudicated" + assert record.__dict__["verdict"] == "unknown" + assert record.__dict__["fallback_reason"] == "privacy_boundary" def _request( diff --git a/tests/builtin/runtime/test_memory_write_gate_paths.py b/tests/builtin/runtime/test_memory_write_gate_paths.py index dd87334cea..634d2dda8a 100644 --- a/tests/builtin/runtime/test_memory_write_gate_paths.py +++ b/tests/builtin/runtime/test_memory_write_gate_paths.py @@ -52,6 +52,7 @@ DecisionResult, FailOpenDecisionModel, ) +from powercontext.builtin.runtime.decision_policy import DecisionPrivacyBoundary from powercontext.builtin.runtime.memory_write_gate import DecisionMemoryWriteGate from powercontext.builtin.scope import ScopeDraft from powercontext.builtin.sources import ContentCapture, ContentSource @@ -244,7 +245,14 @@ async def scenario() -> None: def test_config_enables_the_gate_over_the_decision_backend(tmp_path: Path) -> None: async def scenario() -> None: - config = _config(tmp_path, RuntimeConfig(memory_write_gate_enabled=True), database="enabled.db") + config = _config( + tmp_path, + RuntimeConfig( + memory_write_gate_enabled=True, + memory_write_gate_privacy_boundary=DecisionPrivacyBoundary.LOCAL_ONLY, + ), + database="enabled.db", + ) async with open_builtin_runtime(config, decision_model=_InsufficientDecisionModel()) as runtime: scope_id = await _create_scope(runtime, "gate-config-enabled") with pytest.raises(MemoryWriteRejectedError) as error: @@ -525,6 +533,32 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_gate_request_carries_scope_operation_and_content_free_references(tmp_path: Path) -> None: + async def scenario() -> None: + gate = _ScriptedGate(_assessment(MemoryWriteVerdict.ACCEPT)) + async with open_builtin_contexts(_config(tmp_path), memory_write_gate=gate) as contexts: + context = await contexts.get("project") + source, _ = await context.sources.capture( + ContentCapture(source_id="requirements", content="The deployment requires MySQL 8.") + ) + + await context.artifacts.memory.plan_remember( + memory=None, + sources=(source,), + entries=(MemoryEntryInput(kind="fact", text="Use MySQL 8.", sources=(source,)),), + mode="append", + ) + + request = gate.requests[-1] + assert request.scope_id == "project" + assert request.operation_id == "memory-write:new" + assert request.subject_refs == ("candidate:1",) + assert request.evidence_refs == ("candidate:1 source:content:requirements",) + assert "MySQL 8" not in "\n".join(request.evidence_refs) + + asyncio.run(scenario()) + + def test_explicit_artifact_family_is_not_recovered_as_a_different_allowed_family(tmp_path: Path) -> None: async def scenario() -> None: async with open_builtin_contexts(_config(tmp_path)) as contexts: From 08133ca4ee057d4d128909b20aafd8ac47adba6d Mon Sep 17 00:00:00 2001 From: "Xin.Zh" Date: Sat, 10 Oct 2026 20:07:22 +0800 Subject: [PATCH 03/11] feat(runtime): persist decision observations Persist a Scope-isolated, reference-only observation ledger for Memory write-gate decisions. Sidecar failures do not alter gate outcomes. Constraint: observations exclude candidate content, evidence bodies, model rationale, and exception messages. Scope-risk: retention prunes expired observations only within the Scope receiving a write. Tested: uv run --no-sync pytest tests/builtin/persistence/test_decision_observation_repository.py tests/builtin/runtime/test_decision_config.py tests/builtin/runtime/test_memory_write_gate_contract.py tests/builtin/runtime/test_memory_write_gate_paths.py; uv run --no-sync ruff check <16 changed files>; uv run --no-sync ruff format --check <16 changed files>; uv run --no-sync ty check --python-version 3.11 <16 changed files>. --- .../builtin/artifacts/memory/protocols.py | 15 +- .../builtin/artifacts/memory/service.py | 4 + .../builtin/decision_observations.py | 159 ++++++++++++ .../builtin/persistence/__init__.py | 2 + .../persistence/decision_observations.py | 170 +++++++++++++ .../builtin/persistence/tables.py | 42 ++++ .../builtin/runtime/composition.py | 2 + src/powercontext/builtin/runtime/config.py | 1 + .../runtime/decision_observation_ledger.py | 54 +++++ .../builtin/runtime/decision_policy.py | 130 +--------- .../builtin/runtime/memory_write_gate.py | 48 +++- .../builtin/runtime/relational.py | 13 + .../test_decision_observation_repository.py | 228 ++++++++++++++++++ tests/builtin/runtime/test_decision_config.py | 11 + .../test_memory_write_gate_contract.py | 76 ++++++ .../runtime/test_memory_write_gate_paths.py | 39 +++ 16 files changed, 868 insertions(+), 126 deletions(-) create mode 100644 src/powercontext/builtin/decision_observations.py create mode 100644 src/powercontext/builtin/persistence/decision_observations.py create mode 100644 src/powercontext/builtin/runtime/decision_observation_ledger.py create mode 100644 tests/builtin/persistence/test_decision_observation_repository.py diff --git a/src/powercontext/builtin/artifacts/memory/protocols.py b/src/powercontext/builtin/artifacts/memory/protocols.py index 5a2cd3d950..74fab76b17 100644 --- a/src/powercontext/builtin/artifacts/memory/protocols.py +++ b/src/powercontext/builtin/artifacts/memory/protocols.py @@ -19,7 +19,7 @@ from contextlib import AbstractAsyncContextManager from dataclasses import dataclass from enum import StrEnum -from typing import Protocol +from typing import TYPE_CHECKING, Protocol from pydantic import BaseModel, ConfigDict @@ -39,6 +39,18 @@ from powercontext.builtin.tags import TagFilter from powercontext.sources import Source +if TYPE_CHECKING: + from powercontext.builtin.decision_observations import DecisionObservation + + +class MemoryWriteObservationSink(Protocol): + """Accept a bounded decision sidecar without owning the Memory write.""" + + async def record(self, observation: DecisionObservation, /) -> None: + """Store one decision observation after the gate has made its judgement.""" + + ... + class MemoryCandidateRequest(BaseModel): """Canonical evidence and bounded current entries offered to a pipeline.""" @@ -114,6 +126,7 @@ class MemoryWriteGateRequest: operation_id: str | None = None subject_refs: tuple[str, ...] = () evidence_refs: tuple[str, ...] = () + observation_sink: MemoryWriteObservationSink | None = None class MemoryWriteGate(Protocol): diff --git a/src/powercontext/builtin/artifacts/memory/service.py b/src/powercontext/builtin/artifacts/memory/service.py index e058e1ff84..bf795d2ac7 100644 --- a/src/powercontext/builtin/artifacts/memory/service.py +++ b/src/powercontext/builtin/artifacts/memory/service.py @@ -93,6 +93,7 @@ MemoryWriteAssessment, MemoryWriteGate, MemoryWriteGateRequest, + MemoryWriteObservationSink, MemoryWritePlan, MemoryWriteRejectionCode, MemoryWriteVerdict, @@ -261,6 +262,7 @@ def __init__( prompt_context: ScopedPrompts | None = None, scope_id: str = "unscoped", write_gate: MemoryWriteGate | None = None, + write_gate_observation_sink: MemoryWriteObservationSink | None = None, capacity_budget: MemoryCapacityBudget | None = None, compaction: MemoryCompactionPolicy | None = None, max_history_revisions: int = 100, @@ -269,6 +271,7 @@ def __init__( self._prompt_context = prompt_context self._candidate_pipeline = candidate_pipeline self._write_gate = write_gate + self._write_gate_observation_sink = write_gate_observation_sink self._scope_id = scope_id self._embedding_model = embedding_model if rerank_candidate_limit < 1: @@ -1390,6 +1393,7 @@ async def _assess_write( operation_id=_gate_operation_id(base), subject_refs=_gate_subject_refs(candidates), evidence_refs=tuple(_gate_evidence_ref(entry) for entry in projection.entries), + observation_sink=self._write_gate_observation_sink, ) ) except Exception: diff --git a/src/powercontext/builtin/decision_observations.py b/src/powercontext/builtin/decision_observations.py new file mode 100644 index 0000000000..8497849713 --- /dev/null +++ b/src/powercontext/builtin/decision_observations.py @@ -0,0 +1,159 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Runtime-independent contracts for bounded decision-policy sidecars.""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import UTC, datetime +from enum import StrEnum +from typing import Annotated +from uuid import uuid4 + +from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator + +from powercontext.builtin.inference import InferenceUsage + + +class DecisionPolicyMode(StrEnum): + """Runtime mode for one versioned decision policy.""" + + DISABLED = "disabled" + SHADOW = "shadow" + ADVISORY = "advisory" + ENFORCING = "enforcing" + + +class DecisionFailurePolicy(StrEnum): + """How a consumer treats backend failure when it owns a domain action.""" + + FAIL_OPEN = "fail_open" + FAIL_CLOSED = "fail_closed" + + +class DecisionPrivacyBoundary(StrEnum): + """Content boundary declared by a policy before backend calls.""" + + LOCAL_ONLY = "local_only" + HOSTED_REDACTED = "hosted_redacted" + REFERENCES_ONLY = "references_only" + NO_EXTERNAL_CALL = "no_external_call" + + +class DecisionCoverage(StrEnum): + """Whether a policy evaluation actually judged the supplied content.""" + + ADJUDICATED = "adjudicated" + UNADJUDICATED = "unadjudicated" + + +class DecisionVerdict(StrEnum): + """Domain-neutral verdict before an owning service maps it to an action.""" + + ALLOW = "allow" + DENY = "deny" + REVIEW = "review" + UNKNOWN = "unknown" + + +class DecisionAssessmentSource(StrEnum): + """The source that produced the assessment's substantive judgement.""" + + LOCAL_RULE = "local_rule" + DECISION_MODEL = "decision_model" + NONE = "none" + + +class _StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class DecisionPolicy(_StrictModel): + """Versioned, reviewable policy manifest for one bounded runtime question.""" + + policy_id: Annotated[str, Field(min_length=1, max_length=256)] + decision_kind: Annotated[str, Field(min_length=1, max_length=128)] + version: Annotated[str, Field(min_length=1, max_length=64)] + consumer: Annotated[str, Field(min_length=1, max_length=128)] + mode: DecisionPolicyMode + failure_policy: DecisionFailurePolicy + privacy_boundary: DecisionPrivacyBoundary + local_rules: tuple[Annotated[str, Field(min_length=1, max_length=128)], ...] = () + question: Annotated[str, Field(min_length=1, max_length=8192)] + subject_selector: Annotated[str, Field(min_length=1, max_length=512)] + evidence_selector: Annotated[str, Field(min_length=1, max_length=512)] + outcome_mapping: Mapping[str, Annotated[str, Field(min_length=1, max_length=128)]] = Field(default_factory=dict) + promotion_criteria: tuple[Annotated[str, Field(min_length=1, max_length=512)], ...] = () + + +class DecisionAssessment(_StrictModel): + """Policy-level assessment before domain-specific action mapping.""" + + policy_id: Annotated[str, Field(min_length=1, max_length=256)] + policy_version: Annotated[str, Field(min_length=1, max_length=64)] + mode: DecisionPolicyMode + coverage: DecisionCoverage + verdict: DecisionVerdict + source: DecisionAssessmentSource + reason: Annotated[str | None, Field(min_length=1, max_length=4096)] = None + confidence: Annotated[float | None, Field(ge=0.0, le=1.0)] = None + used_fallback: bool = False + usage: InferenceUsage = Field(default_factory=lambda: InferenceUsage(requests=0)) + latency_ms: Annotated[float | None, Field(ge=0.0, allow_inf_nan=False)] = None + + @model_validator(mode="after") + def validate_fallback_coverage(self) -> DecisionAssessment: + if self.used_fallback and self.coverage is not DecisionCoverage.UNADJUDICATED: + raise ValueError("fallback assessments must be unadjudicated") # noqa: TRY003 + return self + + +class DecisionObservation(_StrictModel): + """Audit/replay sidecar for one policy evaluation attempt.""" + + observation_id: Annotated[str, Field(min_length=1, max_length=128)] = Field( + default_factory=lambda: f"decision-observation-{uuid4().hex}" + ) + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + operation_id: Annotated[str, Field(min_length=1, max_length=256)] + scope_id: Annotated[str, Field(min_length=1, max_length=256)] + consumer: Annotated[str, Field(min_length=1, max_length=128)] + policy_id: Annotated[str, Field(min_length=1, max_length=256)] + policy_version: Annotated[str, Field(min_length=1, max_length=64)] + mode: DecisionPolicyMode + subject_refs: tuple[Annotated[str, Field(min_length=1, max_length=512)], ...] = () + evidence_refs: tuple[Annotated[str, Field(min_length=1, max_length=512)], ...] = () + privacy_boundary: DecisionPrivacyBoundary + privacy_outcome: Annotated[str | None, Field(min_length=1, max_length=128)] = None + provider_id: Annotated[str | None, Field(min_length=1, max_length=128)] = None + backend_model_id: Annotated[str | None, Field(min_length=1, max_length=256)] = None + model_policy_id: Annotated[str | None, Field(min_length=1, max_length=256)] = None + assessment: DecisionAssessment + final_action: Annotated[str, Field(min_length=1, max_length=128)] + fallback_reason: Annotated[str | None, Field(min_length=1, max_length=256)] = None + metadata: Mapping[str, JsonValue] = Field(default_factory=dict) + + +__all__ = [ + "DecisionAssessment", + "DecisionAssessmentSource", + "DecisionCoverage", + "DecisionFailurePolicy", + "DecisionObservation", + "DecisionPolicy", + "DecisionPolicyMode", + "DecisionPrivacyBoundary", + "DecisionVerdict", +] diff --git a/src/powercontext/builtin/persistence/__init__.py b/src/powercontext/builtin/persistence/__init__.py index 0e36bfe664..cfefdfd7b3 100644 --- a/src/powercontext/builtin/persistence/__init__.py +++ b/src/powercontext/builtin/persistence/__init__.py @@ -22,6 +22,7 @@ from powercontext.builtin.persistence.candidates import CandidateRepository from powercontext.builtin.persistence.connectors import ConnectorCheckpointRepository from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.decision_observations import DecisionObservationRepository from powercontext.builtin.persistence.errors import ( ArtifactProcessingLeadershipLostError, ArtifactProcessingWaveIncompleteError, @@ -78,6 +79,7 @@ "CompositeTopicMemoryIndex", "ConnectorCheckpointRepository", "DatabaseClosedError", + "DecisionObservationRepository", "ExternalSkillRepository", "GenerationConflictError", "IdentityMismatchError", diff --git a/src/powercontext/builtin/persistence/decision_observations.py b/src/powercontext/builtin/persistence/decision_observations.py new file mode 100644 index 0000000000..560ad15438 --- /dev/null +++ b/src/powercontext/builtin/persistence/decision_observations.py @@ -0,0 +1,170 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Scope-private persistence for bounded RFC 1770 decision observations.""" + +from __future__ import annotations + +import re +from datetime import datetime +from typing import Any + +from sqlalchemy import delete, insert, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncConnection + +from powercontext.builtin.decision_observations import DecisionObservation +from powercontext.builtin.persistence.codec import dump_model, load_model, stored_bytes +from powercontext.builtin.persistence.errors import StoredPayloadConflictError +from powercontext.builtin.persistence.tables import DECISION_OBSERVATIONS_TABLE +from powercontext.builtin.sources import validate_scope_id + +_KIND = "decision-observation" +_SAFE_REASON = re.compile(r"[a-z][a-z0-9_]{0,127}\Z") +_SAFE_REFERENCE = re.compile( + r"(?:candidate|evidence):[0-9]+|(?:candidate:[0-9]+ )?(?:entry|source|artifact):[A-Za-z0-9_.:@-]{1,500}\Z" +) +_SAFE_METADATA_KEYS = frozenset({"candidate_count", "evidence_count"}) + + +class DecisionObservationRepository: + """Append and read bounded decision sidecars without retaining input bodies.""" + + async def append(self, connection: AsyncConnection, observation: DecisionObservation, /) -> bool: + """Append an observation, accepting only an exact idempotent replay.""" + + _validate_observation(observation) + scope = validate_scope_id(observation.scope_id) + values = _values(observation, scope) + try: + await _ensure_sqlite_outer_transaction(connection) + async with connection.begin_nested(): + await connection.execute(insert(DECISION_OBSERVATIONS_TABLE).values(**values)) + except IntegrityError: + existing = ( + await connection.execute( + select(DECISION_OBSERVATIONS_TABLE.c.payload).where( + DECISION_OBSERVATIONS_TABLE.c.scope_id == scope, + DECISION_OBSERVATIONS_TABLE.c.observation_id == observation.observation_id, + ) + ) + ).scalar_one_or_none() + if existing is None: + raise + if stored_bytes(existing, column="payload") != values["payload"]: + raise StoredPayloadConflictError(_KIND, (scope, observation.observation_id)) from None + return False + return True + + async def observations(self, connection: AsyncConnection, scope_id: str, /) -> tuple[DecisionObservation, ...]: + """Read one Scope's sidecars in immutable creation order.""" + + scope = validate_scope_id(scope_id) + rows = ( + await connection.execute( + select(DECISION_OBSERVATIONS_TABLE) + .where(DECISION_OBSERVATIONS_TABLE.c.scope_id == scope) + .order_by( + DECISION_OBSERVATIONS_TABLE.c.created_at, + DECISION_OBSERVATIONS_TABLE.c.observation_id, + ) + ) + ).mappings() + return tuple(_decode(row) for row in rows) + + async def prune_before(self, connection: AsyncConnection, scope_id: str, before: datetime, /) -> int: + """Remove only this Scope's sidecars older than its explicit retention boundary.""" + + scope = validate_scope_id(scope_id) + result = await connection.execute( + delete(DECISION_OBSERVATIONS_TABLE).where( + DECISION_OBSERVATIONS_TABLE.c.scope_id == scope, + DECISION_OBSERVATIONS_TABLE.c.created_at < before, + ) + ) + return int(result.rowcount or 0) + + +def _values(observation: DecisionObservation, scope_id: str) -> dict[str, object]: + assessment = observation.assessment + usage = assessment.usage + return { + "scope_id": scope_id, + "observation_id": observation.observation_id, + "operation_id": observation.operation_id, + "created_at": observation.created_at, + "consumer": observation.consumer, + "policy_id": observation.policy_id, + "policy_version": observation.policy_version, + "mode": observation.mode.value, + "privacy_boundary": observation.privacy_boundary.value, + "coverage": assessment.coverage.value, + "verdict": assessment.verdict.value, + "assessment_source": assessment.source.value, + "final_action": observation.final_action, + "used_fallback": assessment.used_fallback, + "fallback_reason": observation.fallback_reason, + "provider_id": observation.provider_id, + "backend_model_id": observation.backend_model_id, + "model_policy_id": observation.model_policy_id, + "requests": usage.requests, + "input_tokens": usage.input_tokens, + "output_tokens": usage.output_tokens, + "latency_ms": assessment.latency_ms, + "payload": dump_model(observation, kind=_KIND, name=observation.observation_id), + } + + +def _decode(row: Any) -> DecisionObservation: + identifier = str(row["observation_id"]) + return load_model( + DecisionObservation, + stored_bytes(row["payload"], column="payload"), + kind=_KIND, + name=identifier, + ) + + +def _validate_observation(observation: DecisionObservation, /) -> None: + assessment = observation.assessment + if ( + assessment.policy_id != observation.policy_id + or assessment.policy_version != observation.policy_version + or assessment.mode is not observation.mode + ): + raise ValueError("decision observation assessment policy must match the observation") # noqa: TRY003 + if not all(_SAFE_REFERENCE.fullmatch(value) for value in (*observation.subject_refs, *observation.evidence_refs)): + raise ValueError("decision observation references must be structured identifiers") # noqa: TRY003 + for reason in (observation.assessment.reason, observation.fallback_reason): + if reason is not None and _SAFE_REASON.fullmatch(reason) is None: + raise ValueError("decision observation reasons must be bounded codes") # noqa: TRY003 + if set(observation.metadata) - _SAFE_METADATA_KEYS or any( + isinstance(value, bool) or not isinstance(value, int) or value < 0 for value in observation.metadata.values() + ): + raise ValueError("decision observation metadata must contain only nonnegative safe counters") # noqa: TRY003 + + +async def _ensure_sqlite_outer_transaction(connection: AsyncConnection, /) -> None: + if connection.dialect.name != "sqlite" or not connection.in_transaction() or connection.in_nested_transaction(): + return + raw = await connection.get_raw_connection() + driver = getattr(raw, "driver_connection", None) + in_transaction = getattr(driver, "in_transaction", None) + if in_transaction is None: + in_transaction = getattr(getattr(driver, "_conn", None), "in_transaction", None) + if in_transaction is False: + await connection.exec_driver_sql("BEGIN") + + +__all__ = ["DecisionObservationRepository"] diff --git a/src/powercontext/builtin/persistence/tables.py b/src/powercontext/builtin/persistence/tables.py index e38d0d099f..2744028aaf 100644 --- a/src/powercontext/builtin/persistence/tables.py +++ b/src/powercontext/builtin/persistence/tables.py @@ -21,6 +21,7 @@ Column, Date, DateTime, + Float, ForeignKeyConstraint, Index, Integer, @@ -1071,6 +1072,46 @@ def _entry_text_type(): STATISTICS_TABLES = (MODEL_USAGE_DAILY_TABLE, RECALL_TOKEN_DAILY_TABLE) +DECISION_OBSERVATIONS_TABLE = Table( + "pc_decision_observations", + SHARED_METADATA, + Column("scope_id", identity_string(MAX_SCOPE_ID_LENGTH), primary_key=True), + Column("observation_id", identity_string(128), primary_key=True), + Column("operation_id", identity_string(256), nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False), + Column("consumer", identity_string(128), nullable=False), + Column("policy_id", identity_string(256), nullable=False), + Column("policy_version", identity_string(64), nullable=False), + Column("mode", identity_string(16), nullable=False), + Column("privacy_boundary", identity_string(32), nullable=False), + Column("coverage", identity_string(16), nullable=False), + Column("verdict", identity_string(16), nullable=False), + Column("assessment_source", identity_string(32), nullable=False), + Column("final_action", identity_string(128), nullable=False), + Column("used_fallback", Boolean, nullable=False), + Column("fallback_reason", identity_string(128)), + Column("provider_id", identity_string(128)), + Column("backend_model_id", identity_string(256)), + Column("model_policy_id", identity_string(256)), + Column("requests", BigInteger, nullable=False), + Column("input_tokens", BigInteger), + Column("output_tokens", BigInteger), + Column("latency_ms", Float), + Column("payload", _canonical_payload_type(), nullable=False), + Index("ix_pc_decision_observations_scope_created", "scope_id", "created_at", "observation_id"), + Index("ix_pc_decision_observations_operation", "scope_id", "operation_id", "created_at"), + CheckConstraint("requests >= 0", name="ck_pc_decision_observations_requests_nonnegative"), + CheckConstraint( + "input_tokens IS NULL OR input_tokens >= 0", name="ck_pc_decision_observations_input_tokens_nonnegative" + ), + CheckConstraint( + "output_tokens IS NULL OR output_tokens >= 0", name="ck_pc_decision_observations_output_tokens_nonnegative" + ), + CheckConstraint("latency_ms IS NULL OR latency_ms >= 0", name="ck_pc_decision_observations_latency_nonnegative"), +) + +DECISION_OBSERVATION_TABLES = (DECISION_OBSERVATIONS_TABLE,) + DREAM_RUNS_TABLE = Table( "pc_dream_runs", SHARED_METADATA, @@ -1196,6 +1237,7 @@ def _entry_text_type(): + TOPIC_MEMORY_TABLES + MEMORY_TABLES + STATISTICS_TABLES + + DECISION_OBSERVATION_TABLES + RECURRENCE_TABLES + (ARTIFACT_TAGS_TABLE, DREAM_RUNS_TABLE, RECEIPT_MIGRATION_REVIEW_TABLE) ) diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 2fadef001f..285c732745 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -968,6 +968,7 @@ async def open_builtin_contexts( memory_reranker=memory_reranker, decision_model=decision_model, memory_write_gate=memory_write_gate, + decision_observation_retention_days=config.runtime.decision_observation_retention_days, memory_rerank_candidate_limit=config.runtime.memory_rerank_candidate_limit, memory_capacity_budget=MemoryCapacityBudget( max_active_entries=config.runtime.memory_max_active_entries, @@ -1045,6 +1046,7 @@ async def open_builtin_contexts( memory_reranker=memory_reranker, decision_model=decision_model, memory_write_gate=memory_write_gate, + decision_observation_retention_days=config.runtime.decision_observation_retention_days, memory_rerank_candidate_limit=config.runtime.memory_rerank_candidate_limit, memory_capacity_budget=MemoryCapacityBudget( max_active_entries=config.runtime.memory_max_active_entries, diff --git a/src/powercontext/builtin/runtime/config.py b/src/powercontext/builtin/runtime/config.py index 7d69043c66..edb1a5819d 100644 --- a/src/powercontext/builtin/runtime/config.py +++ b/src/powercontext/builtin/runtime/config.py @@ -151,6 +151,7 @@ def reject_boolean_worker_quota(cls, value: Any) -> Any: # number. memory_write_gate_hold_on: Literal["yes", "no"] = "yes" memory_write_gate_threshold: float | None = Field(default=None, ge=0.0, le=1.0) + decision_observation_retention_days: int | None = Field(default=None, ge=1, le=3650) memory_max_active_entries: int = Field(default=5_000, ge=1, le=100_000) memory_max_manifest_entries: int = Field(default=10_000, ge=1, le=200_000) memory_max_manifest_bytes: int = Field(default=4_194_304, ge=1_024, le=67_108_864) diff --git a/src/powercontext/builtin/runtime/decision_observation_ledger.py b/src/powercontext/builtin/runtime/decision_observation_ledger.py new file mode 100644 index 0000000000..ad8931da6e --- /dev/null +++ b/src/powercontext/builtin/runtime/decision_observation_ledger.py @@ -0,0 +1,54 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Relational sidecar sink for bounded decision-policy observations.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from powercontext.builtin.decision_observations import DecisionObservation +from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.decision_observations import DecisionObservationRepository + + +class RelationalDecisionObservationSink: + """Persist each observation independently of the domain operation it describes.""" + + def __init__( + self, + database: AsyncDatabase, + repository: DecisionObservationRepository, + /, + *, + retention_days: int | None = None, + ) -> None: + self._database = database + self._repository = repository + self._retention_days = retention_days + + async def record(self, observation: DecisionObservation, /) -> None: + """Append one sidecar in a short caller-independent transaction.""" + + async with self._database.transaction() as connection: + await self._repository.append(connection, observation) + if self._retention_days is not None: + await self._repository.prune_before( + connection, + observation.scope_id, + datetime.now(UTC) - timedelta(days=self._retention_days), + ) + + +__all__ = ["RelationalDecisionObservationSink"] diff --git a/src/powercontext/builtin/runtime/decision_policy.py b/src/powercontext/builtin/runtime/decision_policy.py index 80ee02c818..98dbba116b 100644 --- a/src/powercontext/builtin/runtime/decision_policy.py +++ b/src/powercontext/builtin/runtime/decision_policy.py @@ -18,133 +18,25 @@ import logging from collections.abc import Mapping -from enum import StrEnum -from typing import Annotated - -from pydantic import BaseModel, ConfigDict, Field, JsonValue, model_validator from powercontext._logging import log_safely +from powercontext.builtin.decision_observations import ( + DecisionAssessment, + DecisionAssessmentSource, + DecisionCoverage, + DecisionFailurePolicy, + DecisionObservation, + DecisionPolicy, + DecisionPolicyMode, + DecisionPrivacyBoundary, + DecisionVerdict, +) from powercontext.builtin.inference import InferenceUsage from powercontext.builtin.runtime.decision_model import DecisionOutcome, DecisionResult logger = logging.getLogger(__name__) -class DecisionPolicyMode(StrEnum): - """Runtime mode for one versioned decision policy.""" - - DISABLED = "disabled" - SHADOW = "shadow" - ADVISORY = "advisory" - ENFORCING = "enforcing" - - -class DecisionFailurePolicy(StrEnum): - """How a consumer treats backend failure when it owns a domain action.""" - - FAIL_OPEN = "fail_open" - FAIL_CLOSED = "fail_closed" - - -class DecisionPrivacyBoundary(StrEnum): - """Content boundary declared by a policy before backend calls.""" - - LOCAL_ONLY = "local_only" - HOSTED_REDACTED = "hosted_redacted" - REFERENCES_ONLY = "references_only" - NO_EXTERNAL_CALL = "no_external_call" - - -class DecisionCoverage(StrEnum): - """Whether a policy evaluation actually judged the supplied content.""" - - ADJUDICATED = "adjudicated" - UNADJUDICATED = "unadjudicated" - - -class DecisionVerdict(StrEnum): - """Domain-neutral verdict before an owning service maps it to an action.""" - - ALLOW = "allow" - DENY = "deny" - REVIEW = "review" - UNKNOWN = "unknown" - - -class DecisionAssessmentSource(StrEnum): - """The source that produced the assessment's substantive judgement.""" - - LOCAL_RULE = "local_rule" - DECISION_MODEL = "decision_model" - NONE = "none" - - -class _StrictModel(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) - - -class DecisionPolicy(_StrictModel): - """Versioned, reviewable policy manifest for one bounded runtime question.""" - - policy_id: Annotated[str, Field(min_length=1, max_length=256)] - decision_kind: Annotated[str, Field(min_length=1, max_length=128)] - version: Annotated[str, Field(min_length=1, max_length=64)] - consumer: Annotated[str, Field(min_length=1, max_length=128)] - mode: DecisionPolicyMode - failure_policy: DecisionFailurePolicy - privacy_boundary: DecisionPrivacyBoundary - local_rules: tuple[Annotated[str, Field(min_length=1, max_length=128)], ...] = () - question: Annotated[str, Field(min_length=1, max_length=8192)] - subject_selector: Annotated[str, Field(min_length=1, max_length=512)] - evidence_selector: Annotated[str, Field(min_length=1, max_length=512)] - outcome_mapping: Mapping[str, Annotated[str, Field(min_length=1, max_length=128)]] = Field(default_factory=dict) - promotion_criteria: tuple[Annotated[str, Field(min_length=1, max_length=512)], ...] = () - - -class DecisionAssessment(_StrictModel): - """Policy-level assessment before domain-specific action mapping.""" - - policy_id: Annotated[str, Field(min_length=1, max_length=256)] - policy_version: Annotated[str, Field(min_length=1, max_length=64)] - mode: DecisionPolicyMode - coverage: DecisionCoverage - verdict: DecisionVerdict - source: DecisionAssessmentSource - reason: Annotated[str | None, Field(min_length=1, max_length=4096)] = None - confidence: Annotated[float | None, Field(ge=0.0, le=1.0)] = None - used_fallback: bool = False - usage: InferenceUsage = Field(default_factory=lambda: InferenceUsage(requests=0)) - latency_ms: Annotated[float | None, Field(ge=0.0, allow_inf_nan=False)] = None - - @model_validator(mode="after") - def validate_fallback_coverage(self) -> DecisionAssessment: - if self.used_fallback and self.coverage is not DecisionCoverage.UNADJUDICATED: - raise ValueError("fallback assessments must be unadjudicated") # noqa: TRY003 - return self - - -class DecisionObservation(_StrictModel): - """Audit/replay sidecar for one policy evaluation attempt.""" - - operation_id: Annotated[str, Field(min_length=1, max_length=256)] - scope_id: Annotated[str, Field(min_length=1, max_length=256)] - consumer: Annotated[str, Field(min_length=1, max_length=128)] - policy_id: Annotated[str, Field(min_length=1, max_length=256)] - policy_version: Annotated[str, Field(min_length=1, max_length=64)] - mode: DecisionPolicyMode - subject_refs: tuple[Annotated[str, Field(min_length=1, max_length=512)], ...] = () - evidence_refs: tuple[Annotated[str, Field(min_length=1, max_length=512)], ...] = () - privacy_boundary: DecisionPrivacyBoundary - privacy_outcome: Annotated[str | None, Field(min_length=1, max_length=128)] = None - provider_id: Annotated[str | None, Field(min_length=1, max_length=128)] = None - backend_model_id: Annotated[str | None, Field(min_length=1, max_length=256)] = None - model_policy_id: Annotated[str | None, Field(min_length=1, max_length=256)] = None - assessment: DecisionAssessment - final_action: Annotated[str, Field(min_length=1, max_length=128)] - fallback_reason: Annotated[str | None, Field(min_length=1, max_length=256)] = None - metadata: Mapping[str, JsonValue] = Field(default_factory=dict) - - _DEFAULT_OUTCOME_MAPPING: Mapping[DecisionOutcome, DecisionVerdict] = { DecisionOutcome.YES: DecisionVerdict.ALLOW, DecisionOutcome.NO: DecisionVerdict.DENY, diff --git a/src/powercontext/builtin/runtime/memory_write_gate.py b/src/powercontext/builtin/runtime/memory_write_gate.py index b2b05febcd..dce05c8e9f 100644 --- a/src/powercontext/builtin/runtime/memory_write_gate.py +++ b/src/powercontext/builtin/runtime/memory_write_gate.py @@ -177,7 +177,7 @@ async def assess(self, request: MemoryWriteGateRequest, /) -> MemoryWriteAssessm reason="the candidate batch exceeds the gate assessment budget", ) assessment = self._mode_assessment(held) - self._observe(request, policy_assessment, assessment) + await self._observe(request, policy_assessment, assessment) self._log(assessment) return assessment if not self._allow_model_evaluation: @@ -187,7 +187,7 @@ async def assess(self, request: MemoryWriteGateRequest, /) -> MemoryWriteAssessm policy_id=self.policy_id, used_fallback=True, ) - self._observe(request, policy_assessment, assessment) + await self._observe(request, policy_assessment, assessment) self._log(assessment) return assessment decision = await self._decision_model.evaluate( @@ -199,7 +199,7 @@ async def assess(self, request: MemoryWriteGateRequest, /) -> MemoryWriteAssessm ) ) assessment = self._map(request, decision) - self._observe( + await self._observe( request, _assess_memory_write_decision(decision, hold_on=self._hold_on, policy=self._policy), assessment ) self._log(assessment) @@ -244,7 +244,7 @@ def _mode_assessment(self, assessment: MemoryWriteAssessment) -> MemoryWriteAsse ) return assessment - def _observe( + async def _observe( self, request: MemoryWriteGateRequest, policy_assessment: DecisionAssessment, @@ -263,12 +263,31 @@ def _observe( or tuple(f"evidence:{index}" for index in range(1, len(request.evidence) + 1)), privacy_boundary=self._policy.privacy_boundary, model_policy_id=None if policy_assessment.source.value == "none" else self.policy_id, - assessment=policy_assessment, + assessment=_safe_observation_assessment(policy_assessment), final_action=f"memory_write_{assessment.verdict.value}", - fallback_reason=policy_assessment.reason if policy_assessment.used_fallback else None, + fallback_reason=_safe_observation_reason(policy_assessment.reason) + if policy_assessment.used_fallback + else None, metadata={"candidate_count": len(request.candidates), "evidence_count": len(request.evidence)}, ) emit_decision_observation(observation) + if request.observation_sink is None: + return + try: + await request.observation_sink.record(observation) + except Exception as error: + log_safely( + logger, + logging.WARNING, + "Decision observation sink failed", + extra={ + "event": "decision.observation_sink_failed", + "consumer": observation.consumer, + "policy_id": observation.policy_id, + "operation_id": observation.operation_id, + "error_type": type(error).__name__, + }, + ) def _log(self, assessment: MemoryWriteAssessment) -> None: event = { @@ -342,6 +361,23 @@ def _bounded_reason(value: str | None) -> str: return normalized[:_MAX_REASON_LENGTH] if normalized else _DEFAULT_REASON +def _safe_observation_assessment(assessment: DecisionAssessment, /) -> DecisionAssessment: + """Keep durable sidecars free of model-provided rationale text.""" + + return assessment.model_copy(update={"reason": _safe_observation_reason(assessment.reason)}) + + +def _safe_observation_reason(reason: str | None, /) -> str | None: + """Map gate-internal explanations to bounded machine-readable audit codes.""" + + return { + "candidate_batch_budget": "candidate_batch_budget", + "privacy_boundary": "privacy_boundary", + "decision_model_fallback": "decision_model_fallback", + "abstain": "abstain", + }.get(reason, "decision_model_verdict" if reason is not None else None) + + __all__ = [ "DecisionMemoryWriteGate", "MemoryWriteAssessment", diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 9df126df31..6e92323360 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -136,6 +136,7 @@ from powercontext.builtin.persistence.connectors import ConnectorCheckpointRepository from powercontext.builtin.persistence.cursors import SourceCursorRepository, StoredSourceCursor from powercontext.builtin.persistence.database import AsyncDatabase +from powercontext.builtin.persistence.decision_observations import DecisionObservationRepository from powercontext.builtin.persistence.errors import RepositoryNotFoundError, StoredPayloadConflictError from powercontext.builtin.persistence.experience_index import ExperienceIndex, NoExperienceIndex from powercontext.builtin.persistence.external_skills import ExternalSkillRepository @@ -183,6 +184,7 @@ from powercontext.builtin.review.service import ReviewService from powercontext.builtin.runtime._model_usage import _ModelUsageRecorder from powercontext.builtin.runtime.decision_model import DecisionModel +from powercontext.builtin.runtime.decision_observation_ledger import RelationalDecisionObservationSink from powercontext.builtin.runtime.models import ( CommitConnectorCheckpoint, ConnectorCheckpointState, @@ -288,6 +290,7 @@ class _Repositories: skill_packages: SkillPackageRepository agent_skill_targets: RemoteAgentSkillTargetRepository skill_publications: SkillPublicationRepository + decision_observations: DecisionObservationRepository statistics: StatisticsRepository recurrence: RecurrenceRepository processing_pending: ArtifactProcessingPendingRepository @@ -315,6 +318,7 @@ class _ScopedServices: memory_rerank_candidate_limit: int decision_model: DecisionModel | None memory_write_gate: MemoryWriteGate | None + decision_observation_retention_days: int | None memory_capacity_budget: MemoryCapacityBudget memory_compaction: MemoryCompactionPolicy memory_max_history_revisions: int @@ -383,6 +387,11 @@ def memory( ), id_factory=self.id_factory, write_gate=self.memory_write_gate, + write_gate_observation_sink=RelationalDecisionObservationSink( + self.database, + self.repositories.decision_observations, + retention_days=self.decision_observation_retention_days, + ), ) def evidence(self, authorize: EvidenceAuthorizer | None = None) -> EvidenceResolver: @@ -549,6 +558,7 @@ def __init__( memory_reranker: MemoryReranker | None = None, decision_model: DecisionModel | None = None, memory_write_gate: MemoryWriteGate | None = None, + decision_observation_retention_days: int | None = None, memory_rerank_candidate_limit: int = 30, memory_capacity_budget: MemoryCapacityBudget | None = None, memory_compaction: MemoryCompactionPolicy | None = None, @@ -596,6 +606,7 @@ def __init__( skill_packages=SkillPackageRepository(), agent_skill_targets=RemoteAgentSkillTargetRepository(), skill_publications=SkillPublicationRepository(), + decision_observations=DecisionObservationRepository(), statistics=StatisticsRepository(), recurrence=RecurrenceRepository(), processing_pending=ArtifactProcessingPendingRepository(), @@ -714,6 +725,7 @@ def __init__( self._memory_reranker = memory_reranker self._decision_model = decision_model self._memory_write_gate = memory_write_gate + self._decision_observation_retention_days = decision_observation_retention_days self._memory_rerank_candidate_limit = memory_rerank_candidate_limit self._memory_capacity_budget = ( MemoryCapacityBudget() if memory_capacity_budget is None else memory_capacity_budget @@ -1493,6 +1505,7 @@ def _services_for(self, scope_id: str) -> _ScopedServices: memory_rerank_candidate_limit=self._memory_rerank_candidate_limit, decision_model=self._decision_model, memory_write_gate=self._memory_write_gate, + decision_observation_retention_days=self._decision_observation_retention_days, memory_capacity_budget=self._memory_capacity_budget, memory_compaction=self._memory_compaction, memory_max_history_revisions=self._memory_max_history_revisions, diff --git a/tests/builtin/persistence/test_decision_observation_repository.py b/tests/builtin/persistence/test_decision_observation_repository.py new file mode 100644 index 0000000000..59dc7dab02 --- /dev/null +++ b/tests/builtin/persistence/test_decision_observation_repository.py @@ -0,0 +1,228 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta + +import pytest + +from powercontext.builtin.persistence import DecisionObservationRepository +from powercontext.builtin.persistence.decision_observations import ( + DecisionObservationRepository as SameDecisionObservationRepository, +) +from powercontext.builtin.persistence.errors import StoredPayloadConflictError +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import ( + DECISION_OBSERVATION_TABLES, + SHARED_TABLES, +) +from powercontext.builtin.runtime.decision_observation_ledger import RelationalDecisionObservationSink +from powercontext.builtin.runtime.decision_policy import ( + DecisionAssessment, + DecisionAssessmentSource, + DecisionCoverage, + DecisionObservation, + DecisionPolicyMode, + DecisionPrivacyBoundary, + DecisionVerdict, +) + +SCOPE = "scope-a" +CREATED_AT = datetime(2026, 10, 10, 8, 30, tzinfo=UTC) + + +def _observation( + *, observation_id: str = "decision-observation-1", created_at: datetime = CREATED_AT +) -> DecisionObservation: + return DecisionObservation( + observation_id=observation_id, + created_at=created_at, + operation_id="memory-write:memory-a@2", + scope_id=SCOPE, + consumer="memory_write_gate", + policy_id="memory.write.evidence_sufficiency.v1", + policy_version="1", + mode=DecisionPolicyMode.ADVISORY, + subject_refs=("entry:entry-a@version-a",), + evidence_refs=("source:task:1",), + privacy_boundary=DecisionPrivacyBoundary.LOCAL_ONLY, + provider_id="local-provider", + backend_model_id="decision-model-a", + model_policy_id="powercontext.decision.static.v1", + assessment=DecisionAssessment( + policy_id="memory.write.evidence_sufficiency.v1", + policy_version="1", + mode=DecisionPolicyMode.ADVISORY, + coverage=DecisionCoverage.ADJUDICATED, + verdict=DecisionVerdict.DENY, + source=DecisionAssessmentSource.DECISION_MODEL, + reason="decision_model_verdict", + ), + final_action="memory_write_flag", + metadata={"candidate_count": 1, "evidence_count": 1}, + ) + + +def test_repository_is_exported_from_the_persistence_package() -> None: + assert DecisionObservationRepository is SameDecisionObservationRepository + + +def test_decision_observation_tables_are_registered_in_builtin_tables() -> None: + from powercontext.builtin.persistence.tables import BUILTIN_TABLES + + assert {table.name for table in DECISION_OBSERVATION_TABLES} <= {table.name for table in BUILTIN_TABLES} + + +def test_repository_appends_and_reads_back_one_bounded_observation() -> None: + async def scenario() -> None: + repository = DecisionObservationRepository() + async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES + DECISION_OBSERVATION_TABLES) as profile: + async with profile.database.transaction() as connection: + inserted = await repository.append(connection, _observation()) + async with profile.database.transaction() as connection: + stored = await repository.observations(connection, SCOPE) + + assert inserted is True + assert stored == (_observation(),) + + asyncio.run(scenario()) + + +def test_repository_accepts_gate_generated_ordinal_references() -> None: + async def scenario() -> None: + repository = DecisionObservationRepository() + observation = _observation().model_copy( + update={"subject_refs": ("candidate:1",), "evidence_refs": ("evidence:1",)} + ) + async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES + DECISION_OBSERVATION_TABLES) as profile: + async with profile.database.transaction() as connection: + inserted = await repository.append(connection, observation) + async with profile.database.transaction() as connection: + stored = await repository.observations(connection, SCOPE) + + assert inserted is True + assert stored == (observation,) + + asyncio.run(scenario()) + + +def test_repository_treats_an_exact_observation_replay_as_idempotent() -> None: + async def scenario() -> None: + repository = DecisionObservationRepository() + observation = _observation() + async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES + DECISION_OBSERVATION_TABLES) as profile: + async with profile.database.transaction() as connection: + first = await repository.append(connection, observation) + replayed = await repository.append(connection, observation) + async with profile.database.transaction() as connection: + stored = await repository.observations(connection, SCOPE) + + assert first is True + assert replayed is False + assert stored == (observation,) + + asyncio.run(scenario()) + + +def test_repository_rejects_an_observation_with_a_mismatched_assessment_policy() -> None: + async def scenario() -> None: + repository = DecisionObservationRepository() + inconsistent = _observation().model_copy(update={"policy_id": "memory.write.other_policy.v1"}) + async with ( + SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES + DECISION_OBSERVATION_TABLES) as profile, + profile.database.transaction() as connection, + ): + with pytest.raises(ValueError, match="assessment policy"): + await repository.append(connection, inconsistent) + + asyncio.run(scenario()) + + +def test_repository_rejects_an_observation_id_reused_for_different_content() -> None: + async def scenario() -> None: + repository = DecisionObservationRepository() + observation = _observation() + conflicting = observation.model_copy(update={"final_action": "memory_write_accept"}) + async with ( + SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES + DECISION_OBSERVATION_TABLES) as profile, + profile.database.transaction() as connection, + ): + await repository.append(connection, observation) + with pytest.raises(StoredPayloadConflictError): + await repository.append(connection, conflicting) + + asyncio.run(scenario()) + + +def test_repository_rejects_unbounded_metadata_before_persistence() -> None: + async def scenario() -> None: + repository = DecisionObservationRepository() + forbidden = _observation().model_copy(update={"metadata": {"prompt": "secret candidate text"}}) + async with ( + SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES + DECISION_OBSERVATION_TABLES) as profile, + profile.database.transaction() as connection, + ): + with pytest.raises(ValueError, match="metadata"): + await repository.append(connection, forbidden) + + asyncio.run(scenario()) + + +def test_repository_prunes_only_observations_older_than_the_retention_boundary() -> None: + async def scenario() -> None: + repository = DecisionObservationRepository() + old = _observation(observation_id="decision-observation-old", created_at=CREATED_AT - timedelta(days=1)) + current = _observation(observation_id="decision-observation-current") + async with ( + SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES + DECISION_OBSERVATION_TABLES) as profile, + profile.database.transaction() as connection, + ): + await repository.append(connection, old) + await repository.append(connection, current) + removed = await repository.prune_before(connection, SCOPE, CREATED_AT) + stored = await repository.observations(connection, SCOPE) + + assert removed == 1 + assert stored == (current,) + + asyncio.run(scenario()) + + +def test_relational_sink_prunes_only_the_current_scope_when_retention_is_configured() -> None: + async def scenario() -> None: + repository = DecisionObservationRepository() + current = _observation(observation_id="decision-observation-current", created_at=datetime.now(UTC)) + old_current_scope = _observation( + observation_id="decision-observation-old", created_at=current.created_at - timedelta(days=3) + ) + other_scope = _observation( + observation_id="decision-observation-other-scope", + created_at=current.created_at - timedelta(days=3), + ).model_copy(update={"scope_id": "scope-b"}) + async with SQLiteProfile.open(SQLiteConfig(), tables=SHARED_TABLES + DECISION_OBSERVATION_TABLES) as profile: + async with profile.database.transaction() as connection: + await repository.append(connection, old_current_scope) + await repository.append(connection, other_scope) + sink = RelationalDecisionObservationSink(profile.database, repository, retention_days=2) + await sink.record(current) + async with profile.database.transaction() as connection: + current_scope = await repository.observations(connection, SCOPE) + other_scope_observations = await repository.observations(connection, "scope-b") + + assert current_scope == (current,) + assert other_scope_observations == (other_scope,) + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_decision_config.py b/tests/builtin/runtime/test_decision_config.py index 6bb04a5dd9..35691f6e73 100644 --- a/tests/builtin/runtime/test_decision_config.py +++ b/tests/builtin/runtime/test_decision_config.py @@ -32,6 +32,17 @@ def test_memory_write_gate_defaults_to_no_external_call() -> None: assert RuntimeConfig().memory_write_gate_privacy_boundary is DecisionPrivacyBoundary.NO_EXTERNAL_CALL +def test_decision_observation_retention_is_disabled_until_explicitly_configured() -> None: + assert RuntimeConfig().decision_observation_retention_days is None + assert RuntimeConfig(decision_observation_retention_days=30).decision_observation_retention_days == 30 + + +@pytest.mark.parametrize("retention_days", [0, -1]) +def test_decision_observation_retention_rejects_nonpositive_values(retention_days: int) -> None: + with pytest.raises(ValidationError): + RuntimeConfig(decision_observation_retention_days=retention_days) + + def test_memory_write_gate_rejects_hosted_redaction_without_a_sanitizer() -> None: with pytest.raises(ValidationError, match="hosted_redacted"): RuntimeConfig(memory_write_gate_privacy_boundary=cast(DecisionPrivacyBoundary, "hosted_redacted")) diff --git a/tests/builtin/runtime/test_memory_write_gate_contract.py b/tests/builtin/runtime/test_memory_write_gate_contract.py index 60c57f0491..03a697b042 100644 --- a/tests/builtin/runtime/test_memory_write_gate_contract.py +++ b/tests/builtin/runtime/test_memory_write_gate_contract.py @@ -29,6 +29,7 @@ ) from powercontext.builtin.runtime.decision_policy import ( DecisionCoverage, + DecisionObservation, DecisionPolicyMode, DecisionPrivacyBoundary, DecisionVerdict, @@ -83,6 +84,16 @@ async def evaluate(self, request: DecisionRequest, /) -> DecisionResult: raise ValueError("backend unavailable") # noqa: TRY003 +class _RecordingObservationSink: + """Collect the safe sidecar a gate offers to durable runtime storage.""" + + def __init__(self) -> None: + self.observations: list[DecisionObservation] = [] + + async def record(self, observation: DecisionObservation, /) -> None: + self.observations.append(observation) + + class _PolarityBackend: """A backend that calls one known answer insufficient and the other sufficient.""" @@ -349,6 +360,71 @@ async def scenario() -> None: assert "secret evidence" not in str(record.__dict__) +def test_gate_offers_a_bounded_observation_to_the_request_sink() -> None: + async def scenario() -> None: + sink = _RecordingObservationSink() + gate = DecisionMemoryWriteGate(_StaticDecisionModel(_verdict(DecisionOutcome.YES))) + + assessment = await gate.assess( + MemoryWriteGateRequest( + candidates=("secret claim",), + evidence=("secret evidence",), + expected_revision=1, + scope_id="scope-a", + operation_id="memory-write:memory-a@2", + subject_refs=("entry:entry-a@version-a",), + evidence_refs=("source:task:1",), + observation_sink=sink, + ) + ) + + assert assessment.verdict is MemoryWriteVerdict.HOLD + assert len(sink.observations) == 1 + observation = sink.observations[0] + assert observation.scope_id == "scope-a" + assert observation.operation_id == "memory-write:memory-a@2" + assert observation.subject_refs == ("entry:entry-a@version-a",) + assert observation.evidence_refs == ("source:task:1",) + assert observation.final_action == "memory_write_hold" + assert "secret claim" not in observation.model_dump_json() + assert "secret evidence" not in observation.model_dump_json() + + asyncio.run(scenario()) + + +def test_a_failing_observation_sink_keeps_the_gate_verdict_and_logs_only_its_type( + caplog: pytest.LogCaptureFixture, +) -> None: + class _FailingObservationSink: + async def record(self, observation: DecisionObservation, /) -> None: + raise RuntimeError("secret sink failure detail") # noqa: TRY003 + + async def scenario() -> None: + gate = DecisionMemoryWriteGate(_StaticDecisionModel(_verdict(DecisionOutcome.YES))) + + assessment = await gate.assess( + MemoryWriteGateRequest( + candidates=("secret claim",), + evidence=("secret evidence",), + expected_revision=1, + scope_id="scope-a", + operation_id="memory-write:memory-a@2", + observation_sink=_FailingObservationSink(), + ) + ) + + assert assessment.verdict is MemoryWriteVerdict.HOLD + + caplog.set_level(logging.WARNING, logger="powercontext.builtin.runtime.memory_write_gate") + asyncio.run(scenario()) + + record = next( + record for record in caplog.records if getattr(record, "event", None) == "decision.observation_sink_failed" + ) + assert record.__dict__["error_type"] == "RuntimeError" + assert "secret sink failure detail" not in str(record.__dict__) + + def test_the_hold_direction_cannot_be_abstain() -> None: with pytest.raises(ValueError, match="hold direction"): DecisionMemoryWriteGate(_StaticDecisionModel(_verdict(DecisionOutcome.NO)), hold_on=DecisionOutcome.ABSTAIN) diff --git a/tests/builtin/runtime/test_memory_write_gate_paths.py b/tests/builtin/runtime/test_memory_write_gate_paths.py index 634d2dda8a..ee480c1c1b 100644 --- a/tests/builtin/runtime/test_memory_write_gate_paths.py +++ b/tests/builtin/runtime/test_memory_write_gate_paths.py @@ -34,6 +34,7 @@ ) from powercontext.builtin.artifacts.memory.errors import MemoryWriteRejectedError from powercontext.builtin.inference import InferenceUsage +from powercontext.builtin.persistence import DecisionObservationRepository from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime import ( BuiltinConfig, @@ -559,6 +560,44 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_decision_gate_persists_a_content_free_observation_sidecar( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + async def scenario() -> None: + gate = DecisionMemoryWriteGate(_InsufficientDecisionModel()) + async with open_builtin_contexts(_config(tmp_path), memory_write_gate=gate) as contexts: + context = await contexts.get("project") + source, _ = await context.sources.capture(ContentCapture(source_id="private", content="secret evidence")) + + plan = await context.artifacts.memory.plan_remember( + memory=None, + sources=(source,), + entries=(MemoryEntryInput(kind="fact", text="secret claim", sources=(source,)),), + mode="append", + ) + async with contexts.database.transaction() as connection: + observations = await DecisionObservationRepository().observations(connection, "project") + + failures = [ + record for record in caplog.records if getattr(record, "event", None) == "decision.observation_sink_failed" + ] + + assert plan.decision is not None + assert plan.decision.verdict is MemoryWriteVerdict.HOLD + assert [record.__dict__["error_type"] for record in failures] == [] + assert len(observations) == 1 + observation = observations[0] + assert observation.final_action == "memory_write_hold" + assert observation.subject_refs == ("candidate:1",) + assert observation.evidence_refs == ("candidate:1 source:content:private",) + assert observation.assessment.reason is None + payload = observation.model_dump_json() + assert "secret claim" not in payload + assert "secret evidence" not in payload + + asyncio.run(scenario()) + + def test_explicit_artifact_family_is_not_recovered_as_a_different_allowed_family(tmp_path: Path) -> None: async def scenario() -> None: async with open_builtin_contexts(_config(tmp_path)) as contexts: From 62b41173203ca257fe727d354197601a7aa846b8 Mon Sep 17 00:00:00 2001 From: "Xin.Zh" Date: Sat, 10 Oct 2026 20:45:23 +0800 Subject: [PATCH 04/11] fix(runtime): close decision gate policy gaps Apply mode semantics to evidence preflight rejections, enforce loopback-only local evaluation, and make privacy outcomes observable in the private decision ledger and JSON logs. Constraint: observations retain bounded references and counts only; no candidate or evidence content is logged or stored. Scope-risk: unknown, LAN, wildcard, and hosted backends fail open under local_only until an explicitly loopback endpoint is configured. Tested: focused gate, policy, composition, logging, and MySQL schema suite (108 passed); Ruff, format, ty 3.11, and diff check. --- docs/en/docs/operate/troubleshoot.md | 2 +- docs/zh/docs/operate/troubleshoot.md | 2 +- .../builtin/artifacts/memory/protocols.py | 14 +++- .../builtin/artifacts/memory/service.py | 26 +++--- .../builtin/runtime/composition.py | 27 +++++- src/powercontext/builtin/runtime/config.py | 2 +- .../builtin/runtime/decision_model.py | 6 +- .../builtin/runtime/decision_policy.py | 2 + .../builtin/runtime/memory_write_gate.py | 83 +++++++++++++++++-- src/powercontext/server/logging.py | 11 +++ .../builtin/persistence/test_mysql_schema.py | 4 +- .../runtime/test_decision_composition.py | 17 ++++ tests/builtin/runtime/test_decision_config.py | 6 +- .../test_memory_write_gate_contract.py | 57 +++++++++++++ .../runtime/test_memory_write_gate_paths.py | 47 ++++++++++- tests/test_server_logging.py | 21 +++++ 16 files changed, 296 insertions(+), 31 deletions(-) diff --git a/docs/en/docs/operate/troubleshoot.md b/docs/en/docs/operate/troubleshoot.md index 8cbfdcc4c8..01db70edf0 100644 --- a/docs/en/docs/operate/troubleshoot.md +++ b/docs/en/docs/operate/troubleshoot.md @@ -267,7 +267,7 @@ so the previous database remains available for recovery: ```bash obloader -D --csv \ - --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_receipt_migration_review' \ + --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_receipt_migration_review,pc_decision_observations' \ -f ``` diff --git a/docs/zh/docs/operate/troubleshoot.md b/docs/zh/docs/operate/troubleshoot.md index f7ddacc160..ddfc1ec6c2 100644 --- a/docs/zh/docs/operate/troubleshoot.md +++ b/docs/zh/docs/operate/troubleshoot.md @@ -259,7 +259,7 @@ collation,但不会包含数据库 URL 或凭据。 ```bash obloader -D --csv \ - --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_receipt_migration_review' \ + --table 'pc_scopes,pc_source_journal_heads,pc_sources,pc_artifacts,pc_source_cursors,pc_memory_source_windows,pc_artifact_processing_leases,pc_artifact_processing_binding_states,pc_artifact_processing_pending,pc_artifact_processing_auto_wave_targets,pc_artifact_processing_sequences,pc_artifact_processing_intents,pc_topic_memory_processing_targets,pc_artifact_processing_schema,pc_artifact_processing_migration_receipts,pc_topic_memory_work_budgets,pc_topic_memory_retrieval_shape,pc_connector_checkpoints,pc_source_definition_manifests,pc_external_skill_registrations,pc_skill_packages,pc_agent_skill_targets,pc_skill_publications,pc_model_usage_daily,pc_recall_token_daily,pc_receipt_migration_review,pc_decision_observations' \ -f ``` diff --git a/src/powercontext/builtin/artifacts/memory/protocols.py b/src/powercontext/builtin/artifacts/memory/protocols.py index 74fab76b17..c11e600d11 100644 --- a/src/powercontext/builtin/artifacts/memory/protocols.py +++ b/src/powercontext/builtin/artifacts/memory/protocols.py @@ -19,7 +19,7 @@ from contextlib import AbstractAsyncContextManager from dataclasses import dataclass from enum import StrEnum -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Protocol, runtime_checkable from pydantic import BaseModel, ConfigDict @@ -144,6 +144,18 @@ async def assess(self, request: MemoryWriteGateRequest, /) -> MemoryWriteAssessm ... +@runtime_checkable +class MemoryWriteGatePreflight(Protocol): + """Optionally map a deterministic service-side preflight rejection through a gate policy.""" + + async def assess_preflight( + self, request: MemoryWriteGateRequest, rejection: MemoryWriteAssessment, / + ) -> MemoryWriteAssessment: + """Return the mode-aware decision for a bounded preflight rejection.""" + + ... + + class MemoryWritePlan(BaseModel): """A side-effect-free result that can be committed in an outer transaction.""" diff --git a/src/powercontext/builtin/artifacts/memory/service.py b/src/powercontext/builtin/artifacts/memory/service.py index bf795d2ac7..3cc57eb92b 100644 --- a/src/powercontext/builtin/artifacts/memory/service.py +++ b/src/powercontext/builtin/artifacts/memory/service.py @@ -92,6 +92,7 @@ MemorySearchRequest, MemoryWriteAssessment, MemoryWriteGate, + MemoryWriteGatePreflight, MemoryWriteGateRequest, MemoryWriteObservationSink, MemoryWritePlan, @@ -1380,22 +1381,23 @@ async def _assess_write( if self._write_gate is None: return None projection = await self._gate_evidence(base, candidates, evidence, current_entries) + request = MemoryWriteGateRequest( + candidates=tuple(candidate.text for candidate in candidates), + evidence=projection.entries, + expected_revision=None if base is None else base.revision, + scope_id=self._scope_id, + operation_id=_gate_operation_id(base), + subject_refs=_gate_subject_refs(candidates), + evidence_refs=tuple(_gate_evidence_ref(entry) for entry in projection.entries), + observation_sink=self._write_gate_observation_sink, + ) if projection.rejection is not None: + if isinstance(self._write_gate, MemoryWriteGatePreflight): + return await self._write_gate.assess_preflight(request, projection.rejection) _log_gate_assessment(projection.rejection) return projection.rejection try: - return await self._write_gate.assess( - MemoryWriteGateRequest( - candidates=tuple(candidate.text for candidate in candidates), - evidence=projection.entries, - expected_revision=None if base is None else base.revision, - scope_id=self._scope_id, - operation_id=_gate_operation_id(base), - subject_refs=_gate_subject_refs(candidates), - evidence_refs=tuple(_gate_evidence_ref(entry) for entry in projection.entries), - observation_sink=self._write_gate_observation_sink, - ) - ) + return await self._write_gate.assess(request) except Exception: return MemoryWriteAssessment( verdict=MemoryWriteVerdict.ACCEPT, diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 285c732745..70757d88c2 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -23,6 +23,7 @@ from contextlib import AsyncExitStack, asynccontextmanager, nullcontext from datetime import timedelta from functools import partial +from ipaddress import ip_address from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast @@ -175,6 +176,24 @@ from powercontext.errors import InvalidSourceProjectionError, SourceProjectionNotFoundError from powercontext.sources import Source, SourceDefinitionRegistry, SourceProjectionKey + +def _is_loopback_endpoint(base_url: AnyHttpUrl | None, /) -> bool: + """Return whether an explicitly configured HTTP endpoint is local to this machine.""" + + if base_url is None: + return False + host = base_url.host + if host is None: + return False + normalized = host.strip("[]").lower() + if normalized == "localhost": + return True + try: + return ip_address(normalized).is_loopback + except ValueError: + return False + + if TYPE_CHECKING: from pydantic_ai.models import Model from pydantic_ai.models.instrumented import InstrumentationSettings @@ -289,6 +308,7 @@ def __init__(self, delegate: DecisionModel, tracing: RuntimeTracing) -> None: self._delegate = delegate self._tracing = tracing self.policy_id = delegate.policy_id + self.is_local_only = bool(getattr(delegate, "is_local_only", False)) async def evaluate(self, request: DecisionRequest, /) -> DecisionResult: with self._tracing.stage( @@ -1525,7 +1545,12 @@ async def _generation_decision( model_settings=decision_request_settings, name="decision_evaluate", ) - generated_decision = LLMDecisionModel(UsageReportingStructuredGenerator(decision_generator)) + generated_decision = LLMDecisionModel( + UsageReportingStructuredGenerator(decision_generator), + is_local_only=_is_loopback_endpoint( + settings.decision_base_url if settings.decision_model is not None else settings.generation_base_url + ), + ) decision_readiness: ReadinessProbe | None = None if separate_decision_model or settings.decision_model_settings: diff --git a/src/powercontext/builtin/runtime/config.py b/src/powercontext/builtin/runtime/config.py index edb1a5819d..fd9be5cfbe 100644 --- a/src/powercontext/builtin/runtime/config.py +++ b/src/powercontext/builtin/runtime/config.py @@ -144,7 +144,7 @@ def reject_boolean_worker_quota(cls, value: Any) -> Any: memory_rerank_candidate_limit: int = Field(default=30, ge=1, le=100) decision_assistance_enabled: bool = False memory_write_gate_enabled: bool = False - memory_write_gate_mode: DecisionPolicyMode = DecisionPolicyMode.ENFORCING + memory_write_gate_mode: DecisionPolicyMode = DecisionPolicyMode.SHADOW memory_write_gate_privacy_boundary: DecisionPrivacyBoundary = DecisionPrivacyBoundary.NO_EXTERNAL_CALL # Direction only: which verdict means "evidence is insufficient". The strength threshold # stays unset until a calibration probe establishes it, so a hold never depends on a made-up diff --git a/src/powercontext/builtin/runtime/decision_model.py b/src/powercontext/builtin/runtime/decision_model.py index c30bb6a0ac..a6f3edc2ce 100644 --- a/src/powercontext/builtin/runtime/decision_model.py +++ b/src/powercontext/builtin/runtime/decision_model.py @@ -144,8 +144,11 @@ class LLMDecisionModel: policy_id = DECISION_INSTRUCTIONS_VERSION - def __init__(self, generator: StructuredGenerator[DecisionInput, DecisionOutput], /) -> None: + def __init__( + self, generator: StructuredGenerator[DecisionInput, DecisionOutput], /, *, is_local_only: bool = False + ) -> None: self._generator = generator + self.is_local_only = is_local_only async def evaluate(self, request: DecisionRequest, /) -> DecisionResult: """Map the model's validated answer onto one portable result.""" @@ -177,6 +180,7 @@ class FailOpenDecisionModel: def __init__(self, delegate: DecisionModel, /, *, timeout_seconds: float | None = None) -> None: self._delegate = delegate self.policy_id = delegate.policy_id + self.is_local_only = bool(getattr(delegate, "is_local_only", False)) self._timeout_seconds = timeout_seconds async def evaluate(self, request: DecisionRequest, /) -> DecisionResult: diff --git a/src/powercontext/builtin/runtime/decision_policy.py b/src/powercontext/builtin/runtime/decision_policy.py index 98dbba116b..7330167263 100644 --- a/src/powercontext/builtin/runtime/decision_policy.py +++ b/src/powercontext/builtin/runtime/decision_policy.py @@ -152,6 +152,8 @@ def emit_decision_observation(observation: DecisionObservation, /) -> None: "policy_id": observation.policy_id, "policy_version": observation.policy_version, "mode": observation.mode.value, + "privacy_boundary": observation.privacy_boundary.value, + "privacy_outcome": observation.privacy_outcome, "coverage": observation.assessment.coverage.value, "verdict": observation.assessment.verdict.value, "final_action": observation.final_action, diff --git a/src/powercontext/builtin/runtime/memory_write_gate.py b/src/powercontext/builtin/runtime/memory_write_gate.py index dce05c8e9f..02b3256ab2 100644 --- a/src/powercontext/builtin/runtime/memory_write_gate.py +++ b/src/powercontext/builtin/runtime/memory_write_gate.py @@ -86,18 +86,38 @@ DecisionOutcome.NO.value: DecisionVerdict.ALLOW.value, DecisionOutcome.ABSTAIN.value: DecisionVerdict.UNKNOWN.value, }, - promotion_criteria=("explicit runtime opt-in",), + promotion_criteria=( + "labeled calibration and held-out evaluation", + "measured fallback, latency, cost, and friction", + "rollback path", + ), ) def memory_write_policy( *, - mode: DecisionPolicyMode = DecisionPolicyMode.ENFORCING, + hold_on: DecisionOutcome = DecisionOutcome.YES, + threshold: float | None = None, + mode: DecisionPolicyMode = DecisionPolicyMode.SHADOW, privacy_boundary: DecisionPrivacyBoundary = DecisionPrivacyBoundary.LOCAL_ONLY, ) -> DecisionPolicy: """Return the immutable Memory write policy with its configured runtime boundaries.""" - return _MEMORY_WRITE_POLICY.model_copy(update={"mode": mode, "privacy_boundary": privacy_boundary}) + if hold_on is DecisionOutcome.ABSTAIN: + raise ValueError("the hold direction cannot be abstain") # noqa: TRY003 + pass_on = DecisionOutcome.NO if hold_on is DecisionOutcome.YES else DecisionOutcome.YES + return _MEMORY_WRITE_POLICY.model_copy( + update={ + "version": _policy_version(hold_on, threshold), + "mode": mode, + "privacy_boundary": privacy_boundary, + "outcome_mapping": { + hold_on.value: DecisionVerdict.DENY.value, + pass_on.value: DecisionVerdict.ALLOW.value, + DecisionOutcome.ABSTAIN.value: DecisionVerdict.UNKNOWN.value, + }, + } + ) def build_memory_write_gate( @@ -106,7 +126,7 @@ def build_memory_write_gate( enabled: bool, hold_on: Literal["yes", "no"] = "yes", threshold: float | None = None, - mode: DecisionPolicyMode = DecisionPolicyMode.ENFORCING, + mode: DecisionPolicyMode = DecisionPolicyMode.SHADOW, privacy_boundary: DecisionPrivacyBoundary = DecisionPrivacyBoundary.LOCAL_ONLY, ) -> MemoryWriteGate | None: """Build the opt-in gate, or return ``None`` while it stays disabled. @@ -121,8 +141,12 @@ def build_memory_write_gate( decision_model, hold_on=DecisionOutcome(hold_on), threshold=threshold, - policy=memory_write_policy(mode=mode, privacy_boundary=privacy_boundary), - allow_model_evaluation=privacy_boundary is DecisionPrivacyBoundary.LOCAL_ONLY, + policy=memory_write_policy( + hold_on=DecisionOutcome(hold_on), + threshold=threshold, + mode=mode, + privacy_boundary=privacy_boundary, + ), ) @@ -144,15 +168,19 @@ def __init__( hold_on: DecisionOutcome = DecisionOutcome.YES, threshold: float | None = None, policy: DecisionPolicy | None = None, - allow_model_evaluation: bool = True, + allow_model_evaluation: bool | None = None, ) -> None: if hold_on is DecisionOutcome.ABSTAIN: raise ValueError("the hold direction cannot be abstain") # noqa: TRY003 self._decision_model = decision_model self._hold_on = hold_on self._threshold = threshold - self._policy = memory_write_policy() if policy is None else policy - self._allow_model_evaluation = allow_model_evaluation + self._policy = _MEMORY_WRITE_POLICY if policy is None else policy + self._allow_model_evaluation = ( + self._policy.privacy_boundary is DecisionPrivacyBoundary.LOCAL_ONLY + and bool(getattr(decision_model, "is_local_only", False)) + and allow_model_evaluation is not False + ) self.policy_id = decision_model.policy_id @property @@ -205,6 +233,21 @@ async def assess(self, request: MemoryWriteGateRequest, /) -> MemoryWriteAssessm self._log(assessment) return assessment + async def assess_preflight( + self, request: MemoryWriteGateRequest, rejection: MemoryWriteAssessment, / + ) -> MemoryWriteAssessment: + """Apply this policy's mode and observation handling to a service-side evidence rejection.""" + + policy_assessment = assess_local_rule( + self._policy, + verdict=DecisionVerdict.DENY, + reason="evidence_projection_budget", + ) + assessment = self._mode_assessment(rejection) + await self._observe(request, policy_assessment, assessment) + self._log(assessment) + return assessment + def _map(self, request: MemoryWriteGateRequest, decision: DecisionResult) -> MemoryWriteAssessment: assessment = _assess_memory_write_decision(decision, hold_on=self._hold_on, policy=self._policy) if assessment.coverage is DecisionCoverage.UNADJUDICATED: @@ -262,6 +305,7 @@ async def _observe( evidence_refs=request.evidence_refs or tuple(f"evidence:{index}" for index in range(1, len(request.evidence) + 1)), privacy_boundary=self._policy.privacy_boundary, + privacy_outcome=self._privacy_outcome(policy_assessment), model_policy_id=None if policy_assessment.source.value == "none" else self.policy_id, assessment=_safe_observation_assessment(policy_assessment), final_action=f"memory_write_{assessment.verdict.value}", @@ -289,6 +333,19 @@ async def _observe( }, ) + def _privacy_outcome(self, assessment: DecisionAssessment) -> str: + if assessment.source.value == "local_rule": + return "local_rule" + if assessment.source.value == "decision_model": + return "local_model_called" + if self._policy.privacy_boundary is DecisionPrivacyBoundary.NO_EXTERNAL_CALL: + return "no_external_call" + if not self._allow_model_evaluation: + return "local_only_refused" + if assessment.used_fallback: + return "local_model_fallback" + return "no_call" + def _log(self, assessment: MemoryWriteAssessment) -> None: event = { MemoryWriteVerdict.HOLD: "memory.write-gate.hold", @@ -338,6 +395,13 @@ def _assess_memory_write_decision( ) +def _policy_version(hold_on: DecisionOutcome, threshold: float | None) -> str: + """Bind every action-changing calibration input into the reviewable policy version.""" + + threshold_value = "none" if threshold is None else format(threshold, ".17g") + return f"1;hold_on={hold_on.value};threshold={threshold_value}" + + def _bounded_subject(candidates: tuple[str, ...]) -> str: return _candidate_subject(candidates)[:_MAX_SUBJECT_LENGTH] @@ -372,6 +436,7 @@ def _safe_observation_reason(reason: str | None, /) -> str | None: return { "candidate_batch_budget": "candidate_batch_budget", + "evidence_projection_budget": "evidence_projection_budget", "privacy_boundary": "privacy_boundary", "decision_model_fallback": "decision_model_fallback", "abstain": "abstain", diff --git a/src/powercontext/server/logging.py b/src/powercontext/server/logging.py index c33141597a..1c5f13d67d 100644 --- a/src/powercontext/server/logging.py +++ b/src/powercontext/server/logging.py @@ -61,6 +61,17 @@ "source_count", "held_count", "hold_codes", + "consumer", + "policy_id", + "policy_version", + "privacy_boundary", + "privacy_outcome", + "coverage", + "verdict", + "final_action", + "fallback_reason", + "candidate_count", + "evidence_count", "trace_id", "span_id", *_PROCESSING_FIELDS, diff --git a/tests/builtin/persistence/test_mysql_schema.py b/tests/builtin/persistence/test_mysql_schema.py index a95975bf1a..ae0b90a5b4 100644 --- a/tests/builtin/persistence/test_mysql_schema.py +++ b/tests/builtin/persistence/test_mysql_schema.py @@ -15,7 +15,7 @@ import re from pathlib import Path -from sqlalchemy import BigInteger, Date, Integer, LargeBinary, String, Table +from sqlalchemy import BigInteger, Date, DateTime, Integer, LargeBinary, String, Table from sqlalchemy.dialects import mysql from sqlalchemy.schema import CreateTable, ForeignKeyConstraint, PrimaryKeyConstraint, UniqueConstraint @@ -51,6 +51,8 @@ def _column_budget(column) -> int: return 4 if isinstance(column.type, Date): return 3 + if isinstance(column.type, DateTime): + return 8 raise _UnbudgetedColumnTypeError(column.type) diff --git a/tests/builtin/runtime/test_decision_composition.py b/tests/builtin/runtime/test_decision_composition.py index 10f616ad58..71eb230674 100644 --- a/tests/builtin/runtime/test_decision_composition.py +++ b/tests/builtin/runtime/test_decision_composition.py @@ -20,12 +20,14 @@ from typing import Any import pytest +from pydantic import AnyHttpUrl from powercontext.builtin.inference import InferenceUnavailableError, InferenceUsage from powercontext.builtin.persistence.sqlite import SQLiteConfig from powercontext.builtin.runtime import ( BuiltinConfig, RuntimeConfig, + composition, open_builtin_runtime, preflight_builtin_runtime, ) @@ -186,3 +188,18 @@ def test_decision_role_is_not_registered_as_an_mcp_tool() -> None: from powercontext.server import mcp assert all("decision" not in operation_id for operation_id in mcp._MCP_OPERATION_IDS) + + +@pytest.mark.parametrize( + ("endpoint", "expected"), + [ + ("http://localhost:11434/v1", True), + ("http://127.0.0.1:11434/v1", True), + ("http://[::1]:11434/v1", True), + ("http://0.0.0.0:11434/v1", False), + ("http://192.168.1.2:11434/v1", False), + ("https://api.example.test/v1", False), + ], +) +def test_only_explicit_loopback_decision_endpoints_are_local(endpoint: str, expected: bool) -> None: + assert composition._is_loopback_endpoint(AnyHttpUrl(endpoint)) is expected diff --git a/tests/builtin/runtime/test_decision_config.py b/tests/builtin/runtime/test_decision_config.py index 35691f6e73..26320cc140 100644 --- a/tests/builtin/runtime/test_decision_config.py +++ b/tests/builtin/runtime/test_decision_config.py @@ -21,7 +21,7 @@ from powercontext.builtin.runtime import RuntimeConfig from powercontext.builtin.runtime.config import InferenceConfig -from powercontext.builtin.runtime.decision_policy import DecisionPrivacyBoundary +from powercontext.builtin.runtime.decision_policy import DecisionPolicyMode, DecisionPrivacyBoundary def test_decision_assistance_is_disabled_by_default() -> None: @@ -32,6 +32,10 @@ def test_memory_write_gate_defaults_to_no_external_call() -> None: assert RuntimeConfig().memory_write_gate_privacy_boundary is DecisionPrivacyBoundary.NO_EXTERNAL_CALL +def test_memory_write_gate_defaults_to_shadow_mode() -> None: + assert RuntimeConfig().memory_write_gate_mode is DecisionPolicyMode.SHADOW + + def test_decision_observation_retention_is_disabled_until_explicitly_configured() -> None: assert RuntimeConfig().decision_observation_retention_days is None assert RuntimeConfig(decision_observation_retention_days=30).decision_observation_retention_days == 30 diff --git a/tests/builtin/runtime/test_memory_write_gate_contract.py b/tests/builtin/runtime/test_memory_write_gate_contract.py index 03a697b042..767100cd9b 100644 --- a/tests/builtin/runtime/test_memory_write_gate_contract.py +++ b/tests/builtin/runtime/test_memory_write_gate_contract.py @@ -57,6 +57,7 @@ class _StaticDecisionModel: """A backend that always returns one prepared verdict.""" policy_id = "powercontext.decision.static.v1" + is_local_only = True def __init__(self, result: DecisionResult) -> None: self._result = result @@ -79,6 +80,7 @@ class _FailingDecisionModel: """A backend whose every evaluation raises.""" policy_id = "powercontext.decision.failing.v1" + is_local_only = True async def evaluate(self, request: DecisionRequest, /) -> DecisionResult: raise ValueError("backend unavailable") # noqa: TRY003 @@ -98,6 +100,7 @@ class _PolarityBackend: """A backend that calls one known answer insufficient and the other sufficient.""" policy_id = "powercontext.decision.polarity.v1" + is_local_only = True def __init__(self, *, insufficient_for: frozenset[str]) -> None: self._insufficient_for = frozenset(_candidate_subject((value,)) for value in insufficient_for) @@ -355,6 +358,8 @@ async def scenario() -> None: record = next(record for record in caplog.records if getattr(record, "event", None) == "decision.observation") assert record.__dict__["policy_id"] == "memory.write.evidence_sufficiency.v1" assert record.__dict__["mode"] == "shadow" + assert record.__dict__["privacy_boundary"] == "local_only" + assert record.__dict__["privacy_outcome"] == "local_model_called" assert record.__dict__["coverage"] == "adjudicated" assert "secret claim" not in str(record.__dict__) assert "secret evidence" not in str(record.__dict__) @@ -386,6 +391,7 @@ async def scenario() -> None: assert observation.subject_refs == ("entry:entry-a@version-a",) assert observation.evidence_refs == ("source:task:1",) assert observation.final_action == "memory_write_hold" + assert observation.privacy_outcome == "local_model_called" assert "secret claim" not in observation.model_dump_json() assert "secret evidence" not in observation.model_dump_json() @@ -490,6 +496,18 @@ def test_the_gate_is_built_from_configuration() -> None: assert gate.mode is DecisionPolicyMode.SHADOW +def test_new_memory_write_policies_default_to_shadow_mode() -> None: + assert memory_write_policy().mode is DecisionPolicyMode.SHADOW + + +def test_memory_write_policy_version_binds_the_hold_direction_and_threshold() -> None: + default = memory_write_policy(hold_on=DecisionOutcome.YES, threshold=None) + different_direction = memory_write_policy(hold_on=DecisionOutcome.NO, threshold=None) + different_threshold = memory_write_policy(hold_on=DecisionOutcome.YES, threshold=0.4) + + assert len({default.version, different_direction.version, different_threshold.version}) == 3 + + def test_disabled_mode_does_not_construct_a_memory_write_gate() -> None: gate = build_memory_write_gate( _StaticDecisionModel(_verdict(DecisionOutcome.YES)), @@ -528,6 +546,45 @@ async def scenario() -> None: assert record.__dict__["fallback_reason"] == "privacy_boundary" +def test_direct_gate_construction_honors_the_no_external_call_boundary() -> None: + async def scenario() -> None: + backend = _RecordingDecisionModel(_verdict(DecisionOutcome.YES)) + gate = DecisionMemoryWriteGate( + backend, + policy=memory_write_policy(privacy_boundary=DecisionPrivacyBoundary.NO_EXTERNAL_CALL), + ) + + assessment = await gate.assess(_request(evidence=("source:task:1",))) + + assert assessment.verdict is MemoryWriteVerdict.ACCEPT + assert assessment.used_fallback is True + assert backend.requests == [] + + asyncio.run(scenario()) + + +def test_local_only_boundary_refuses_a_backend_without_a_locality_attestation() -> None: + class _RemoteDecisionModel(_RecordingDecisionModel): + is_local_only = False + + async def scenario() -> None: + backend = _RemoteDecisionModel(_verdict(DecisionOutcome.YES)) + gate = build_memory_write_gate( + backend, + enabled=True, + privacy_boundary=DecisionPrivacyBoundary.LOCAL_ONLY, + ) + + assert isinstance(gate, DecisionMemoryWriteGate) + assessment = await gate.assess(_request(evidence=("source:task:1",))) + + assert assessment.verdict is MemoryWriteVerdict.ACCEPT + assert assessment.used_fallback is True + assert backend.requests == [] + + asyncio.run(scenario()) + + def _request( *, candidates: tuple[str, ...] = ("Remember the contract change.",), diff --git a/tests/builtin/runtime/test_memory_write_gate_paths.py b/tests/builtin/runtime/test_memory_write_gate_paths.py index ee480c1c1b..c847ff0cc9 100644 --- a/tests/builtin/runtime/test_memory_write_gate_paths.py +++ b/tests/builtin/runtime/test_memory_write_gate_paths.py @@ -53,8 +53,8 @@ DecisionResult, FailOpenDecisionModel, ) -from powercontext.builtin.runtime.decision_policy import DecisionPrivacyBoundary -from powercontext.builtin.runtime.memory_write_gate import DecisionMemoryWriteGate +from powercontext.builtin.runtime.decision_policy import DecisionPolicyMode, DecisionPrivacyBoundary +from powercontext.builtin.runtime.memory_write_gate import DecisionMemoryWriteGate, memory_write_policy from powercontext.builtin.scope import ScopeDraft from powercontext.builtin.sources import ContentCapture, ContentSource from powercontext.errors import ArtifactNotFoundError @@ -140,6 +140,7 @@ async def assess(self, request: MemoryWriteGateRequest, /) -> MemoryWriteAssessm class _FailingDecisionModel: policy_id = "test.decision.failing.v1" + is_local_only = True async def evaluate(self, request: DecisionRequest, /) -> DecisionResult: raise ValueError("backend unavailable") # noqa: TRY003 @@ -149,6 +150,7 @@ class _InsufficientDecisionModel: """A backend that always answers "evidence is insufficient" (the hold direction).""" policy_id = "test.decision.insufficient.v1" + is_local_only = True async def evaluate(self, request: DecisionRequest, /) -> DecisionResult: return DecisionResult(DecisionOutcome.YES, self.policy_id, InferenceUsage(requests=1)) @@ -250,6 +252,7 @@ async def scenario() -> None: tmp_path, RuntimeConfig( memory_write_gate_enabled=True, + memory_write_gate_mode=DecisionPolicyMode.ENFORCING, memory_write_gate_privacy_boundary=DecisionPrivacyBoundary.LOCAL_ONLY, ), database="enabled.db", @@ -664,6 +667,46 @@ async def scenario() -> None: asyncio.run(scenario()) +@pytest.mark.parametrize( + ("mode", "verdict"), + [ + (DecisionPolicyMode.SHADOW, MemoryWriteVerdict.ACCEPT), + (DecisionPolicyMode.ADVISORY, MemoryWriteVerdict.FLAG), + ], +) +def test_mode_aware_gate_observes_projection_budget_rejections_without_holding( + tmp_path: Path, mode: DecisionPolicyMode, verdict: MemoryWriteVerdict +) -> None: + async def scenario() -> None: + gate = DecisionMemoryWriteGate( + _InsufficientDecisionModel(), + policy=memory_write_policy(mode=mode), + ) + async with open_builtin_contexts(_config(tmp_path), memory_write_gate=gate) as contexts: + context = await contexts.get("project") + long_source, _ = await context.sources.capture( + ContentCapture(source_id="over-budget", content=f"{'Evidence. ' * 500}Tail."), + ) + + plan = await context.artifacts.memory.plan_remember( + memory=None, + sources=(long_source,), + entries=(MemoryEntryInput(kind="fact", text="Tail.", sources=(long_source,)),), + mode="append", + ) + async with contexts.database.transaction() as connection: + observations = await DecisionObservationRepository().observations(connection, "project") + + assert plan.commit is not None + assert plan.decision is not None + assert plan.decision.verdict is verdict + assert len(observations) == 1 + assert observations[0].assessment.reason == "evidence_projection_budget" + assert observations[0].final_action == f"memory_write_{verdict.value}" + + asyncio.run(scenario()) + + def test_without_a_gate_the_plan_carries_no_decision(tmp_path: Path) -> None: async def scenario() -> None: async with open_builtin_contexts(_config(tmp_path)) as contexts: diff --git a/tests/test_server_logging.py b/tests/test_server_logging.py index 339ce36dc8..80c7523140 100644 --- a/tests/test_server_logging.py +++ b/tests/test_server_logging.py @@ -121,6 +121,17 @@ def test_json_formatter_emits_stable_operational_fields() -> None: "code": "insufficient_coverage", "held_count": 1, "hold_codes": ("insufficient_coverage",), + "consumer": "memory_write_gate", + "policy_id": "memory.write.evidence_sufficiency.v1", + "policy_version": "1", + "privacy_boundary": "local_only", + "privacy_outcome": "local_model_called", + "coverage": "adjudicated", + "verdict": "deny", + "final_action": "memory_write_hold", + "fallback_reason": "decision_model_fallback", + "candidate_count": 1, + "evidence_count": 2, "ignored": "not serialized", }) OperationalContextFilter().filter(record) @@ -136,6 +147,16 @@ def test_json_formatter_emits_stable_operational_fields() -> None: assert payload["code"] == "insufficient_coverage" assert payload["held_count"] == 1 assert payload["hold_codes"] == ["insufficient_coverage"] + assert payload["consumer"] == "memory_write_gate" + assert payload["policy_id"] == "memory.write.evidence_sufficiency.v1" + assert payload["policy_version"] == "1" + assert payload["privacy_boundary"] == "local_only" + assert payload["privacy_outcome"] == "local_model_called" + assert payload["coverage"] == "adjudicated" + assert payload["verdict"] == "deny" + assert payload["final_action"] == "memory_write_hold" + assert payload["fallback_reason"] == "decision_model_fallback" + assert (payload["candidate_count"], payload["evidence_count"]) == (1, 2) assert "ignored" not in payload From 7da33c6c40bf86b330ab25e8d08b0dbb90e28daa Mon Sep 17 00:00:00 2001 From: "Xin.Zh" Date: Sun, 11 Oct 2026 11:01:30 +0800 Subject: [PATCH 05/11] docs(rfc): bound decision policy adoption Clarify that replay and rescore are promotion evidence for enforcing policies, rather than mandatory runtime capabilities for every consumer. Track the Memory write-gate evidence work in #1930. Constraint: the RFC does not require additional consumers, a dashboard, replay tooling, or a cross-consumer evaluation bundle. Scope-risk: consumers without promotion evidence remain disabled, shadow, or advisory; existing authority, Scope, revision, and citation boundaries are unchanged. Tested: website pnpm lint; website pnpm test (13 passed); website pnpm build (255 public pages and 405 repository files link-validated; 950 public pages export-verified); git diff --check. --- .../en/rfcs/1770-decision-policy-governance.md | 18 ++++++++++-------- .../zh/rfcs/1770-decision-policy-governance.md | 15 +++++++++------ 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/docs/en/rfcs/1770-decision-policy-governance.md b/docs/en/rfcs/1770-decision-policy-governance.md index 9910cd96ab..a86ca75844 100644 --- a/docs/en/rfcs/1770-decision-policy-governance.md +++ b/docs/en/rfcs/1770-decision-policy-governance.md @@ -58,7 +58,7 @@ behaviour. RFC 1652 reserves Memory consolidation, supersession, and lifecycle e shared policy and observation contract, each consumer will reinvent its own threshold, fallback, privacy, and audit rules. -The expected outcome is a small shared layer that lets implementation PRs proceed in a consistent order: +For a policy that seeks promotion beyond `shadow` or `advisory`, the shared layer supports this evidence sequence: 1. define a policy in shadow mode; 2. collect observations without changing domain behaviour; @@ -66,7 +66,9 @@ The expected outcome is a small shared layer that lets implementation PRs procee 4. promote only the narrow action a policy has earned; 5. keep domain authority, review, persistence, and access control in the owning service. -This is an enablement RFC, not an adoption recommendation for a specific backend. +This is an enablement RFC, not an adoption recommendation for a specific backend or a commitment to implement every +listed consumer. A consumer may stop at `disabled`, `shadow`, or `advisory`; it does not need a replay runner, dashboard, +or cross-consumer evaluation bundle unless it seeks a stronger mode. # Guide-level explanation @@ -350,8 +352,8 @@ Shadow mode requirements: 2. Every gate attempt emits a `DecisionObservation`. 3. Observations distinguish no-call, local-rule, successful backend, backend fallback, privacy fallback, and parse failure. -4. The policy can be replayed or rescored without re-running the whole workload when enough exact references remain - resolvable. +4. A policy considered for `enforcing` can be replayed or rescored without re-running the whole workload when enough + exact references remain resolvable. Replay has two forms: @@ -361,6 +363,10 @@ Replay has two forms: Both forms must report their input coverage. A replay result that cannot resolve enough original material is not a negative result for the policy; it is an incomplete replay. +Replay/rescore is required promotion evidence, not a required runtime feature for every `shadow` or `advisory` consumer. +A consumer without that evidence must not be promoted to `enforcing`. +Memory write-gate promotion evidence is tracked separately in [#1930](https://github.com/oceanbase/powercontext/issues/1930). + ## Promotion criteria A policy must declare promotion criteria before moving beyond shadow. @@ -484,9 +490,6 @@ are candidate backends or reference points, not policy owners. # Unresolved questions -- What is the first concrete storage target for `DecisionObservation`: structured logs, evaluation artifacts, or a - private internal ledger? -- Which subset of observations should be retained by default, and for how long? - Should policy definitions live as Python constants, JSON/YAML resources, or both? - Which numeric promotion criteria should #1742 use if it is revised to conform to this RFC? - Do hosted-provider privacy boundaries need a user-facing configuration document before the first hosted backend is @@ -499,6 +502,5 @@ are candidate backends or reference points, not policy owners. - A policy registry page in the dashboard showing active policies, modes, fallback rates, and last replay results. - A CLI command that replays one policy against recent observations and prints candidate thresholds. - A standard evaluation bundle for decision policies, shared by Jev, Laya, local rules, and future backends. -- A private decision-observation ledger with retention controls and export for offline evaluation. - Policy-aware Review Inbox grouping, where a review item links back to the policy observation that suggested it. - Local-only decision backends for air-gapped deployments, using the same policy and observation contract. diff --git a/docs/zh/rfcs/1770-decision-policy-governance.md b/docs/zh/rfcs/1770-decision-policy-governance.md index 804f471020..4906b8d7e8 100644 --- a/docs/zh/rfcs/1770-decision-policy-governance.md +++ b/docs/zh/rfcs/1770-decision-policy-governance.md @@ -44,7 +44,7 @@ 项目已经在这个边界上有具体压力。#1742 提出了 Memory 写入时的 evidence gate。#1745 提出了 decision-model reranker,并明确把 rerank 的 fail-closed 行为与 advisory gate 的 fail-open 行为分开。RFC 1652 将 Memory consolidation、supersession 和 lifecycle effects 留给 review-gated 流程。如果没有共享 policy 与 observation 契约,每个消费者都会重新发明自己的阈值、fallback、隐私和审计规则。 -预期结果是一层很小的共享机制,使实现 PR 能按一致顺序推进: +对于寻求从 `shadow` 或 `advisory` 晋升的 policy,这层共享机制支持以下证据链: 1. 先用 shadow mode 定义 policy; 2. 在不改变领域行为的前提下收集 observation; @@ -52,7 +52,9 @@ 4. 只 promote 该 policy 已经证明可以承担的窄域动作; 5. 领域权威、review、持久化和访问控制仍由所属服务负责。 -这是一个 enablement RFC,不是对某个具体后端的采用建议。 +这是一个 enablement RFC,不是对某个具体后端的采用建议,也不承诺实现列出的每个 consumer。consumer 可以停在 +`disabled`、`shadow` 或 `advisory`,除非要进入更强模式,否则不需要实现 replay runner、dashboard 或跨 consumer +evaluation bundle。 # Guide-level explanation @@ -285,7 +287,7 @@ Shadow mode 要求: 1. 最终领域输出与 no-gate 路径 byte-for-byte 或语义等价。 2. 每次 gate attempt 都产生 `DecisionObservation`。 3. observation 区分 no-call、local-rule、successful backend、backend fallback、privacy fallback 和 parse failure。 -4. 只要仍能解析足够精确引用,policy 就可以在不重跑整个 workload 的情况下 replay 或 rescore。 +4. 被考虑进入 `enforcing` 的 policy,在仍能解析足够精确引用时,可以不重跑整个 workload 而 replay 或 rescore。 Replay 有两种形式: @@ -294,6 +296,10 @@ Replay 有两种形式: 两者都必须报告输入覆盖率。无法解析足够原始材料的 replay 结果不是 policy 的负面结果;它是不完整 replay。 +replay/rescore 是 promotion 所需的证据,不是每个 `shadow` 或 `advisory` consumer 都必须交付的运行时功能。缺少该 +证据的 consumer 不得晋升到 `enforcing`。 +Memory write gate 的 promotion evidence 另由 [#1930](https://github.com/oceanbase/powercontext/issues/1930) 跟踪。 + ## Promotion criteria policy 在超越 shadow 前必须先声明 promotion criteria。 @@ -394,8 +400,6 @@ PowerContext 已经有相关基础: # Unresolved questions -- `DecisionObservation` 的第一个具体存储目标是什么:结构化日志、evaluation artifact,还是私有内部 ledger? -- 默认应保留哪些 observation,保留多久? - policy definition 应以 Python constant、JSON/YAML resource,还是两者并存? - 如果 #1742 调整为符合本 RFC,它应使用哪些数字 promotion criteria? - 第一个托管后端启用前,是否需要先有面向用户的 hosted-provider privacy configuration 文档? @@ -406,6 +410,5 @@ PowerContext 已经有相关基础: - 在 dashboard 中增加 policy registry 页面,展示 active policy、mode、fallback rate 和最近 replay 结果。 - 增加 CLI 命令,对最近 observation replay 一个 policy,并输出候选阈值。 - 为 decision policy 提供标准 evaluation bundle,供 Jev、Laya、本地规则和未来后端共用。 -- 增加带 retention controls 的私有 decision-observation ledger,并支持导出做离线 evaluation。 - Review Inbox 支持按 policy 分组,让 review item 反链到触发它的 policy observation。 - 面向气隙部署的 local-only decision backend,复用相同 policy 与 observation 契约。 From 5098f5d2ca37603249dfaf2eb43f846cc447307e Mon Sep 17 00:00:00 2001 From: "Xin.Zh" Date: Sun, 11 Oct 2026 14:10:43 +0800 Subject: [PATCH 06/11] fix(runtime): finalize memory gate observations Move Memory decision sidecar persistence to the owner boundary, record actual outcomes and post-commit entry versions, and preserve all supported source and artifact identities. Constraint: Keep gate failures fail-open and retain no raw candidate or evidence content. Scope-risk: Memory write-gate observation contracts and runtime metadata only. Tested: python -m pytest tests/builtin/runtime/test_memory_write_gate_contract.py tests/builtin/runtime/test_memory_write_gate_paths.py tests/builtin/runtime/test_decision_config.py tests/builtin/runtime/test_decision_composition.py tests/builtin/runtime/test_decision_policy.py tests/test_server_logging.py tests/builtin/persistence/test_decision_observation_repository.py tests/builtin/persistence/test_mysql_schema.py -q (129 passed); python -m ruff check changed files; python -m ruff format --check changed files; python -m ty check changed runtime files --- .env.example | 2 +- .../builtin/artifacts/memory/protocols.py | 7 +- .../builtin/artifacts/memory/service.py | 180 ++++++++++++--- .../builtin/decision_observations.py | 4 +- .../persistence/decision_observations.py | 4 +- .../builtin/runtime/composition.py | 8 +- .../builtin/runtime/decision_model.py | 12 +- .../builtin/runtime/memory_write_gate.py | 86 ++++++-- .../test_memory_write_gate_contract.py | 59 ++++- .../runtime/test_memory_write_gate_paths.py | 206 ++++++++++++++++-- 10 files changed, 494 insertions(+), 74 deletions(-) diff --git a/.env.example b/.env.example index 4c096ca8d3..645355b31a 100644 --- a/.env.example +++ b/.env.example @@ -116,7 +116,7 @@ POWERCONTEXT_SERVER_RUNTIME_DREAM_MAX_PENDING_PER_SCOPE=32 # The default makes no decision-model call. Set `local_only` only for an in-process or loopback # decision backend controlled by this deployment. `hosted_redacted` is rejected until a shared # PowerContext content sanitizer is separately reviewed. -# POWERCONTEXT_SERVER_RUNTIME_MEMORY_WRITE_GATE_PRIVACY_BOUNDARY=local_only +# POWERCONTEXT_SERVER_RUNTIME_MEMORY_WRITE_GATE_PRIVACY_BOUNDARY=no_external_call # Direction only (which verdict means the cited evidence is insufficient): "yes" or "no". # POWERCONTEXT_SERVER_RUNTIME_MEMORY_WRITE_GATE_HOLD_ON=yes # Optional confidence floor below which a hold becomes a written-but-annotated change. diff --git a/src/powercontext/builtin/artifacts/memory/protocols.py b/src/powercontext/builtin/artifacts/memory/protocols.py index c11e600d11..cd607eaf89 100644 --- a/src/powercontext/builtin/artifacts/memory/protocols.py +++ b/src/powercontext/builtin/artifacts/memory/protocols.py @@ -19,7 +19,7 @@ from contextlib import AbstractAsyncContextManager from dataclasses import dataclass from enum import StrEnum -from typing import TYPE_CHECKING, Protocol, runtime_checkable +from typing import Protocol, runtime_checkable from pydantic import BaseModel, ConfigDict @@ -35,13 +35,11 @@ MemoryRevisionChanges, MemoryUsedSearchMode, ) +from powercontext.builtin.decision_observations import DecisionObservation from powercontext.builtin.inference import EmbeddingModel, EmbeddingVector from powercontext.builtin.tags import TagFilter from powercontext.sources import Source -if TYPE_CHECKING: - from powercontext.builtin.decision_observations import DecisionObservation - class MemoryWriteObservationSink(Protocol): """Accept a bounded decision sidecar without owning the Memory write.""" @@ -162,6 +160,7 @@ class MemoryWritePlan(BaseModel): result: Memory | None commit: MemoryCommit | None decision: MemoryWriteAssessment | None = None + decision_observation: DecisionObservation | None = None class MemorySearchRequest(BaseModel): diff --git a/src/powercontext/builtin/artifacts/memory/service.py b/src/powercontext/builtin/artifacts/memory/service.py index 3cc57eb92b..a1c6a95143 100644 --- a/src/powercontext/builtin/artifacts/memory/service.py +++ b/src/powercontext/builtin/artifacts/memory/service.py @@ -17,6 +17,7 @@ from __future__ import annotations import logging +from base64 import urlsafe_b64encode from collections.abc import Callable, Sequence from contextlib import nullcontext from dataclasses import dataclass @@ -102,6 +103,7 @@ from powercontext.builtin.artifacts.memory.reranking import MemoryReranker from powercontext.builtin.artifacts.prompt.service import ScopedPrompts, current_prompt, prompt_operation from powercontext.builtin.artifacts.search import AdmissionCounts, AdmissionFloor, analyze_fts_query, analyze_text +from powercontext.builtin.decision_observations import DecisionObservation from powercontext.builtin.inference import ( EmbeddingModel, EmbeddingVector, @@ -178,6 +180,16 @@ class _GateEvidenceProjection: rejection: MemoryWriteAssessment | None = None +@dataclass(slots=True) +class _PendingDecisionObservation: + """Capture a gate sidecar until the Memory owner knows the write outcome.""" + + observation: DecisionObservation | None = None + + async def record(self, observation: DecisionObservation, /) -> None: + self.observation = observation + + @dataclass(slots=True) class _GateEvidenceBuilder: policy_id: str @@ -543,36 +555,77 @@ async def plan_remember( if not candidates: return MemoryWritePlan(result=base, commit=None) - assessment = await self._assess_write(base, candidates, evidence, current_entries) + assessment, observation = await self._assess_write(base, candidates, evidence, current_entries) if assessment is not None and assessment.verdict is MemoryWriteVerdict.HOLD: # A refused write stays visible: the caller reads the structured code and reason # from the plan. The plan carries no commit, so nothing is written. - return MemoryWritePlan(result=base, commit=None, decision=assessment) + await self._record_decision_observation( + observation, + final_action="memory_write_held", + incomplete_subject_count=len(observation.subject_refs) if observation is not None else 0, + ) + return MemoryWritePlan(result=base, commit=None, decision=assessment, decision_observation=observation) flagged_reason = ( assessment.reason if assessment is not None and assessment.verdict is MemoryWriteVerdict.FLAG else None ) - commit = await self._prepare_commit( - base=base, - candidates=candidates, - evidence=evidence, - current_entries=current_entries, - flagged_reason=flagged_reason, - ) + try: + commit = await self._prepare_commit( + base=base, + candidates=candidates, + evidence=evidence, + current_entries=current_entries, + flagged_reason=flagged_reason, + ) + except Exception: + await self._record_decision_observation( + observation, + final_action="memory_write_failed", + incomplete_subject_count=len(observation.subject_refs) if observation is not None else 0, + ) + raise if commit is None: - return MemoryWritePlan(result=base, commit=None, decision=assessment) - return MemoryWritePlan(result=commit.memory, commit=commit, decision=assessment) + await self._record_decision_observation( + observation, + final_action="memory_write_unchanged", + incomplete_subject_count=len(observation.subject_refs) if observation is not None else 0, + ) + return MemoryWritePlan(result=base, commit=None, decision=assessment, decision_observation=observation) + return MemoryWritePlan( + result=commit.memory, + commit=commit, + decision=assessment, + decision_observation=observation, + ) async def apply(self, plan: MemoryWritePlan, /) -> Memory | None: """Apply one prepared write through this service's transaction boundary.""" if plan.commit is None: return plan.result - async with self._backend.begin() as unit_of_work: - committed = await unit_of_work.commit(plan.commit) + try: + async with self._backend.begin() as unit_of_work: + committed = await unit_of_work.commit(plan.commit) + except Exception: + await self._record_decision_observation( + plan.decision_observation, + final_action="memory_write_failed", + incomplete_subject_count=( + len(plan.decision_observation.subject_refs) if plan.decision_observation is not None else 0 + ), + ) + raise if plan.result != committed: raise InvalidMemoryCitationError("memory-mismatch") self._cache_projections(committed, plan.commit.projections) + await self._record_decision_observation( + plan.decision_observation, + final_action="memory_write_applied", + operation_id=f"memory-write:{committed.artifact_id}@{committed.revision}", + subject_refs=tuple( + f"entry:{entry.entry_id}@{entry.entry_version_id}" for entry in plan.commit.entry_versions + ), + ) return committed async def forget( @@ -1375,12 +1428,13 @@ async def _assess_write( candidates: tuple[MemoryEntryInput, ...], evidence: _OperationEvidence, current_entries: tuple[MemoryEntryVersion, ...] | None, - ) -> MemoryWriteAssessment | None: + ) -> tuple[MemoryWriteAssessment | None, DecisionObservation | None]: """Ask the configured gate about one candidate set; ``None`` means no gate is active.""" if self._write_gate is None: - return None + return None, None projection = await self._gate_evidence(base, candidates, evidence, current_entries) + pending = _PendingDecisionObservation() request = MemoryWriteGateRequest( candidates=tuple(candidate.text for candidate in candidates), evidence=projection.entries, @@ -1389,20 +1443,75 @@ async def _assess_write( operation_id=_gate_operation_id(base), subject_refs=_gate_subject_refs(candidates), evidence_refs=tuple(_gate_evidence_ref(entry) for entry in projection.entries), - observation_sink=self._write_gate_observation_sink, + observation_sink=pending, ) if projection.rejection is not None: if isinstance(self._write_gate, MemoryWriteGatePreflight): - return await self._write_gate.assess_preflight(request, projection.rejection) + try: + return await self._write_gate.assess_preflight(request, projection.rejection), pending.observation + except Exception: + return ( + MemoryWriteAssessment( + verdict=MemoryWriteVerdict.ACCEPT, + policy_id=self._write_gate.policy_id, + used_fallback=True, + ), + pending.observation, + ) _log_gate_assessment(projection.rejection) - return projection.rejection + return projection.rejection, pending.observation try: - return await self._write_gate.assess(request) + return await self._write_gate.assess(request), pending.observation except Exception: - return MemoryWriteAssessment( - verdict=MemoryWriteVerdict.ACCEPT, - policy_id=self._write_gate.policy_id, - used_fallback=True, + return ( + MemoryWriteAssessment( + verdict=MemoryWriteVerdict.ACCEPT, + policy_id=self._write_gate.policy_id, + used_fallback=True, + ), + pending.observation, + ) + + async def _record_decision_observation( + self, + observation: DecisionObservation | None, + *, + final_action: str, + operation_id: str | None = None, + subject_refs: tuple[str, ...] | None = None, + incomplete_subject_count: int = 0, + ) -> None: + if observation is None or self._write_gate_observation_sink is None: + return + metadata = dict(observation.metadata) + if incomplete_subject_count: + metadata["incomplete_subject_count"] = incomplete_subject_count + finalized = observation.model_copy( + update={ + "final_action": final_action, + "operation_id": observation.operation_id if operation_id is None else operation_id, + "subject_refs": () + if incomplete_subject_count + else observation.subject_refs + if subject_refs is None + else subject_refs, + "metadata": metadata, + } + ) + try: + await self._write_gate_observation_sink.record(finalized) + except Exception as error: + log_safely( + logger, + logging.WARNING, + "Decision observation sink failed", + extra={ + "event": "decision.observation_sink_failed", + "consumer": finalized.consumer, + "policy_id": finalized.policy_id, + "operation_id": finalized.operation_id, + "error_type": type(error).__name__, + }, ) async def _gate_evidence( @@ -1454,7 +1563,7 @@ async def _candidate_gate_evidence(self, candidate: _GateCandidateEvidence) -> t def _source_gate_evidence(self, candidate_index: int, source: Source) -> _GateEvidenceEntry: ref = self._source_refs((source,))[0] - identity = _candidate_gate_identity(candidate_index, f"source:{ref.source_type}:{ref.source_id}") + identity = _candidate_gate_identity(candidate_index, _gate_source_reference(ref)) content = _source_gate_content(source, self._source_resolver) if isinstance(content, str) and content.strip(): return _bounded_gate_evidence(identity, content) @@ -1464,12 +1573,12 @@ def _source_gate_evidence(self, candidate_index: int, source: Source) -> _GateEv def _artifact_gate_evidence(candidate_index: int, artifact: Artifact[object]) -> _GateEvidenceEntry: ref = artifact.as_ref() return _bounded_gate_evidence( - _candidate_gate_identity(candidate_index, f"artifact:{ref.family}:{ref.artifact_id}@{ref.revision}"), + _candidate_gate_identity(candidate_index, _gate_artifact_reference(ref)), _artifact_gate_content(artifact), ) async def _source_ref_gate_evidence(self, candidate_index: int, ref: SourceRef) -> _GateEvidenceEntry: - identity = _candidate_gate_identity(candidate_index, f"source:{ref.source_type}:{ref.source_id}") + identity = _candidate_gate_identity(candidate_index, _gate_source_reference(ref)) if self._source_resolver is None: return _incomplete_gate_evidence(identity) try: @@ -1478,7 +1587,7 @@ async def _source_ref_gate_evidence(self, candidate_index: int, ref: SourceRef) return _incomplete_gate_evidence(identity) async def _artifact_ref_gate_evidence(self, candidate_index: int, ref: ArtifactRef) -> _GateEvidenceEntry: - identity = _candidate_gate_identity(candidate_index, f"artifact:{ref.family}:{ref.artifact_id}@{ref.revision}") + identity = _candidate_gate_identity(candidate_index, _gate_artifact_reference(ref)) if self._artifact_resolver is None: return _incomplete_gate_evidence(identity) try: @@ -1851,6 +1960,23 @@ def _candidate_gate_identity(candidate_index: int, identity: str) -> str: return f"candidate:{candidate_index} {identity}" +def _gate_source_reference(reference: SourceRef, /) -> str: + """Encode source components so every supported identifier remains one safe reference token.""" + + return f"source:{_gate_reference_component(reference.source_type)}:{_gate_reference_component(reference.source_id)}" + + +def _gate_artifact_reference(reference: ArtifactRef, /) -> str: + return ( + f"artifact:{_gate_reference_component(reference.family)}:" + f"{_gate_reference_component(reference.artifact_id)}@{reference.revision}" + ) + + +def _gate_reference_component(value: str, /) -> str: + return urlsafe_b64encode(value.encode("utf-8")).decode("ascii").rstrip("=") + + def _gate_operation_id(base: Memory | None) -> str: if base is None: return "memory-write:new" diff --git a/src/powercontext/builtin/decision_observations.py b/src/powercontext/builtin/decision_observations.py index 8497849713..dcdb0c824e 100644 --- a/src/powercontext/builtin/decision_observations.py +++ b/src/powercontext/builtin/decision_observations.py @@ -133,8 +133,8 @@ class DecisionObservation(_StrictModel): policy_id: Annotated[str, Field(min_length=1, max_length=256)] policy_version: Annotated[str, Field(min_length=1, max_length=64)] mode: DecisionPolicyMode - subject_refs: tuple[Annotated[str, Field(min_length=1, max_length=512)], ...] = () - evidence_refs: tuple[Annotated[str, Field(min_length=1, max_length=512)], ...] = () + subject_refs: tuple[Annotated[str, Field(min_length=1, max_length=2200)], ...] = () + evidence_refs: tuple[Annotated[str, Field(min_length=1, max_length=2200)], ...] = () privacy_boundary: DecisionPrivacyBoundary privacy_outcome: Annotated[str | None, Field(min_length=1, max_length=128)] = None provider_id: Annotated[str | None, Field(min_length=1, max_length=128)] = None diff --git a/src/powercontext/builtin/persistence/decision_observations.py b/src/powercontext/builtin/persistence/decision_observations.py index 560ad15438..1543e34613 100644 --- a/src/powercontext/builtin/persistence/decision_observations.py +++ b/src/powercontext/builtin/persistence/decision_observations.py @@ -33,9 +33,9 @@ _KIND = "decision-observation" _SAFE_REASON = re.compile(r"[a-z][a-z0-9_]{0,127}\Z") _SAFE_REFERENCE = re.compile( - r"(?:candidate|evidence):[0-9]+|(?:candidate:[0-9]+ )?(?:entry|source|artifact):[A-Za-z0-9_.:@-]{1,500}\Z" + r"(?:candidate|evidence):[0-9]+|(?:candidate:[0-9]+ )?(?:entry|source|artifact):[A-Za-z0-9_.:@-]{1,2200}\Z" ) -_SAFE_METADATA_KEYS = frozenset({"candidate_count", "evidence_count"}) +_SAFE_METADATA_KEYS = frozenset({"candidate_count", "evidence_count", "incomplete_subject_count"}) class DecisionObservationRepository: diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 07fbef0079..e5f8fea7a9 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -309,6 +309,8 @@ def __init__(self, delegate: DecisionModel, tracing: RuntimeTracing) -> None: self._tracing = tracing self.policy_id = delegate.policy_id self.is_local_only = bool(getattr(delegate, "is_local_only", False)) + self.provider_id = getattr(delegate, "provider_id", None) + self.backend_model_id = getattr(delegate, "backend_model_id", None) async def evaluate(self, request: DecisionRequest, /) -> DecisionResult: with self._tracing.stage( @@ -1506,6 +1508,7 @@ async def _generation_decision( decision_provider_model = generation_provider_model decision_model = generation_model + decision_model_name = settings.decision_model or settings.generation_model inherits_generation = settings.decision_model is None decision_headers = ( _merge_headers(settings.generation_headers, settings.decision_headers) @@ -1514,7 +1517,6 @@ async def _generation_decision( ) separate_decision_model = settings.decision_model is not None or bool(settings.decision_headers) if separate_decision_model: - decision_model_name = settings.decision_model or settings.generation_model if decision_model_name is None: raise BuiltinConfigurationError("decision-model") decision_provider_model, decision_model = await _open_pydantic_ai_model( @@ -1555,6 +1557,10 @@ async def _generation_decision( is_local_only=_is_loopback_endpoint( settings.decision_base_url if settings.decision_model is not None else settings.generation_base_url ), + provider_id=None if decision_model_name is None else decision_model_name.partition(":")[0], + backend_model_id=None + if decision_model_name is None + else decision_model_name.partition(":")[2] or decision_model_name, ) decision_readiness: ReadinessProbe | None = None diff --git a/src/powercontext/builtin/runtime/decision_model.py b/src/powercontext/builtin/runtime/decision_model.py index a6f3edc2ce..0128f9c325 100644 --- a/src/powercontext/builtin/runtime/decision_model.py +++ b/src/powercontext/builtin/runtime/decision_model.py @@ -145,10 +145,18 @@ class LLMDecisionModel: policy_id = DECISION_INSTRUCTIONS_VERSION def __init__( - self, generator: StructuredGenerator[DecisionInput, DecisionOutput], /, *, is_local_only: bool = False + self, + generator: StructuredGenerator[DecisionInput, DecisionOutput], + /, + *, + is_local_only: bool = False, + provider_id: str | None = None, + backend_model_id: str | None = None, ) -> None: self._generator = generator self.is_local_only = is_local_only + self.provider_id = provider_id + self.backend_model_id = backend_model_id async def evaluate(self, request: DecisionRequest, /) -> DecisionResult: """Map the model's validated answer onto one portable result.""" @@ -181,6 +189,8 @@ def __init__(self, delegate: DecisionModel, /, *, timeout_seconds: float | None self._delegate = delegate self.policy_id = delegate.policy_id self.is_local_only = bool(getattr(delegate, "is_local_only", False)) + self.provider_id = getattr(delegate, "provider_id", None) + self.backend_model_id = getattr(delegate, "backend_model_id", None) self._timeout_seconds = timeout_seconds async def evaluate(self, request: DecisionRequest, /) -> DecisionResult: diff --git a/src/powercontext/builtin/runtime/memory_write_gate.py b/src/powercontext/builtin/runtime/memory_write_gate.py index 02b3256ab2..563ab798d7 100644 --- a/src/powercontext/builtin/runtime/memory_write_gate.py +++ b/src/powercontext/builtin/runtime/memory_write_gate.py @@ -25,6 +25,7 @@ import json import logging +from time import perf_counter from typing import Literal from powercontext._logging import log_safely @@ -135,18 +136,21 @@ def build_memory_write_gate( enabled; a disabled gate and a missing backend both resolve to ``None``. """ - if not enabled or mode is DecisionPolicyMode.DISABLED or decision_model is None: + if not enabled or mode is DecisionPolicyMode.DISABLED: + return None + policy = memory_write_policy( + hold_on=DecisionOutcome(hold_on), + threshold=threshold, + mode=mode, + privacy_boundary=privacy_boundary, + ) + if decision_model is None and privacy_boundary is not DecisionPrivacyBoundary.NO_EXTERNAL_CALL: return None return DecisionMemoryWriteGate( decision_model, hold_on=DecisionOutcome(hold_on), threshold=threshold, - policy=memory_write_policy( - hold_on=DecisionOutcome(hold_on), - threshold=threshold, - mode=mode, - privacy_boundary=privacy_boundary, - ), + policy=policy, ) @@ -162,26 +166,43 @@ class DecisionMemoryWriteGate: def __init__( self, - decision_model: DecisionModel, + decision_model: DecisionModel | None, /, *, - hold_on: DecisionOutcome = DecisionOutcome.YES, + hold_on: DecisionOutcome | None = None, threshold: float | None = None, policy: DecisionPolicy | None = None, allow_model_evaluation: bool | None = None, ) -> None: if hold_on is DecisionOutcome.ABSTAIN: raise ValueError("the hold direction cannot be abstain") # noqa: TRY003 + if policy is None: + resolved_hold_on = DecisionOutcome.YES if hold_on is None else hold_on + policy = memory_write_policy( + hold_on=resolved_hold_on, + threshold=threshold, + mode=DecisionPolicyMode.ENFORCING, + privacy_boundary=DecisionPrivacyBoundary.LOCAL_ONLY, + ) + else: + if policy.mode is DecisionPolicyMode.DISABLED: + raise ValueError("a disabled policy cannot construct a memory write gate") # noqa: TRY003 + resolved_hold_on = _policy_hold_on(policy) + if hold_on is not None and hold_on is not resolved_hold_on: + raise ValueError("the hold direction must match the supplied policy") # noqa: TRY003 + if policy.version != _policy_version(resolved_hold_on, threshold): + raise ValueError("the operational parameters must match the supplied policy version") # noqa: TRY003 self._decision_model = decision_model - self._hold_on = hold_on + self._hold_on = resolved_hold_on self._threshold = threshold - self._policy = _MEMORY_WRITE_POLICY if policy is None else policy + self._policy = policy self._allow_model_evaluation = ( - self._policy.privacy_boundary is DecisionPrivacyBoundary.LOCAL_ONLY + decision_model is not None + and self._policy.privacy_boundary is DecisionPrivacyBoundary.LOCAL_ONLY and bool(getattr(decision_model, "is_local_only", False)) and allow_model_evaluation is not False ) - self.policy_id = decision_model.policy_id + self.policy_id = "memory.write-gate.no-backend" if decision_model is None else decision_model.policy_id @property def mode(self) -> DecisionPolicyMode: @@ -218,6 +239,17 @@ async def assess(self, request: MemoryWriteGateRequest, /) -> MemoryWriteAssessm await self._observe(request, policy_assessment, assessment) self._log(assessment) return assessment + if self._decision_model is None: + policy_assessment = unadjudicated_decision_assessment(self._policy, reason="decision_model_unavailable") + assessment = MemoryWriteAssessment( + verdict=MemoryWriteVerdict.ACCEPT, + policy_id=self.policy_id, + used_fallback=True, + ) + await self._observe(request, policy_assessment, assessment) + self._log(assessment) + return assessment + started = perf_counter() decision = await self._decision_model.evaluate( DecisionRequest( decision_kind=DecisionKind.MEMORY_WRITE_GATE.value, @@ -226,10 +258,12 @@ async def assess(self, request: MemoryWriteGateRequest, /) -> MemoryWriteAssessm evidence=request.evidence, ) ) + latency_ms = (perf_counter() - started) * 1000 assessment = self._map(request, decision) - await self._observe( - request, _assess_memory_write_decision(decision, hold_on=self._hold_on, policy=self._policy), assessment - ) + policy_assessment = _assess_memory_write_decision( + decision, hold_on=self._hold_on, policy=self._policy + ).model_copy(update={"latency_ms": latency_ms}) + await self._observe(request, policy_assessment, assessment) self._log(assessment) return assessment @@ -306,6 +340,8 @@ async def _observe( or tuple(f"evidence:{index}" for index in range(1, len(request.evidence) + 1)), privacy_boundary=self._policy.privacy_boundary, privacy_outcome=self._privacy_outcome(policy_assessment), + provider_id=_model_identity(self._decision_model, "provider_id"), + backend_model_id=_model_identity(self._decision_model, "backend_model_id"), model_policy_id=None if policy_assessment.source.value == "none" else self.policy_id, assessment=_safe_observation_assessment(policy_assessment), final_action=f"memory_write_{assessment.verdict.value}", @@ -402,6 +438,24 @@ def _policy_version(hold_on: DecisionOutcome, threshold: float | None) -> str: return f"1;hold_on={hold_on.value};threshold={threshold_value}" +def _policy_hold_on(policy: DecisionPolicy, /) -> DecisionOutcome: + """Validate and recover the one decision outcome that the policy maps to a hold.""" + + holding = [ + DecisionOutcome(outcome) + for outcome, verdict in policy.outcome_mapping.items() + if verdict == DecisionVerdict.DENY.value + ] + if len(holding) != 1 or holding[0] is DecisionOutcome.ABSTAIN: + raise ValueError("the supplied policy must map exactly one decision outcome to deny") # noqa: TRY003 + return holding[0] + + +def _model_identity(model: DecisionModel | None, attribute: str, /) -> str | None: + value = None if model is None else getattr(model, attribute, None) + return value if isinstance(value, str) and value.strip() else None + + def _bounded_subject(candidates: tuple[str, ...]) -> str: return _candidate_subject(candidates)[:_MAX_SUBJECT_LENGTH] diff --git a/tests/builtin/runtime/test_memory_write_gate_contract.py b/tests/builtin/runtime/test_memory_write_gate_contract.py index 767100cd9b..241c335bec 100644 --- a/tests/builtin/runtime/test_memory_write_gate_contract.py +++ b/tests/builtin/runtime/test_memory_write_gate_contract.py @@ -76,6 +76,11 @@ async def evaluate(self, request: DecisionRequest, /) -> DecisionResult: return await super().evaluate(request) +class _IdentifiedDecisionModel(_StaticDecisionModel): + provider_id = "test-provider" + backend_model_id = "test-model" + + class _FailingDecisionModel: """A backend whose every evaluation raises.""" @@ -164,6 +169,52 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_direct_gate_binds_its_effective_hold_direction_into_the_observation_version() -> None: + async def scenario() -> None: + sink = _RecordingObservationSink() + gate = DecisionMemoryWriteGate(_StaticDecisionModel(_verdict(DecisionOutcome.YES)), hold_on=DecisionOutcome.NO) + + await gate.assess(_request(evidence=("source:task:1",), observation_sink=sink)) + + assert sink.observations[0].policy_version == "1;hold_on=no;threshold=none" + + asyncio.run(scenario()) + + +def test_direct_gate_rejects_a_disabled_or_conflicting_policy() -> None: + disabled = memory_write_policy(mode=DecisionPolicyMode.DISABLED) + with pytest.raises(ValueError, match="disabled policy"): + DecisionMemoryWriteGate(_StaticDecisionModel(_verdict(DecisionOutcome.YES)), policy=disabled) + + policy = memory_write_policy(hold_on=DecisionOutcome.NO) + with pytest.raises(ValueError, match="hold direction"): + DecisionMemoryWriteGate( + _StaticDecisionModel(_verdict(DecisionOutcome.YES)), + hold_on=DecisionOutcome.YES, + policy=policy, + ) + + threshold_policy = memory_write_policy(threshold=0.4) + with pytest.raises(ValueError, match="operational parameters"): + DecisionMemoryWriteGate(_StaticDecisionModel(_verdict(DecisionOutcome.YES)), policy=threshold_policy) + + +def test_observation_records_backend_identity_and_evaluation_latency() -> None: + async def scenario() -> None: + sink = _RecordingObservationSink() + gate = DecisionMemoryWriteGate(_IdentifiedDecisionModel(_verdict(DecisionOutcome.NO))) + + await gate.assess(_request(evidence=("source:task:1",), observation_sink=sink)) + + observation = sink.observations[0] + assert observation.provider_id == "test-provider" + assert observation.backend_model_id == "test-model" + assert observation.assessment.latency_ms is not None + assert observation.assessment.latency_ms >= 0 + + asyncio.run(scenario()) + + def test_a_hold_carries_a_structured_code_and_reason() -> None: async def scenario() -> None: gate = DecisionMemoryWriteGate( @@ -589,5 +640,11 @@ def _request( *, candidates: tuple[str, ...] = ("Remember the contract change.",), evidence: tuple[str, ...] = (), + observation_sink: _RecordingObservationSink | None = None, ) -> MemoryWriteGateRequest: - return MemoryWriteGateRequest(candidates=candidates, evidence=evidence, expected_revision=1) + return MemoryWriteGateRequest( + candidates=candidates, + evidence=evidence, + expected_revision=1, + observation_sink=observation_sink, + ) diff --git a/tests/builtin/runtime/test_memory_write_gate_paths.py b/tests/builtin/runtime/test_memory_write_gate_paths.py index c847ff0cc9..a1921cd49d 100644 --- a/tests/builtin/runtime/test_memory_write_gate_paths.py +++ b/tests/builtin/runtime/test_memory_write_gate_paths.py @@ -15,7 +15,6 @@ from __future__ import annotations import asyncio -import logging from pathlib import Path from typing import ClassVar @@ -32,7 +31,7 @@ MemoryWriteRejectionCode, MemoryWriteVerdict, ) -from powercontext.builtin.artifacts.memory.errors import MemoryWriteRejectedError +from powercontext.builtin.artifacts.memory.errors import MemoryCapacityExceededError, MemoryWriteRejectedError from powercontext.builtin.inference import InferenceUsage from powercontext.builtin.persistence import DecisionObservationRepository from powercontext.builtin.persistence.sqlite import SQLiteConfig @@ -54,7 +53,11 @@ FailOpenDecisionModel, ) from powercontext.builtin.runtime.decision_policy import DecisionPolicyMode, DecisionPrivacyBoundary -from powercontext.builtin.runtime.memory_write_gate import DecisionMemoryWriteGate, memory_write_policy +from powercontext.builtin.runtime.memory_write_gate import ( + DecisionMemoryWriteGate, + build_memory_write_gate, + memory_write_policy, +) from powercontext.builtin.scope import ScopeDraft from powercontext.builtin.sources import ContentCapture, ContentSource from powercontext.errors import ArtifactNotFoundError @@ -138,6 +141,13 @@ async def assess(self, request: MemoryWriteGateRequest, /) -> MemoryWriteAssessm raise ValueError("gate unavailable") # noqa: TRY003 +class _FailingPreflightGate(_ScriptedGate): + async def assess_preflight( + self, request: MemoryWriteGateRequest, rejection: MemoryWriteAssessment, / + ) -> MemoryWriteAssessment: + raise ValueError("preflight unavailable") # noqa: TRY003 + + class _FailingDecisionModel: policy_id = "test.decision.failing.v1" is_local_only = True @@ -156,6 +166,14 @@ async def evaluate(self, request: DecisionRequest, /) -> DecisionResult: return DecisionResult(DecisionOutcome.YES, self.policy_id, InferenceUsage(requests=1)) +class _SufficientDecisionModel: + policy_id = "test.decision.sufficient.v1" + is_local_only = True + + async def evaluate(self, request: DecisionRequest, /) -> DecisionResult: + return DecisionResult(DecisionOutcome.NO, self.policy_id, InferenceUsage(requests=1)) + + class _ContentCandidatePipeline: async def extract(self, request: MemoryCandidateRequest, /) -> tuple[MemoryEntryInput, ...]: return tuple( @@ -270,9 +288,7 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_enabling_the_gate_without_a_backend_warns_and_passes_writes_through( - tmp_path: Path, caplog: pytest.LogCaptureFixture -) -> None: +def test_enabling_the_gate_without_a_backend_passes_writes_through(tmp_path: Path) -> None: async def scenario() -> None: config = _config(tmp_path, RuntimeConfig(memory_write_gate_enabled=True), database="unavailable.db") async with open_builtin_runtime(config) as runtime: @@ -283,11 +299,7 @@ async def scenario() -> None: assert written.memory_ref is not None - with caplog.at_level(logging.WARNING, logger="powercontext.builtin.runtime.composition"): - asyncio.run(scenario()) - - assert any("no decision backend is available" in message for message in caplog.messages) - assert "memory.write-gate.unavailable" in {getattr(record, "event", None) for record in caplog.records} + asyncio.run(scenario()) def test_a_held_write_is_not_committed_and_stays_visible(tmp_path: Path) -> None: @@ -404,7 +416,7 @@ async def scenario() -> None: ) async with contexts.database.transaction() as connection: stored = await contexts.repositories.artifacts.create( - connection, "project", "experience-db-outcome", draft + connection, "project", "experience/db \u4f1a", draft ) artifact = Experience.model_validate(stored.model_dump(mode="json")) @@ -418,6 +430,7 @@ async def scenario() -> None: assert plan.commit is None evidence = "\n".join(gate.requests[-1].evidence) assert "Opposite outcome text must be visible to the judge." in evidence + assert "experience/db \u4f1a" not in evidence asyncio.run(scenario()) @@ -529,9 +542,9 @@ async def scenario() -> None: ) evidence = "\n".join(gate.requests[-1].evidence) - assert "candidate:1 source:content:alpha" in evidence + assert "candidate:1 source:Y29udGVudA:YWxwaGE" in evidence assert "Alpha uses MySQL." in evidence - assert "candidate:2 source:content:beta" in evidence + assert "candidate:2 source:Y29udGVudA:YmV0YQ" in evidence assert "Beta uses PostgreSQL." in evidence asyncio.run(scenario()) @@ -557,7 +570,7 @@ async def scenario() -> None: assert request.scope_id == "project" assert request.operation_id == "memory-write:new" assert request.subject_refs == ("candidate:1",) - assert request.evidence_refs == ("candidate:1 source:content:requirements",) + assert request.evidence_refs == ("candidate:1 source:Y29udGVudA:cmVxdWlyZW1lbnRz",) assert "MySQL 8" not in "\n".join(request.evidence_refs) asyncio.run(scenario()) @@ -590,9 +603,10 @@ async def scenario() -> None: assert [record.__dict__["error_type"] for record in failures] == [] assert len(observations) == 1 observation = observations[0] - assert observation.final_action == "memory_write_hold" - assert observation.subject_refs == ("candidate:1",) - assert observation.evidence_refs == ("candidate:1 source:content:private",) + assert observation.final_action == "memory_write_held" + assert observation.subject_refs == () + assert observation.evidence_refs == ("candidate:1 source:Y29udGVudA:cHJpdmF0ZQ",) + assert observation.metadata["incomplete_subject_count"] == 1 assert observation.assessment.reason is None payload = observation.model_dump_json() assert "secret claim" not in payload @@ -601,6 +615,159 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_decision_observation_is_persisted_only_after_the_memory_commit(tmp_path: Path) -> None: + async def scenario() -> None: + gate = DecisionMemoryWriteGate( + _SufficientDecisionModel(), + policy=memory_write_policy(mode=DecisionPolicyMode.ENFORCING), + ) + async with open_builtin_contexts(_config(tmp_path), memory_write_gate=gate) as contexts: + service = (await contexts.get("project")).artifacts.memory + plan = await service.plan_remember( + memory=None, + entries=(MemoryEntryInput(kind="fact", text="Commit the accepted claim."),), + mode="append", + ) + async with contexts.database.transaction() as connection: + before = await DecisionObservationRepository().observations(connection, "project") + + committed = await service.apply(plan) + + async with contexts.database.transaction() as connection: + after = await DecisionObservationRepository().observations(connection, "project") + + assert committed is not None + assert before == () + assert len(after) == 1 + assert after[0].final_action == "memory_write_applied" + assert after[0].subject_refs == ( + f"entry:{plan.commit.entry_versions[0].entry_id}@{plan.commit.entry_versions[0].entry_version_id}", + ) + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + "source_id", + ("https://example.test/page", "src/settings.py", "\u4f1a\u8bae\u8bb0\u5f55", "\u4f1a" * 256), +) +def test_decision_observation_keeps_supported_source_identities(tmp_path: Path, source_id: str) -> None: + async def scenario() -> None: + gate = DecisionMemoryWriteGate( + _SufficientDecisionModel(), + policy=memory_write_policy(mode=DecisionPolicyMode.ENFORCING), + ) + async with open_builtin_contexts(_config(tmp_path), memory_write_gate=gate) as contexts: + context = await contexts.get("project") + source, _ = await context.sources.capture( + ContentCapture(source_id=source_id, content="Supported evidence.") + ) + stored = await context.artifacts.memory.remember( + memory=None, + sources=(source,), + entries=(MemoryEntryInput(kind="fact", text="Supported claim.", sources=(source,)),), + mode="append", + ) + async with contexts.database.transaction() as connection: + observations = await DecisionObservationRepository().observations(connection, "project") + + assert stored is not None + assert len(observations) == 1 + assert observations[0].final_action == "memory_write_applied" + + asyncio.run(scenario()) + + +def test_failing_preflight_gate_fails_open_and_keeps_the_plan_committable(tmp_path: Path) -> None: + async def scenario() -> None: + gate = _FailingPreflightGate(_assessment(MemoryWriteVerdict.HOLD)) + async with open_builtin_contexts(_config(tmp_path), memory_write_gate=gate) as contexts: + context = await contexts.get("project") + source, _ = await context.sources.capture( + ContentCapture(source_id="over-budget", content=f"{'Evidence. ' * 500}Tail.") + ) + + plan = await context.artifacts.memory.plan_remember( + memory=None, + sources=(source,), + entries=(MemoryEntryInput(kind="fact", text="Tail.", sources=(source,)),), + mode="append", + ) + + assert plan.commit is not None + assert plan.decision is not None + assert plan.decision.verdict is MemoryWriteVerdict.ACCEPT + assert plan.decision.used_fallback is True + + asyncio.run(scenario()) + + +def test_no_external_call_gate_without_a_backend_records_an_unadjudicated_observation(tmp_path: Path) -> None: + async def scenario() -> None: + gate = build_memory_write_gate( + None, + enabled=True, + privacy_boundary=DecisionPrivacyBoundary.NO_EXTERNAL_CALL, + ) + assert gate is not None + async with open_builtin_contexts( + _config(tmp_path, database="no-backend.db"), memory_write_gate=gate + ) as contexts: + context = await contexts.get("project") + stored = await context.artifacts.memory.remember( + memory=None, + entries=(MemoryEntryInput(kind="note", text="No-call observation."),), + mode="append", + ) + async with contexts.database.transaction() as connection: + observations = await DecisionObservationRepository().observations(connection, "project") + + assert stored is not None + assert len(observations) == 1 + assert observations[0].privacy_outcome == "no_external_call" + assert observations[0].assessment.coverage.value == "unadjudicated" + + asyncio.run(scenario()) + + +def test_memory_capacity_failure_records_the_actual_failed_action(tmp_path: Path) -> None: + async def scenario() -> None: + gate = DecisionMemoryWriteGate( + _SufficientDecisionModel(), + policy=memory_write_policy(mode=DecisionPolicyMode.ENFORCING), + ) + config = _config( + tmp_path, + RuntimeConfig(memory_max_active_entries=1, memory_max_manifest_entries=1), + database="capacity.db", + ) + async with open_builtin_contexts(config, memory_write_gate=gate) as contexts: + context = await contexts.get("project") + first = await context.artifacts.memory.remember( + memory=None, + entries=(MemoryEntryInput(kind="note", text="First."),), + mode="append", + ) + assert first is not None + + with pytest.raises(MemoryCapacityExceededError): + await context.artifacts.memory.remember( + memory=first, + entries=(MemoryEntryInput(kind="note", text="Second."),), + mode="append", + ) + async with contexts.database.transaction() as connection: + observations = await DecisionObservationRepository().observations(connection, "project") + + assert [observation.final_action for observation in observations] == [ + "memory_write_applied", + "memory_write_failed", + ] + assert observations[-1].metadata["incomplete_subject_count"] == 1 + + asyncio.run(scenario()) + + def test_explicit_artifact_family_is_not_recovered_as_a_different_allowed_family(tmp_path: Path) -> None: async def scenario() -> None: async with open_builtin_contexts(_config(tmp_path)) as contexts: @@ -694,6 +861,7 @@ async def scenario() -> None: entries=(MemoryEntryInput(kind="fact", text="Tail.", sources=(long_source,)),), mode="append", ) + await context.artifacts.memory.apply(plan) async with contexts.database.transaction() as connection: observations = await DecisionObservationRepository().observations(connection, "project") @@ -702,7 +870,7 @@ async def scenario() -> None: assert plan.decision.verdict is verdict assert len(observations) == 1 assert observations[0].assessment.reason == "evidence_projection_budget" - assert observations[0].final_action == f"memory_write_{verdict.value}" + assert observations[0].final_action == "memory_write_applied" asyncio.run(scenario()) From cd2bc83267bd73be54a80be0a71d58d3045b65d3 Mon Sep 17 00:00:00 2001 From: "Xin.Zh" Date: Sun, 11 Oct 2026 14:15:59 +0800 Subject: [PATCH 07/11] test(runtime): narrow committed memory plan Assert the prepared commit before reading its exact entry references in the post-commit observation regression. Constraint: Preserve the observable commit-only sidecar assertion. Scope-risk: Test-only type narrowing for the memory write-gate regression. Tested: python -m pytest tests/builtin/runtime/test_memory_write_gate_contract.py tests/builtin/runtime/test_memory_write_gate_paths.py tests/builtin/runtime/test_decision_config.py tests/builtin/runtime/test_decision_composition.py tests/builtin/runtime/test_decision_policy.py tests/test_server_logging.py tests/builtin/persistence/test_decision_observation_repository.py tests/builtin/persistence/test_mysql_schema.py -q (129 passed); .venv/Scripts/python.exe -m ty check --python .venv tests/builtin/runtime/test_memory_write_gate_paths.py --- tests/builtin/runtime/test_memory_write_gate_paths.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/builtin/runtime/test_memory_write_gate_paths.py b/tests/builtin/runtime/test_memory_write_gate_paths.py index a1921cd49d..3e35f1a42f 100644 --- a/tests/builtin/runtime/test_memory_write_gate_paths.py +++ b/tests/builtin/runtime/test_memory_write_gate_paths.py @@ -637,6 +637,7 @@ async def scenario() -> None: after = await DecisionObservationRepository().observations(connection, "project") assert committed is not None + assert plan.commit is not None assert before == () assert len(after) == 1 assert after[0].final_action == "memory_write_applied" From 927985e5507c2d65fc2251643fd45a39b78041f6 Mon Sep 17 00:00:00 2001 From: "Xin.Zh" Date: Sun, 11 Oct 2026 17:24:54 +0800 Subject: [PATCH 08/11] fix(runtime): finalize memory gate audit boundaries Finalize gate observations only after the Memory owner knows the domain outcome, bind relational sidecars to a caller-owned transaction, and retain encoded entry references for every assessed candidate in an applied batch. Reject supplied policies that the specialized gate cannot execute literally, and disable redirects for loopback OpenAI endpoints. Constraint: Preserve fail-open gate behavior and avoid retaining raw candidate or evidence content. Scope-risk: Changes observation finalization, relational transaction ownership, and local-only provider transport only. Tested: pytest focused decision and memory gate suites; ruff check/format; ty check --python-version 3.11. --- .../builtin/artifacts/memory/protocols.py | 2 + .../builtin/artifacts/memory/service.py | 34 +++++- .../builtin/runtime/composition.py | 21 ++++ .../runtime/decision_observation_ledger.py | 10 +- .../builtin/runtime/memory_write_gate.py | 37 +++++- .../builtin/runtime/relational.py | 1 + .../runtime/test_decision_composition.py | 17 +++ .../test_memory_write_gate_contract.py | 19 +++ .../runtime/test_memory_write_gate_paths.py | 110 +++++++++++++++++- 9 files changed, 238 insertions(+), 13 deletions(-) diff --git a/src/powercontext/builtin/artifacts/memory/protocols.py b/src/powercontext/builtin/artifacts/memory/protocols.py index cd607eaf89..b6d106e620 100644 --- a/src/powercontext/builtin/artifacts/memory/protocols.py +++ b/src/powercontext/builtin/artifacts/memory/protocols.py @@ -75,6 +75,7 @@ class MemoryCommit(BaseModel): content_hash: str entry_versions: tuple[MemoryEntryVersion, ...] projections: tuple[MemoryProjection, ...] + assessed_entry_refs: tuple[tuple[str, str], ...] = () class MemoryWriteRejectionCode(StrEnum): @@ -125,6 +126,7 @@ class MemoryWriteGateRequest: subject_refs: tuple[str, ...] = () evidence_refs: tuple[str, ...] = () observation_sink: MemoryWriteObservationSink | None = None + defer_observation_emission: bool = False class MemoryWriteGate(Protocol): diff --git a/src/powercontext/builtin/artifacts/memory/service.py b/src/powercontext/builtin/artifacts/memory/service.py index a1c6a95143..f5e00fdf67 100644 --- a/src/powercontext/builtin/artifacts/memory/service.py +++ b/src/powercontext/builtin/artifacts/memory/service.py @@ -111,6 +111,7 @@ InferenceUnavailableError, embed_query, ) +from powercontext.builtin.runtime.decision_policy import emit_decision_observation from powercontext.builtin.tags import TagFilter from powercontext.errors import RevisionConflictError from powercontext.sources import ( @@ -623,8 +624,15 @@ async def apply(self, plan: MemoryWritePlan, /) -> Memory | None: final_action="memory_write_applied", operation_id=f"memory-write:{committed.artifact_id}@{committed.revision}", subject_refs=tuple( - f"entry:{entry.entry_id}@{entry.entry_version_id}" for entry in plan.commit.entry_versions + _gate_entry_reference(entry_id, entry_version_id) + for entry_id, entry_version_id in plan.commit.assessed_entry_refs ), + incomplete_subject_count=max( + 0, + len(plan.decision_observation.subject_refs) - len(plan.commit.assessed_entry_refs), + ) + if plan.decision_observation is not None + else 0, ) return committed @@ -1444,6 +1452,7 @@ async def _assess_write( subject_refs=_gate_subject_refs(candidates), evidence_refs=tuple(_gate_evidence_ref(entry) for entry in projection.entries), observation_sink=pending, + defer_observation_emission=True, ) if projection.rejection is not None: if isinstance(self._write_gate, MemoryWriteGatePreflight): @@ -1481,7 +1490,7 @@ async def _record_decision_observation( subject_refs: tuple[str, ...] | None = None, incomplete_subject_count: int = 0, ) -> None: - if observation is None or self._write_gate_observation_sink is None: + if observation is None: return metadata = dict(observation.metadata) if incomplete_subject_count: @@ -1498,6 +1507,9 @@ async def _record_decision_observation( "metadata": metadata, } ) + emit_decision_observation(finalized) + if self._write_gate_observation_sink is None: + return try: await self._write_gate_observation_sink.record(finalized) except Exception as error: @@ -1614,17 +1626,21 @@ async def _prepare_commit( manifest = {} if base is None else {entry.entry_id: entry for entry in base.content.manifest.entries} current_by_entry = {} if current_entries is None else {entry.entry_id: entry for entry in current_entries} new_versions: list[MemoryEntryVersion] = [] + assessed_entry_refs: list[tuple[str, str]] = [] changes: list[MemoryChange] = [] targeted: set[str] = set() # The manifest records each active entry's validated content hash, so exact # duplicate detection compares hashes; rebuilding every body here would cost # one material per entry to answer the same question. new_content: set[str] = {item.entry_content_hash for item in manifest.values() if item.state == "active"} + active_by_content = {item.entry_content_hash: item for item in manifest.values() if item.state == "active"} for candidate in candidates: if candidate.entry is None: material = await self._material_from_candidate(candidate, evidence.sources, evidence.artifacts) - if material.content_hash in new_content: + existing = active_by_content.get(material.content_hash) + if existing is not None: + assessed_entry_refs.append((existing.entry_id, existing.entry_version_id)) continue new_content.add(material.content_hash) entry_id = self._new_id("entry") @@ -1638,8 +1654,10 @@ async def _prepare_commit( created_in_revision=next_revision, ) manifest[entry_id] = _manifest_entry(version, state="active") + active_by_content[material.content_hash] = manifest[entry_id] current_by_entry[entry_id] = version new_versions.append(version) + assessed_entry_refs.append((version.entry_id, version.entry_version_id)) changes.append( MemoryChange( op="add", @@ -1668,6 +1686,7 @@ async def _prepare_commit( material.content_hash == previous_material.content_hash and material.content_bytes == previous_material.content_bytes ): + assessed_entry_refs.append((previous.entry_id, previous.entry_version_id)) continue version = self._new_entry_version( memory_id=memory_id, @@ -1677,8 +1696,10 @@ async def _prepare_commit( created_in_revision=next_revision, ) manifest[entry_id] = _manifest_entry(version, state="active") + active_by_content[material.content_hash] = manifest[entry_id] current_by_entry[entry_id] = version new_versions.append(version) + assessed_entry_refs.append((version.entry_id, version.entry_version_id)) changes.append( MemoryChange( op="revise", @@ -1723,6 +1744,7 @@ async def _prepare_commit( content_hash=sha256(content_bytes).hexdigest(), entry_versions=tuple(new_versions), projections=projections, + assessed_entry_refs=tuple(assessed_entry_refs), ) async def _claim_revision_target( @@ -1973,6 +1995,10 @@ def _gate_artifact_reference(reference: ArtifactRef, /) -> str: ) +def _gate_entry_reference(entry_id: str, entry_version_id: str, /) -> str: + return f"entry:{_gate_reference_component(entry_id)}@{_gate_reference_component(entry_version_id)}" + + def _gate_reference_component(value: str, /) -> str: return urlsafe_b64encode(value.encode("utf-8")).decode("ascii").rstrip("=") @@ -1987,7 +2013,7 @@ def _gate_subject_refs(candidates: tuple[MemoryEntryInput, ...]) -> tuple[str, . return tuple( f"candidate:{index}" if candidate.entry is None - else f"entry:{candidate.entry.entry_id}@{candidate.entry.entry_version_id}" + else _gate_entry_reference(candidate.entry.entry_id, candidate.entry.entry_version_id) for index, candidate in enumerate(candidates, start=1) ) diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index e5f8fea7a9..738acaf1cb 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -1689,10 +1689,23 @@ def create_provider(provider_name: str) -> Provider[Any]: if headers: from openai import AsyncOpenAI + http_client = _openai_http_client(follow_redirects=False) if base_url is not None else None + client = AsyncOpenAI( base_url=None if base_url is None else str(base_url), api_key=os.getenv("OPENAI_API_KEY") or "api-key-not-set", default_headers=_resolve_headers(headers), + http_client=http_client, + ) + resources.push_async_callback(client.close) + return OpenAIProvider(openai_client=client) + if base_url is not None and _is_loopback_endpoint(base_url): + from openai import AsyncOpenAI + + client = AsyncOpenAI( + base_url=str(base_url), + api_key=os.getenv("OPENAI_API_KEY") or "api-key-not-set", + http_client=_openai_http_client(follow_redirects=False), ) resources.push_async_callback(client.close) return OpenAIProvider(openai_client=client) @@ -1720,6 +1733,14 @@ def create_provider(provider_name: str) -> Provider[Any]: return create_provider +def _openai_http_client(*, follow_redirects: bool): + """Create the SDK transport with an explicit redirect policy for local-only endpoints.""" + + import httpx + + return httpx.AsyncClient(follow_redirects=follow_redirects) + + def _resolve_headers(headers: Mapping[str, SecretStr]) -> dict[str, str]: return {name: value.get_secret_value() for name, value in headers.items()} diff --git a/src/powercontext/builtin/runtime/decision_observation_ledger.py b/src/powercontext/builtin/runtime/decision_observation_ledger.py index ad8931da6e..8348868579 100644 --- a/src/powercontext/builtin/runtime/decision_observation_ledger.py +++ b/src/powercontext/builtin/runtime/decision_observation_ledger.py @@ -18,13 +18,15 @@ from datetime import UTC, datetime, timedelta +from sqlalchemy.ext.asyncio import AsyncConnection + from powercontext.builtin.decision_observations import DecisionObservation from powercontext.builtin.persistence.database import AsyncDatabase from powercontext.builtin.persistence.decision_observations import DecisionObservationRepository class RelationalDecisionObservationSink: - """Persist each observation independently of the domain operation it describes.""" + """Persist a sidecar in the caller's transaction when one owns the domain write.""" def __init__( self, @@ -33,15 +35,17 @@ def __init__( /, *, retention_days: int | None = None, + connection: AsyncConnection | None = None, ) -> None: self._database = database self._repository = repository self._retention_days = retention_days + self._connection = connection async def record(self, observation: DecisionObservation, /) -> None: - """Append one sidecar in a short caller-independent transaction.""" + """Append one sidecar, opening a short transaction only when no connection is bound.""" - async with self._database.transaction() as connection: + async with self._database.connection(self._connection) as connection: await self._repository.append(connection, observation) if self._retention_days is not None: await self._repository.prune_before( diff --git a/src/powercontext/builtin/runtime/memory_write_gate.py b/src/powercontext/builtin/runtime/memory_write_gate.py index 563ab798d7..4c9bbf195c 100644 --- a/src/powercontext/builtin/runtime/memory_write_gate.py +++ b/src/powercontext/builtin/runtime/memory_write_gate.py @@ -190,8 +190,7 @@ def __init__( resolved_hold_on = _policy_hold_on(policy) if hold_on is not None and hold_on is not resolved_hold_on: raise ValueError("the hold direction must match the supplied policy") # noqa: TRY003 - if policy.version != _policy_version(resolved_hold_on, threshold): - raise ValueError("the operational parameters must match the supplied policy version") # noqa: TRY003 + _validate_memory_write_policy(policy, hold_on=resolved_hold_on, threshold=threshold) self._decision_model = decision_model self._hold_on = resolved_hold_on self._threshold = threshold @@ -350,7 +349,8 @@ async def _observe( else None, metadata={"candidate_count": len(request.candidates), "evidence_count": len(request.evidence)}, ) - emit_decision_observation(observation) + if not request.defer_observation_emission: + emit_decision_observation(observation) if request.observation_sink is None: return try: @@ -451,6 +451,37 @@ def _policy_hold_on(policy: DecisionPolicy, /) -> DecisionOutcome: return holding[0] +def _validate_memory_write_policy( + policy: DecisionPolicy, + /, + *, + hold_on: DecisionOutcome, + threshold: float | None, +) -> None: + """Reject a supplied policy whenever the specialized gate would not execute it literally.""" + + if policy.decision_kind != DecisionKind.MEMORY_WRITE_GATE.value: + raise ValueError("the supplied policy must describe the memory write gate") # noqa: TRY003 + if policy.consumer != "memory_write_gate": + raise ValueError("the supplied policy must use the memory write gate consumer") # noqa: TRY003 + if policy.failure_policy is not DecisionFailurePolicy.FAIL_OPEN: + raise ValueError("the supplied policy must fail open") # noqa: TRY003 + if policy.question != _GATE_QUESTION: + raise ValueError("the supplied policy question must match the memory write gate") # noqa: TRY003 + if policy.subject_selector != "candidate_text" or policy.evidence_selector != "candidate_citations": + raise ValueError("the supplied policy selectors must match the memory write gate") # noqa: TRY003 + pass_on = DecisionOutcome.NO if hold_on is DecisionOutcome.YES else DecisionOutcome.YES + expected_mapping = { + hold_on.value: DecisionVerdict.DENY.value, + pass_on.value: DecisionVerdict.ALLOW.value, + DecisionOutcome.ABSTAIN.value: DecisionVerdict.UNKNOWN.value, + } + if dict(policy.outcome_mapping) != expected_mapping: + raise ValueError("the supplied policy outcome mapping must match the memory write gate") # noqa: TRY003 + if policy.version != _policy_version(hold_on, threshold): + raise ValueError("the operational parameters must match the supplied policy version") # noqa: TRY003 + + def _model_identity(model: DecisionModel | None, attribute: str, /) -> str | None: value = None if model is None else getattr(model, attribute, None) return value if isinstance(value, str) and value.strip() else None diff --git a/src/powercontext/builtin/runtime/relational.py b/src/powercontext/builtin/runtime/relational.py index 5e2eec9115..c8920f06e6 100644 --- a/src/powercontext/builtin/runtime/relational.py +++ b/src/powercontext/builtin/runtime/relational.py @@ -392,6 +392,7 @@ def memory( self.database, self.repositories.decision_observations, retention_days=self.decision_observation_retention_days, + connection=connection, ), ) diff --git a/tests/builtin/runtime/test_decision_composition.py b/tests/builtin/runtime/test_decision_composition.py index 71eb230674..6b0a5e968f 100644 --- a/tests/builtin/runtime/test_decision_composition.py +++ b/tests/builtin/runtime/test_decision_composition.py @@ -203,3 +203,20 @@ def test_decision_role_is_not_registered_as_an_mcp_tool() -> None: ) def test_only_explicit_loopback_decision_endpoints_are_local(endpoint: str, expected: bool) -> None: assert composition._is_loopback_endpoint(AnyHttpUrl(endpoint)) is expected + + +def test_loopback_openai_provider_disables_redirects() -> None: + async def scenario() -> None: + async with AsyncExitStack() as resources: + factory = composition._unbounded_provider_factory( + AnyHttpUrl("http://127.0.0.1:11434/v1"), + {}, + workload="generation", + resources=resources, + ) + + provider = factory("openai-chat") + + assert provider.client._client.follow_redirects is False # pyright: ignore[reportPrivateUsage] + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_memory_write_gate_contract.py b/tests/builtin/runtime/test_memory_write_gate_contract.py index 241c335bec..a6843cebab 100644 --- a/tests/builtin/runtime/test_memory_write_gate_contract.py +++ b/tests/builtin/runtime/test_memory_write_gate_contract.py @@ -199,6 +199,25 @@ def test_direct_gate_rejects_a_disabled_or_conflicting_policy() -> None: DecisionMemoryWriteGate(_StaticDecisionModel(_verdict(DecisionOutcome.YES)), policy=threshold_policy) +def test_direct_gate_rejects_policy_fields_it_does_not_execute() -> None: + model = _StaticDecisionModel(_verdict(DecisionOutcome.YES)) + question_policy = memory_write_policy().model_copy(update={"question": "Does this unrelated question hold?"}) + with pytest.raises(ValueError, match="question"): + DecisionMemoryWriteGate(model, policy=question_policy) + + mapping_policy = memory_write_policy().model_copy( + update={ + "outcome_mapping": { + DecisionOutcome.YES.value: DecisionVerdict.DENY.value, + DecisionOutcome.NO.value: "review", + DecisionOutcome.ABSTAIN.value: DecisionVerdict.UNKNOWN.value, + } + } + ) + with pytest.raises(ValueError, match="outcome mapping"): + DecisionMemoryWriteGate(model, policy=mapping_policy) + + def test_observation_records_backend_identity_and_evaluation_latency() -> None: async def scenario() -> None: sink = _RecordingObservationSink() diff --git a/tests/builtin/runtime/test_memory_write_gate_paths.py b/tests/builtin/runtime/test_memory_write_gate_paths.py index 3e35f1a42f..0610c75bd6 100644 --- a/tests/builtin/runtime/test_memory_write_gate_paths.py +++ b/tests/builtin/runtime/test_memory_write_gate_paths.py @@ -15,6 +15,8 @@ from __future__ import annotations import asyncio +import logging +from base64 import urlsafe_b64encode from pathlib import Path from typing import ClassVar @@ -34,7 +36,8 @@ from powercontext.builtin.artifacts.memory.errors import MemoryCapacityExceededError, MemoryWriteRejectedError from powercontext.builtin.inference import InferenceUsage from powercontext.builtin.persistence import DecisionObservationRepository -from powercontext.builtin.persistence.sqlite import SQLiteConfig +from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile +from powercontext.builtin.persistence.tables import BUILTIN_TABLES from powercontext.builtin.runtime import ( BuiltinConfig, BuiltinRuntime, @@ -58,6 +61,7 @@ build_memory_write_gate, memory_write_policy, ) +from powercontext.builtin.runtime.relational import RelationalContexts from powercontext.builtin.scope import ScopeDraft from powercontext.builtin.sources import ContentCapture, ContentSource from powercontext.errors import ArtifactNotFoundError @@ -642,7 +646,9 @@ async def scenario() -> None: assert len(after) == 1 assert after[0].final_action == "memory_write_applied" assert after[0].subject_refs == ( - f"entry:{plan.commit.entry_versions[0].entry_id}@{plan.commit.entry_versions[0].entry_version_id}", + "entry:" + f"{urlsafe_b64encode(plan.commit.entry_versions[0].entry_id.encode()).decode().rstrip('=')}@" + f"{urlsafe_b64encode(plan.commit.entry_versions[0].entry_version_id.encode()).decode().rstrip('=')}", ) asyncio.run(scenario()) @@ -731,7 +737,9 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_memory_capacity_failure_records_the_actual_failed_action(tmp_path: Path) -> None: +def test_memory_capacity_failure_records_the_actual_failed_action( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: async def scenario() -> None: gate = DecisionMemoryWriteGate( _SufficientDecisionModel(), @@ -766,6 +774,102 @@ async def scenario() -> None: ] assert observations[-1].metadata["incomplete_subject_count"] == 1 + caplog.set_level(logging.INFO, logger="powercontext.builtin.runtime.decision_policy") + asyncio.run(scenario()) + assert [ + record.__dict__["final_action"] + for record in caplog.records + if getattr(record, "event", None) == "decision.observation" + ] == ["memory_write_applied", "memory_write_failed"] + + +def test_applied_batch_observation_references_each_assessed_candidate(tmp_path: Path) -> None: + async def scenario() -> None: + gate = DecisionMemoryWriteGate( + _SufficientDecisionModel(), + policy=memory_write_policy(mode=DecisionPolicyMode.ENFORCING), + ) + async with open_builtin_contexts(_config(tmp_path), memory_write_gate=gate) as contexts: + service = (await contexts.get("project")).artifacts.memory + first = await service.remember( + memory=None, + entries=(MemoryEntryInput(kind="note", text="First fact."),), + mode="append", + ) + assert first is not None + stored = await service.remember( + memory=first, + entries=( + MemoryEntryInput(kind="note", text="First fact."), + MemoryEntryInput(kind="note", text="Second fact."), + ), + mode="append", + ) + assert stored is not None + async with contexts.database.transaction() as connection: + observations = await DecisionObservationRepository().observations(connection, "project") + + observation = observations[-1] + assert observation.metadata["candidate_count"] == 2 + assert len(observation.subject_refs) == 2 + assert "incomplete_subject_count" not in observation.metadata + + asyncio.run(scenario()) + + +def test_applied_observation_encodes_entry_identity_components(tmp_path: Path) -> None: + async def scenario() -> None: + gate = DecisionMemoryWriteGate( + _SufficientDecisionModel(), + policy=memory_write_policy(mode=DecisionPolicyMode.ENFORCING), + ) + config = _config(tmp_path) + assert isinstance(config.database, SQLiteConfig) + async with SQLiteProfile.open(config.database, tables=BUILTIN_TABLES) as profile: + contexts = RelationalContexts( + database=profile.database, + memory_write_gate=gate, + id_factory=lambda kind: f"{kind}/1", + ) + service = (await contexts.get("project")).artifacts.memory + stored = await service.remember( + memory=None, + entries=(MemoryEntryInput(kind="note", text="Encoded identity."),), + mode="append", + ) + assert stored is not None + async with contexts.database.transaction() as connection: + observations = await DecisionObservationRepository().observations(connection, "project") + + assert len(observations) == 1 + assert observations[0].subject_refs == ("entry:ZW50cnkvMQ@dmVyc2lvbi8x",) + + asyncio.run(scenario()) + + +def test_ingestion_flush_persists_the_observation_in_its_memory_transaction(tmp_path: Path) -> None: + async def scenario() -> None: + gate = DecisionMemoryWriteGate( + _SufficientDecisionModel(), + policy=memory_write_policy(mode=DecisionPolicyMode.ENFORCING), + ) + async with open_builtin_contexts( + _config(tmp_path, database="ingestion.db"), + candidate_pipeline=_ContentCandidatePipeline(), + memory_write_gate=gate, + ) as contexts: + context = await contexts.get("project") + await context.sources.capture(ContentCapture(source_id="task-1", content="A durable note.")) + + result = await contexts.process_memory("project", 100) + + async with contexts.database.transaction() as connection: + observations = await DecisionObservationRepository().observations(connection, "project") + + assert result.memory_ref is not None + assert result.current_cursor == 1 + assert [observation.final_action for observation in observations] == ["memory_write_applied"] + asyncio.run(scenario()) From cb00001d79ab2c0e0a3685d97bcc549235c13ed7 Mon Sep 17 00:00:00 2001 From: "Xin.Zh" Date: Sun, 11 Oct 2026 19:22:48 +0800 Subject: [PATCH 09/11] fix(runtime): classify string loopback endpoints Normalize runtime URL overrides before applying loopback policy so model_copy configuration paths cannot abort composition. Constraint: malformed or hostless runtime values remain non-local. Scope-risk: only loopback classification for string runtime overrides changes. Tested: uv run --no-sync pytest tests/builtin/runtime/test_decision_composition.py -q; uv run --no-sync pytest tests/e2e/test_artifact_dreaming.py -q; ruff and ty checks passed. --- src/powercontext/builtin/runtime/composition.py | 13 ++++++++++--- tests/builtin/runtime/test_decision_composition.py | 11 +++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index bbcd34d5dd..24380680ee 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -26,6 +26,7 @@ from ipaddress import ip_address from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast +from urllib.parse import urlsplit from pydantic import AnyHttpUrl, JsonValue, SecretStr from typing_extensions import override @@ -1783,12 +1784,18 @@ def _openai_http_client(*, follow_redirects: bool): return httpx.AsyncClient(follow_redirects=follow_redirects) -def _is_loopback_endpoint(endpoint: AnyHttpUrl | None) -> bool: +def _is_loopback_endpoint(endpoint: AnyHttpUrl | str | None) -> bool: """Allow only explicit local names and addresses for raw decision content.""" - if endpoint is None or endpoint.host is None: + if endpoint is None: return False - host = endpoint.host.strip("[]").lower() + try: + host = urlsplit(str(endpoint)).hostname + except ValueError: + return False + if host is None: + return False + host = host.lower() if host == "localhost": return True try: diff --git a/tests/builtin/runtime/test_decision_composition.py b/tests/builtin/runtime/test_decision_composition.py index 6b0a5e968f..105795981f 100644 --- a/tests/builtin/runtime/test_decision_composition.py +++ b/tests/builtin/runtime/test_decision_composition.py @@ -205,6 +205,17 @@ def test_only_explicit_loopback_decision_endpoints_are_local(endpoint: str, expe assert composition._is_loopback_endpoint(AnyHttpUrl(endpoint)) is expected +@pytest.mark.parametrize( + ("endpoint", "expected"), + [ + ("http://127.0.0.1:11434/v1", True), + ("https://api.example.test/v1", False), + ], +) +def test_loopback_classifier_accepts_runtime_string_overrides(endpoint: str, expected: bool) -> None: + assert composition._is_loopback_endpoint(endpoint) is expected + + def test_loopback_openai_provider_disables_redirects() -> None: async def scenario() -> None: async with AsyncExitStack() as resources: From 992a0f072b1779b7df032bb57863f08a05033d50 Mon Sep 17 00:00:00 2001 From: "Xin.Zh" Date: Sun, 11 Oct 2026 19:36:08 +0800 Subject: [PATCH 10/11] 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. --- .../runtime/atomic_memory_processing.py | 83 +++++++++++++------ .../builtin/runtime/composition.py | 9 +- .../runtime/test_decision_composition.py | 19 ++++- .../runtime/test_memory_window_recovery.py | 83 +++++++++++++++++++ 4 files changed, 163 insertions(+), 31 deletions(-) diff --git a/src/powercontext/builtin/runtime/atomic_memory_processing.py b/src/powercontext/builtin/runtime/atomic_memory_processing.py index 758ba2bc04..b91ce95711 100644 --- a/src/powercontext/builtin/runtime/atomic_memory_processing.py +++ b/src/powercontext/builtin/runtime/atomic_memory_processing.py @@ -16,6 +16,7 @@ from __future__ import annotations +import logging import math from base64 import urlsafe_b64encode from contextlib import asynccontextmanager, nullcontext @@ -25,6 +26,7 @@ from sqlalchemy import update +from powercontext._logging import log_safely from powercontext.artifacts import ArtifactLineage, ArtifactRef from powercontext.artifacts.search import ArtifactSearchExecutionContext from powercontext.builtin.artifacts.atomic_memory.errors import AtomicMemoryConflictError @@ -77,6 +79,9 @@ from powercontext.builtin.runtime.config import RuntimeConfig +logger = logging.getLogger(__name__) + + @dataclass(frozen=True) class AtomicMemoryProcessingConfig: related_mode: Literal["auto", "fts", "vector", "hybrid"] = "auto" @@ -215,22 +220,28 @@ async def _flush_once(self, scope_id, limit, processing, context, span) -> Memor if reducible and action.through - action.after > 1: await self._reduce_window(scope_id, context, processing, cursor, state, action, high_watermark) raise - await self._publish( - scope_id, - context, - processing, - cursor, - transition, - action, - window, - eligible, - consulted_artifacts, - workset, - prepared, - decision, - observation, - high_watermark, - ) + try: + finalized = await self._publish( + scope_id, + context, + processing, + cursor, + transition, + action, + window, + eligible, + consulted_artifacts, + workset, + prepared, + decision, + observation, + high_watermark, + ) + except Exception: + self._emit_failed_observation(observation) + raise + if finalized is not None: + emit_decision_observation(finalized) return self._result( action.after, action.through, @@ -310,6 +321,7 @@ async def _publish( ): application = self.application writes = tuple(write for item in prepared for write in item.plan.writes) + finalized = None with self._stage( "memory.commit", { @@ -352,7 +364,7 @@ async def _publish( results = await application.service.commit_window( connection, scope_id, prepared, context, read_set=tuple(workset.reads.values()) ) - await self._finalize_observation( + finalized = await self._finalize_observation( connection, observation, final_action=( @@ -372,6 +384,7 @@ async def _publish( await self.windows.clear_consumed(connection, scope_id, action.through) if processing is not None: await processing.complete(connection, remaining_work=action.through < high_watermark) + return finalized async def _prepare(self, scope_id, eligible, pipeline, context): workset = AtomicMemoryWindowWorkset() @@ -638,23 +651,41 @@ async def _assess_write_gate(self, scope_id, workset, prepared): async def _finalize_observation(self, connection, observation, *, final_action, subject_refs): if observation is None or self.decision_observations is None: - return + return None finalized = observation.model_copy( update={ "final_action": final_action, "subject_refs": observation.subject_refs if final_action == "memory_write_hold" else subject_refs, } ) - await self.decision_observations.append(connection, finalized) - if self.decision_observation_retention_days is not None: - from datetime import UTC, datetime, timedelta + try: + async with connection.begin_nested(): + await self.decision_observations.append(connection, finalized) + if self.decision_observation_retention_days is not None: + from datetime import UTC, datetime, timedelta - await self.decision_observations.prune_before( - connection, - finalized.scope_id, - datetime.now(UTC) - timedelta(days=self.decision_observation_retention_days), + await self.decision_observations.prune_before( + connection, + finalized.scope_id, + datetime.now(UTC) - timedelta(days=self.decision_observation_retention_days), + ) + except Exception as error: + log_safely( + logger, + logging.WARNING, + "Decision observation persistence failed", + exc_info=error, + extra={"event": "decision.observation_sink_failed", "error_type": type(error).__name__}, + ) + return None + return finalized + + @staticmethod + def _emit_failed_observation(observation): + if observation is not None: + emit_decision_observation( + observation.model_copy(update={"final_action": "memory_write_failed", "subject_refs": ()}) ) - emit_decision_observation(finalized) @staticmethod def _result(after, through, high_watermark, count, *, held=False, hold_codes=()): diff --git a/src/powercontext/builtin/runtime/composition.py b/src/powercontext/builtin/runtime/composition.py index 24380680ee..988560dcd9 100644 --- a/src/powercontext/builtin/runtime/composition.py +++ b/src/powercontext/builtin/runtime/composition.py @@ -1733,7 +1733,7 @@ def create_provider(provider_name: str) -> Provider[Any]: if headers: from openai import AsyncOpenAI - http_client = _openai_http_client(follow_redirects=False) if base_url is not None else None + http_client = _endpoint_http_client(follow_redirects=False) if base_url is not None else None client = AsyncOpenAI( base_url=None if base_url is None else str(base_url), api_key=os.getenv("OPENAI_API_KEY") or "api-key-not-set", @@ -1748,7 +1748,7 @@ def create_provider(provider_name: str) -> Provider[Any]: client = AsyncOpenAI( base_url=str(base_url), api_key=os.getenv("OPENAI_API_KEY") or "api-key-not-set", - http_client=_openai_http_client(follow_redirects=False), + http_client=_endpoint_http_client(follow_redirects=False), ) resources.push_async_callback(client.close) return OpenAIProvider(openai_client=client) @@ -1764,6 +1764,7 @@ def create_provider(provider_name: str) -> Provider[Any]: base_url=None if base_url is None else str(base_url), api_key=api_key or os.getenv("ANTHROPIC_API_KEY") or "api-key-not-set", default_headers=default_headers, + http_client=_endpoint_http_client(follow_redirects=False) if base_url is not None else None, ) resources.push_async_callback(client.close) return AnthropicProvider(anthropic_client=client) @@ -1776,8 +1777,8 @@ def create_provider(provider_name: str) -> Provider[Any]: return create_provider -def _openai_http_client(*, follow_redirects: bool): - """Create the SDK transport with an explicit redirect policy for local-only endpoints.""" +def _endpoint_http_client(*, follow_redirects: bool): + """Create the SDK transport with an explicit redirect policy for custom endpoints.""" import httpx diff --git a/tests/builtin/runtime/test_decision_composition.py b/tests/builtin/runtime/test_decision_composition.py index 105795981f..4bea56d89c 100644 --- a/tests/builtin/runtime/test_decision_composition.py +++ b/tests/builtin/runtime/test_decision_composition.py @@ -20,7 +20,7 @@ from typing import Any import pytest -from pydantic import AnyHttpUrl +from pydantic import AnyHttpUrl, SecretStr from powercontext.builtin.inference import InferenceUnavailableError, InferenceUsage from powercontext.builtin.persistence.sqlite import SQLiteConfig @@ -231,3 +231,20 @@ async def scenario() -> None: assert provider.client._client.follow_redirects is False # pyright: ignore[reportPrivateUsage] asyncio.run(scenario()) + + +def test_loopback_anthropic_headers_provider_disables_redirects() -> None: + async def scenario() -> None: + async with AsyncExitStack() as resources: + factory = composition._unbounded_provider_factory( + AnyHttpUrl("http://127.0.0.1:11434"), + {"x-api-key": SecretStr("test-key")}, + workload="generation", + resources=resources, + ) + + provider = factory("anthropic") + + assert provider.client._client.follow_redirects is False # pyright: ignore[reportPrivateUsage] + + asyncio.run(scenario()) diff --git a/tests/builtin/runtime/test_memory_window_recovery.py b/tests/builtin/runtime/test_memory_window_recovery.py index bb3ab4c80d..d7baad8a27 100644 --- a/tests/builtin/runtime/test_memory_window_recovery.py +++ b/tests/builtin/runtime/test_memory_window_recovery.py @@ -18,6 +18,8 @@ from uuid import uuid4 import pytest +from sqlalchemy import text +from sqlalchemy.exc import IntegrityError from powercontext.builtin.artifacts.atomic_memory.extraction import AtomicMemoryCandidate, AtomicMemoryExtractionOutput from powercontext.builtin.decision_observations import DecisionPrivacyBoundary @@ -165,6 +167,87 @@ async def scenario(): assert [record.__dict__["final_action"] for record in events] == ["memory_write_applied"] +def test_atomic_memory_gate_emits_failed_outcome_after_late_transaction_rollback( + tmp_path, caplog: pytest.LogCaptureFixture +): + async def scenario(): + gate = build_memory_write_gate( + None, + enabled=True, + privacy_boundary=DecisionPrivacyBoundary.NO_EXTERNAL_CALL, + ) + assert gate is not None + config = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'atomic-gate-rollback.db'}") + async with SQLiteProfile.open(config, tables=BUILTIN_TABLES) as profile: + contexts = await _ready_contexts( + profile, + candidate_pipeline=atomic_memory_pipeline(Pipeline()), + memory_write_gate=gate, + ) + scope = await create_scope(contexts, 1) + context = await contexts.get(scope) + async with profile.database.transaction() as connection: + await connection.execute( + text( + "CREATE TRIGGER reject_memory_cursor " + "BEFORE INSERT ON pc_source_cursors " + "BEGIN SELECT RAISE(ABORT, 'cursor rejected'); END" + ) + ) + + with pytest.raises(IntegrityError, match="cursor rejected"): + await context.triggers.flush(limit=1) + + assert (await context.triggers.cursor()).sequence == 0 + assert not (await contexts.atomic_memory.for_scope(scope).list()).items + async with profile.database.transaction() as connection: + assert not await DecisionObservationRepository().observations(connection, scope) + + caplog.set_level("INFO", logger="powercontext.builtin.runtime.decision_policy") + asyncio.run(scenario()) + events = [record for record in caplog.records if getattr(record, "event", None) == "decision.observation"] + assert [record.__dict__["final_action"] for record in events] == ["memory_write_failed"] + + +def test_atomic_memory_audit_failure_does_not_block_shadow_write(tmp_path, caplog: pytest.LogCaptureFixture): + async def scenario(): + gate = build_memory_write_gate( + None, + enabled=True, + privacy_boundary=DecisionPrivacyBoundary.NO_EXTERNAL_CALL, + ) + assert gate is not None + config = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'atomic-gate-audit.db'}") + async with SQLiteProfile.open(config, tables=BUILTIN_TABLES) as profile: + contexts = await _ready_contexts( + profile, + candidate_pipeline=atomic_memory_pipeline(Pipeline()), + memory_write_gate=gate, + ) + scope = await create_scope(contexts, 1) + context = await contexts.get(scope) + async with profile.database.transaction() as connection: + await connection.execute( + text( + "CREATE TRIGGER reject_decision_observation " + "BEFORE INSERT ON pc_decision_observations " + "BEGIN SELECT RAISE(ABORT, 'audit rejected'); END" + ) + ) + + assert (await context.triggers.flush(limit=1)).current_cursor == 1 + assert len((await contexts.atomic_memory.for_scope(scope).list()).items) == 1 + async with profile.database.transaction() as connection: + assert not await DecisionObservationRepository().observations(connection, scope) + + caplog.set_level("WARNING", logger="powercontext.builtin.runtime.atomic_memory_processing") + asyncio.run(scenario()) + diagnostics = [ + record for record in caplog.records if getattr(record, "event", None) == "decision.observation_sink_failed" + ] + assert len(diagnostics) == 1 + + @pytest.mark.parametrize("error", [InferenceTimeoutError("embed", 60), InferenceUnavailableError("generate")]) def test_non_extraction_failures_do_not_reduce_source_window(error): async def scenario(): From 8b271379b3d4ccd3e0535f73afa9083319e1ee7f Mon Sep 17 00:00:00 2001 From: "Xin.Zh" Date: Sun, 11 Oct 2026 23:04:42 +0800 Subject: [PATCH 11/11] fix(runtime): retain source windows after audit rollback Allow ordinary observation persistence failures to degrade without blocking the owner write, but propagate SQLite savepoint loss caused by a full owner rollback. Log only the bounded exception class for degraded audit failures. Constraint: Audit writes remain in the owner transaction and no diagnostic contains database exception payloads. Scope-risk: SQLite RAISE(ROLLBACK) now fails the flush rather than committing a later cursor update after the owner write was lost. Tested: uv run --no-sync pytest tests/builtin/runtime/test_memory_window_recovery.py 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; uv run --no-sync ruff check src/powercontext/builtin/runtime/atomic_memory_processing.py tests/builtin/runtime/test_memory_window_recovery.py; uv run --no-sync ruff format --check src/powercontext/builtin/runtime/atomic_memory_processing.py tests/builtin/runtime/test_memory_window_recovery.py; uv run --no-sync ty check --python-version 3.11 src/powercontext/builtin/runtime/atomic_memory_processing.py tests/builtin/runtime/test_memory_window_recovery.py; git diff --check --- .../runtime/atomic_memory_processing.py | 13 ++- .../runtime/test_memory_window_recovery.py | 83 ++++++++++++++++++- 2 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/powercontext/builtin/runtime/atomic_memory_processing.py b/src/powercontext/builtin/runtime/atomic_memory_processing.py index b91ce95711..e3eb4d9721 100644 --- a/src/powercontext/builtin/runtime/atomic_memory_processing.py +++ b/src/powercontext/builtin/runtime/atomic_memory_processing.py @@ -25,6 +25,7 @@ from typing import TYPE_CHECKING, Literal, NoReturn from sqlalchemy import update +from sqlalchemy.exc import OperationalError from powercontext._logging import log_safely from powercontext.artifacts import ArtifactLineage, ArtifactRef @@ -670,12 +671,20 @@ async def _finalize_observation(self, connection, observation, *, final_action, datetime.now(UTC) - timedelta(days=self.decision_observation_retention_days), ) except Exception as error: + # SQLite RAISE(ROLLBACK, ...) invalidates the owner transaction and + # destroys this savepoint. Continuing would consume the source window + # in a new transaction after the Memory write was lost. + if ( + connection.dialect.name == "sqlite" + and isinstance(error, OperationalError) + and "no such savepoint" in str(error) + ): + raise log_safely( logger, logging.WARNING, "Decision observation persistence failed", - exc_info=error, - extra={"event": "decision.observation_sink_failed", "error_type": type(error).__name__}, + extra={"event": "decision.observation_sink_failed", "exception_type": type(error).__name__}, ) return None return finalized diff --git a/tests/builtin/runtime/test_memory_window_recovery.py b/tests/builtin/runtime/test_memory_window_recovery.py index d7baad8a27..22fafb2c62 100644 --- a/tests/builtin/runtime/test_memory_window_recovery.py +++ b/tests/builtin/runtime/test_memory_window_recovery.py @@ -19,7 +19,7 @@ import pytest from sqlalchemy import text -from sqlalchemy.exc import IntegrityError +from sqlalchemy.exc import IntegrityError, OperationalError from powercontext.builtin.artifacts.atomic_memory.extraction import AtomicMemoryCandidate, AtomicMemoryExtractionOutput from powercontext.builtin.decision_observations import DecisionPrivacyBoundary @@ -31,6 +31,7 @@ from powercontext.builtin.runtime.memory_write_gate import build_memory_write_gate from powercontext.builtin.runtime.relational import RelationalContexts from powercontext.builtin.scope import ScopeDraft +from powercontext.server.logging import JsonFormatter from tests.e2e.dream_support import atomic_memory_pipeline, memory_source_text @@ -248,6 +249,86 @@ async def scenario(): assert len(diagnostics) == 1 +def test_atomic_memory_audit_rollback_does_not_consume_source_window(tmp_path): + """An audit failure that rolls back the owner transaction must retain the source window.""" + + async def scenario(): + gate = build_memory_write_gate( + None, + enabled=True, + privacy_boundary=DecisionPrivacyBoundary.NO_EXTERNAL_CALL, + ) + assert gate is not None + config = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'atomic-gate-audit-rollback.db'}") + async with SQLiteProfile.open(config, tables=BUILTIN_TABLES) as profile: + contexts = await _ready_contexts( + profile, + candidate_pipeline=atomic_memory_pipeline(Pipeline()), + memory_write_gate=gate, + ) + scope = await create_scope(contexts, 1) + context = await contexts.get(scope) + async with profile.database.transaction() as connection: + await connection.execute( + text( + "CREATE TRIGGER rollback_decision_observation " + "BEFORE INSERT ON pc_decision_observations " + "BEGIN SELECT RAISE(ROLLBACK, 'audit rolled back'); END" + ) + ) + + with pytest.raises(OperationalError, match="savepoint"): + await context.triggers.flush(limit=1) + assert (await context.triggers.cursor()).sequence == 0 + assert not (await contexts.atomic_memory.for_scope(scope).list()).items + + asyncio.run(scenario()) + + +def test_atomic_memory_audit_failure_log_omits_database_exception(tmp_path, caplog: pytest.LogCaptureFixture): + """Audit diagnostics expose only a bounded exception class, never a driver payload or traceback.""" + + marker = "x" * 8192 + + async def scenario(): + gate = build_memory_write_gate( + None, + enabled=True, + privacy_boundary=DecisionPrivacyBoundary.NO_EXTERNAL_CALL, + ) + assert gate is not None + config = SQLiteConfig(url=f"sqlite+aiosqlite:///{tmp_path / 'atomic-gate-audit-log.db'}") + async with SQLiteProfile.open(config, tables=BUILTIN_TABLES) as profile: + contexts = await _ready_contexts( + profile, + candidate_pipeline=atomic_memory_pipeline(Pipeline()), + memory_write_gate=gate, + ) + scope = await create_scope(contexts, 1) + context = await contexts.get(scope) + async with profile.database.transaction() as connection: + await connection.execute( + text( + "CREATE TRIGGER reject_decision_observation_with_marker " + "BEFORE INSERT ON pc_decision_observations " + f"BEGIN SELECT RAISE(ABORT, '{marker}'); END" + ) + ) + + assert (await context.triggers.flush(limit=1)).current_cursor == 1 + + caplog.set_level("WARNING", logger="powercontext.builtin.runtime.atomic_memory_processing") + asyncio.run(scenario()) + diagnostic = next( + record for record in caplog.records if getattr(record, "event", None) == "decision.observation_sink_failed" + ) + rendered = JsonFormatter().format(diagnostic) + assert diagnostic.exc_info is None + assert diagnostic.__dict__["exception_type"] == "IntegrityError" + assert marker not in rendered + assert '"exception_type":"IntegrityError"' in rendered + + @pytest.mark.parametrize("error", [InferenceTimeoutError("embed", 60), InferenceUnavailableError("generate")]) def test_non_extraction_failures_do_not_reduce_source_window(error): async def scenario():