mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-04 22:02:14 +02:00
fix(blackbox): C1 unconditional quarantine release + C2 trace destructive writes
Two more review findings — both real, both blockers — plus stale-doc cleanup.
C1: the B1 release used reverse_suppression(subject_id, labile_hours), which
REFUSES once the 24h active-forgetting labile window has passed. So a Memory PR
reviewed late could be marked "promoted" while its memory stayed suppressed.
Approving a quarantined write is an explicit reviewer decision and must release
the memory regardless of elapsed time. New SqliteMemoryStore::release_quarantine
fully clears the suppression (count→0, suppressed_at→NULL) with NO time-window
limit; the PR handler now uses it. Proven: a test backdates suppressed_at to
+100h, shows reverse_suppression refuses, and release_quarantine still releases.
C2: memory(action="purge"|"delete") returns `action` + nodeId but those labels
weren't in is_write_decision, so destructive removal bypassed the memory.write
trace and the PR gate. Added purge/purged/delete/deleted/forget/forgotten.
Proven live: purging a node now records a second memory.write event
({"decision":"purge"}) under the run.
Docs: REVIEW.md de-staled — removed the fixed 140b15f diff-stat / "3 commits"
prose (it moved with each fix), listed all commits, added C1/C2 to the findings
table, updated the test count.
Gates: 1002 lib tests pass (+3 new regressions), clippy -D warnings clean,
dashboard check + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8f7bed0463
commit
6a0173dc7b
5 changed files with 143 additions and 12 deletions
|
|
@ -1785,6 +1785,61 @@ impl SqliteMemoryStore {
|
|||
.ok_or_else(|| StorageError::NotFound(id.to_string()))
|
||||
}
|
||||
|
||||
/// Release a memory from quarantine **unconditionally** (no labile-window
|
||||
/// limit), used when a Memory PR is approved.
|
||||
///
|
||||
/// Unlike [`Self::reverse_suppression`] (which models a time-bounded "undo"
|
||||
/// of an active-forgetting decision and refuses after the labile window),
|
||||
/// approving a quarantined risky write is an explicit reviewer decision that
|
||||
/// must always restore the memory's retrieval influence — even days later.
|
||||
/// Fully clears the suppression (count → 0, `suppressed_at` → NULL) and
|
||||
/// restores strengths. A no-op (returns the node) if it isn't suppressed.
|
||||
pub fn release_quarantine(&self, id: &str) -> Result<KnowledgeNode> {
|
||||
let node = self
|
||||
.get_node(id)?
|
||||
.ok_or_else(|| StorageError::NotFound(id.to_string()))?;
|
||||
|
||||
if node.suppression_count == 0 && node.suppressed_at.is_none() {
|
||||
// Nothing to release — idempotent.
|
||||
return Ok(node);
|
||||
}
|
||||
|
||||
{
|
||||
let writer = self
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Writer lock poisoned".into()))?;
|
||||
writer.execute(
|
||||
"UPDATE knowledge_nodes SET
|
||||
suppression_count = 0,
|
||||
suppressed_at = NULL,
|
||||
retrieval_strength = MIN(1.0, retrieval_strength + 0.15),
|
||||
retention_strength = MIN(1.0, retention_strength + 0.10),
|
||||
stability = stability * 1.25
|
||||
WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
}
|
||||
|
||||
let _ = self.log_access(id, "release_quarantine");
|
||||
|
||||
self.get_node(id)?
|
||||
.ok_or_else(|| StorageError::NotFound(id.to_string()))
|
||||
}
|
||||
|
||||
/// Test-only: backdate a node's `suppressed_at` to simulate a suppression
|
||||
/// that happened long ago (e.g. to verify release works past the labile
|
||||
/// window). `pub(crate)` so sibling test modules can reach it.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_suppressed_at_for_test(&self, id: &str, when: DateTime<Utc>) {
|
||||
if let Ok(writer) = self.writer.lock() {
|
||||
let _ = writer.execute(
|
||||
"UPDATE knowledge_nodes SET suppressed_at = ?1 WHERE id = ?2",
|
||||
params![when.to_rfc3339(), id],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Count memories currently in a suppressed state (suppression_count > 0).
|
||||
pub fn count_suppressed(&self) -> Result<usize> {
|
||||
let reader = self
|
||||
|
|
|
|||
|
|
@ -647,15 +647,63 @@ mod tests {
|
|||
);
|
||||
|
||||
// Promote = release. (The action releases_memory() == true; the handler
|
||||
// calls reverse_suppression on the subject.)
|
||||
// calls release_quarantine on the subject.)
|
||||
assert!(crate::MemoryPrAction::Promote.releases_memory());
|
||||
let released = s
|
||||
.reverse_suppression(&node.id, 24)
|
||||
.expect("reverse suppression within labile window");
|
||||
.release_quarantine(&node.id)
|
||||
.expect("release quarantine");
|
||||
assert_eq!(
|
||||
released.suppression_count, 0,
|
||||
"promoting the PR must release the memory — not leave it suppressed"
|
||||
);
|
||||
assert!(
|
||||
released.suppressed_at.is_none(),
|
||||
"release must clear suppressed_at"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_quarantine_works_past_the_labile_window_c1() {
|
||||
// C1: a PR reviewed LATE (past the 24h active-forgetting labile window)
|
||||
// must still release the memory. reverse_suppression refuses after the
|
||||
// window; release_quarantine must not.
|
||||
let s = store();
|
||||
let node = s
|
||||
.ingest(crate::IngestInput {
|
||||
content: "Risky write quarantined and reviewed days later.".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("ingest");
|
||||
s.suppress_memory(&node.id).expect("suppress");
|
||||
|
||||
// Backdate suppressed_at to 100h ago — well past any labile window.
|
||||
s.set_suppressed_at_for_test(&node.id, chrono::Utc::now() - chrono::Duration::hours(100));
|
||||
|
||||
// reverse_suppression refuses (window expired)...
|
||||
assert!(
|
||||
s.reverse_suppression(&node.id, 24).is_err(),
|
||||
"reverse_suppression must refuse past the labile window"
|
||||
);
|
||||
// ...but release_quarantine still releases it.
|
||||
let released = s.release_quarantine(&node.id).expect("release past window");
|
||||
assert_eq!(released.suppression_count, 0);
|
||||
assert!(released.suppressed_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_quarantine_is_idempotent_on_unsuppressed() {
|
||||
let s = store();
|
||||
let node = s
|
||||
.ingest(crate::IngestInput {
|
||||
content: "Never suppressed.".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("ingest");
|
||||
// No-op when not suppressed — must not error.
|
||||
let same = s.release_quarantine(&node.id).expect("idempotent release");
|
||||
assert_eq!(same.suppression_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -2232,9 +2232,10 @@ pub async fn act_on_memory_pr(
|
|||
if action.releases_memory()
|
||||
&& let Some(subject_id) = decided.subject_id.as_deref()
|
||||
{
|
||||
use vestige_core::neuroscience::active_forgetting::ActiveForgettingSystem;
|
||||
let labile_hours = ActiveForgettingSystem::new().labile_hours;
|
||||
match state.storage.reverse_suppression(subject_id, labile_hours) {
|
||||
// Use the UNCONDITIONAL quarantine release, not reverse_suppression:
|
||||
// approving a PR must restore the memory even if reviewed days later,
|
||||
// past the active-forgetting labile window (the C1 fix).
|
||||
match state.storage.release_quarantine(subject_id) {
|
||||
Ok(node) => {
|
||||
released = true;
|
||||
state.emit(VestigeEvent::MemoryUnsuppressed {
|
||||
|
|
|
|||
|
|
@ -68,6 +68,14 @@ fn is_write_decision(label: &str) -> bool {
|
|||
| "remember_pattern"
|
||||
| "remember_decision"
|
||||
| "remembered"
|
||||
// C2: destructive removals are brain mutations too — they must
|
||||
// trace as memory.write and be gateable, not bypass review.
|
||||
| "purge"
|
||||
| "purged"
|
||||
| "delete"
|
||||
| "deleted"
|
||||
| "forget"
|
||||
| "forgotten"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -834,6 +842,19 @@ mod tests {
|
|||
assert!(extract_writes(&state).is_empty(), "state is not a write");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_writes_recognizes_destructive_actions_c2() {
|
||||
// C2: purge/delete are brain mutations and must trace + be gateable.
|
||||
for act in ["purge", "delete"] {
|
||||
let r = serde_json::json!({ "action": act, "nodeId": "m1", "success": true });
|
||||
assert_eq!(
|
||||
extract_writes(&r),
|
||||
vec![("m1".into(), act.to_string())],
|
||||
"{act} must be traced as a write"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_tool_set_includes_codebase_b2() {
|
||||
assert!(is_write_tool("codebase"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue