feat: add subject-scoped memory tools

This commit is contained in:
2026-10-02 07:39:04 +09:00
parent 1f26a7f2bb
commit b391392246
17 changed files with 3048 additions and 19 deletions
File diff suppressed because it is too large Load Diff
+650 -3
View File
@@ -161,8 +161,74 @@ impl SubjectSessionAttribution {
}
}
/// The existing extraction schema with only host-owned subject scope and
/// persistence time added. Model input still contains neither field.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RevisionProposalIntent {
Revise,
Resolve,
Retract,
Reopen,
}
/// Host-owned metadata describing how a staged candidate is expected to change
/// one existing Memory. It is validated against the current scoped Memory when
/// the candidate is staged; it is not model-authored and does not itself apply
/// the revision.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RevisionProposal {
pub intent: RevisionProposalIntent,
pub memory_id: String,
pub expected_revision: u64,
pub change_reason: String,
}
impl RevisionProposal {
pub fn new(
intent: RevisionProposalIntent,
memory_id: impl Into<String>,
expected_revision: u64,
change_reason: impl Into<String>,
) -> Result<Self> {
let proposal = Self {
intent,
memory_id: memory_id.into(),
expected_revision,
change_reason: change_reason.into(),
};
validate_revision_proposal_metadata(&proposal)?;
Ok(proposal)
}
}
impl<'de> Deserialize<'de> for RevisionProposal {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct WireProposal {
intent: RevisionProposalIntent,
memory_id: String,
expected_revision: u64,
change_reason: String,
}
let proposal = WireProposal::deserialize(deserializer)?;
RevisionProposal::new(
proposal.intent,
proposal.memory_id,
proposal.expected_revision,
proposal.change_reason,
)
.map_err(serde::de::Error::custom)
}
}
/// The existing extraction schema with only host-owned subject scope,
/// persistence time, and optional revision proposal metadata added. Model input
/// still contains none of those fields.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SubjectStagingRecord {
@@ -180,6 +246,8 @@ pub struct SubjectStagingRecord {
pub evidence: Vec<StagingEvidence>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub source_refs: Vec<SourceEvidenceRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub revision_proposal: Option<RevisionProposal>,
pub created_at: String,
}
@@ -203,9 +271,24 @@ impl SubjectStagingRecord {
staleness: record.staleness,
evidence: record.evidence,
source_refs: record.source_refs,
revision_proposal: None,
created_at,
}
}
/// Attaches host-owned revision proposal metadata to this candidate.
pub fn with_revision_proposal(mut self, proposal: RevisionProposal) -> Result<Self> {
validate_revision_proposal_metadata(&proposal)?;
self.revision_proposal = Some(proposal);
Ok(self)
}
/// Mutably attaches host-owned revision proposal metadata to this candidate.
pub fn attach_revision_proposal(&mut self, proposal: RevisionProposal) -> Result<()> {
validate_revision_proposal_metadata(&proposal)?;
self.revision_proposal = Some(proposal);
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -1119,6 +1202,95 @@ impl SubjektivStore {
})
}
/// Reads one current Memory while distinguishing an absent target from a
/// Memory id owned by another subject.
pub fn scoped_memory(&self, subject_id: &str, memory_id: &str) -> Result<MemoryRecord> {
validate_label("subject id", subject_id)?;
validate_label("Memory id", memory_id)?;
self.database.try_with_connection(|connection| {
require_subject_in_connection(connection, subject_id)?;
scoped_memory_in_connection(connection, subject_id, memory_id)
})
}
/// Reads one immutable revision after verifying that the Memory belongs to
/// the requested subject. A missing revision of a valid scoped Memory is
/// returned as `None`.
pub fn scoped_memory_revision(
&self,
subject_id: &str,
memory_id: &str,
revision: u64,
) -> Result<Option<MemoryRecord>> {
validate_label("subject id", subject_id)?;
validate_label("Memory id", memory_id)?;
if revision == 0 {
return Err(SubjektivError::InvalidRecord(
"Memory revision must be greater than zero".into(),
));
}
self.database.try_with_connection(|connection| {
require_subject_in_connection(connection, subject_id)?;
scoped_memory_in_connection(connection, subject_id, memory_id)?;
let raw = connection
.query_row(
"SELECT record_json FROM memory_revisions
WHERE subject_id = ?1 AND memory_id = ?2 AND revision = ?3",
params![subject_id, memory_id, to_i64(revision)?],
|row| row.get::<_, String>(0),
)
.optional()?;
raw.map(|raw| parse_memory(&raw)).transpose()
})
}
/// Lists current Memory records for one existing subject in deterministic
/// most-recently-updated order.
pub fn list_memories(&self, subject_id: &str) -> Result<Vec<MemoryRecord>> {
validate_label("subject id", subject_id)?;
self.database.try_with_connection(|connection| {
require_subject_in_connection(connection, subject_id)?;
let mut statement = connection.prepare(
"SELECT record_json FROM memory_records
WHERE subject_id = ?1
ORDER BY updated_at DESC, memory_id ASC",
)?;
let rows = statement.query_map([subject_id], |row| row.get::<_, String>(0))?;
let mut records = Vec::new();
for row in rows {
records.push(parse_memory(&row?)?);
}
Ok(records)
})
}
/// Lists every immutable revision of one scoped Memory, newest first.
pub fn list_memory_revisions(
&self,
subject_id: &str,
memory_id: &str,
) -> Result<Vec<MemoryRecord>> {
validate_label("subject id", subject_id)?;
validate_label("Memory id", memory_id)?;
self.database.try_with_connection(|connection| {
require_subject_in_connection(connection, subject_id)?;
scoped_memory_in_connection(connection, subject_id, memory_id)?;
let mut statement = connection.prepare(
"SELECT record_json FROM memory_revisions
WHERE subject_id = ?1 AND memory_id = ?2
ORDER BY revision DESC",
)?;
let rows = statement.query_map(params![subject_id, memory_id], |row| {
row.get::<_, String>(0)
})?;
let mut records = Vec::new();
for row in rows {
records.push(parse_memory(&row?)?);
}
Ok(records)
})
}
pub fn create_surface_snapshot(
&self,
subject_id: &str,
@@ -1382,6 +1554,7 @@ fn write_staging_candidate(
}
return Err(SubjektivError::CandidateConflict(record.id.clone()));
}
validate_staged_revision_proposal(transaction, &record)?;
transaction.execute(
"INSERT INTO staging_records (
subject_id, candidate_id, kind, record_json, created_at
@@ -1571,7 +1744,14 @@ fn require_unresolved_candidate(
}
fn require_subject(transaction: &Transaction<'_>, subject_id: &str) -> Result<SubjectRecord> {
let raw = transaction
require_subject_in_connection(transaction, subject_id)
}
fn require_subject_in_connection(
connection: &rusqlite::Connection,
subject_id: &str,
) -> Result<SubjectRecord> {
let raw = connection
.query_row(
"SELECT record_json FROM subjects WHERE subject_id = ?1",
[subject_id],
@@ -1774,9 +1954,98 @@ fn validate_staging_record(workspace_id: &str, record: &SubjectStagingRecord) ->
});
}
}
if let Some(proposal) = &record.revision_proposal {
validate_revision_proposal_metadata(proposal)?;
}
Ok(())
}
fn validate_revision_proposal_metadata(proposal: &RevisionProposal) -> Result<()> {
validate_label("revision proposal Memory id", &proposal.memory_id)?;
if proposal.expected_revision == 0 {
return Err(SubjektivError::InvalidRecord(
"revision proposal expected_revision must be greater than zero".into(),
));
}
validate_nonempty("revision proposal change_reason", &proposal.change_reason)
}
fn validate_staged_revision_proposal(
transaction: &Transaction<'_>,
record: &SubjectStagingRecord,
) -> Result<()> {
let Some(proposal) = &record.revision_proposal else {
return Ok(());
};
let current =
scoped_memory_in_connection(transaction, &record.subject_id, &proposal.memory_id)?;
if current.revision != proposal.expected_revision {
return Err(SubjektivError::RevisionConflict {
memory_id: proposal.memory_id.clone(),
expected: proposal.expected_revision,
actual: current.revision,
});
}
let to = match proposal.intent {
RevisionProposalIntent::Revise => current.state,
RevisionProposalIntent::Resolve => MemoryState::Resolved,
RevisionProposalIntent::Retract => MemoryState::Retracted,
RevisionProposalIntent::Reopen => MemoryState::Active,
};
let allowed = match proposal.intent {
RevisionProposalIntent::Revise => {
matches!(current.state, MemoryState::Active | MemoryState::Resolved)
}
RevisionProposalIntent::Resolve => current.state == MemoryState::Active,
RevisionProposalIntent::Retract => {
matches!(current.state, MemoryState::Active | MemoryState::Resolved)
}
RevisionProposalIntent::Reopen => current.state == MemoryState::Resolved,
};
if allowed {
Ok(())
} else {
Err(SubjektivError::InvalidStateTransition {
memory_id: proposal.memory_id.clone(),
from: current.state,
to,
})
}
}
fn scoped_memory_in_connection(
connection: &rusqlite::Connection,
subject_id: &str,
memory_id: &str,
) -> Result<MemoryRecord> {
let raw = connection
.query_row(
"SELECT record_json FROM memory_records
WHERE subject_id = ?1 AND memory_id = ?2",
params![subject_id, memory_id],
|row| row.get::<_, String>(0),
)
.optional()?;
if let Some(raw) = raw {
return parse_memory(&raw);
}
let foreign_owner = connection
.query_row(
"SELECT subject_id FROM memory_records WHERE memory_id = ?1 LIMIT 1",
[memory_id],
|row| row.get::<_, String>(0),
)
.optional()?;
if foreign_owner.is_some() {
Err(SubjektivError::SubjectScopeMismatch {
subject_id: subject_id.to_string(),
reference: memory_id.to_string(),
})
} else {
Err(SubjektivError::MemoryNotFound(memory_id.to_string()))
}
}
fn validate_memory_draft(draft: &MemoryDraft) -> Result<()> {
validate_nonempty("Memory claim", &draft.claim)?;
validate_nonempty("Memory why_useful", &draft.why_useful)?;
@@ -2011,6 +2280,7 @@ fn resolution_action_name(action: StagingResolutionAction) -> &'static str {
mod tests {
use std::sync::{Arc, Barrier};
use std::thread;
use std::time::Duration;
use memory::extract::{ExtractedCandidate, StagingEvidence};
use memory::schema::{
@@ -2100,6 +2370,15 @@ mod tests {
)
}
fn proposal(
intent: RevisionProposalIntent,
memory_id: &str,
expected_revision: u64,
change_reason: &str,
) -> RevisionProposal {
RevisionProposal::new(intent, memory_id, expected_revision, change_reason).unwrap()
}
fn open_store(
root: &std::path::Path,
workspace_id: &str,
@@ -2122,8 +2401,52 @@ mod tests {
decoded.source_refs[0].session_id.as_deref(),
Some("session-1")
);
assert!(decoded.revision_proposal.is_none());
assert!(!raw.contains("revision_proposal"));
assert!(raw.contains("human_input"));
let proposed = staging
.clone()
.with_revision_proposal(proposal(
RevisionProposalIntent::Revise,
"memory-1",
3,
"Incorporate the new bounded evidence",
))
.unwrap();
let proposed_raw = serde_json::to_string(&proposed).unwrap();
let proposed_decoded: SubjectStagingRecord = serde_json::from_str(&proposed_raw).unwrap();
assert_eq!(
proposed_decoded.revision_proposal,
Some(proposal(
RevisionProposalIntent::Revise,
"memory-1",
3,
"Incorporate the new bounded evidence",
))
);
assert!(
RevisionProposal::new(
RevisionProposalIntent::Revise,
"memory-1",
0,
"invalid zero revision"
)
.is_err()
);
assert!(
RevisionProposal::new(RevisionProposalIntent::Revise, "memory-1", 1, " ").is_err()
);
assert!(
serde_json::from_value::<RevisionProposal>(serde_json::json!({
"intent": "revise",
"memory_id": "memory-1",
"expected_revision": 0,
"change_reason": "invalid"
}))
.is_err()
);
let mut value = serde_json::to_value(&staging).unwrap();
value
.as_object_mut()
@@ -2393,6 +2716,330 @@ mod tests {
);
}
#[test]
fn revision_proposal_roundtrips_through_staging_and_resolution() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a");
let subject = store.create_subject(role()).unwrap();
let memory = store
.create_memory(&subject.id, draft("Existing memory", "initial observation"))
.unwrap();
let proposal = proposal(
RevisionProposalIntent::Revise,
&memory.id,
1,
"Refine the wording from new evidence",
);
let staged = store
.stage_candidate(
candidate(&subject.id, "candidate-proposed", "workspace-a")
.with_revision_proposal(proposal.clone())
.unwrap(),
)
.unwrap();
assert_eq!(staged.revision_proposal, Some(proposal.clone()));
assert_eq!(
store
.staging_candidate(&subject.id, &staged.id)
.unwrap()
.unwrap()
.revision_proposal,
Some(proposal.clone())
);
let resolution = store
.resolve_candidate(
&subject.id,
&staged.id,
StagingResolutionAction::Discarded,
"The current wording remains preferable",
vec![],
)
.unwrap();
assert_eq!(
resolution.candidate.revision_proposal,
Some(proposal.clone())
);
assert_eq!(
store
.staging_resolution(&subject.id, &staged.id)
.unwrap()
.unwrap()
.candidate
.revision_proposal,
Some(proposal)
);
}
#[test]
fn revision_proposals_enforce_scope_revision_and_state_transitions() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a");
let subject = store.create_subject(role()).unwrap();
let other_subject = store.create_subject(role()).unwrap();
let memory = store
.create_memory(
&subject.id,
draft("Transition target", "initial observation"),
)
.unwrap();
let foreign = candidate(&other_subject.id, "candidate-foreign-target", "workspace-a")
.with_revision_proposal(proposal(
RevisionProposalIntent::Revise,
&memory.id,
1,
"Must remain subject scoped",
))
.unwrap();
assert!(matches!(
store.stage_candidate(foreign),
Err(SubjektivError::SubjectScopeMismatch {
subject_id,
reference,
}) if subject_id == other_subject.id && reference == memory.id
));
let stale = candidate(&subject.id, "candidate-stale", "workspace-a")
.with_revision_proposal(proposal(
RevisionProposalIntent::Resolve,
&memory.id,
2,
"Resolve the active memory",
))
.unwrap();
assert!(matches!(
store.stage_candidate(stale),
Err(SubjektivError::RevisionConflict {
memory_id,
expected: 2,
actual: 1,
}) if memory_id == memory.id
));
let resolve_proposal = candidate(&subject.id, "candidate-resolve", "workspace-a")
.with_revision_proposal(proposal(
RevisionProposalIntent::Resolve,
&memory.id,
1,
"The active condition has ended",
))
.unwrap();
assert!(store.stage_candidate(resolve_proposal).is_ok());
let invalid_reopen = candidate(&subject.id, "candidate-invalid-reopen", "workspace-a")
.with_revision_proposal(proposal(
RevisionProposalIntent::Reopen,
&memory.id,
1,
"An active Memory cannot be reopened",
))
.unwrap();
assert!(matches!(
store.stage_candidate(invalid_reopen),
Err(SubjektivError::InvalidStateTransition {
from: MemoryState::Active,
to: MemoryState::Active,
..
})
));
let mut resolve = draft("Resolved target", "the condition ended");
resolve.state = MemoryState::Resolved;
let resolved = store
.revise_memory(&subject.id, &memory.id, 1, resolve)
.unwrap();
let mut attributed_stale =
candidate(&subject.id, "candidate-attributed-stale", "workspace-a");
attributed_stale.source_refs[0].session_id = None;
attributed_stale
.attach_revision_proposal(proposal(
RevisionProposalIntent::Revise,
&memory.id,
1,
"This attribution must roll back with the stale proposal",
))
.unwrap();
assert!(matches!(
store.stage_candidate_with_attribution(
attributed_stale,
attribution(
&subject.id,
"runtime-proposal",
"worker-proposal",
"session-proposal-stale",
"2026-09-28T12:00:00.000Z",
),
),
Err(SubjektivError::RevisionConflict {
expected: 1,
actual: 2,
..
})
));
assert!(
store
.session_attribution("session-proposal-stale")
.unwrap()
.is_none()
);
let reopen = candidate(&subject.id, "candidate-reopen", "workspace-a")
.with_revision_proposal(proposal(
RevisionProposalIntent::Reopen,
&memory.id,
resolved.revision,
"The condition applies again",
))
.unwrap();
assert_eq!(
store
.stage_candidate(reopen)
.unwrap()
.revision_proposal
.unwrap()
.intent,
RevisionProposalIntent::Reopen
);
let retract_proposal = candidate(&subject.id, "candidate-retract", "workspace-a")
.with_revision_proposal(proposal(
RevisionProposalIntent::Retract,
&memory.id,
resolved.revision,
"The supporting evidence was invalidated",
))
.unwrap();
assert!(store.stage_candidate(retract_proposal).is_ok());
let mut retract = draft("Retracted target", "the evidence was invalidated");
retract.state = MemoryState::Retracted;
let retracted = store
.revise_memory(&subject.id, &memory.id, resolved.revision, retract)
.unwrap();
let terminal = candidate(&subject.id, "candidate-after-retract", "workspace-a")
.with_revision_proposal(proposal(
RevisionProposalIntent::Revise,
&memory.id,
retracted.revision,
"Retraction must remain terminal",
))
.unwrap();
assert!(matches!(
store.stage_candidate(terminal),
Err(SubjektivError::InvalidStateTransition {
memory_id,
from: MemoryState::Retracted,
to: MemoryState::Retracted,
}) if memory_id == memory.id
));
}
#[test]
fn exact_proposal_retry_remains_idempotent_after_target_advances() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a");
let subject = store.create_subject(role()).unwrap();
let memory = store
.create_memory(&subject.id, draft("Retry target", "initial observation"))
.unwrap();
let staged = store
.stage_candidate(
candidate(&subject.id, "candidate-retry-proposal", "workspace-a")
.with_revision_proposal(proposal(
RevisionProposalIntent::Revise,
&memory.id,
1,
"Refine the current revision",
))
.unwrap(),
)
.unwrap();
store
.revise_memory(
&subject.id,
&memory.id,
1,
draft("Advanced target", "another accepted refinement"),
)
.unwrap();
let mut retry = staged.clone();
retry.created_at = "2026-09-28T11:00:00.000Z".into();
let retried = store.stage_candidate(retry).unwrap();
assert_eq!(retried.created_at, staged.created_at);
assert_eq!(retried.revision_proposal, staged.revision_proposal);
}
#[test]
fn bounded_memory_reads_are_scoped_ordered_and_immutable() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a");
let subject = store.create_subject(role()).unwrap();
let other_subject = store.create_subject(role()).unwrap();
assert!(matches!(
store.list_memories("subject-missing"),
Err(SubjektivError::SubjectNotFound(subject_id)) if subject_id == "subject-missing"
));
let mut first_draft = draft("First memory", "first reason");
first_draft.body_md = "Immutable first body".into();
let first = store.create_memory(&subject.id, first_draft).unwrap();
thread::sleep(Duration::from_millis(2));
let second = store
.create_memory(&subject.id, draft("Second memory", "second reason"))
.unwrap();
let memories = store.list_memories(&subject.id).unwrap();
assert_eq!(
memories.iter().map(|record| &record.id).collect::<Vec<_>>(),
vec![&second.id, &first.id]
);
thread::sleep(Duration::from_millis(2));
let mut revision = draft("First memory revised", "second revision reason");
revision.body_md = "New second body".into();
let revised = store
.revise_memory(&subject.id, &first.id, 1, revision)
.unwrap();
let memories = store.list_memories(&subject.id).unwrap();
assert_eq!(memories[0].id, first.id);
assert_eq!(memories[0].revision, 2);
let history = store.list_memory_revisions(&subject.id, &first.id).unwrap();
assert_eq!(
history
.iter()
.map(|record| record.revision)
.collect::<Vec<_>>(),
vec![2, 1]
);
assert_eq!(history[0].body_md, "New second body");
assert_eq!(history[0].change_reason, "second revision reason");
assert_eq!(history[1].body_md, "Immutable first body");
assert_eq!(history[1].change_reason, "first reason");
assert_eq!(
store
.scoped_memory_revision(&subject.id, &first.id, 1)
.unwrap()
.unwrap()
.body_md,
"Immutable first body"
);
let scoped_current = store.scoped_memory(&subject.id, &first.id).unwrap();
assert_eq!(scoped_current.id, revised.id);
assert_eq!(scoped_current.revision, revised.revision);
assert_eq!(scoped_current.body_md, revised.body_md);
assert!(matches!(
store.list_memory_revisions(&other_subject.id, &first.id),
Err(SubjektivError::SubjectScopeMismatch {
subject_id,
reference,
}) if subject_id == other_subject.id && reference == first.id
));
assert!(matches!(
store.scoped_memory(&other_subject.id, &first.id),
Err(SubjektivError::SubjectScopeMismatch { .. })
));
}
#[test]
fn candidate_apply_revision_history_and_resolution_are_atomic() {
let temp = tempfile::tempdir().unwrap();