Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor Author

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_boundary defaults to no_external_call (runtime/config.py, and test_memory_write_gate_defaults_to_no_external_call pins 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=shadow two lines up matches its actual default), and here it shows local_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 the local_only opt-in), or rewording so the example is clearly an opt-in and not the default.

Copy link
Copy Markdown
Contributor Author

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.

Copy link
Copy Markdown
Contributor Author

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.

# 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.
Expand Down
4 changes: 4 additions & 0 deletions src/powercontext/builtin/artifacts/memory/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
25 changes: 25 additions & 0 deletions src/powercontext/builtin/artifacts/memory/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Comment on lines +1987 to +1991

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 MemoryService.remember() with real SQLite, storing First fact. and then writing [First fact., Second fact.] evaluates two candidates but persists only the newly created Second fact. entry in subject_refs; metadata says candidate_count=2 and has no incomplete_subject_count.

apply() uses only plan.commit.entry_versions, which excludes deduplicated/unchanged candidates and loses their ordinal mapping to evidence. Preserve exact refs for those candidates too, or explicitly record incomplete subjects and their positions, so re-evaluation can reconstruct the input actually judged.

)


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):
Expand Down
22 changes: 22 additions & 0 deletions src/powercontext/builtin/runtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -392,6 +413,7 @@
"TopicMemoryProcessingUnavailableError",
"UsageStatistics",
"WorkApplication",
"assess_decision_result",
"build_memory_write_gate",
"dependency_readiness_probe",
"open_builtin_contexts",
Expand Down
2 changes: 2 additions & 0 deletions src/powercontext/builtin/runtime/composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions src/powercontext/builtin/runtime/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading
Loading