Repository navigation
feat(runtime): add decision policy governance types #1921
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 4 commits
b1d544b
ba7c086
08133ca
62b4117
7da33c6
d4a8603
5098f5d
cd2bc83
927985e
abeed49
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -92,7 +92,9 @@ | |
| MemorySearchRequest, | ||
| MemoryWriteAssessment, | ||
| MemoryWriteGate, | ||
| MemoryWriteGatePreflight, | ||
| MemoryWriteGateRequest, | ||
| MemoryWriteObservationSink, | ||
| MemoryWritePlan, | ||
| MemoryWriteRejectionCode, | ||
| MemoryWriteVerdict, | ||
|
|
@@ -259,7 +261,9 @@ 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, | ||
| write_gate_observation_sink: MemoryWriteObservationSink | None = None, | ||
| capacity_budget: MemoryCapacityBudget | None = None, | ||
| compaction: MemoryCompactionPolicy | None = None, | ||
| max_history_revisions: int = 100, | ||
|
|
@@ -268,6 +272,8 @@ 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: | ||
| raise _InvalidMemoryOperationError("search-limit") | ||
|
|
@@ -1375,17 +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, | ||
|
Copilot marked this conversation as resolved.
Outdated
|
||
| ) | ||
| if projection.rejection is not None: | ||
| if isinstance(self._write_gate, MemoryWriteGatePreflight): | ||
| return await self._write_gate.assess_preflight(request, projection.rejection) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2]
Before this PR the projection-rejection path never called gate code, so this is a new failure mode introduced by the preflight hook. Suggest wrapping the preflight call in the same fail-open fallback (return an ACCEPT assessment with
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 5098f5d. assess_preflight now shares the fail-open behavior of assess(): any gate exception returns an ACCEPT assessment with used_fallback=True, leaving the plan committable. Added a focused raising-preflight regression test. |
||
| _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, | ||
| ) | ||
| ) | ||
| return await self._write_gate.assess(request) | ||
| except Exception: | ||
| return MemoryWriteAssessment( | ||
| verdict=MemoryWriteVerdict.ACCEPT, | ||
|
|
@@ -1839,6 +1851,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) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 5098f5d. Applied writes replace candidate placeholders with exact committed entry/version refs and the committed Memory revision operation ID. Held, unchanged, and failed candidates cannot be reconstructed without retaining raw input, so their sidecars explicitly clear subject_refs and set incomplete_subject_count rather than claiming replayability.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Retain references for the complete assessed candidate batch Applied single-candidate writes now have exact committed refs, but batch coverage is still incomplete on cd2bc83. Through
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed as part of the Atomic Memory migration in |
||
| ) | ||
|
|
||
|
|
||
| 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): | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
| ] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] The example value contradicts the actual default for the privacy boundary
RuntimeConfig.memory_write_gate_privacy_boundarydefaults tono_external_call(runtime/config.py, andtest_memory_write_gate_defaults_to_no_external_callpins it), and the comment above this line correctly says "The default makes no decision-model call". But by this file's own convention the commented line shows the default (..._MODE=shadowtwo lines up matches its actual default), and here it showslocal_only- a value that DOES make a decision-model call on a loopback backend.An operator who uncomments the line to "keep the documented default" actually enables model calls. Suggest changing the example to
# POWERCONTEXT_SERVER_RUNTIME_MEMORY_WRITE_GATE_PRIVACY_BOUNDARY=no_external_call(and optionally a follow-up line showing thelocal_onlyopt-in), or rewording so the example is clearly an opt-in and not the default.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 5098f5d. .env.example now shows no_external_call, matching RuntimeConfig and the surrounding documentation. local_only remains described as the explicit loopback opt-in.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 5098f5d. .env.example now shows no_external_call, matching RuntimeConfig and the surrounding documentation. local_only remains described as the explicit loopback opt-in.