feat(subjektiv): share domain and add explicit standalone Subject hosts

This commit is contained in:
2026-10-07 01:52:06 +09:00
parent 13055d7ee1
commit 24ae6288a5
65 changed files with 18273 additions and 9785 deletions
+21
View File
@@ -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
+76
View File
@@ -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
+155
View File
@@ -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(())
}
}
+255
View File
@@ -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(),
})
}
})
}
+343
View File
@@ -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, &registration).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();
}
+153
View File
@@ -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;
+183
View File
@@ -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, &registration).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