feat(subjektiv): share domain and add explicit standalone Subject hosts
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "subjektiv"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
feature-storage = { path = "../feature-storage" }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
memory.workspace = true
|
||||
manifest.workspace = true
|
||||
rusqlite.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
server-api.workspace = true
|
||||
thiserror.workspace = true
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,76 @@
|
||||
# subjektiv
|
||||
|
||||
Shared Subject-scoped Memory domain and model-facing operations. This crate has
|
||||
no Workspace Server dependency. `server-api` is the existing transport-neutral
|
||||
request/response contract crate, not an authentication or Server implementation.
|
||||
|
||||
## Storage scope
|
||||
|
||||
The trusted Host creates `feature_storage::FeatureStorage`, selects
|
||||
`FeatureStorage::scope(scope_id)`, registers `REGISTRATION` (or uses
|
||||
`SubjektivStore::register`), and opens `SubjektivStore::open`. A trusted managed
|
||||
`FeatureDatabase` can also be opened with `SubjektivStore::from_database`.
|
||||
|
||||
`scope_id` identifies the physical storage sharing boundary. Backend uses its
|
||||
existing Workspace ID. Standalone uses a persisted random local storage-scope
|
||||
identity under its operator-managed state directory, **not a fake Workspace**.
|
||||
Subject IDs are independently issued random domain identities. An ID is a lookup
|
||||
key, never an authorization credential.
|
||||
|
||||
Existing SQL schema and migrations are retained. In particular,
|
||||
`store_scope.workspace_id` still stores the physical storage scope, under its
|
||||
historical column name. Existing Backend placement and snapshot manifest keys
|
||||
are unchanged. Optional historical `EvidenceOrigin.workspace_id` values continue
|
||||
to be checked against the physical scope; local provenance need not invent a
|
||||
Workspace value. `workspace_id()` is a Backend compatibility alias for
|
||||
`scope_id()`.
|
||||
|
||||
## Host operation boundary
|
||||
|
||||
`api::execute(store, &HostOperationContext, operation)` is the common dispatcher
|
||||
used by Backend and local Hosts. It owns query/cursor rules, bounded body and
|
||||
nested provenance projections, immutable revision reads, proposal validation,
|
||||
explicit staging/receipt rules, resident context, candidate decisions and
|
||||
surface generation/publication/failure projections.
|
||||
|
||||
The Host context is not serializable or model input. Trusted Rust Hosts construct
|
||||
it only after validating their external execution authority:
|
||||
|
||||
- `HostOperationContext::validated_body` binds one Subject and optionally an
|
||||
existing immutable `SubjectSessionAttribution`. Explicit staging requires the
|
||||
request Session to match that attribution.
|
||||
- `HostOperationContext::validated_consolidation` binds one Subject, an optional
|
||||
immutable candidate batch, and an optional `JobAttemptBinding`. Attenuated Jobs
|
||||
must supply `Some(batch)`, including an empty batch; `None` is legacy non-Job
|
||||
Host consolidation authority. Body-only operations are unavailable to
|
||||
consolidation contexts, and candidate reads/decisions cannot escape the batch.
|
||||
|
||||
These constructors validate store/domain bindings, not external authentication.
|
||||
Backend retains authenticated Runtime/Worker identity, current singleton and
|
||||
live Job attempt checks, candidate grant checks, and public Session validation.
|
||||
Local Hosts retain exclusive Subject execution leases, generic Job admission,
|
||||
Worker/Session ownership, and committed public Session evidence validation.
|
||||
Before explicit staging, the Host resolves evidence using its committed public
|
||||
Session projection; the common API does not inspect Session files, hidden
|
||||
reasoning, system prompts, or arbitrary paths. Lower-level repository/projection
|
||||
functions are trusted Rust APIs, not model capabilities.
|
||||
|
||||
## Common consolidation Job rules
|
||||
|
||||
`job::bounded_candidate_batch(ids, max_result_bytes)` selects the exact T-704
|
||||
ordered-prefix budget (100 candidates maximum, 2,048-byte envelope, twice each
|
||||
JSON-escaped ID size plus 128 bytes per disposition).
|
||||
`job::consolidation_required(count, bytes, force, availability)` owns the shared
|
||||
trigger thresholds and empty-ready/surface-retry rules.
|
||||
|
||||
`job::validate_result(store, subject_id, batch, job_id, attempt_id, value)` checks
|
||||
the strict result shape, exact admitted Subject/batch, durable dispositions, and
|
||||
the persisted terminal surface outcome from that attempt. After validation,
|
||||
`job::with_dispositions(store, subject_id, batch, value)` adds the deterministic
|
||||
compact disposition projection. Hosts retain Job result binding/admission; these
|
||||
helpers never run or replay a Job. The store's `prepare_job_surface_generation`,
|
||||
`require_job_surface_generation`, and `validate_job_surface_outcome` are public
|
||||
trusted-domain methods for both Backend and local Hosts.
|
||||
|
||||
Dependencies: `feature-storage`, `chrono`, `memory`, `manifest`, `rusqlite`,
|
||||
`serde`, `serde_json`, `server-api`, `thiserror`, `uuid`; tests use `tempfile`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
//! Trusted Host operation context, never deserialized from model input.
|
||||
use super::{OperationError, OperationResult, subjektiv_store_error};
|
||||
use crate::{SubjectSessionAttribution, SubjektivStore};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct JobAttemptBinding {
|
||||
pub job_id: String,
|
||||
pub attempt_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum Authority {
|
||||
Body {
|
||||
attribution: Option<SubjectSessionAttribution>,
|
||||
},
|
||||
Consolidation {
|
||||
candidate_ids: Option<Vec<String>>,
|
||||
job: Option<JobAttemptBinding>,
|
||||
},
|
||||
}
|
||||
|
||||
/// A capability issued by trusted Rust Host code after authentication/lease,
|
||||
/// delegation, and committed Session evidence checks. Not a request DTO.
|
||||
/// Constructors check domain binding, not external identity or execution leases.
|
||||
/// Backend must retain its authenticated Worker and active Job grant checks;
|
||||
/// standalone must hold the selected Subject lease and attenuate Job batches.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostOperationContext {
|
||||
scope_id: String,
|
||||
subject_id: String,
|
||||
authority: Authority,
|
||||
}
|
||||
|
||||
impl HostOperationContext {
|
||||
/// The Host has validated body execution authority. Attribution, when supplied,
|
||||
/// must already be immutable history in this store. Before StageExplicit the
|
||||
/// Host must resolve/validate all evidence against committed public Session
|
||||
/// entries; neither this constructor nor the operation reads Session files.
|
||||
pub fn validated_body(
|
||||
store: &SubjektivStore,
|
||||
subject_id: &str,
|
||||
attribution: Option<SubjectSessionAttribution>,
|
||||
) -> OperationResult<Self> {
|
||||
Self::require_subject(store, subject_id)?;
|
||||
if let Some(record) = &attribution {
|
||||
if record.subject_id != subject_id
|
||||
|| store
|
||||
.session_attribution(&record.session_id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
.as_ref()
|
||||
!= Some(record)
|
||||
{
|
||||
return Err(OperationError::PermissionDenied(
|
||||
"Session attribution does not match the Host subject".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
scope_id: store.scope_id().into(),
|
||||
subject_id: subject_id.into(),
|
||||
authority: Authority::Body { attribution },
|
||||
})
|
||||
}
|
||||
|
||||
/// The Host has validated consolidation authority and (for a Job) its active
|
||||
/// attempt. `Some(candidate_ids)` is the immutable admitted batch; no candidate
|
||||
/// outside it is accessible. `None` retains Backend's non-Job consolidation
|
||||
/// authority, and must not be issued to an attenuated Job.
|
||||
pub fn validated_consolidation(
|
||||
store: &SubjektivStore,
|
||||
subject_id: &str,
|
||||
candidate_ids: Option<Vec<String>>,
|
||||
job: Option<JobAttemptBinding>,
|
||||
) -> OperationResult<Self> {
|
||||
Self::require_subject(store, subject_id)?;
|
||||
if let Some(ids) = &candidate_ids {
|
||||
for id in ids {
|
||||
if store
|
||||
.staging_candidate(subject_id, id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
.is_none()
|
||||
{
|
||||
return Err(OperationError::InvalidInput(format!(
|
||||
"candidate_not_found: {id}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if job.as_ref().is_some_and(|binding| {
|
||||
binding.job_id.trim().is_empty() || binding.attempt_id.trim().is_empty()
|
||||
}) {
|
||||
return Err(OperationError::InvalidInput(
|
||||
"Job attempt binding must not be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
scope_id: store.scope_id().into(),
|
||||
subject_id: subject_id.into(),
|
||||
authority: Authority::Consolidation { candidate_ids, job },
|
||||
})
|
||||
}
|
||||
|
||||
fn require_subject(store: &SubjektivStore, id: &str) -> OperationResult<()> {
|
||||
store
|
||||
.subject(id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
.ok_or_else(|| OperationError::SubjectNotFound(id.into()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn subject_id(&self) -> &str {
|
||||
&self.subject_id
|
||||
}
|
||||
|
||||
pub(super) fn require_store(&self, store: &SubjektivStore) -> OperationResult<()> {
|
||||
if self.scope_id != store.scope_id() {
|
||||
return Err(OperationError::PermissionDenied(
|
||||
"Host operation context belongs to another storage scope".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn body(&self) -> OperationResult<Option<&SubjectSessionAttribution>> {
|
||||
match &self.authority {
|
||||
Authority::Body { attribution } => Ok(attribution.as_ref()),
|
||||
_ => Err(OperationError::PermissionDenied(
|
||||
"operation requires Subject body authority".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn consolidation(
|
||||
&self,
|
||||
) -> OperationResult<(Option<&[String]>, Option<&JobAttemptBinding>)> {
|
||||
match &self.authority {
|
||||
Authority::Consolidation { candidate_ids, job } => {
|
||||
Ok((candidate_ids.as_deref(), job.as_ref()))
|
||||
}
|
||||
_ => Err(OperationError::PermissionDenied(
|
||||
"operation requires consolidation authority".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn candidate(&self, id: &str) -> OperationResult<()> {
|
||||
let (ids, _) = self.consolidation()?;
|
||||
if ids.is_some_and(|ids| !ids.iter().any(|allowed| allowed == id)) {
|
||||
return Err(OperationError::PermissionDenied(
|
||||
"candidate is outside the admitted Job batch".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
use super::*;
|
||||
use server_api::{
|
||||
SubjektivMemoryBackendOperation as Op, SubjektivMemoryBackendResponse as Response,
|
||||
};
|
||||
|
||||
/// Executes the same domain operations and bounded projections for every Host.
|
||||
/// The caller authenticates and validates evidence before issuing `context`.
|
||||
pub fn execute(
|
||||
store: &crate::SubjektivStore,
|
||||
context: &HostOperationContext,
|
||||
operation: Op,
|
||||
) -> OperationResult<Response> {
|
||||
context.require_store(store)?;
|
||||
let subject_id = context.subject_id();
|
||||
Ok(match operation {
|
||||
Op::ResidentSummary(_) => {
|
||||
context.body()?;
|
||||
Response::ResidentSummary(resident_summary_output(
|
||||
store
|
||||
.resident_surface(subject_id)
|
||||
.map_err(subjektiv_store_error)?,
|
||||
))
|
||||
}
|
||||
Op::ResidentContext(_) => {
|
||||
context.body()?;
|
||||
let resident = store
|
||||
.resident_context(subject_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
Response::ResidentContext(server_api::SubjektivResidentContextOutput {
|
||||
behavior_md: resident.subject.behavior_md,
|
||||
behavior_revision: resident.subject.behavior_revision,
|
||||
memory_surface: resident_summary_output(resident.surface),
|
||||
})
|
||||
}
|
||||
Op::Query(input) => Response::Query(subjektiv_memory_query(store, subject_id, input)?),
|
||||
Op::Read(input) => Response::Read(subjektiv_memory_read(store, subject_id, input)?),
|
||||
Op::ListRevisions(input) => {
|
||||
Response::ListRevisions(subjektiv_memory_list_revisions(store, subject_id, input)?)
|
||||
}
|
||||
Op::ValidateProposal(input) => {
|
||||
context.body()?;
|
||||
Response::ProposalValidated(subjektiv_memory_validate_proposal(
|
||||
store, subject_id, input,
|
||||
)?)
|
||||
}
|
||||
Op::ReceiptStatus(input) => {
|
||||
context.body()?;
|
||||
validate_subjektiv_receipt_id(&input.receipt_id)?;
|
||||
let candidate = store
|
||||
.staging_candidate(subject_id, &input.receipt_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
Response::ReceiptStatus(server_api::SubjektivMemoryReceiptStatusResponse {
|
||||
receipt_id: input.receipt_id,
|
||||
status: if candidate.is_some() {
|
||||
server_api::SubjektivMemoryReceiptStatus::Staged
|
||||
} else {
|
||||
server_api::SubjektivMemoryReceiptStatus::Missing
|
||||
},
|
||||
candidate_id: candidate.map(|record| record.id),
|
||||
})
|
||||
}
|
||||
Op::StageExplicit(input) => {
|
||||
let attribution = context.body()?.ok_or_else(|| {
|
||||
OperationError::PermissionDenied(
|
||||
"explicit staging requires validated Session attribution".into(),
|
||||
)
|
||||
})?;
|
||||
if input.session_id != attribution.session_id {
|
||||
return Err(OperationError::PermissionDenied(
|
||||
"explicit staging Session does not match Host attribution".into(),
|
||||
));
|
||||
}
|
||||
Response::Staged(subjektiv_memory_stage_explicit(
|
||||
store,
|
||||
input,
|
||||
attribution.clone(),
|
||||
)?)
|
||||
}
|
||||
Op::ListCandidates(input) => {
|
||||
let (ids, _) = context.consolidation()?;
|
||||
let limit = bounded_subjektiv_limit(input.limit, SUBJEKTIV_QUERY_DEFAULT_LIMIT)?;
|
||||
let mut candidates = if let Some(ids) = ids {
|
||||
let mut pending = Vec::new();
|
||||
for id in ids {
|
||||
if store
|
||||
.staging_resolution(subject_id, id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
.is_none()
|
||||
{
|
||||
if let Some(record) = store
|
||||
.staging_candidate(subject_id, id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
{
|
||||
pending.push(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
pending
|
||||
} else {
|
||||
store
|
||||
.pending_staging_candidates(subject_id, limit.saturating_add(1))
|
||||
.map_err(subjektiv_store_error)?
|
||||
};
|
||||
let has_more = candidates.len() > limit;
|
||||
candidates.truncate(limit);
|
||||
Response::Candidates(server_api::SubjektivMemoryCandidateListResponse {
|
||||
items: candidates
|
||||
.into_iter()
|
||||
.map(subjektiv_candidate_summary)
|
||||
.collect(),
|
||||
has_more,
|
||||
})
|
||||
}
|
||||
Op::ReadCandidate(input) => {
|
||||
context.candidate(&input.candidate_id)?;
|
||||
let candidate = store
|
||||
.staging_candidate(subject_id, &input.candidate_id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
.ok_or_else(|| {
|
||||
OperationError::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(OperationError::Conflict(format!(
|
||||
"candidate_resolved: candidate `{}` is already resolved",
|
||||
input.candidate_id
|
||||
)));
|
||||
}
|
||||
Response::Candidate(subjektiv_candidate(candidate))
|
||||
}
|
||||
Op::DecideCandidate(input) => {
|
||||
context.candidate(&input.candidate_id)?;
|
||||
let receipt = store
|
||||
.decide_candidate(subject_id, subjektiv_candidate_decision(input)?)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
Response::CandidateDecided(subjektiv_candidate_decision_response(receipt))
|
||||
}
|
||||
Op::PrepareSurface(_) => {
|
||||
let (ids, job) = context.consolidation()?;
|
||||
if let Some(ids) = ids {
|
||||
for id in ids {
|
||||
if store
|
||||
.staging_resolution(subject_id, id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
.is_none()
|
||||
{
|
||||
return Err(OperationError::InvalidInput(format!(
|
||||
"surface generation requires a durable disposition for batch candidate `{id}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let generation = store
|
||||
.prepare_job_surface_generation(
|
||||
subject_id,
|
||||
job.map(|b| (b.job_id.as_str(), b.attempt_id.as_str())),
|
||||
)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
let resident = store
|
||||
.resident_surface(subject_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
let mut response = subjektiv_surface_prepare_response(generation);
|
||||
if resident.availability == crate::SurfaceAvailability::Ready {
|
||||
response.current_snapshot_id = resident
|
||||
.snapshot
|
||||
.filter(|s| s.built_from_store_revision == response.store_revision)
|
||||
.map(|s| s.id);
|
||||
}
|
||||
Response::SurfacePrepared(response)
|
||||
}
|
||||
Op::PublishSurface(input) => {
|
||||
let (_, job) = context.consolidation()?;
|
||||
if let Some(job) = job {
|
||||
store
|
||||
.require_job_surface_generation(
|
||||
subject_id,
|
||||
&input.generation_id,
|
||||
&job.job_id,
|
||||
&job.attempt_id,
|
||||
)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
}
|
||||
let points = input
|
||||
.points
|
||||
.into_iter()
|
||||
.map(|point| crate::SurfacePoint {
|
||||
body_md: point.body_md,
|
||||
memory_refs: point
|
||||
.memory_refs
|
||||
.into_iter()
|
||||
.map(|r| crate::MemoryRevisionRef {
|
||||
memory_id: r.memory_id,
|
||||
revision: r.revision,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect();
|
||||
let snapshot = store
|
||||
.publish_surface_generation(subject_id, &input.generation_id, points)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
Response::SurfacePublished(server_api::SubjektivSurfacePublishResponse {
|
||||
snapshot_id: snapshot.id,
|
||||
built_from_store_revision: snapshot.built_from_store_revision,
|
||||
empty: snapshot.body_md.is_empty(),
|
||||
})
|
||||
}
|
||||
Op::FailSurface(input) => {
|
||||
let (_, job) = context.consolidation()?;
|
||||
if let Some(job) = job {
|
||||
store
|
||||
.require_job_surface_generation(
|
||||
subject_id,
|
||||
&input.generation_id,
|
||||
&job.job_id,
|
||||
&job.attempt_id,
|
||||
)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
}
|
||||
let revision = store
|
||||
.fail_surface_generation(subject_id, &input.generation_id, &input.reason_code)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
let status = if let Some(job) = job {
|
||||
store
|
||||
.validate_job_surface_outcome(
|
||||
subject_id,
|
||||
&input.generation_id,
|
||||
revision,
|
||||
"failed",
|
||||
None,
|
||||
Some(&input.reason_code),
|
||||
&job.job_id,
|
||||
&job.attempt_id,
|
||||
)
|
||||
.map_err(|e| {
|
||||
OperationError::Conflict(format!(
|
||||
"surface failure was not recorded for this attempt: {e}"
|
||||
))
|
||||
})?;
|
||||
"failed_confirmed"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
Response::SurfaceFailed(server_api::SubjektivSurfaceFailureResponse {
|
||||
store_revision: revision,
|
||||
status: status.into(),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
use super::*;
|
||||
use crate::{MemoryDraft, SubjectRole, SubjectSessionAttribution, SubjektivStore};
|
||||
use memory::extract::CandidateKind;
|
||||
use server_api::{
|
||||
SubjektivMemoryBackendOperation as Op, SubjektivMemoryBackendResponse as Response,
|
||||
};
|
||||
|
||||
fn open(root: &std::path::Path, scope: &str) -> (feature_storage::FeatureStorage, SubjektivStore) {
|
||||
let manager = feature_storage::FeatureStorage::new(root);
|
||||
let scope = manager.scope(scope).unwrap();
|
||||
let registration = SubjektivStore::register(&scope).unwrap();
|
||||
let store = SubjektivStore::open(&scope, ®istration).unwrap();
|
||||
(manager, store)
|
||||
}
|
||||
|
||||
fn query(cursor: Option<String>) -> Op {
|
||||
Op::Query(server_api::SubjektivMemoryQueryRequest {
|
||||
query: None,
|
||||
kinds: None,
|
||||
states: None,
|
||||
limit: Some(1),
|
||||
cursor,
|
||||
})
|
||||
}
|
||||
|
||||
fn read(id: &str, revision: Option<u64>, offset: Option<usize>, byte_offset: Option<usize>) -> Op {
|
||||
Op::Read(server_api::SubjektivMemoryReadRequest {
|
||||
memory_id: id.into(),
|
||||
revision,
|
||||
offset,
|
||||
byte_offset,
|
||||
limit: Some(1),
|
||||
evidence_cursor: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_scope_reopen_and_context_attenuation() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let random_scope = uuid::Uuid::now_v7().to_string();
|
||||
let (manager, store) = open(temp.path(), &random_scope);
|
||||
assert_eq!(store.scope_id(), random_scope);
|
||||
let subject = store
|
||||
.create_subject(SubjectRole::new("companion").unwrap())
|
||||
.unwrap();
|
||||
let body = HostOperationContext::validated_body(&store, &subject.id, None).unwrap();
|
||||
let consolidation =
|
||||
HostOperationContext::validated_consolidation(&store, &subject.id, Some(vec![]), None)
|
||||
.unwrap();
|
||||
assert!(
|
||||
execute(
|
||||
&store,
|
||||
&body,
|
||||
Op::ListCandidates(server_api::SubjektivMemoryCandidateListRequest { limit: None })
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
execute(
|
||||
&store,
|
||||
&consolidation,
|
||||
Op::ResidentSummary(memory::backend::MemoryResidentSummaryOperation {})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let attribution =
|
||||
SubjectSessionAttribution::new(&subject.id, "local-host", "worker", "session").unwrap();
|
||||
assert!(
|
||||
HostOperationContext::validated_body(&store, &subject.id, Some(attribution.clone()))
|
||||
.is_err()
|
||||
);
|
||||
store
|
||||
.record_session_attribution(attribution.clone())
|
||||
.unwrap();
|
||||
HostOperationContext::validated_body(&store, &subject.id, Some(attribution)).unwrap();
|
||||
let (_, foreign) = open(&temp.path().join("foreign"), "foreign-scope");
|
||||
assert!(matches!(
|
||||
execute(&foreign, &body, query(None)),
|
||||
Err(OperationError::PermissionDenied(_))
|
||||
));
|
||||
manager.shutdown().unwrap();
|
||||
drop(store);
|
||||
let (_manager, reopened) = open(temp.path(), &random_scope);
|
||||
assert_eq!(reopened.subject(&subject.id).unwrap().unwrap(), subject);
|
||||
assert!(reopened.session_attribution("session").unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_query_cursor_and_utf8_fixed_revision_projection() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, store) = open(temp.path(), "local-random-scope");
|
||||
let subject = store
|
||||
.create_subject(SubjectRole::new("companion").unwrap())
|
||||
.unwrap();
|
||||
let context = HostOperationContext::validated_body(&store, &subject.id, None).unwrap();
|
||||
let original = "日本語\\\"".repeat(10_000);
|
||||
let first = store
|
||||
.create_memory(
|
||||
&subject.id,
|
||||
MemoryDraft::active(
|
||||
CandidateKind::Lesson,
|
||||
"first",
|
||||
&original,
|
||||
"reason",
|
||||
"initial",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.create_memory(
|
||||
&subject.id,
|
||||
MemoryDraft::active(
|
||||
CandidateKind::Decision,
|
||||
"second",
|
||||
"second body",
|
||||
"reason",
|
||||
"initial",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let Response::Query(page) = execute(&store, &context, query(None)).unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert!(page.has_more);
|
||||
assert_eq!(page.items.len(), 1);
|
||||
let cursor = page.next_cursor.unwrap();
|
||||
store
|
||||
.create_memory(
|
||||
&subject.id,
|
||||
MemoryDraft::active(
|
||||
CandidateKind::Constraint,
|
||||
"third",
|
||||
"third body",
|
||||
"reason",
|
||||
"initial",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
execute(&store, &context, query(Some(cursor))),
|
||||
Err(OperationError::Conflict(_))
|
||||
));
|
||||
assert!(execute(&store, &context, read(&first.id, None, Some(1), None)).is_err());
|
||||
let mut offset = None;
|
||||
let mut byte_offset = None;
|
||||
let mut reconstructed = String::new();
|
||||
loop {
|
||||
let Response::Read(page) = execute(
|
||||
&store,
|
||||
&context,
|
||||
read(&first.id, Some(1), offset, byte_offset),
|
||||
)
|
||||
.unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert!(
|
||||
serde_json::to_vec_pretty(&page).unwrap().len()
|
||||
<= server_api::SUBJEKTIV_MEMORY_READ_MAX_TOOL_CONTENT_BYTES
|
||||
);
|
||||
reconstructed.push_str(&page.body_md);
|
||||
if !page.body_truncated {
|
||||
break;
|
||||
}
|
||||
assert!(!page.body_md.is_empty());
|
||||
offset = page.body_next_offset;
|
||||
byte_offset = page.body_next_byte_offset;
|
||||
}
|
||||
assert_eq!(reconstructed, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_surface_empty_snapshot_is_ready_not_missing() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, store) = open(temp.path(), "local-random-scope");
|
||||
let subject = store
|
||||
.create_subject(SubjectRole::new("companion").unwrap())
|
||||
.unwrap();
|
||||
let consolidation = HostOperationContext::validated_consolidation(
|
||||
&store,
|
||||
&subject.id,
|
||||
Some(vec![]),
|
||||
Some(JobAttemptBinding {
|
||||
job_id: "job".into(),
|
||||
attempt_id: "attempt".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let prepared: server_api::SubjektivSurfacePrepareRequest = serde_json::from_str("{}").unwrap();
|
||||
let Response::SurfacePrepared(prepared) =
|
||||
execute(&store, &consolidation, Op::PrepareSurface(prepared)).unwrap()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
let Response::SurfacePublished(published) = execute(
|
||||
&store,
|
||||
&consolidation,
|
||||
Op::PublishSurface(server_api::SubjektivSurfacePublishRequest {
|
||||
generation_id: prepared.generation_id,
|
||||
points: vec![],
|
||||
}),
|
||||
)
|
||||
.unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert!(published.empty);
|
||||
let body = HostOperationContext::validated_body(&store, &subject.id, None).unwrap();
|
||||
let Response::ResidentSummary(resident) = execute(
|
||||
&store,
|
||||
&body,
|
||||
Op::ResidentSummary(memory::backend::MemoryResidentSummaryOperation {}),
|
||||
)
|
||||
.unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
resident.availability,
|
||||
memory::backend::MemoryResidentSummaryAvailability::Ready
|
||||
);
|
||||
assert_eq!(resident.content, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_receipts_candidate_batch_and_decision_retries_share_logic() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, store) = open(temp.path(), "random-local-scope");
|
||||
let subject = store
|
||||
.create_subject(SubjectRole::new("companion").unwrap())
|
||||
.unwrap();
|
||||
let attribution =
|
||||
SubjectSessionAttribution::new(&subject.id, "host", "worker", "session").unwrap();
|
||||
store
|
||||
.record_session_attribution(attribution.clone())
|
||||
.unwrap();
|
||||
let context =
|
||||
HostOperationContext::validated_body(&store, &subject.id, Some(attribution)).unwrap();
|
||||
// Host-resolved public Session evidence with no invented Workspace identity.
|
||||
let input = server_api::SubjektivMemoryStageExplicitRequest {
|
||||
receipt_id: "receipt-1".into(),
|
||||
session_id: "session".into(),
|
||||
kind: CandidateKind::Lesson,
|
||||
claim: "Preserve correction history".into(),
|
||||
why_useful: "Allows auditing".into(),
|
||||
staleness: None,
|
||||
proposal: None,
|
||||
evidence: vec![memory::extract::StagingEvidence {
|
||||
id: "e1".into(),
|
||||
entry_range: Some([1, 1]),
|
||||
kind: memory::schema::EvidenceKind::new("model_output"),
|
||||
origin: None,
|
||||
excerpt: Some("Use immutable revisions".into()),
|
||||
summary: None,
|
||||
}],
|
||||
source_refs: vec![memory::schema::SourceEvidenceRef {
|
||||
session_id: Some("session".into()),
|
||||
segment_id: Some("segment".into()),
|
||||
entry_range: Some([1, 1]),
|
||||
evidence_id: Some("e1".into()),
|
||||
..Default::default()
|
||||
}],
|
||||
};
|
||||
let mut foreign_session = input.clone();
|
||||
foreign_session.session_id = "foreign".into();
|
||||
assert!(matches!(
|
||||
execute(&store, &context, Op::StageExplicit(foreign_session)),
|
||||
Err(OperationError::PermissionDenied(_))
|
||||
));
|
||||
execute(&store, &context, Op::StageExplicit(input.clone())).unwrap();
|
||||
execute(&store, &context, Op::StageExplicit(input)).unwrap();
|
||||
let Response::ReceiptStatus(receipt) = execute(
|
||||
&store,
|
||||
&context,
|
||||
Op::ReceiptStatus(server_api::SubjektivMemoryReceiptStatusRequest {
|
||||
receipt_id: "receipt-1".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
receipt.status,
|
||||
server_api::SubjektivMemoryReceiptStatus::Staged
|
||||
);
|
||||
let candidate_id = receipt.candidate_id.unwrap();
|
||||
let no_batch =
|
||||
HostOperationContext::validated_consolidation(&store, &subject.id, Some(vec![]), None)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
execute(
|
||||
&store,
|
||||
&no_batch,
|
||||
Op::ReadCandidate(server_api::SubjektivMemoryCandidateReadRequest {
|
||||
candidate_id: candidate_id.clone()
|
||||
})
|
||||
),
|
||||
Err(OperationError::PermissionDenied(_))
|
||||
));
|
||||
let batch = HostOperationContext::validated_consolidation(
|
||||
&store,
|
||||
&subject.id,
|
||||
Some(vec![candidate_id.clone()]),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
execute(
|
||||
&store,
|
||||
&batch,
|
||||
Op::PrepareSurface(serde_json::from_str("{}").unwrap())
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let decision = server_api::SubjektivMemoryCandidateDecisionRequest {
|
||||
request_id: "decision-1".into(),
|
||||
candidate_id,
|
||||
reason: "adopt lesson".into(),
|
||||
decision: server_api::SubjektivMemoryCandidateDecision::Apply {
|
||||
target: server_api::SubjektivMemoryApplyTarget::Create,
|
||||
memory: server_api::SubjektivMemoryDraft {
|
||||
kind: CandidateKind::Lesson,
|
||||
state: server_api::SubjektivMemoryState::Active,
|
||||
claim: "Preserve correction history".into(),
|
||||
body_md: "Use immutable revisions".into(),
|
||||
why_useful: "Allows auditing".into(),
|
||||
staleness: None,
|
||||
derived_from: vec![],
|
||||
change_reason: "adopt".into(),
|
||||
},
|
||||
},
|
||||
};
|
||||
let first = execute(&store, &batch, Op::DecideCandidate(decision.clone())).unwrap();
|
||||
let retry = execute(&store, &batch, Op::DecideCandidate(decision)).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(first).unwrap(),
|
||||
serde_json::to_value(retry).unwrap()
|
||||
);
|
||||
assert_eq!(store.list_memories(&subject.id).unwrap().len(), 1);
|
||||
execute(
|
||||
&store,
|
||||
&batch,
|
||||
Op::PrepareSurface(serde_json::from_str("{}").unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Common consolidation Job policy and verified result projections.
|
||||
//!
|
||||
//! Hosts authenticate/admit Jobs and bind their immutable batch and attempt.
|
||||
//! These helpers do not dispatch, replay, or grant Job execution authority.
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::api::{OperationError, OperationResult};
|
||||
use crate::{SubjektivStore, SurfaceAvailability};
|
||||
|
||||
pub const CANDIDATE_BATCH_LIMIT: usize = 100;
|
||||
pub const CONSOLIDATION_THRESHOLD_FILES: usize = 5;
|
||||
pub const CONSOLIDATION_THRESHOLD_BYTES: u64 = 50_000;
|
||||
|
||||
/// Selects an ordered prefix using the T-704 complete-result budget. Reserve
|
||||
/// 2,048 bytes for the Subject/Host surface envelope, then twice the JSON-escaped
|
||||
/// ID size plus 128 bytes per candidate for its compact verified disposition.
|
||||
pub fn bounded_candidate_batch(
|
||||
ids: impl IntoIterator<Item = String>,
|
||||
max_result_bytes: u32,
|
||||
) -> OperationResult<Vec<String>> {
|
||||
let mut estimated_bytes = 2_048_usize;
|
||||
let mut selected = Vec::new();
|
||||
for id in ids.into_iter().take(CANDIDATE_BATCH_LIMIT) {
|
||||
let id_bytes = serde_json::to_vec(&id)
|
||||
.map_err(|e| OperationError::InvalidInput(e.to_string()))?
|
||||
.len();
|
||||
let additional = 2 * id_bytes + 128;
|
||||
if estimated_bytes + additional > max_result_bytes as usize {
|
||||
break;
|
||||
}
|
||||
estimated_bytes += additional;
|
||||
selected.push(id);
|
||||
}
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
/// Empty ready Subjects need no work, even when forced. Missing/stale/failed
|
||||
/// surfaces bypass candidate thresholds; confirmed Memory is never discarded.
|
||||
pub fn consolidation_required(
|
||||
candidate_count: usize,
|
||||
total_bytes: u64,
|
||||
force: bool,
|
||||
surface_availability: SurfaceAvailability,
|
||||
) -> bool {
|
||||
let surface_needs_generation = surface_availability != SurfaceAvailability::Ready;
|
||||
if candidate_count == 0 {
|
||||
return surface_needs_generation;
|
||||
}
|
||||
force
|
||||
|| surface_needs_generation
|
||||
|| candidate_count >= CONSOLIDATION_THRESHOLD_FILES
|
||||
|| total_bytes >= CONSOLIDATION_THRESHOLD_BYTES
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ConsolidationResult {
|
||||
subject_id: String,
|
||||
candidate_ids: Vec<String>,
|
||||
surface: SurfaceResult,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SurfaceResult {
|
||||
availability: String,
|
||||
generation_id: String,
|
||||
store_revision: u64,
|
||||
#[serde(default)]
|
||||
snapshot_id: Option<String>,
|
||||
#[serde(default)]
|
||||
reason_code: Option<String>,
|
||||
}
|
||||
|
||||
/// Verifies the exact Subject/batch, every immutable disposition, and the actual
|
||||
/// terminal surface outcome begun by this Job attempt. Model claims alone never
|
||||
/// establish completion. Same admission semantics as the Backend T-704 adapter.
|
||||
pub fn validate_result(
|
||||
store: &SubjektivStore,
|
||||
subject_id: &str,
|
||||
batch: &[String],
|
||||
job_id: &str,
|
||||
attempt_id: &str,
|
||||
value: &serde_json::Value,
|
||||
) -> OperationResult<()> {
|
||||
let result: ConsolidationResult = serde_json::from_value(value.clone())
|
||||
.map_err(|e| OperationError::InvalidInput(format!("invalid consolidation result: {e}")))?;
|
||||
if result.subject_id != subject_id || result.candidate_ids != batch {
|
||||
return Err(OperationError::InvalidInput(
|
||||
"consolidation result does not match the immutable Subject/batch grant".into(),
|
||||
));
|
||||
}
|
||||
for candidate_id in batch {
|
||||
if store
|
||||
.staging_resolution(subject_id, candidate_id)
|
||||
.map_err(|e| OperationError::Store(e.to_string()))?
|
||||
.is_none()
|
||||
{
|
||||
return Err(OperationError::InvalidInput(format!(
|
||||
"consolidation candidate `{candidate_id}` is unresolved"
|
||||
)));
|
||||
}
|
||||
}
|
||||
store
|
||||
.validate_job_surface_outcome(
|
||||
subject_id,
|
||||
&result.surface.generation_id,
|
||||
result.surface.store_revision,
|
||||
&result.surface.availability,
|
||||
result.surface.snapshot_id.as_deref(),
|
||||
result.surface.reason_code.as_deref(),
|
||||
job_id,
|
||||
attempt_id,
|
||||
)
|
||||
.map_err(|e| {
|
||||
OperationError::InvalidInput(format!("consolidation surface result mismatch: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Enriches a previously verified result with compact durable dispositions. Full
|
||||
/// reasons, affected revisions and provenance remain in the receipt store. This
|
||||
/// deterministic projection neither applies candidates nor creates a new ledger.
|
||||
pub fn with_dispositions(
|
||||
store: &SubjektivStore,
|
||||
subject_id: &str,
|
||||
batch: &[String],
|
||||
value: &serde_json::Value,
|
||||
) -> OperationResult<serde_json::Value> {
|
||||
let mut dispositions = Vec::new();
|
||||
for id in batch {
|
||||
let resolution = store
|
||||
.staging_resolution(subject_id, id)
|
||||
.map_err(|e| OperationError::Store(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
OperationError::InvalidInput(format!("candidate `{id}` has no durable disposition"))
|
||||
})?;
|
||||
dispositions.push(serde_json::json!({"candidate_id": id, "action": resolution.action}));
|
||||
}
|
||||
let mut enriched = value.clone();
|
||||
enriched
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| {
|
||||
OperationError::InvalidInput("consolidation result must be an object".into())
|
||||
})?
|
||||
.insert(
|
||||
"candidate_dispositions".into(),
|
||||
serde_json::Value::Array(dispositions),
|
||||
);
|
||||
Ok(enriched)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,183 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
CandidateDecision, CandidateDecisionRequest, StagingResolutionAction, SubjectRole,
|
||||
SubjectStagingRecord,
|
||||
};
|
||||
use memory::extract::{CandidateKind, ExtractedCandidate, StagingRecord};
|
||||
use memory::schema::SourceRef;
|
||||
|
||||
#[test]
|
||||
fn escaped_ids_use_exact_t704_complete_result_budget() {
|
||||
const MAX_RESULT_BYTES: u32 = 16 * 1024;
|
||||
let ids: Vec<_> = (0..100)
|
||||
.map(|i| format!("{i:03}{}", "\\\"".repeat(126)))
|
||||
.collect();
|
||||
let batch = bounded_candidate_batch(ids.clone(), MAX_RESULT_BYTES).unwrap();
|
||||
assert!(!batch.is_empty());
|
||||
assert!(batch.len() < ids.len());
|
||||
assert_eq!(batch, ids[..batch.len()]);
|
||||
let dispositions: Vec<_> = batch
|
||||
.iter()
|
||||
.map(|id| serde_json::json!({"candidate_id": id, "action": "already_covered"}))
|
||||
.collect();
|
||||
let result = serde_json::json!({
|
||||
"subject_id": "\\\"".repeat(128), "candidate_ids": batch, "candidate_dispositions": dispositions,
|
||||
"surface": { "availability": "failed", "generation_id": "g".repeat(100), "store_revision": u64::MAX, "reason_code": "\\\"".repeat(128) }
|
||||
});
|
||||
assert!(serde_json::to_vec(&result).unwrap().len() <= MAX_RESULT_BYTES as usize);
|
||||
assert!(
|
||||
bounded_candidate_batch(["one".into()], 2048)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
bounded_candidate_batch((0..200).map(|i| i.to_string()), u32::MAX)
|
||||
.unwrap()
|
||||
.len(),
|
||||
CANDIDATE_BATCH_LIMIT
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_policy_preserves_empty_ready_and_surface_retry_rules() {
|
||||
for availability in [
|
||||
SurfaceAvailability::Ungenerated,
|
||||
SurfaceAvailability::Stale,
|
||||
SurfaceAvailability::Failed,
|
||||
] {
|
||||
assert!(consolidation_required(0, 0, false, availability));
|
||||
assert!(consolidation_required(1, 1, false, availability));
|
||||
}
|
||||
assert!(!consolidation_required(
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
SurfaceAvailability::Ready
|
||||
));
|
||||
assert!(!consolidation_required(
|
||||
4,
|
||||
49_999,
|
||||
false,
|
||||
SurfaceAvailability::Ready
|
||||
));
|
||||
assert!(consolidation_required(
|
||||
5,
|
||||
0,
|
||||
false,
|
||||
SurfaceAvailability::Ready
|
||||
));
|
||||
assert!(consolidation_required(
|
||||
1,
|
||||
50_000,
|
||||
false,
|
||||
SurfaceAvailability::Ready
|
||||
));
|
||||
assert!(consolidation_required(
|
||||
1,
|
||||
1,
|
||||
true,
|
||||
SurfaceAvailability::Ready
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_checks_durable_dispositions_and_exact_job_surface_outcome() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let manager = feature_storage::FeatureStorage::new(temp.path());
|
||||
let scope = manager.scope("local-random-scope").unwrap();
|
||||
let registration = SubjektivStore::register(&scope).unwrap();
|
||||
let store = SubjektivStore::open(&scope, ®istration).unwrap();
|
||||
let subject = store
|
||||
.create_subject(SubjectRole::new("companion").unwrap())
|
||||
.unwrap();
|
||||
let candidate = SubjectStagingRecord::attach(
|
||||
&subject.id,
|
||||
StagingRecord::from_candidate(
|
||||
"candidate",
|
||||
"dedup",
|
||||
SourceRef {
|
||||
segment_id: "segment".into(),
|
||||
range: [1, 1],
|
||||
},
|
||||
ExtractedCandidate {
|
||||
kind: CandidateKind::Lesson,
|
||||
claim: "claim".into(),
|
||||
why_useful: "reason".into(),
|
||||
staleness: None,
|
||||
evidence_ids: vec![],
|
||||
},
|
||||
vec![],
|
||||
vec![],
|
||||
),
|
||||
);
|
||||
store.stage_candidate(candidate).unwrap();
|
||||
let batch = vec!["candidate".to_string()];
|
||||
let generation = store
|
||||
.prepare_job_surface_generation(&subject.id, Some(("job", "attempt")))
|
||||
.unwrap();
|
||||
let snapshot = store
|
||||
.publish_surface_generation(&subject.id, &generation.id, vec![])
|
||||
.unwrap();
|
||||
let ready = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch,
|
||||
"surface": {"availability": "ready", "generation_id": generation.id, "store_revision": snapshot.built_from_store_revision, "snapshot_id": snapshot.id}});
|
||||
assert!(
|
||||
validate_result(&store, &subject.id, &batch, "job", "attempt", &ready)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("unresolved")
|
||||
);
|
||||
assert!(with_dispositions(&store, &subject.id, &batch, &ready).is_err());
|
||||
store
|
||||
.decide_candidate(
|
||||
&subject.id,
|
||||
CandidateDecisionRequest {
|
||||
request_id: "decision".into(),
|
||||
candidate_id: "candidate".into(),
|
||||
reason: "invalid".into(),
|
||||
decision: CandidateDecision::Close {
|
||||
action: StagingResolutionAction::Invalid,
|
||||
affected_memory: vec![],
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).unwrap();
|
||||
let enriched = with_dispositions(&store, &subject.id, &batch, &ready).unwrap();
|
||||
assert_eq!(
|
||||
enriched["candidate_dispositions"],
|
||||
serde_json::json!([{"candidate_id": "candidate", "action": "invalid"}])
|
||||
);
|
||||
assert_eq!(
|
||||
enriched,
|
||||
with_dispositions(&store, &subject.id, &batch, &ready).unwrap()
|
||||
);
|
||||
assert!(validate_result(&store, &subject.id, &batch, "job", "other-attempt", &ready).is_err());
|
||||
assert!(validate_result(&store, &subject.id, &[], "job", "attempt", &ready).is_err());
|
||||
// A current ready snapshot survives a redundant editor failure. Advance
|
||||
// Memory first so this attempt genuinely needs a new surface and records failure.
|
||||
store
|
||||
.create_memory(
|
||||
&subject.id,
|
||||
crate::MemoryDraft::active(
|
||||
CandidateKind::Lesson,
|
||||
"new lesson",
|
||||
"new body",
|
||||
"reason",
|
||||
"change",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let failure = store
|
||||
.prepare_job_surface_generation(&subject.id, Some(("job", "attempt")))
|
||||
.unwrap();
|
||||
let revision = store
|
||||
.fail_surface_generation(&subject.id, &failure.id, "model_failure")
|
||||
.unwrap();
|
||||
let failed = serde_json::json!({"subject_id": subject.id, "candidate_ids": batch,
|
||||
"surface": {"availability": "failed", "generation_id": failure.id, "store_revision": revision, "reason_code": "model_failure"}});
|
||||
validate_result(&store, &subject.id, &batch, "job", "attempt", &failed).unwrap();
|
||||
assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &ready).is_err());
|
||||
let mut false_reason = failed;
|
||||
false_reason["surface"]["reason_code"] = serde_json::json!("invented");
|
||||
assert!(validate_result(&store, &subject.id, &batch, "job", "attempt", &false_reason).is_err());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user