Skip to content

Commit 7658331

Browse files
feat(query): gate hosted memory extraction (OpenCoven#101 OpenCoven#107 OpenCoven#108)
Fixes OpenCoven#101. Fixes OpenCoven#107. Fixes OpenCoven#108. - gate hosted durable memory writes by source trust - add hosted memory candidate approval flow - keep local memory extraction behavior unchanged Signed-off-by: Timothy Wayne Gregg <Timothy.Gregg@complete.tech>
1 parent 7573a67 commit 7658331

6 files changed

Lines changed: 621 additions & 13 deletions

File tree

docs/advanced.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,20 @@ policy settings such as `hostedReview.allowManagedRules`,
543543
`hostedReview.allowPlugins` can opt specific surfaces back in for controlled
544544
deployments. Prefer tenant-approved managed rules over `allowUserMemory`.
545545

546+
Session memory extraction is also approval-gated in hosted review mode.
547+
Untrusted fork or contributor sessions cannot automatically append learned
548+
facts to durable `.coven-code/AGENTS.md` memory. Instead, extracted memories
549+
are written as JSON candidates under `.coven-code/memory-candidates/` with
550+
content, semantic category, confidence, provenance, source trust, proposed
551+
scope, proposed visibility, status, and rejection reason metadata. Approved
552+
candidates can be promoted into durable memory as maintainer-approved entries;
553+
rejected candidates remain artifacts and are not loaded into future prompts.
554+
555+
Direct hosted auto-persistence requires an explicit trusted policy:
556+
`hostedReview.allowAutoMemoryPersistence` must be true and
557+
`hostedReview.memorySourceTrust` must meet or exceed
558+
`hostedReview.memoryTrustThreshold`.
559+
546560
---
547561

548562
## Security and permissions

docs/configuration.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,35 @@ shared surfaces back in:
157157
review jobs should prefer tenant-approved managed rules over operator-global
158158
user memory.
159159

160+
Auto-extracted memories are approval-gated in hosted review mode. By default,
161+
hosted sessions write reviewable JSON candidates under
162+
`.coven-code/memory-candidates/` instead of appending directly to durable
163+
`.coven-code/AGENTS.md` memory. Each candidate records content, category,
164+
confidence, provenance, source trust, proposed scope, proposed visibility,
165+
status, and any rejection reason.
166+
167+
Trusted deployments can opt into direct durable writes only when the source
168+
trust meets the configured threshold:
169+
170+
```json
171+
{
172+
"config": {
173+
"hostedReview": {
174+
"enabled": true,
175+
"allowAutoMemoryPersistence": true,
176+
"memorySourceTrust": "maintainer-approved",
177+
"memoryTrustThreshold": "maintainer-approved"
178+
}
179+
}
180+
}
181+
```
182+
183+
Supported `memorySourceTrust` and `memoryTrustThreshold` values are
184+
`system-policy`, `maintainer-approved`, `default-branch-code`,
185+
`contributor-input`, `fork-input`, `model-inferred`, and `unknown`.
186+
Untrusted fork or contributor contexts should leave durable persistence
187+
disabled and promote only reviewed candidates.
188+
160189
### Tool access
161190

162191
| Key | Type | Default | Description |
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Hosted Review Phase 3 PR Notes
2+
3+
## Linked issues
4+
5+
Fixes #101.
6+
Fixes #107.
7+
Fixes #108.
8+
9+
## Summary
10+
11+
- Adds hosted memory source trust classification with configurable `memorySourceTrust`, `memoryTrustThreshold`, and `allowAutoMemoryPersistence`.
12+
- Routes hosted session memory extraction through a policy gate instead of always appending to durable `.coven-code/AGENTS.md`.
13+
- Writes untrusted or unapproved hosted extractions as reviewable JSON candidates under `.coven-code/memory-candidates/`.
14+
- Adds candidate approval and rejection APIs; approval promotes candidates to durable memory as maintainer-approved entries, while rejection records a reason without durable writes.
15+
- Preserves local mode direct memory persistence.
16+
17+
## Test evidence
18+
19+
- `cargo fmt --all -- --check`
20+
- `cargo check --workspace`
21+
- `cargo clippy --workspace --all-targets -- -D warnings`
22+
- `cargo test -p claurst-core --lib hosted --quiet`
23+
- `cargo test -p claurst-query session_memory --quiet`
24+
- `cargo test --workspace --quiet`
25+
26+
## Risk notes
27+
28+
- Candidate approval/rejection is exposed as Rust API surface in this phase; hosted dashboard or CLI wiring can call it in a later integration PR.
29+
- Hosted direct durable writes remain disabled by default and require both explicit policy and sufficient source trust.
30+
- Candidate artifacts are not loaded into prompts by the existing memory loader, so rejected or pending candidates do not affect future sessions.

src-rust/crates/core/src/hosted_review.rs

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ impl RuntimeMode {
1919
}
2020

2121
/// Settings-backed hosted review configuration.
22-
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
22+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2323
#[serde(rename_all = "camelCase")]
2424
pub struct HostedReviewConfig {
2525
#[serde(default, skip_serializing_if = "is_false")]
@@ -34,6 +34,31 @@ pub struct HostedReviewConfig {
3434
pub allow_mcp_servers: bool,
3535
#[serde(default, skip_serializing_if = "is_false")]
3636
pub allow_plugins: bool,
37+
#[serde(default, skip_serializing_if = "is_false")]
38+
pub allow_auto_memory_persistence: bool,
39+
#[serde(default, skip_serializing_if = "MemorySourceTrust::is_unknown")]
40+
pub memory_source_trust: MemorySourceTrust,
41+
#[serde(
42+
default = "default_memory_trust_threshold",
43+
skip_serializing_if = "is_default_memory_trust_threshold"
44+
)]
45+
pub memory_trust_threshold: MemorySourceTrust,
46+
}
47+
48+
impl Default for HostedReviewConfig {
49+
fn default() -> Self {
50+
Self {
51+
enabled: false,
52+
allow_user_memory: false,
53+
allow_managed_rules: false,
54+
allow_write_tools: false,
55+
allow_mcp_servers: false,
56+
allow_plugins: false,
57+
allow_auto_memory_persistence: false,
58+
memory_source_trust: MemorySourceTrust::Unknown,
59+
memory_trust_threshold: default_memory_trust_threshold(),
60+
}
61+
}
3762
}
3863

3964
impl HostedReviewConfig {
@@ -44,9 +69,71 @@ impl HostedReviewConfig {
4469
&& !self.allow_write_tools
4570
&& !self.allow_mcp_servers
4671
&& !self.allow_plugins
72+
&& !self.allow_auto_memory_persistence
73+
&& self.memory_source_trust == MemorySourceTrust::Unknown
74+
&& self.memory_trust_threshold == default_memory_trust_threshold()
75+
}
76+
77+
pub fn memory_source_trust(&self) -> MemorySourceTrust {
78+
self.memory_source_trust
79+
}
80+
81+
pub fn memory_trust_threshold(&self) -> MemorySourceTrust {
82+
self.memory_trust_threshold
83+
}
84+
85+
pub fn allows_auto_memory_persistence(&self) -> bool {
86+
self.allow_auto_memory_persistence
87+
&& self
88+
.memory_source_trust
89+
.meets_threshold(self.memory_trust_threshold)
90+
}
91+
}
92+
93+
/// Trust classification for the source that produced or approved memory.
94+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
95+
#[serde(rename_all = "kebab-case")]
96+
pub enum MemorySourceTrust {
97+
SystemPolicy,
98+
MaintainerApproved,
99+
DefaultBranchCode,
100+
ContributorInput,
101+
ForkInput,
102+
ModelInferred,
103+
#[default]
104+
Unknown,
105+
}
106+
107+
impl MemorySourceTrust {
108+
pub fn is_unknown(&self) -> bool {
109+
matches!(self, Self::Unknown)
110+
}
111+
112+
pub fn meets_threshold(self, threshold: Self) -> bool {
113+
self.rank() >= threshold.rank()
114+
}
115+
116+
fn rank(self) -> u8 {
117+
match self {
118+
Self::Unknown => 0,
119+
Self::ForkInput => 10,
120+
Self::ContributorInput => 20,
121+
Self::ModelInferred => 30,
122+
Self::DefaultBranchCode => 60,
123+
Self::MaintainerApproved => 80,
124+
Self::SystemPolicy => 100,
125+
}
47126
}
48127
}
49128

129+
fn default_memory_trust_threshold() -> MemorySourceTrust {
130+
MemorySourceTrust::MaintainerApproved
131+
}
132+
133+
fn is_default_memory_trust_threshold(value: &MemorySourceTrust) -> bool {
134+
*value == default_memory_trust_threshold()
135+
}
136+
50137
/// Canonical repository identity supplied by the hosted control plane or
51138
/// derived from a git remote for local diagnostics.
52139
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -327,6 +414,31 @@ mod tests {
327414
);
328415
}
329416

417+
#[test]
418+
fn memory_source_trust_enforces_threshold_order() {
419+
assert!(MemorySourceTrust::MaintainerApproved
420+
.meets_threshold(MemorySourceTrust::DefaultBranchCode));
421+
assert!(!MemorySourceTrust::ContributorInput
422+
.meets_threshold(MemorySourceTrust::MaintainerApproved));
423+
assert!(!MemorySourceTrust::ForkInput.meets_threshold(MemorySourceTrust::ContributorInput));
424+
}
425+
426+
#[test]
427+
fn hosted_memory_persistence_requires_explicit_trusted_policy() {
428+
let mut config = HostedReviewConfig {
429+
enabled: true,
430+
..Default::default()
431+
};
432+
assert!(!config.allows_auto_memory_persistence());
433+
434+
config.allow_auto_memory_persistence = true;
435+
config.memory_source_trust = MemorySourceTrust::ContributorInput;
436+
assert!(!config.allows_auto_memory_persistence());
437+
438+
config.memory_source_trust = MemorySourceTrust::MaintainerApproved;
439+
assert!(config.allows_auto_memory_persistence());
440+
}
441+
330442
#[test]
331443
fn security_private_domain_requires_explicit_public_review_allowance() {
332444
assert!(!MemoryDomain::SecurityPrivate.can_load_in_public_review(false));

src-rust/crates/query/src/lib.rs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ pub use compact::{
3232
pub use cron_scheduler::start_cron_scheduler;
3333
pub use goal_loop::{check_and_continue_goal, mark_goal_complete, GoalContinuation, StopReason};
3434
pub use session_memory::{
35-
ExtractedMemory, MemoryCategory, SessionMemoryExtractor, SessionMemoryState,
35+
ExtractedMemory, MemoryCandidate, MemoryCandidateStatus, MemoryCandidateStore, MemoryCategory,
36+
MemoryPersistenceOutcome, SessionMemoryExtractor, SessionMemoryState,
3637
};
3738
pub use skill_prefetch::{
3839
format_skill_listing, prefetch_skills, SharedSkillIndex, SkillDefinition, SkillIndex,
@@ -1956,6 +1957,8 @@ pub async fn run_query_loop(
19561957
let model_clone = config.model.clone();
19571958
let messages_clone = messages.clone();
19581959
let working_dir_clone = tool_ctx.working_dir.clone();
1960+
let runtime_mode = tool_ctx.config.runtime_mode();
1961+
let hosted_review_config = tool_ctx.config.hosted_review.clone();
19591962

19601963
// Build a fresh client using the same API key. This avoids
19611964
// requiring an Arc in the existing run_query_loop signature.
@@ -1979,15 +1982,22 @@ pub async fn run_query_loop(
19791982
let target = working_dir_clone
19801983
.join(".coven-code")
19811984
.join("AGENTS.md");
1982-
if let Err(e) =
1983-
session_memory::SessionMemoryExtractor::persist(
1984-
&memories, &target,
1985-
)
1986-
.await
1985+
let candidate_store =
1986+
session_memory::MemoryCandidateStore::for_working_dir(
1987+
&working_dir_clone,
1988+
);
1989+
if let Err(e) = session_memory::SessionMemoryExtractor::persist_with_policy(
1990+
&memories,
1991+
&target,
1992+
&candidate_store,
1993+
runtime_mode,
1994+
&hosted_review_config,
1995+
)
1996+
.await
19871997
{
19881998
tracing::warn!(
19891999
error = %e,
1990-
"Failed to persist session memories"
2000+
"Failed to store session memories"
19912001
);
19922002
}
19932003
}

0 commit comments

Comments
 (0)