feat: connect subject memory consolidation

This commit is contained in:
2026-10-02 10:46:12 +09:00
parent 8f8c2676b4
commit 046e606a38
17 changed files with 1857 additions and 106 deletions
+549 -22
View File
@@ -7166,6 +7166,31 @@ impl server_api::ServerApi for ServerApiContractService {
.map_err(ApiError::into_repository_api_error)
}
async fn subjektiv_memory_consolidation(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
request: server_api::MemoryConsolidateStagingRequest,
) -> std::result::Result<server_api::MemoryConsolidationResponse, server_api::RepositoryApiError>
{
scoped_subjektiv_memory_consolidation(
State(self.workspace_api()?.clone()),
AxumPath(ScopedWorkspacePath { workspace_id }),
context,
Json(MemoryConsolidateStagingOperation {
force: request.force,
}),
)
.await
.map(|Json(response)| server_api::MemoryConsolidationResponse {
status: response.status,
summary: response.summary,
candidate_count: response.candidate_count,
total_bytes: response.total_bytes,
})
.map_err(ApiError::into_repository_api_error)
}
async fn skill_list(
&self,
workspace_id: String,
@@ -16519,25 +16544,42 @@ async fn scoped_memory_backend_operation(
}
const SUBJEKTIV_SINGLETON_PREFIX: &str = "subjektiv:";
const SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX: &str = "subjektiv-consolidation:";
fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult<crate::subjektiv::SubjektivStore> {
crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration)
.map_err(|error| ApiError::from(Error::Store(error.to_string())))
}
fn subjektiv_subject_id_from_singleton_key(key: &str) -> ApiResult<&str> {
key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX)
.filter(|subject_id| {
!subject_id.is_empty()
&& !subject_id.chars().any(char::is_control)
&& subject_id.trim() == *subject_id
})
.ok_or_else(|| {
Error::WorkspacePermissionDenied(
"current Worker is not connected to a subjektiv subject".to_string(),
)
.into()
})
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SubjektivWorkerAuthority {
Subject,
Consolidation,
}
fn subjektiv_scope_from_singleton_key(key: &str) -> ApiResult<(&str, SubjektivWorkerAuthority)> {
let (subject_id, authority) = if let Some(subject_id) =
key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX)
{
(subject_id, SubjektivWorkerAuthority::Subject)
} else if let Some(subject_id) = key.strip_prefix(SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX) {
(subject_id, SubjektivWorkerAuthority::Consolidation)
} else {
return Err(Error::WorkspacePermissionDenied(
"current Worker is not connected to a subjektiv subject".to_string(),
)
.into());
};
if subject_id.is_empty()
|| subject_id.chars().any(char::is_control)
|| subject_id.trim() != subject_id
{
return Err(Error::WorkspacePermissionDenied(
"current Worker is not connected to a subjektiv subject".to_string(),
)
.into());
}
Ok((subject_id, authority))
}
fn subjektiv_singleton_key(subject_id: &str) -> ApiResult<String> {
@@ -16553,7 +16595,7 @@ fn subjektiv_subject_scope(
api: &WorkspaceApi,
workspace_id: &str,
context: &server_api::ServerRequestContext,
) -> ApiResult<(String, RuntimeWorkerRef)> {
) -> ApiResult<(String, RuntimeWorkerRef, SubjektivWorkerAuthority)> {
let source = context.runtime_source.as_ref().ok_or_else(|| {
Error::WorkspacePermissionDenied(
"subjektiv operations require authenticated Runtime-owned source authority".to_string(),
@@ -16573,8 +16615,23 @@ fn subjektiv_subject_scope(
"current Worker has no keyed singleton ownership".to_string(),
)
})?;
let subject_id = subjektiv_subject_id_from_singleton_key(&lease.key)?.to_string();
Ok((subject_id, worker))
let (subject_id, authority) = subjektiv_scope_from_singleton_key(&lease.key)?;
Ok((subject_id.to_string(), worker, authority))
}
fn require_subjektiv_worker_authority(
actual: SubjektivWorkerAuthority,
expected: SubjektivWorkerAuthority,
operation: &str,
) -> ApiResult<()> {
if actual == expected {
Ok(())
} else {
Err(Error::WorkspacePermissionDenied(format!(
"subjektiv {operation} is not authorized for this Worker role"
))
.into())
}
}
fn subjektiv_session_attribution(
@@ -16583,7 +16640,13 @@ fn subjektiv_session_attribution(
context: &server_api::ServerRequestContext,
session_id: &str,
) -> ApiResult<crate::subjektiv::SubjectSessionAttribution> {
let (subject_id, worker) = subjektiv_subject_scope(api, workspace_id, context)?;
let (subject_id, worker, authority) = subjektiv_subject_scope(api, workspace_id, context)?;
if authority != SubjektivWorkerAuthority::Subject {
return Err(Error::WorkspacePermissionDenied(
"subject Session attribution requires the current subject Worker".into(),
)
.into());
}
let session_id = session_id.trim();
if session_id.is_empty() || session_id.chars().any(char::is_control) {
return Err(Error::InvalidInput("committed session_id is invalid".to_string()).into());
@@ -16824,7 +16887,7 @@ async fn scoped_subjektiv_memory_backend(
Json(request): Json<server_api::SubjektivMemoryBackendRequest>,
) -> ApiResult<Json<server_api::SubjektivMemoryBackendResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let (subject_id, _) = subjektiv_subject_scope(&api, &path.workspace_id, &context)?;
let (subject_id, _, authority) = subjektiv_subject_scope(&api, &path.workspace_id, &context)?;
let store = open_subjektiv_store(&api)?;
let response = match request.operation {
server_api::SubjektivMemoryBackendOperation::Query(input) => {
@@ -16847,11 +16910,21 @@ async fn scoped_subjektiv_memory_backend(
)
}
server_api::SubjektivMemoryBackendOperation::ValidateProposal(input) => {
require_subjektiv_worker_authority(
authority,
SubjektivWorkerAuthority::Subject,
"revision proposal validation",
)?;
server_api::SubjektivMemoryBackendResponse::ProposalValidated(
subjektiv_memory_validate_proposal(&store, &subject_id, input)?,
)
}
server_api::SubjektivMemoryBackendOperation::ReceiptStatus(input) => {
require_subjektiv_worker_authority(
authority,
SubjektivWorkerAuthority::Subject,
"explicit candidate receipt lookup",
)?;
validate_subjektiv_receipt_id(&input.receipt_id)?;
let candidate = store
.staging_candidate(&subject_id, &input.receipt_id)
@@ -16869,6 +16942,11 @@ async fn scoped_subjektiv_memory_backend(
)
}
server_api::SubjektivMemoryBackendOperation::StageExplicit(input) => {
require_subjektiv_worker_authority(
authority,
SubjektivWorkerAuthority::Subject,
"explicit candidate staging",
)?;
// Re-derive the same subject and immutable Session attribution for the
// write. The request never carries subject, Worker, Runtime, origin,
// or arbitrary source JSON from the model.
@@ -16884,10 +16962,264 @@ async fn scoped_subjektiv_memory_backend(
attribution,
)?)
}
server_api::SubjektivMemoryBackendOperation::ListCandidates(input) => {
require_subjektiv_worker_authority(
authority,
SubjektivWorkerAuthority::Consolidation,
"candidate listing",
)?;
let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?;
let mut candidates = store
.pending_staging_candidates(&subject_id, limit.saturating_add(1))
.map_err(subjektiv_store_error)?;
let has_more = candidates.len() > limit;
candidates.truncate(limit);
server_api::SubjektivMemoryBackendResponse::Candidates(
server_api::SubjektivMemoryCandidateListResponse {
items: candidates
.into_iter()
.map(subjektiv_candidate_summary)
.collect(),
has_more,
},
)
}
server_api::SubjektivMemoryBackendOperation::ReadCandidate(input) => {
require_subjektiv_worker_authority(
authority,
SubjektivWorkerAuthority::Consolidation,
"candidate read",
)?;
let candidate = store
.staging_candidate(&subject_id, &input.candidate_id)
.map_err(subjektiv_store_error)?
.ok_or_else(|| {
Error::InvalidInput(format!(
"candidate_not_found: pending candidate `{}` was not found",
input.candidate_id
))
})?;
if store
.staging_resolution(&subject_id, &input.candidate_id)
.map_err(subjektiv_store_error)?
.is_some()
{
return Err(Error::RepositoryConflict(format!(
"candidate_resolved: candidate `{}` is already resolved",
input.candidate_id
))
.into());
}
server_api::SubjektivMemoryBackendResponse::Candidate(subjektiv_candidate(candidate))
}
server_api::SubjektivMemoryBackendOperation::DecideCandidate(input) => {
require_subjektiv_worker_authority(
authority,
SubjektivWorkerAuthority::Consolidation,
"candidate decision",
)?;
let receipt = store
.decide_candidate(&subject_id, subjektiv_candidate_decision(input)?)
.map_err(subjektiv_store_error)?;
server_api::SubjektivMemoryBackendResponse::CandidateDecided(
subjektiv_candidate_decision_response(receipt),
)
}
};
Ok(Json(response))
}
fn subjektiv_candidate_summary(
candidate: crate::subjektiv::SubjectStagingRecord,
) -> server_api::SubjektivMemoryCandidateSummary {
server_api::SubjektivMemoryCandidateSummary {
candidate_id: candidate.id,
kind: candidate.kind,
claim: candidate.claim,
revision_proposal: candidate.revision_proposal.map(subjektiv_proposal_metadata),
created_at: candidate.created_at,
}
}
fn subjektiv_candidate(
candidate: crate::subjektiv::SubjectStagingRecord,
) -> server_api::SubjektivMemoryCandidate {
server_api::SubjektivMemoryCandidate {
candidate_id: candidate.id,
kind: candidate.kind,
claim: candidate.claim,
why_useful: candidate.why_useful,
staleness: candidate.staleness,
source: candidate.source,
evidence: candidate.evidence,
source_refs: candidate.source_refs,
revision_proposal: candidate.revision_proposal.map(subjektiv_proposal_metadata),
created_at: candidate.created_at,
}
}
fn subjektiv_proposal_metadata(
proposal: crate::subjektiv::RevisionProposal,
) -> server_api::SubjektivMemoryRevisionProposalMetadata {
server_api::SubjektivMemoryRevisionProposalMetadata {
memory_id: proposal.memory_id,
expected_revision: proposal.expected_revision,
intent: match proposal.intent {
crate::subjektiv::RevisionProposalIntent::Revise => {
server_api::SubjektivMemoryRevisionIntent::Revise
}
crate::subjektiv::RevisionProposalIntent::Resolve => {
server_api::SubjektivMemoryRevisionIntent::Resolve
}
crate::subjektiv::RevisionProposalIntent::Retract => {
server_api::SubjektivMemoryRevisionIntent::Retract
}
crate::subjektiv::RevisionProposalIntent::Reopen => {
server_api::SubjektivMemoryRevisionIntent::Reopen
}
},
change_reason: proposal.change_reason,
}
}
fn subjektiv_candidate_decision(
input: server_api::SubjektivMemoryCandidateDecisionRequest,
) -> ApiResult<crate::subjektiv::CandidateDecisionRequest> {
let decision = match input.decision {
server_api::SubjektivMemoryCandidateDecision::Apply { target, memory } => {
let target = match target {
server_api::SubjektivMemoryApplyTarget::Create => {
crate::subjektiv::MemoryRevisionTarget::Create
}
server_api::SubjektivMemoryApplyTarget::Revise {
memory_id,
expected_revision,
} => crate::subjektiv::MemoryRevisionTarget::Revise {
memory_id,
expected_revision,
},
};
crate::subjektiv::CandidateDecision::Apply {
target,
draft: crate::subjektiv::MemoryDraft {
kind: memory.kind,
state: domain_memory_state(memory.state),
claim: memory.claim,
body_md: memory.body_md,
why_useful: memory.why_useful,
staleness: memory.staleness,
source_candidate_ids: Vec::new(),
derived_from: memory
.derived_from
.into_iter()
.map(|reference| crate::subjektiv::MemoryRevisionRef {
memory_id: reference.memory_id,
revision: reference.revision,
})
.collect(),
change_reason: memory.change_reason,
},
}
}
server_api::SubjektivMemoryCandidateDecision::Close {
action,
affected_memory,
} => crate::subjektiv::CandidateDecision::Close {
action: match action {
server_api::SubjektivMemoryCandidateCloseAction::Discarded => {
crate::subjektiv::StagingResolutionAction::Discarded
}
server_api::SubjektivMemoryCandidateCloseAction::Invalid => {
crate::subjektiv::StagingResolutionAction::Invalid
}
server_api::SubjektivMemoryCandidateCloseAction::Duplicate => {
crate::subjektiv::StagingResolutionAction::Duplicate
}
server_api::SubjektivMemoryCandidateCloseAction::AlreadyCovered => {
crate::subjektiv::StagingResolutionAction::AlreadyCovered
}
},
affected_memory: affected_memory
.into_iter()
.map(|reference| crate::subjektiv::MemoryRevisionRef {
memory_id: reference.memory_id,
revision: reference.revision,
})
.collect(),
},
};
Ok(crate::subjektiv::CandidateDecisionRequest {
request_id: input.request_id,
candidate_id: input.candidate_id,
reason: input.reason,
decision,
})
}
fn subjektiv_candidate_decision_response(
receipt: crate::subjektiv::CandidateDecisionReceipt,
) -> server_api::SubjektivMemoryCandidateDecisionResponse {
let operation = match receipt.operation {
Some(crate::subjektiv::MemoryDecisionOperation::Create) => {
server_api::SubjektivMemoryAffectedOperation::Create
}
Some(crate::subjektiv::MemoryDecisionOperation::Revise) => {
server_api::SubjektivMemoryAffectedOperation::Revise
}
Some(crate::subjektiv::MemoryDecisionOperation::Reference) | None => {
server_api::SubjektivMemoryAffectedOperation::Reference
}
};
server_api::SubjektivMemoryCandidateDecisionResponse {
request_id: receipt.request_id,
candidate_id: receipt.candidate_id,
action: match receipt.resolution.action {
crate::subjektiv::StagingResolutionAction::Applied => {
server_api::SubjektivMemoryCandidateResolutionAction::Applied
}
crate::subjektiv::StagingResolutionAction::Discarded => {
server_api::SubjektivMemoryCandidateResolutionAction::Discarded
}
crate::subjektiv::StagingResolutionAction::Invalid => {
server_api::SubjektivMemoryCandidateResolutionAction::Invalid
}
crate::subjektiv::StagingResolutionAction::Duplicate => {
server_api::SubjektivMemoryCandidateResolutionAction::Duplicate
}
crate::subjektiv::StagingResolutionAction::AlreadyCovered => {
server_api::SubjektivMemoryCandidateResolutionAction::AlreadyCovered
}
},
reason: receipt.resolution.reason,
affected_memory: receipt
.resolution
.affected_memory
.into_iter()
.map(|reference| server_api::SubjektivMemoryAffectedRef {
memory_id: reference.memory_id,
revision: reference.revision,
operation,
})
.collect(),
memory: receipt
.memory
.map(|memory| server_api::SubjektivMemoryRevisionRef {
memory_id: memory.id,
revision: memory.revision,
}),
store_revision: receipt.store_revision,
surface_dirty: receipt.surface_dirty,
}
}
fn domain_memory_state(state: server_api::SubjektivMemoryState) -> crate::subjektiv::MemoryState {
match state {
server_api::SubjektivMemoryState::Active => crate::subjektiv::MemoryState::Active,
server_api::SubjektivMemoryState::Resolved => crate::subjektiv::MemoryState::Resolved,
server_api::SubjektivMemoryState::Retracted => crate::subjektiv::MemoryState::Retracted,
}
}
fn subjektiv_memory_query(
store: &crate::subjektiv::SubjektivStore,
subject_id: &str,
@@ -17842,6 +18174,10 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error {
crate::subjektiv::SubjektivError::RevisionConflict { .. } => {
Error::RepositoryConflict(format!("revision_conflict: {error}"))
}
crate::subjektiv::SubjektivError::CandidateResolved(_)
| crate::subjektiv::SubjektivError::DecisionRequestConflict(_) => {
Error::RepositoryConflict(format!("candidate_decision_conflict: {error}"))
}
crate::subjektiv::SubjektivError::MemoryNotFound(_) => {
Error::InvalidInput(format!("memory_not_found: {error}"))
}
@@ -17851,8 +18187,185 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error {
}
const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation";
const SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE: &str = "subjektiv-memory-consolidation";
const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation";
async fn scoped_subjektiv_memory_consolidation(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
context: server_api::ServerRequestContext,
Json(operation): Json<MemoryConsolidateStagingOperation>,
) -> ApiResult<Json<MemoryConsolidationOutput>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let (subject_id, _, authority) = subjektiv_subject_scope(&api, &path.workspace_id, &context)?;
require_subjektiv_worker_authority(
authority,
SubjektivWorkerAuthority::Subject,
"consolidation request",
)?;
Ok(Json(start_subjektiv_staging_consolidation(
api,
&subject_id,
operation,
)?))
}
fn start_subjektiv_staging_consolidation(
api: WorkspaceApi,
subject_id: &str,
operation: MemoryConsolidateStagingOperation,
) -> ApiResult<MemoryConsolidationOutput> {
let (candidate_count, total_bytes) = open_subjektiv_store(&api)?
.pending_staging_backlog(subject_id)
.map_err(subjektiv_store_error)?;
if candidate_count == 0 {
return Ok(MemoryConsolidationOutput {
status: "skipped_empty".into(),
summary: "No subject Memory staging candidates are pending.".into(),
candidate_count,
total_bytes,
});
}
const CONSOLIDATION_THRESHOLD_FILES: usize = 5;
const CONSOLIDATION_THRESHOLD_BYTES: u64 = 50_000;
if !operation.force
&& candidate_count < CONSOLIDATION_THRESHOLD_FILES
&& total_bytes < CONSOLIDATION_THRESHOLD_BYTES
{
return Ok(MemoryConsolidationOutput {
status: "skipped_below_threshold".into(),
summary: format!(
"Subject Memory staging backlog has {candidate_count} candidate(s), {total_bytes} byte(s), below Backend policy threshold."
),
candidate_count,
total_bytes,
});
}
let runtime_id = select_memory_consolidation_runtime(&api)?;
let singleton_key = format!("{SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX}{subject_id}");
let input_content = format!(
"Process the delegated subject's {candidate_count} pending Memory candidate(s) ({total_bytes} bytes) with MemoryStagingList, MemoryStagingRead, subject Memory reads, and MemoryApplyCandidate."
);
if let Some(output) = try_reuse_subject_consolidation_worker(
&api,
&singleton_key,
&input_content,
candidate_count,
total_bytes,
)? {
return Ok(output);
}
let result = api.spawn_workspace_worker(
&runtime_id,
WorkerSpawnRequest {
requested_worker_name: Some(SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE.to_string()),
singleton_key: Some(singleton_key),
intent: WorkerSpawnIntent::WorkspaceOrchestrator,
acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted {
expected_segments: 1,
},
profile: ProfileSelector::Builtin(SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE.to_string()),
ticket_assignment: None,
initial_submit: vec![Segment::text(input_content)],
workdir_attachment_requests: Vec::new(),
resolved_workdir_attachment_requests: Vec::new(),
resolved_workdir_attachments: Vec::new(),
resolved_config_bundle: None,
resolved_worker_observation_enabled: false,
resolved_worker_observation_grants: Vec::new(),
resolved_workspace_api: None,
resolved_memory_settings: None,
resolved_control_operation: None,
},
)?;
if result.state != InternalWorkerOperationState::Accepted {
return Ok(MemoryConsolidationOutput {
status: "skipped_spawn_rejected".into(),
summary: "Runtime rejected subject Memory consolidater spawn.".into(),
candidate_count,
total_bytes,
});
}
let Some(worker) = result.worker else {
return Ok(MemoryConsolidationOutput {
status: "skipped_spawn_missing_worker".into(),
summary: "Runtime accepted subject Memory consolidater spawn without returning a Worker summary.".into(),
candidate_count,
total_bytes,
});
};
Ok(MemoryConsolidationOutput {
status: "started".into(),
summary: format!(
"Started subject Memory consolidater '{}' for {candidate_count} staging candidate(s).",
worker.worker.worker_id
),
candidate_count,
total_bytes,
})
}
fn try_reuse_subject_consolidation_worker(
api: &WorkspaceApi,
singleton_key: &str,
input_content: &str,
candidate_count: usize,
total_bytes: u64,
) -> ApiResult<Option<MemoryConsolidationOutput>> {
let Some(owner) = api
.store
.current_worker_singleton_owner(&api.config.workspace_id, singleton_key)?
else {
return Ok(None);
};
let mut worker = api
.runtime
.worker(&owner.worker)
.map_err(|error| error.into_error())?;
worker.singleton_key = Some(owner.key);
if worker.state != "idle" {
return Ok(Some(MemoryConsolidationOutput {
status: "skipped_existing_not_idle".into(),
summary: format!(
"Existing subject Memory consolidater '{}' is '{}', not confirmed idle.",
worker.worker.worker_id, worker.state
),
candidate_count,
total_bytes,
}));
}
let input = api
.runtime
.send_input(
&worker.worker,
WorkerInputRequest {
kind: WorkerInputKind::User,
content: input_content.to_string(),
submission_request_id: None,
segments: None,
},
)
.map_err(|error| error.into_error())?;
if input.state != InternalWorkerOperationState::Accepted {
return Ok(Some(MemoryConsolidationOutput {
status: "skipped_existing_input_rejected".into(),
summary: "Existing subject Memory consolidater rejected new input.".into(),
candidate_count,
total_bytes,
}));
}
Ok(Some(MemoryConsolidationOutput {
status: "reused".into(),
summary: format!(
"Reused subject Memory consolidater '{}' for {candidate_count} staging candidate(s).",
worker.worker.worker_id
),
candidate_count,
total_bytes,
}))
}
async fn scoped_memory_consolidation(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedWorkspacePath>,
@@ -50647,12 +51160,26 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
#[test]
fn subjektiv_singleton_scope_is_explicit_and_opaque() {
assert_eq!(
subjektiv_subject_id_from_singleton_key("subjektiv:subject-42").unwrap(),
subjektiv_scope_from_singleton_key("subjektiv:subject-42")
.unwrap()
.0,
"subject-42"
);
assert!(subjektiv_subject_id_from_singleton_key("workspace-orchestrator").is_err());
assert!(subjektiv_subject_id_from_singleton_key("subjektiv:").is_err());
assert!(subjektiv_subject_id_from_singleton_key("subjektiv: subject-42").is_err());
assert_eq!(
subjektiv_scope_from_singleton_key("subjektiv-consolidation:subject-42")
.unwrap()
.1,
SubjektivWorkerAuthority::Consolidation
);
assert_eq!(
subjektiv_scope_from_singleton_key("subjektiv:subject-42")
.unwrap()
.1,
SubjektivWorkerAuthority::Subject
);
assert!(subjektiv_scope_from_singleton_key("workspace-orchestrator").is_err());
assert!(subjektiv_scope_from_singleton_key("subjektiv:").is_err());
assert!(subjektiv_scope_from_singleton_key("subjektiv: subject-42").is_err());
assert_eq!(
subjektiv_singleton_key("subject-42").unwrap(),
"subjektiv:subject-42"
+714 -45
View File
@@ -42,6 +42,8 @@ pub enum SubjektivError {
CandidateConflict(String),
#[error("staging candidate `{0}` is already resolved")]
CandidateResolved(String),
#[error("candidate decision request `{0}` was already used with different input")]
DecisionRequestConflict(String),
#[error(
"session `{session_id}` is already attributed to subject `{existing_subject_id}` and cannot be attributed to `{requested_subject_id}`"
)]
@@ -336,7 +338,8 @@ pub struct MemoryRecord {
pub updated_at: String,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryDraft {
pub kind: CandidateKind,
pub state: MemoryState,
@@ -411,7 +414,8 @@ pub struct SurfaceSnapshot {
pub created_at: String,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MemoryRevisionTarget {
Create,
Revise {
@@ -420,6 +424,51 @@ pub enum MemoryRevisionTarget {
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryDecisionOperation {
Create,
Revise,
Reference,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CandidateDecision {
Apply {
target: MemoryRevisionTarget,
draft: MemoryDraft,
},
Close {
action: StagingResolutionAction,
#[serde(default)]
affected_memory: Vec<MemoryRevisionRef>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CandidateDecisionRequest {
pub request_id: String,
pub candidate_id: String,
pub reason: String,
pub decision: CandidateDecision,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CandidateDecisionReceipt {
pub request_id: String,
pub candidate_id: String,
pub resolution: StagingResolution,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<MemoryRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation: Option<MemoryDecisionOperation>,
pub store_revision: u64,
pub surface_dirty: bool,
}
fn create_schema(transaction: &Transaction<'_>) -> crate::feature_storage::Result<()> {
transaction.execute_batch(
r#"
@@ -761,6 +810,37 @@ END;
Ok(())
}
fn add_candidate_decision_receipts(
transaction: &Transaction<'_>,
) -> crate::feature_storage::Result<()> {
transaction.execute_batch(
r#"
CREATE TABLE candidate_decision_receipts (
subject_id TEXT NOT NULL,
request_id TEXT NOT NULL,
candidate_id TEXT NOT NULL,
request_json TEXT NOT NULL,
receipt_json TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (subject_id, request_id),
UNIQUE (subject_id, candidate_id),
FOREIGN KEY (subject_id, candidate_id)
REFERENCES staging_resolutions(subject_id, candidate_id) ON DELETE RESTRICT
);
CREATE TRIGGER candidate_decision_receipts_no_update
BEFORE UPDATE ON candidate_decision_receipts BEGIN
SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are immutable');
END;
CREATE TRIGGER candidate_decision_receipts_no_delete
BEFORE DELETE ON candidate_decision_receipts BEGIN
SELECT RAISE(ABORT, 'subjektiv candidate decision receipts are retained');
END;
"#,
)?;
Ok(())
}
static MIGRATIONS: &[FeatureMigration] = &[
FeatureMigration::new(1, "create subjektiv subject memory store", create_schema),
FeatureMigration::new(
@@ -768,6 +848,11 @@ static MIGRATIONS: &[FeatureMigration] = &[
"add immutable subject session attribution",
add_subject_session_attribution,
),
FeatureMigration::new(
3,
"add idempotent atomic candidate decision receipts",
add_candidate_decision_receipts,
),
];
pub const REGISTRATION: FeatureRegistration =
@@ -1024,6 +1109,56 @@ impl SubjektivStore {
})
}
pub fn pending_staging_candidates(
&self,
subject_id: &str,
limit: usize,
) -> Result<Vec<SubjectStagingRecord>> {
self.database.try_with_connection(|connection| {
require_subject_in_connection(connection, subject_id)?;
let mut statement = connection.prepare(
"SELECT s.record_json
FROM staging_records s
LEFT JOIN staging_resolutions r
ON r.subject_id = s.subject_id AND r.candidate_id = s.candidate_id
WHERE s.subject_id = ?1 AND r.candidate_id IS NULL
ORDER BY s.created_at ASC, s.candidate_id ASC
LIMIT ?2",
)?;
let rows = statement.query_map(params![subject_id, to_i64(limit as u64)?], |row| {
row.get::<_, String>(0)
})?;
let mut records = Vec::new();
for row in rows {
records.push(parse_staging(self.workspace_id(), &row?)?);
}
Ok(records)
})
}
pub fn pending_staging_backlog(&self, subject_id: &str) -> Result<(usize, u64)> {
self.database.try_with_connection(|connection| {
require_subject_in_connection(connection, subject_id)?;
let (count, bytes) = connection.query_row(
"SELECT COUNT(*), COALESCE(SUM(LENGTH(s.record_json)), 0)
FROM staging_records s
LEFT JOIN staging_resolutions r
ON r.subject_id = s.subject_id AND r.candidate_id = s.candidate_id
WHERE s.subject_id = ?1 AND r.candidate_id IS NULL",
[subject_id],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
)?;
Ok((
usize::try_from(count).map_err(|_| {
SubjektivError::InvalidRecord("pending candidate count exceeds usize".into())
})?,
u64::try_from(bytes).map_err(|_| {
SubjektivError::InvalidRecord("pending candidate bytes are negative".into())
})?,
))
})
}
pub fn create_memory(&self, subject_id: &str, draft: MemoryDraft) -> Result<MemoryRecord> {
validate_direct_memory_draft(&draft)?;
if draft.state != MemoryState::Active {
@@ -1080,7 +1215,7 @@ impl SubjektivStore {
subject_id: &str,
candidate_ids: &[String],
target: MemoryRevisionTarget,
mut draft: MemoryDraft,
draft: MemoryDraft,
reason: impl Into<String>,
) -> Result<(MemoryRecord, Vec<StagingResolution>)> {
if candidate_ids.is_empty() {
@@ -1092,52 +1227,127 @@ impl SubjektivStore {
for candidate_id in candidate_ids {
validate_label("candidate id", candidate_id)?;
}
draft.source_candidate_ids = candidate_ids.to_vec();
let reason = reason.into();
validate_nonempty("resolution reason", &reason)?;
let new_memory_id = issued_id("memory");
self.database.try_transaction(|transaction| {
for candidate_id in candidate_ids {
require_unresolved_candidate(transaction, subject_id, candidate_id)?;
}
let memory = match &target {
MemoryRevisionTarget::Create => {
if draft.state != MemoryState::Active {
return Err(SubjektivError::InvalidRecord(
"a new Memory must start in active state".to_string(),
));
}
write_memory_revision(transaction, subject_id, &new_memory_id, None, draft)?
apply_candidates_in_transaction(
transaction,
subject_id,
candidate_ids,
&target,
draft,
&reason,
)
})
}
pub fn decide_candidate(
&self,
subject_id: &str,
request: CandidateDecisionRequest,
) -> Result<CandidateDecisionReceipt> {
validate_label("candidate decision request id", &request.request_id)?;
validate_label("candidate id", &request.candidate_id)?;
validate_nonempty("resolution reason", &request.reason)?;
let request_raw = serde_json::to_string(&request)?;
self.database.try_transaction(|transaction| {
if let Some((stored_request, stored_receipt)) = transaction
.query_row(
"SELECT request_json, receipt_json
FROM candidate_decision_receipts
WHERE subject_id = ?1 AND request_id = ?2",
params![subject_id, request.request_id],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.optional()?
{
if stored_request == request_raw {
return serde_json::from_str(&stored_receipt).map_err(Into::into);
}
MemoryRevisionTarget::Revise {
memory_id,
expected_revision,
} => write_memory_revision(
transaction,
subject_id,
memory_id,
Some(*expected_revision),
draft,
)?,
};
let reference = MemoryRevisionRef {
memory_id: memory.id.clone(),
revision: memory.revision,
};
let resolutions = candidate_ids
.iter()
.map(|candidate_id| {
insert_resolution(
return Err(SubjektivError::DecisionRequestConflict(
request.request_id.clone(),
));
}
if transaction
.query_row(
"SELECT 1 FROM candidate_decision_receipts
WHERE subject_id = ?1 AND candidate_id = ?2",
params![subject_id, request.candidate_id],
|_| Ok(()),
)
.optional()?
.is_some()
{
return Err(SubjektivError::CandidateResolved(
request.candidate_id.clone(),
));
}
let (memory, operation, resolution) = match request.decision.clone() {
CandidateDecision::Apply { target, draft } => {
let operation = match &target {
MemoryRevisionTarget::Create => MemoryDecisionOperation::Create,
MemoryRevisionTarget::Revise { .. } => MemoryDecisionOperation::Revise,
};
let candidate_ids = [request.candidate_id.clone()];
let (memory, mut resolutions) = apply_candidates_in_transaction(
transaction,
subject_id,
candidate_id,
StagingResolutionAction::Applied,
&reason,
std::slice::from_ref(&reference),
)
})
.collect::<Result<Vec<_>>>()?;
Ok((memory, resolutions))
&candidate_ids,
&target,
draft,
&request.reason,
)?;
(Some(memory), Some(operation), resolutions.remove(0))
}
CandidateDecision::Close {
action,
affected_memory,
} => {
if action == StagingResolutionAction::Applied {
return Err(SubjektivError::InvalidRecord(
"applied candidates require an apply decision".into(),
));
}
let resolution = insert_resolution(
transaction,
subject_id,
&request.candidate_id,
action,
&request.reason,
&affected_memory,
)?;
let operation =
(!affected_memory.is_empty()).then_some(MemoryDecisionOperation::Reference);
(None, operation, resolution)
}
};
let store_revision = require_subject(transaction, subject_id)?.store_revision;
let receipt = CandidateDecisionReceipt {
request_id: request.request_id.clone(),
candidate_id: request.candidate_id.clone(),
surface_dirty: memory.is_some(),
memory,
operation,
resolution,
store_revision,
};
let receipt_raw = serde_json::to_string(&receipt)?;
transaction.execute(
"INSERT INTO candidate_decision_receipts (
subject_id, request_id, candidate_id, request_json,
receipt_json, created_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![
subject_id,
request.request_id,
request.candidate_id,
request_raw,
receipt_raw,
now()
],
)?;
Ok(receipt)
})
}
@@ -1390,6 +1600,64 @@ impl SubjektivStore {
}
}
fn apply_candidates_in_transaction(
transaction: &Transaction<'_>,
subject_id: &str,
candidate_ids: &[String],
target: &MemoryRevisionTarget,
mut draft: MemoryDraft,
reason: &str,
) -> Result<(MemoryRecord, Vec<StagingResolution>)> {
let mut candidates = Vec::with_capacity(candidate_ids.len());
for candidate_id in candidate_ids {
let raw = require_unresolved_candidate(transaction, subject_id, candidate_id)?;
candidates.push(parse_staging_without_workspace(&raw)?);
}
for candidate in &candidates {
validate_candidate_application(transaction, subject_id, candidate, target, &draft)?;
}
draft.source_candidate_ids = candidate_ids.to_vec();
let new_memory_id = issued_id("memory");
let memory = match target {
MemoryRevisionTarget::Create => {
if draft.state != MemoryState::Active {
return Err(SubjektivError::InvalidRecord(
"a new Memory must start in active state".into(),
));
}
write_memory_revision(transaction, subject_id, &new_memory_id, None, draft)?
}
MemoryRevisionTarget::Revise {
memory_id,
expected_revision,
} => write_memory_revision(
transaction,
subject_id,
memory_id,
Some(*expected_revision),
draft,
)?,
};
let reference = MemoryRevisionRef {
memory_id: memory.id.clone(),
revision: memory.revision,
};
let resolutions = candidate_ids
.iter()
.map(|candidate_id| {
insert_resolution(
transaction,
subject_id,
candidate_id,
StagingResolutionAction::Applied,
reason,
std::slice::from_ref(&reference),
)
})
.collect::<Result<Vec<_>>>()?;
Ok((memory, resolutions))
}
fn write_memory_revision(
transaction: &Transaction<'_>,
subject_id: &str,
@@ -1744,8 +2012,26 @@ fn require_unresolved_candidate(
params![subject_id, candidate_id],
|row| row.get::<_, String>(0),
)
.optional()?
.ok_or_else(|| SubjektivError::CandidateNotFound(candidate_id.to_string()))?;
.optional()?;
let raw = match raw {
Some(raw) => raw,
None => {
let foreign = transaction
.query_row(
"SELECT subject_id FROM staging_records WHERE candidate_id = ?1 LIMIT 1",
[candidate_id],
|row| row.get::<_, String>(0),
)
.optional()?;
if foreign.is_some() {
return Err(SubjektivError::SubjectScopeMismatch {
subject_id: subject_id.to_string(),
reference: candidate_id.to_string(),
});
}
return Err(SubjektivError::CandidateNotFound(candidate_id.to_string()));
}
};
let resolved = transaction
.query_row(
"SELECT 1 FROM staging_resolutions
@@ -2001,6 +2287,74 @@ fn validate_revision_proposal_metadata(proposal: &RevisionProposal) -> Result<()
validate_nonempty("revision proposal change_reason", &proposal.change_reason)
}
fn validate_candidate_application(
transaction: &Transaction<'_>,
subject_id: &str,
candidate: &SubjectStagingRecord,
target: &MemoryRevisionTarget,
draft: &MemoryDraft,
) -> Result<()> {
let Some(proposal) = &candidate.revision_proposal else {
return Ok(());
};
let MemoryRevisionTarget::Revise {
memory_id,
expected_revision,
} = target
else {
return Err(SubjektivError::InvalidRecord(
"a revision proposal cannot create a different Memory".into(),
));
};
if memory_id != &proposal.memory_id || expected_revision != &proposal.expected_revision {
return Err(SubjektivError::InvalidRecord(
"candidate application target must exactly match revision proposal metadata".into(),
));
}
let current = scoped_memory_in_connection(transaction, subject_id, memory_id)?;
if current.revision != *expected_revision {
return Err(SubjektivError::RevisionConflict {
memory_id: memory_id.clone(),
expected: *expected_revision,
actual: current.revision,
});
}
let expected_state = match proposal.intent {
RevisionProposalIntent::Revise => current.state,
RevisionProposalIntent::Resolve => MemoryState::Resolved,
RevisionProposalIntent::Retract => MemoryState::Retracted,
RevisionProposalIntent::Reopen => MemoryState::Active,
};
let intent_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 !intent_allowed || draft.state != expected_state {
return Err(SubjektivError::InvalidStateTransition {
memory_id: memory_id.clone(),
from: current.state,
to: draft.state,
});
}
if draft.kind != current.kind || draft.kind != candidate.kind {
return Err(SubjektivError::InvalidRecord(
"revision proposal application must preserve the target Memory kind".into(),
));
}
if draft.change_reason != proposal.change_reason {
return Err(SubjektivError::InvalidRecord(
"revision proposal application must preserve change_reason".into(),
));
}
Ok(())
}
fn validate_staged_revision_proposal(
transaction: &Transaction<'_>,
record: &SubjectStagingRecord,
@@ -2802,6 +3156,321 @@ mod tests {
);
}
#[test]
fn atomic_candidate_decision_is_idempotent_and_preserves_proposal_metadata() {
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("Original", "initial observation"))
.unwrap();
let change_reason = "The remembered condition has ended";
let staged = store
.stage_candidate(
candidate(&subject.id, "candidate-resolve-atomic", "workspace-a")
.with_revision_proposal(proposal(
RevisionProposalIntent::Resolve,
&memory.id,
memory.revision,
change_reason,
))
.unwrap(),
)
.unwrap();
let mut revised = draft("Resolved", change_reason);
revised.state = MemoryState::Resolved;
let request = CandidateDecisionRequest {
request_id: "decision-resolve-1".into(),
candidate_id: staged.id.clone(),
reason: "Accepted exact resolution proposal".into(),
decision: CandidateDecision::Apply {
target: MemoryRevisionTarget::Revise {
memory_id: memory.id.clone(),
expected_revision: memory.revision,
},
draft: revised,
},
};
let first = store
.decide_candidate(&subject.id, request.clone())
.unwrap();
let retried = store.decide_candidate(&subject.id, request).unwrap();
assert_eq!(first.memory.as_ref().unwrap().revision, 2);
assert_eq!(retried.memory.as_ref().unwrap().revision, 2);
assert_eq!(first.resolution.id, retried.resolution.id);
assert_eq!(first.store_revision, 2);
assert!(first.surface_dirty);
assert_eq!(
first
.resolution
.candidate
.revision_proposal
.unwrap()
.change_reason,
change_reason
);
assert_eq!(
store
.list_memory_revisions(&subject.id, &memory.id)
.unwrap()
.len(),
2
);
}
#[test]
fn candidate_decision_revalidates_stale_proposals_and_intent_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 memory = store
.create_memory(&subject.id, draft("Lifecycle", "initial"))
.unwrap();
let stale = store
.stage_candidate(
candidate(&subject.id, "candidate-stale-apply", "workspace-a")
.with_revision_proposal(proposal(
RevisionProposalIntent::Revise,
&memory.id,
1,
"stale correction",
))
.unwrap(),
)
.unwrap();
store
.revise_memory(&subject.id, &memory.id, 1, draft("Advanced", "advance"))
.unwrap();
let stale_result = store.decide_candidate(
&subject.id,
CandidateDecisionRequest {
request_id: "decision-stale".into(),
candidate_id: stale.id.clone(),
reason: "must not auto-rebase".into(),
decision: CandidateDecision::Apply {
target: MemoryRevisionTarget::Revise {
memory_id: memory.id.clone(),
expected_revision: 1,
},
draft: draft("Stale", "stale correction"),
},
},
);
assert!(matches!(
stale_result,
Err(SubjektivError::RevisionConflict {
expected: 1,
actual: 2,
..
})
));
assert!(
store
.staging_resolution(&subject.id, &stale.id)
.unwrap()
.is_none()
);
let apply_intent = |store: &SubjektivStore,
candidate_id: &str,
intent: RevisionProposalIntent,
expected_revision: u64,
state: MemoryState,
reason: &str| {
let staged = store
.stage_candidate(
candidate(&subject.id, candidate_id, "workspace-a")
.with_revision_proposal(proposal(
intent,
&memory.id,
expected_revision,
reason,
))
.unwrap(),
)
.unwrap();
let mut next = draft(candidate_id, reason);
next.state = state;
store
.decide_candidate(
&subject.id,
CandidateDecisionRequest {
request_id: format!("decision-{candidate_id}"),
candidate_id: staged.id,
reason: format!("apply {candidate_id}"),
decision: CandidateDecision::Apply {
target: MemoryRevisionTarget::Revise {
memory_id: memory.id.clone(),
expected_revision,
},
draft: next,
},
},
)
.unwrap()
.memory
.unwrap()
};
let resolved = apply_intent(
&store,
"resolve",
RevisionProposalIntent::Resolve,
2,
MemoryState::Resolved,
"resolve reason",
);
let reopened = apply_intent(
&store,
"reopen",
RevisionProposalIntent::Reopen,
resolved.revision,
MemoryState::Active,
"reopen reason",
);
let retracted = apply_intent(
&store,
"retract",
RevisionProposalIntent::Retract,
reopened.revision,
MemoryState::Retracted,
"retract reason",
);
assert_eq!(retracted.state, MemoryState::Retracted);
assert!(matches!(
store.stage_candidate(
candidate(&subject.id, "post-retract", "workspace-a")
.with_revision_proposal(proposal(
RevisionProposalIntent::Reopen,
&memory.id,
retracted.revision,
"cannot revive",
))
.unwrap()
),
Err(SubjektivError::InvalidStateTransition { .. })
));
}
#[test]
fn candidate_decisions_reject_conflicts_and_roll_back_partial_writes() {
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 = store.create_subject(role()).unwrap();
let staged = store
.stage_candidate(candidate(&subject.id, "candidate-rollback", "workspace-a"))
.unwrap();
let foreign = store
.stage_candidate(candidate(&other.id, "candidate-foreign", "workspace-a"))
.unwrap();
let mut invalid = draft("Would be written", "must roll back");
invalid.derived_from.push(MemoryRevisionRef {
memory_id: "missing-memory".into(),
revision: 1,
});
let failed = store.decide_candidate(
&subject.id,
CandidateDecisionRequest {
request_id: "decision-rollback".into(),
candidate_id: staged.id.clone(),
reason: "invalid derivation".into(),
decision: CandidateDecision::Apply {
target: MemoryRevisionTarget::Create,
draft: invalid,
},
},
);
assert!(matches!(
failed,
Err(SubjektivError::SubjectScopeMismatch { .. })
));
assert!(
store
.staging_resolution(&subject.id, &staged.id)
.unwrap()
.is_none()
);
assert!(store.list_memories(&subject.id).unwrap().is_empty());
let cross_subject = store.decide_candidate(
&subject.id,
CandidateDecisionRequest {
request_id: "decision-foreign".into(),
candidate_id: foreign.id,
reason: "must remain scoped".into(),
decision: CandidateDecision::Close {
action: StagingResolutionAction::Invalid,
affected_memory: vec![],
},
},
);
assert!(matches!(
cross_subject,
Err(SubjektivError::SubjectScopeMismatch { .. })
));
let closed = store
.decide_candidate(
&subject.id,
CandidateDecisionRequest {
request_id: "decision-close".into(),
candidate_id: staged.id.clone(),
reason: "already represented elsewhere".into(),
decision: CandidateDecision::Close {
action: StagingResolutionAction::AlreadyCovered,
affected_memory: vec![],
},
},
)
.unwrap();
assert!(!closed.surface_dirty);
assert!(closed.memory.is_none());
for (suffix, action) in [
("discarded", StagingResolutionAction::Discarded),
("invalid", StagingResolutionAction::Invalid),
("duplicate", StagingResolutionAction::Duplicate),
] {
let candidate_id = format!("candidate-{suffix}");
store
.stage_candidate(candidate(&subject.id, &candidate_id, "workspace-a"))
.unwrap();
let receipt = store
.decide_candidate(
&subject.id,
CandidateDecisionRequest {
request_id: format!("decision-{suffix}"),
candidate_id,
reason: format!("specific {suffix} reason"),
decision: CandidateDecision::Close {
action,
affected_memory: vec![],
},
},
)
.unwrap();
assert_eq!(receipt.resolution.action, action);
assert!(!receipt.surface_dirty);
}
let conflicting_retry = store.decide_candidate(
&subject.id,
CandidateDecisionRequest {
request_id: "decision-close".into(),
candidate_id: staged.id,
reason: "different disposition".into(),
decision: CandidateDecision::Close {
action: StagingResolutionAction::Discarded,
affected_memory: vec![],
},
},
);
assert!(matches!(
conflicting_retry,
Err(SubjektivError::DecisionRequestConflict(_))
));
}
#[test]
fn revision_proposals_enforce_scope_revision_and_state_transitions() {
let temp = tempfile::tempdir().unwrap();