feat: integrate persistent Subject behavior context
This commit is contained in:
@@ -5811,6 +5811,7 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
||||
.merge(server_api::server_api_axum::subjektiv_subject_get(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::subjektiv_subject_behavior_update(service.clone()))
|
||||
.merge(server_api::server_api_axum::subjektiv_resident_surface(
|
||||
service.clone(),
|
||||
))
|
||||
@@ -7486,6 +7487,26 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn subjektiv_subject_behavior_update(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
subject_id: String,
|
||||
request: server_api::SubjektivSubjectBehaviorUpdateRequest,
|
||||
) -> std::result::Result<server_api::SubjektivSubjectResponse, server_api::RepositoryApiError>
|
||||
{
|
||||
scoped_update_subjektiv_subject_behavior(
|
||||
State(self.workspace_api()?.clone()),
|
||||
AxumPath(ScopedSubjektivSubjectPath {
|
||||
workspace_id,
|
||||
subject_id,
|
||||
}),
|
||||
Json(request),
|
||||
)
|
||||
.await
|
||||
.map(|Json(response)| response)
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn subjektiv_resident_surface(
|
||||
&self,
|
||||
workspace_id: String,
|
||||
@@ -17447,6 +17468,8 @@ fn subjektiv_subject_response(
|
||||
server_api::SubjektivSubjectResponse {
|
||||
id: subject.id,
|
||||
role: subject.role.as_str().to_string(),
|
||||
behavior_md: subject.behavior_md,
|
||||
behavior_revision: subject.behavior_revision,
|
||||
state: match subject.state {
|
||||
crate::subjektiv::SubjectState::Active => server_api::SubjektivSubjectState::Active,
|
||||
crate::subjektiv::SubjectState::Retired => server_api::SubjektivSubjectState::Retired,
|
||||
@@ -17466,9 +17489,11 @@ async fn scoped_create_subjektiv_subject(
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let role = crate::subjektiv::SubjectRole::new(request.role)
|
||||
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
||||
crate::subjektiv::validate_subject_behavior(&request.behavior_md)
|
||||
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
||||
let subject = open_subjektiv_store(&api)?
|
||||
.create_subject(role)
|
||||
.map_err(|error| Error::Store(error.to_string()))?;
|
||||
.create_subject_with_behavior(role, request.behavior_md)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(subjektiv_subject_response(&api, subject)),
|
||||
@@ -17560,6 +17585,24 @@ async fn scoped_get_subjektiv_subject(
|
||||
Ok(Json(subjektiv_subject_response(&api, subject)))
|
||||
}
|
||||
|
||||
async fn scoped_update_subjektiv_subject_behavior(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>,
|
||||
Json(request): Json<server_api::SubjektivSubjectBehaviorUpdateRequest>,
|
||||
) -> ApiResult<Json<server_api::SubjektivSubjectResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
crate::subjektiv::validate_subject_behavior(&request.behavior_md)
|
||||
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
||||
let subject = open_subjektiv_store(&api)?
|
||||
.update_subject_behavior(
|
||||
&path.subject_id,
|
||||
request.expected_behavior_revision,
|
||||
request.behavior_md,
|
||||
)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
Ok(Json(subjektiv_subject_response(&api, subject)))
|
||||
}
|
||||
|
||||
fn require_subjektiv_subject(
|
||||
store: &crate::subjektiv::SubjektivStore,
|
||||
subject_id: &str,
|
||||
@@ -19166,6 +19209,23 @@ async fn scoped_subjektiv_memory_backend(
|
||||
resident,
|
||||
))
|
||||
}
|
||||
server_api::SubjektivMemoryBackendOperation::ResidentContext(_) => {
|
||||
require_subjektiv_worker_authority(
|
||||
authority,
|
||||
SubjektivWorkerAuthority::Subject,
|
||||
"resident subject context read",
|
||||
)?;
|
||||
let resident = store
|
||||
.resident_context(&subject_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
server_api::SubjektivMemoryBackendResponse::ResidentContext(
|
||||
server_api::SubjektivResidentContextOutput {
|
||||
behavior_md: resident.subject.behavior_md,
|
||||
behavior_revision: resident.subject.behavior_revision,
|
||||
memory_surface: resident_summary_output(resident.surface),
|
||||
},
|
||||
)
|
||||
}
|
||||
server_api::SubjektivMemoryBackendOperation::Query(input) => {
|
||||
server_api::SubjektivMemoryBackendResponse::Query(subjektiv_memory_query(
|
||||
&store,
|
||||
@@ -20540,6 +20600,7 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error {
|
||||
Error::WorkspacePermissionDenied(format!("subject_scope_mismatch: {error}"))
|
||||
}
|
||||
crate::subjektiv::SubjektivError::RevisionConflict { .. }
|
||||
| crate::subjektiv::SubjektivError::SubjectBehaviorConflict { .. }
|
||||
| crate::subjektiv::SubjektivError::SurfaceGenerationConflict(_) => {
|
||||
Error::RepositoryConflict(format!("revision_conflict: {error}"))
|
||||
}
|
||||
@@ -39199,18 +39260,80 @@ mod tests {
|
||||
app.clone(),
|
||||
"POST",
|
||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||
Some(serde_json::json!({ "role": " Browser author " })),
|
||||
Some(serde_json::json!({
|
||||
"role": " Browser author ",
|
||||
"behavior_md": "Prefer explicit evidence.\nAsk when uncertain."
|
||||
})),
|
||||
&owner_token,
|
||||
StatusCode::CREATED,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(created["role"], " Browser author ");
|
||||
assert_eq!(
|
||||
created["behavior_md"],
|
||||
"Prefer explicit evidence.\nAsk when uncertain."
|
||||
);
|
||||
assert_eq!(created["behavior_revision"], 0);
|
||||
assert_eq!(created["state"], "active");
|
||||
assert_eq!(created["store_revision"], 0);
|
||||
assert!(created.get("current_worker").is_none());
|
||||
let created_id = created["id"].as_str().unwrap();
|
||||
let persisted = store.subject(created_id).unwrap().unwrap();
|
||||
assert_eq!(persisted.role.as_str(), " Browser author ");
|
||||
assert_eq!(persisted.behavior_revision, 0);
|
||||
let behavior_uri =
|
||||
format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{created_id}/behavior");
|
||||
request_json(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
&behavior_uri,
|
||||
Some(serde_json::json!({
|
||||
"expected_behavior_revision": 0,
|
||||
"behavior_md": "Updated without rewriting history."
|
||||
})),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
)
|
||||
.await;
|
||||
let updated = request_json_authenticated(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
&behavior_uri,
|
||||
Some(serde_json::json!({
|
||||
"expected_behavior_revision": 0,
|
||||
"behavior_md": "Updated without rewriting history."
|
||||
})),
|
||||
&owner_token,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(updated["behavior_md"], "Updated without rewriting history.");
|
||||
assert_eq!(updated["behavior_revision"], 1);
|
||||
request_json_authenticated(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
&behavior_uri,
|
||||
Some(serde_json::json!({
|
||||
"expected_behavior_revision": 0,
|
||||
"behavior_md": "stale write"
|
||||
})),
|
||||
&owner_token,
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
let cleared = request_json_authenticated(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
&behavior_uri,
|
||||
Some(serde_json::json!({
|
||||
"expected_behavior_revision": 1,
|
||||
"behavior_md": ""
|
||||
})),
|
||||
&owner_token,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(cleared["behavior_md"], "");
|
||||
assert_eq!(cleared["behavior_revision"], 2);
|
||||
assert!(
|
||||
api.store
|
||||
.current_worker_singleton_owner(
|
||||
@@ -39311,6 +39434,90 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subjektiv_browser_behavior_create_update_clear_and_conflict_are_typed() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(workspace.path());
|
||||
let api = test_api(workspace.path()).await;
|
||||
|
||||
let (status, Json(created)) = scoped_create_subjektiv_subject(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
Json(server_api::SubjektivSubjectCreateRequest {
|
||||
role: "companion".to_string(),
|
||||
behavior_md: "Prefer explicit evidence.".to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
assert_eq!(created.behavior_revision, 0);
|
||||
assert_eq!(created.behavior_md, "Prefer explicit evidence.");
|
||||
|
||||
let Json(updated) = scoped_update_subjektiv_subject_behavior(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedSubjektivSubjectPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
subject_id: created.id.clone(),
|
||||
}),
|
||||
Json(server_api::SubjektivSubjectBehaviorUpdateRequest {
|
||||
expected_behavior_revision: 0,
|
||||
behavior_md: "Ask when uncertain.".to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.behavior_revision, 1);
|
||||
assert_eq!(updated.behavior_md, "Ask when uncertain.");
|
||||
|
||||
let conflict = scoped_update_subjektiv_subject_behavior(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedSubjektivSubjectPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
subject_id: created.id.clone(),
|
||||
}),
|
||||
Json(server_api::SubjektivSubjectBehaviorUpdateRequest {
|
||||
expected_behavior_revision: 0,
|
||||
behavior_md: "Stale edit".to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(conflict.into_response().status(), StatusCode::CONFLICT);
|
||||
|
||||
let Json(cleared) = scoped_update_subjektiv_subject_behavior(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedSubjektivSubjectPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
subject_id: created.id,
|
||||
}),
|
||||
Json(server_api::SubjektivSubjectBehaviorUpdateRequest {
|
||||
expected_behavior_revision: 1,
|
||||
behavior_md: String::new(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cleared.behavior_revision, 2);
|
||||
assert!(cleared.behavior_md.is_empty());
|
||||
|
||||
let oversized = scoped_create_subjektiv_subject(
|
||||
State(api),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
Json(server_api::SubjektivSubjectCreateRequest {
|
||||
role: "reviewer".to_string(),
|
||||
behavior_md: "x".repeat(server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES + 1),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(oversized.into_response().status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subjektiv_browser_reads_are_bounded_scoped_and_revision_exact() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
@@ -39733,7 +39940,10 @@ mod tests {
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
let subject = open_subjektiv_store(&api)
|
||||
.unwrap()
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap())
|
||||
.create_subject_with_behavior(
|
||||
crate::subjektiv::SubjectRole::new("companion").unwrap(),
|
||||
"Prefer explicit evidence.".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let request = CreateWorkspaceWorkerRequest {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(),
|
||||
@@ -39852,6 +40062,33 @@ mod tests {
|
||||
}
|
||||
)
|
||||
));
|
||||
let Json(resident_context) = scoped_subjektiv_memory_backend(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
}),
|
||||
context.clone(),
|
||||
Json(server_api::SubjektivMemoryBackendRequest {
|
||||
operation: server_api::SubjektivMemoryBackendOperation::ResidentContext(
|
||||
Default::default(),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
resident_context,
|
||||
server_api::SubjektivMemoryBackendResponse::ResidentContext(
|
||||
server_api::SubjektivResidentContextOutput {
|
||||
behavior_md,
|
||||
behavior_revision: 0,
|
||||
memory_surface: memory::backend::MemoryResidentSummaryOutput {
|
||||
availability: memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
|
||||
content: None,
|
||||
},
|
||||
}
|
||||
) if behavior_md == "Prefer explicit evidence."
|
||||
));
|
||||
let missing_legacy = scoped_record_subjektiv_session(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedWorkspacePath {
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::collections::HashSet;
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord};
|
||||
use memory::schema::{SourceEvidenceRef, SourceRef};
|
||||
use rusqlite::{OptionalExtension, Transaction, params};
|
||||
use rusqlite::{Connection, OptionalExtension, Transaction, params};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -24,6 +24,8 @@ pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1;
|
||||
pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv";
|
||||
pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:";
|
||||
pub const MAX_STAGING_ANCHORS: usize = 10;
|
||||
/// Maximum UTF-8 size of the user-managed Subject behavior document.
|
||||
pub const MAX_SUBJECT_BEHAVIOR_BYTES: usize = server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES;
|
||||
/// Deterministic surface-generation policy. Input estimates use the repository's
|
||||
/// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material
|
||||
/// payload is capped below the whole-input budget to reserve room for the fixed
|
||||
@@ -74,6 +76,14 @@ pub enum SubjektivError {
|
||||
expected: u64,
|
||||
actual: u64,
|
||||
},
|
||||
#[error(
|
||||
"subject `{subject_id}` behavior revision conflict: expected {expected}, current {actual}"
|
||||
)]
|
||||
SubjectBehaviorConflict {
|
||||
subject_id: String,
|
||||
expected: u64,
|
||||
actual: u64,
|
||||
},
|
||||
#[error("surface generation conflict: {0}")]
|
||||
SurfaceGenerationConflict(String),
|
||||
#[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")]
|
||||
@@ -136,6 +146,14 @@ pub struct SubjectRecord {
|
||||
pub schema_version: u32,
|
||||
pub id: String,
|
||||
pub role: SubjectRole,
|
||||
/// User-managed, host-injected behavior document. This is not Memory and
|
||||
/// Memory lifecycle operations must preserve it byte-for-byte.
|
||||
#[serde(default)]
|
||||
pub behavior_md: String,
|
||||
/// Monotonic CAS generation for behavior updates, independent of Memory's
|
||||
/// `store_revision`.
|
||||
#[serde(default)]
|
||||
pub behavior_revision: u64,
|
||||
pub state: SubjectState,
|
||||
/// Monotonic generation incremented exactly once for every committed Memory
|
||||
/// creation or revision for this subject.
|
||||
@@ -500,6 +518,12 @@ pub struct ResidentSurface {
|
||||
pub snapshot: Option<SurfaceSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubjectResidentContext {
|
||||
pub subject: SubjectRecord,
|
||||
pub surface: ResidentSurface,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum MemoryRevisionTarget {
|
||||
@@ -986,6 +1010,17 @@ END;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_subject_behavior(transaction: &Transaction<'_>) -> crate::feature_storage::Result<()> {
|
||||
transaction.execute_batch(
|
||||
r#"
|
||||
ALTER TABLE subjects ADD COLUMN behavior_md TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE subjects ADD COLUMN behavior_revision INTEGER NOT NULL DEFAULT 0
|
||||
CHECK (behavior_revision >= 0);
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
static MIGRATIONS: &[FeatureMigration] = &[
|
||||
FeatureMigration::new(1, "create subjektiv subject memory store", create_schema),
|
||||
FeatureMigration::new(
|
||||
@@ -1003,6 +1038,7 @@ static MIGRATIONS: &[FeatureMigration] = &[
|
||||
"add bounded surface generation state and runs",
|
||||
add_surface_generation_state,
|
||||
),
|
||||
FeatureMigration::new(5, "add user-managed subject behavior", add_subject_behavior),
|
||||
];
|
||||
|
||||
pub const REGISTRATION: FeatureRegistration =
|
||||
@@ -1070,11 +1106,23 @@ impl SubjektivStore {
|
||||
/// Issues a random host-owned subject id. It is never derived from Worker,
|
||||
/// Profile, Session, or Memory content.
|
||||
pub fn create_subject(&self, role: SubjectRole) -> Result<SubjectRecord> {
|
||||
self.create_subject_with_behavior(role, String::new())
|
||||
}
|
||||
|
||||
/// Creates a Subject with its optional user-managed behavior document.
|
||||
pub fn create_subject_with_behavior(
|
||||
&self,
|
||||
role: SubjectRole,
|
||||
behavior_md: String,
|
||||
) -> Result<SubjectRecord> {
|
||||
validate_subject_behavior(&behavior_md)?;
|
||||
let timestamp = now();
|
||||
let record = SubjectRecord {
|
||||
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
||||
id: issued_id("subject"),
|
||||
role,
|
||||
behavior_md,
|
||||
behavior_revision: 0,
|
||||
state: SubjectState::Active,
|
||||
store_revision: 0,
|
||||
created_at: timestamp.clone(),
|
||||
@@ -1084,11 +1132,13 @@ impl SubjektivStore {
|
||||
self.database.try_transaction(|transaction| {
|
||||
transaction.execute(
|
||||
"INSERT INTO subjects (
|
||||
subject_id, role, state, store_revision, record_json, created_at, updated_at
|
||||
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
|
||||
subject_id, role, behavior_md, behavior_revision, state,
|
||||
store_revision, record_json, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, 0, 'active', 0, ?4, ?5, ?6)",
|
||||
params![
|
||||
record.id,
|
||||
record.role.as_str(),
|
||||
record.behavior_md,
|
||||
raw,
|
||||
record.created_at,
|
||||
record.updated_at
|
||||
@@ -1111,6 +1161,51 @@ impl SubjektivStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Replaces or clears the user-managed behavior document using its own CAS
|
||||
/// generation. Memory revisions do not participate in this generation.
|
||||
pub fn update_subject_behavior(
|
||||
&self,
|
||||
subject_id: &str,
|
||||
expected_behavior_revision: u64,
|
||||
behavior_md: String,
|
||||
) -> Result<SubjectRecord> {
|
||||
validate_subject_behavior(&behavior_md)?;
|
||||
self.database.try_transaction(|transaction| {
|
||||
let mut subject = require_subject(transaction, subject_id)?;
|
||||
if subject.behavior_revision != expected_behavior_revision {
|
||||
return Err(SubjektivError::SubjectBehaviorConflict {
|
||||
subject_id: subject_id.to_string(),
|
||||
expected: expected_behavior_revision,
|
||||
actual: subject.behavior_revision,
|
||||
});
|
||||
}
|
||||
if subject.behavior_md == behavior_md {
|
||||
return Ok(subject);
|
||||
}
|
||||
subject.behavior_md = behavior_md;
|
||||
subject.behavior_revision =
|
||||
subject.behavior_revision.checked_add(1).ok_or_else(|| {
|
||||
SubjektivError::InvalidRecord("subject behavior revision overflow".to_string())
|
||||
})?;
|
||||
subject.updated_at = now();
|
||||
let raw = serde_json::to_string(&subject)?;
|
||||
transaction.execute(
|
||||
"UPDATE subjects
|
||||
SET behavior_md = ?2, behavior_revision = ?3,
|
||||
record_json = ?4, updated_at = ?5
|
||||
WHERE subject_id = ?1",
|
||||
params![
|
||||
subject.id,
|
||||
subject.behavior_md,
|
||||
to_i64(subject.behavior_revision)?,
|
||||
raw,
|
||||
subject.updated_at
|
||||
],
|
||||
)?;
|
||||
Ok(subject)
|
||||
})
|
||||
}
|
||||
|
||||
/// Validates an explicit product launch connection against this Workspace's
|
||||
/// subject store and the already-resolved Profile policy. Subject knowledge
|
||||
/// alone is insufficient: only an active local record produces host launch material.
|
||||
@@ -2021,60 +2116,17 @@ impl SubjektivStore {
|
||||
pub fn resident_surface(&self, subject_id: &str) -> Result<ResidentSurface> {
|
||||
self.database.try_with_connection(|connection| {
|
||||
let subject = require_subject_in_connection(connection, subject_id)?;
|
||||
let state = connection
|
||||
.query_row(
|
||||
"SELECT store_revision, status, snapshot_id
|
||||
FROM surface_generation_state WHERE subject_id = ?1",
|
||||
[subject_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
let Some((revision, status, snapshot_id)) = state else {
|
||||
return Ok(ResidentSurface {
|
||||
availability: SurfaceAvailability::Ungenerated,
|
||||
snapshot: None,
|
||||
});
|
||||
};
|
||||
let revision = u64::try_from(revision).map_err(|_| {
|
||||
SubjektivError::InvalidRecord("surface state revision is negative".into())
|
||||
})?;
|
||||
if revision != subject.store_revision || status == "dirty" {
|
||||
return Ok(ResidentSurface {
|
||||
availability: SurfaceAvailability::Stale,
|
||||
snapshot: None,
|
||||
});
|
||||
}
|
||||
if status == "failed" {
|
||||
return Ok(ResidentSurface {
|
||||
availability: SurfaceAvailability::Failed,
|
||||
snapshot: None,
|
||||
});
|
||||
}
|
||||
let snapshot_id = snapshot_id.ok_or_else(|| {
|
||||
SubjektivError::InvalidRecord("ready surface state has no snapshot id".into())
|
||||
})?;
|
||||
let raw = connection.query_row(
|
||||
"SELECT snapshot_json FROM surface_snapshots
|
||||
WHERE subject_id = ?1 AND snapshot_id = ?2",
|
||||
params![subject_id, snapshot_id],
|
||||
|row| row.get::<_, String>(0),
|
||||
)?;
|
||||
let snapshot = parse_surface_snapshot(&raw)?;
|
||||
if snapshot.built_from_store_revision != subject.store_revision {
|
||||
return Err(SubjektivError::InvalidRecord(
|
||||
"ready surface snapshot generation does not match subject".into(),
|
||||
));
|
||||
}
|
||||
Ok(ResidentSurface {
|
||||
availability: SurfaceAvailability::Ready,
|
||||
snapshot: Some(snapshot),
|
||||
})
|
||||
resident_surface_in_connection(connection, &subject)
|
||||
})
|
||||
}
|
||||
|
||||
/// Atomically reads user-managed behavior and the independently-fresh
|
||||
/// generated Memory surface from one SQLite connection snapshot.
|
||||
pub fn resident_context(&self, subject_id: &str) -> Result<SubjectResidentContext> {
|
||||
self.database.try_transaction(|transaction| {
|
||||
let subject = require_subject(transaction, subject_id)?;
|
||||
let surface = resident_surface_in_connection(transaction, &subject)?;
|
||||
Ok(SubjectResidentContext { subject, surface })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3414,6 +3466,87 @@ fn validate_range(kind: &str, range: [u64; 2]) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn resident_surface_in_connection(
|
||||
connection: &Connection,
|
||||
subject: &SubjectRecord,
|
||||
) -> Result<ResidentSurface> {
|
||||
let state = connection
|
||||
.query_row(
|
||||
"SELECT store_revision, status, snapshot_id
|
||||
FROM surface_generation_state WHERE subject_id = ?1",
|
||||
[&subject.id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
let Some((revision, status, snapshot_id)) = state else {
|
||||
return Ok(ResidentSurface {
|
||||
availability: SurfaceAvailability::Ungenerated,
|
||||
snapshot: None,
|
||||
});
|
||||
};
|
||||
let revision = u64::try_from(revision)
|
||||
.map_err(|_| SubjektivError::InvalidRecord("surface state revision is negative".into()))?;
|
||||
if revision != subject.store_revision || status == "dirty" {
|
||||
return Ok(ResidentSurface {
|
||||
availability: SurfaceAvailability::Stale,
|
||||
snapshot: None,
|
||||
});
|
||||
}
|
||||
if status == "failed" {
|
||||
return Ok(ResidentSurface {
|
||||
availability: SurfaceAvailability::Failed,
|
||||
snapshot: None,
|
||||
});
|
||||
}
|
||||
let snapshot_id = snapshot_id.ok_or_else(|| {
|
||||
SubjektivError::InvalidRecord("ready surface state has no snapshot id".into())
|
||||
})?;
|
||||
let raw = connection.query_row(
|
||||
"SELECT snapshot_json FROM surface_snapshots
|
||||
WHERE subject_id = ?1 AND snapshot_id = ?2",
|
||||
params![subject.id, snapshot_id],
|
||||
|row| row.get::<_, String>(0),
|
||||
)?;
|
||||
let snapshot = parse_surface_snapshot(&raw)?;
|
||||
if snapshot.built_from_store_revision != subject.store_revision {
|
||||
return Err(SubjektivError::InvalidRecord(
|
||||
"ready surface snapshot generation does not match subject".into(),
|
||||
));
|
||||
}
|
||||
Ok(ResidentSurface {
|
||||
availability: SurfaceAvailability::Ready,
|
||||
snapshot: Some(snapshot),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_subject_behavior(value: &str) -> Result<()> {
|
||||
if value.len() > MAX_SUBJECT_BEHAVIOR_BYTES {
|
||||
return Err(SubjektivError::InvalidRecord(format!(
|
||||
"subject behavior must be at most {MAX_SUBJECT_BEHAVIOR_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
if !value.is_empty() && value.trim().is_empty() {
|
||||
return Err(SubjektivError::InvalidRecord(
|
||||
"subject behavior must be empty or contain non-whitespace text".to_string(),
|
||||
));
|
||||
}
|
||||
if value
|
||||
.chars()
|
||||
.any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
|
||||
{
|
||||
return Err(SubjektivError::InvalidRecord(
|
||||
"subject behavior contains an unsupported control character".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_label(kind: &str, value: &str) -> Result<()> {
|
||||
validate_nonempty(kind, value)?;
|
||||
if value.len() > 256 || value.chars().any(char::is_control) {
|
||||
@@ -3668,6 +3801,67 @@ mod tests {
|
||||
assert!(!complete.has_more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_behavior_is_bounded_revisioned_and_independent_from_memory_revision() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a");
|
||||
let subject = store
|
||||
.create_subject_with_behavior(role(), "Prefer explicit evidence.".to_string())
|
||||
.unwrap();
|
||||
assert_eq!(subject.behavior_revision, 0);
|
||||
assert_eq!(subject.store_revision, 0);
|
||||
|
||||
let updated = store
|
||||
.update_subject_behavior(
|
||||
&subject.id,
|
||||
0,
|
||||
"Prefer explicit evidence.\nAsk when uncertain.".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.behavior_revision, 1);
|
||||
assert_eq!(updated.store_revision, 0);
|
||||
|
||||
store
|
||||
.create_memory(
|
||||
&subject.id,
|
||||
draft(
|
||||
"A confirmed observation",
|
||||
"exercise Memory lifecycle isolation",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let after_memory = store.subject(&subject.id).unwrap().unwrap();
|
||||
assert_eq!(after_memory.behavior_md, updated.behavior_md);
|
||||
assert_eq!(after_memory.behavior_revision, 1);
|
||||
assert_eq!(after_memory.store_revision, 1);
|
||||
|
||||
let (_reopened_manager, _reopened_workspace, reopened) =
|
||||
open_store(temp.path(), "workspace-a");
|
||||
let persisted = reopened.subject(&subject.id).unwrap().unwrap();
|
||||
assert_eq!(persisted.behavior_md, updated.behavior_md);
|
||||
assert_eq!(persisted.behavior_revision, 1);
|
||||
assert!(matches!(
|
||||
store.update_subject_behavior(&subject.id, 0, String::new()),
|
||||
Err(SubjektivError::SubjectBehaviorConflict {
|
||||
expected: 0,
|
||||
actual: 1,
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
let unchanged = store
|
||||
.update_subject_behavior(&subject.id, 1, updated.behavior_md.clone())
|
||||
.unwrap();
|
||||
assert_eq!(unchanged.behavior_revision, 1);
|
||||
let cleared = store
|
||||
.update_subject_behavior(&subject.id, 1, String::new())
|
||||
.unwrap();
|
||||
assert_eq!(cleared.behavior_revision, 2);
|
||||
assert!(cleared.behavior_md.is_empty());
|
||||
assert!(validate_subject_behavior(&"x".repeat(MAX_SUBJECT_BEHAVIOR_BYTES + 1)).is_err());
|
||||
assert!(validate_subject_behavior(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attributed_candidates_preserve_revision_and_surface_reference_continuity() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -3892,10 +4086,38 @@ mod tests {
|
||||
))
|
||||
.unwrap();
|
||||
let store = SubjektivStore::open(&workspace, ®istration).unwrap();
|
||||
store.create_subject(role()).unwrap().id
|
||||
let timestamp = now();
|
||||
let subject_id = issued_id("subject");
|
||||
let mut raw = serde_json::json!({
|
||||
"schema_version": SUBJEKTIV_SCHEMA_VERSION,
|
||||
"id": subject_id,
|
||||
"role": role(),
|
||||
"state": SubjectState::Active,
|
||||
"store_revision": 0,
|
||||
"created_at": timestamp.clone(),
|
||||
"updated_at": timestamp.clone(),
|
||||
});
|
||||
let raw = serde_json::to_string(&raw.take()).unwrap();
|
||||
store
|
||||
.database
|
||||
.try_transaction(|transaction| -> Result<()> {
|
||||
transaction.execute(
|
||||
"INSERT INTO subjects (
|
||||
subject_id, role, state, store_revision,
|
||||
record_json, created_at, updated_at
|
||||
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
|
||||
params![subject_id, role().as_str(), raw, timestamp, timestamp],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
subject_id
|
||||
};
|
||||
|
||||
let (_manager, _workspace, store) = open_store(&root, "workspace-a");
|
||||
let migrated_subject = store.subject(&subject_id).unwrap().unwrap();
|
||||
assert!(migrated_subject.behavior_md.is_empty());
|
||||
assert_eq!(migrated_subject.behavior_revision, 0);
|
||||
let lifecycle_attribution = attribution(
|
||||
&subject_id,
|
||||
"runtime-1",
|
||||
@@ -3935,26 +4157,50 @@ mod tests {
|
||||
))
|
||||
.unwrap();
|
||||
let store = SubjektivStore::open(&workspace, ®istration).unwrap();
|
||||
let subject = store.create_subject(role()).unwrap();
|
||||
let subject_id = issued_id("subject");
|
||||
let timestamp = now();
|
||||
let subject_json = serde_json::to_string(&serde_json::json!({
|
||||
"schema_version": SUBJEKTIV_SCHEMA_VERSION,
|
||||
"id": subject_id,
|
||||
"role": role(),
|
||||
"state": SubjectState::Active,
|
||||
"store_revision": 0,
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
}))
|
||||
.unwrap();
|
||||
let snapshot = SurfaceSnapshot {
|
||||
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
||||
id: "legacy-surface".into(),
|
||||
subject_id: subject.id.clone(),
|
||||
subject_id: subject_id.clone(),
|
||||
body_md: "Legacy summary without generation-policy evidence.".into(),
|
||||
memory_refs: Vec::new(),
|
||||
built_from_store_revision: subject.store_revision,
|
||||
built_from_store_revision: 0,
|
||||
created_at: now(),
|
||||
};
|
||||
store
|
||||
.database
|
||||
.try_transaction(|transaction| -> Result<()> {
|
||||
transaction.execute(
|
||||
"INSERT INTO subjects (
|
||||
subject_id, role, state, store_revision,
|
||||
record_json, created_at, updated_at
|
||||
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
|
||||
params![
|
||||
subject_id,
|
||||
role().as_str(),
|
||||
subject_json,
|
||||
timestamp,
|
||||
timestamp
|
||||
],
|
||||
)?;
|
||||
transaction.execute(
|
||||
"INSERT INTO surface_snapshots (
|
||||
subject_id, snapshot_id, built_from_store_revision,
|
||||
snapshot_json, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
subject.id,
|
||||
subject_id,
|
||||
snapshot.id,
|
||||
to_i64(snapshot.built_from_store_revision)?,
|
||||
serde_json::to_string(&snapshot)?,
|
||||
@@ -3964,12 +4210,12 @@ mod tests {
|
||||
transaction.execute(
|
||||
"INSERT INTO surface_snapshot_seals (subject_id, snapshot_id)
|
||||
VALUES (?1, ?2)",
|
||||
params![subject.id, snapshot.id],
|
||||
params![subject_id, snapshot.id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
(subject.id, snapshot.id)
|
||||
(subject_id, snapshot.id)
|
||||
};
|
||||
|
||||
let (_manager, _workspace, store) = open_store(&root, "workspace-a");
|
||||
|
||||
Reference in New Issue
Block a user