Merge branch 'develop' of /home/hare/Projects/yoi into work/t-807-subject-lifecycle
This commit is contained in:
@@ -1751,7 +1751,7 @@ mod tests {
|
|||||||
assert!(feature.subjektiv.profile.enabled);
|
assert!(feature.subjektiv.profile.enabled);
|
||||||
assert!(feature.subjektiv.profile.chat.enabled);
|
assert!(feature.subjektiv.profile.chat.enabled);
|
||||||
assert!(!feature.subjektiv.profile.consolidation_tools);
|
assert!(!feature.subjektiv.profile.consolidation_tools);
|
||||||
assert!(!feature.subjektiv.profile.extraction.enabled);
|
assert!(feature.subjektiv.profile.extraction.enabled);
|
||||||
assert!(feature.subjektiv.workspace_settings.is_none());
|
assert!(feature.subjektiv.workspace_settings.is_none());
|
||||||
assert!(!feature.subjektiv.execution_enabled());
|
assert!(!feature.subjektiv.execution_enabled());
|
||||||
assert!(!feature.memory.profile.enabled);
|
assert!(!feature.memory.profile.enabled);
|
||||||
@@ -1823,6 +1823,8 @@ mod tests {
|
|||||||
"SubjektivMemoryQuery",
|
"SubjektivMemoryQuery",
|
||||||
"SubjektivMemoryRead",
|
"SubjektivMemoryRead",
|
||||||
"SubjektivMemoryListChanges",
|
"SubjektivMemoryListChanges",
|
||||||
|
"SubjektivMemoryRemember",
|
||||||
|
"SubjektivMemoryProposeChange",
|
||||||
"QueryObjective",
|
"QueryObjective",
|
||||||
"ShowObjective",
|
"ShowObjective",
|
||||||
"QueryTicket",
|
"QueryTicket",
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
mod catalog;
|
mod catalog;
|
||||||
pub use catalog::*;
|
pub use catalog::*;
|
||||||
|
mod subject_defaults;
|
||||||
|
pub use subject_defaults::*;
|
||||||
|
|
||||||
pub const MAX_SCOPED_PERMISSIONS: usize = 256;
|
pub const MAX_SCOPED_PERMISSIONS: usize = 256;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
//! Initial conversation authority, not a runtime fallback or a Profile capability.
|
||||||
|
//!
|
||||||
|
//! Hosts persist this set only while creating a new Subject. Existing Subjects opt in
|
||||||
|
//! with the normal administrator CAS replacement: read the current source, merge only
|
||||||
|
//! the desired entries into `execution`, preserve `delegation`, and submit that observed
|
||||||
|
//! change ID with a stable operation ID. Never apply it on launch, restore or migration;
|
||||||
|
//! an unset or explicitly emptied existing policy must remain denied. The Subject detail
|
||||||
|
//! permissions editor and `GET`/`POST /api/w/{workspace}/subjektiv/subjects/{subject}/permissions`
|
||||||
|
//! expose the same saved source and CAS. Replacing a source also invalidates previously
|
||||||
|
//! delegated children; an administrator must deliberately review that effect before applying.
|
||||||
|
//! No automatic opt-in or overwrite of individual exceptions is provided.
|
||||||
|
//!
|
||||||
|
//! `builtin:async-chat` requests the conversation composition and an explicit Subject
|
||||||
|
//! connection selects the body. Neither action writes policy. All entries below target
|
||||||
|
//! exactly that Subject in its storage scope; none covers Workspace, files, Workers or
|
||||||
|
//! other Subjects. Background Worker delegation is deliberately empty. Host-owned
|
||||||
|
//! Memory/Journal Jobs additionally require their kind/attempt/input/writer grants;
|
||||||
|
//! these source permissions alone do not create or authorize an arbitrary Job.
|
||||||
|
use super::{PermissionResource, ScopedPermission};
|
||||||
|
|
||||||
|
pub const SUBJECT_CONVERSATION_PROFILE: &str = "builtin:async-chat";
|
||||||
|
|
||||||
|
/// Basic body operations and the current-policy ceiling for standard memory Jobs.
|
||||||
|
/// Confirmed Memory mutation remains behind consolidation; explicit remembering stages
|
||||||
|
/// evidence, never directly edits confirmed Memory. Journal recording is a separate Job.
|
||||||
|
pub const SUBJECT_CONVERSATION_OPERATIONS: &[&str] = &[
|
||||||
|
"session.record",
|
||||||
|
"session.list",
|
||||||
|
"session.search",
|
||||||
|
"session.read",
|
||||||
|
"chat.read",
|
||||||
|
"chat.send",
|
||||||
|
"memory.query",
|
||||||
|
"memory.read",
|
||||||
|
"memory.changes.list",
|
||||||
|
"memory.resident.read",
|
||||||
|
"memory.resident.supply",
|
||||||
|
"memory.staging.write",
|
||||||
|
"memory.staging.list",
|
||||||
|
"memory.staging.read",
|
||||||
|
"memory.receipt.read",
|
||||||
|
"memory.proposal.validate",
|
||||||
|
"memory.candidate.decide",
|
||||||
|
"memory.extract.read",
|
||||||
|
"memory.extract.supply",
|
||||||
|
"memory.extract.commit",
|
||||||
|
"memory.consolidation.request",
|
||||||
|
"memory.consolidation.supply",
|
||||||
|
"memory.surface.prepare",
|
||||||
|
"memory.surface.supply",
|
||||||
|
"memory.surface.publish",
|
||||||
|
"memory.surface.fail",
|
||||||
|
"journal.startup.read",
|
||||||
|
"journal.startup.supply",
|
||||||
|
"journal.compaction.read",
|
||||||
|
"journal.compaction.supply",
|
||||||
|
"journal.recording.request",
|
||||||
|
"journal.job.input.read",
|
||||||
|
"journal.job.input.supply",
|
||||||
|
"journal.state.read",
|
||||||
|
"journal.text.read",
|
||||||
|
"journal.operation.query",
|
||||||
|
"journal.operation.list",
|
||||||
|
"journal.operation.coverage.read",
|
||||||
|
"journal.entry.create",
|
||||||
|
"journal.entry.fix",
|
||||||
|
"journal.entry.rewrite",
|
||||||
|
"journal.position.update",
|
||||||
|
"journal.unchanged.confirm",
|
||||||
|
"journal.checkpoint",
|
||||||
|
"journal.finish",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn subject_conversation_permissions(scope: &str, subject: &str) -> Vec<ScopedPermission> {
|
||||||
|
SUBJECT_CONVERSATION_OPERATIONS
|
||||||
|
.iter()
|
||||||
|
.map(|operation| ScopedPermission {
|
||||||
|
operation: (*operation).into(),
|
||||||
|
resource: PermissionResource::Subject {
|
||||||
|
workspace_id: scope.into(),
|
||||||
|
subject_id: subject.into(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -5686,6 +5686,8 @@ pub struct SubjektivSubjectDeleteResponse {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct SubjektivSubjectCreateRequest {
|
pub struct SubjektivSubjectCreateRequest {
|
||||||
|
/// Stable across exact retries; changed input with the same ID conflicts.
|
||||||
|
pub operation_id: String,
|
||||||
pub role: String,
|
pub role: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub behavior_md: String,
|
pub behavior_md: String,
|
||||||
@@ -11315,7 +11317,7 @@ pub fn memory_api_typescript() -> String {
|
|||||||
// `ts-rs` solely for this read-only projection.
|
// `ts-rs` solely for this read-only projection.
|
||||||
let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired";
|
let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired";
|
||||||
|
|
||||||
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
|
export type SubjektivSubjectCreateRequest = { operation_id: string, role: string, behavior_md?: string, };
|
||||||
|
|
||||||
export type SubjektivSubjectDeleteRequest = { operation_id: string, };
|
export type SubjektivSubjectDeleteRequest = { operation_id: string, };
|
||||||
|
|
||||||
|
|||||||
@@ -640,12 +640,21 @@ fn policy(
|
|||||||
operation: &str,
|
operation: &str,
|
||||||
expected: Option<&str>,
|
expected: Option<&str>,
|
||||||
) -> String {
|
) -> String {
|
||||||
|
// Initial test replacement observes the source installed by normal Subject creation.
|
||||||
|
// Keep explicit expectations unchanged so replacement races still use their pinned CAS.
|
||||||
|
let expected = expected.map(str::to_owned).unwrap_or_else(|| {
|
||||||
|
store
|
||||||
|
.subject_permissions(subject)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.change_id
|
||||||
|
});
|
||||||
store
|
store
|
||||||
.set_subject_permissions(
|
.set_subject_permissions(
|
||||||
subject,
|
subject,
|
||||||
&server_api::authorization::SubjectPermissionsUpdate {
|
&server_api::authorization::SubjectPermissionsUpdate {
|
||||||
operation_id: operation.into(),
|
operation_id: operation.into(),
|
||||||
expected_change_id: expected.map(str::to_owned),
|
expected_change_id: Some(expected),
|
||||||
execution: Vec::new(),
|
execution: Vec::new(),
|
||||||
delegation: Vec::new(),
|
delegation: Vec::new(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -226,7 +226,12 @@ fn deletion_identity_conflicts_are_scope_wide_but_do_not_cross_workspaces() {
|
|||||||
assert_eq!(store.list_memories(&b).unwrap().len(), 1);
|
assert_eq!(store.list_memories(&b).unwrap().len(), 1);
|
||||||
let fresh = store.create_subject(role()).unwrap().id;
|
let fresh = store.create_subject(role()).unwrap().id;
|
||||||
assert!(store.list_memories(&fresh).unwrap().is_empty());
|
assert!(store.list_memories(&fresh).unwrap().is_empty());
|
||||||
assert!(store.subject_permissions(&fresh).unwrap().is_none());
|
let fresh_source = store.subject_permissions(&fresh).unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fresh_source.execution,
|
||||||
|
server_api::authorization::subject_conversation_permissions(store.scope_id(), &fresh)
|
||||||
|
);
|
||||||
|
assert!(fresh_source.delegation.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -259,8 +264,14 @@ fn concurrent_begin_and_write_serialize_and_exact_finish_is_replayable() {
|
|||||||
fn every_subject_owned_table_has_old_and_new_sql_fences_and_retention_is_scoped() {
|
fn every_subject_owned_table_has_old_and_new_sql_fences_and_retention_is_scoped() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||||
let a = store.create_subject(role()).unwrap().id;
|
let a = store
|
||||||
let b = store.create_subject(role()).unwrap().id;
|
.create_subject_idempotent(role(), "private behavior a".into(), "create-a")
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
let b = store
|
||||||
|
.create_subject_idempotent(role(), "private behavior b".into(), "create-b")
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
store
|
store
|
||||||
.stage_candidate(candidate(&a, "a", store.workspace_id()))
|
.stage_candidate(candidate(&a, "a", store.workspace_id()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -268,11 +279,18 @@ fn every_subject_owned_table_has_old_and_new_sql_fences_and_retention_is_scoped(
|
|||||||
.stage_candidate(candidate(&b, "b", store.workspace_id()))
|
.stage_candidate(candidate(&b, "b", store.workspace_id()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store.begin_subject_deletion(&a, "delete").unwrap();
|
store.begin_subject_deletion(&a, "delete").unwrap();
|
||||||
store.database.try_with_connection::<_, SubjektivError>(|c| {
|
let creation_receipts = store.database.try_with_connection::<_, SubjektivError>(|c| {
|
||||||
let tables = c.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")?.query_map([], |r| r.get::<_,String>(0))?.collect::<std::result::Result<Vec<_>,_>>()?;
|
let tables = c.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")?.query_map([], |r| r.get::<_,String>(0))?.collect::<std::result::Result<Vec<_>,_>>()?;
|
||||||
for table in tables {
|
for table in tables {
|
||||||
let columns = c.prepare(&format!("PRAGMA table_info({table})"))?.query_map([], |r| r.get::<_,String>(1))?.collect::<std::result::Result<Vec<_>,_>>()?;
|
let columns = c.prepare(&format!("PRAGMA table_info({table})"))?.query_map([], |r| r.get::<_,String>(1))?.collect::<std::result::Result<Vec<_>,_>>()?;
|
||||||
if !columns.iter().any(|v| v == "subject_id") || table.starts_with("subject_delet") { continue; }
|
if !columns.iter().any(|v| v == "subject_id") || table.starts_with("subject_delet") { continue; }
|
||||||
|
if table == "subject_creation_receipts" {
|
||||||
|
// Retained non-content identity tombstones prevent duplicate recreation after erase.
|
||||||
|
// They have no Subject FK or content to purge, and intentionally need no deletion fence.
|
||||||
|
assert_eq!(columns, ["operation_id", "subject_id", "request_fingerprint"]);
|
||||||
|
assert!(c.prepare("PRAGMA foreign_key_list(subject_creation_receipts)")?.query([])?.next()?.is_none());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
assert!(DIRECT.contains(&table.as_str()) || table == "journal_erased_sessions" || table == "chat_conversations", "unclassified Subject table: {table}");
|
assert!(DIRECT.contains(&table.as_str()) || table == "journal_erased_sessions" || table == "chat_conversations", "unclassified Subject table: {table}");
|
||||||
}
|
}
|
||||||
for table in DIRECT.iter().copied().chain(INDIRECT.iter().map(|v| v.0)).chain(["journal_erased_sessions"]) {
|
for table in DIRECT.iter().copied().chain(INDIRECT.iter().map(|v| v.0)).chain(["journal_erased_sessions"]) {
|
||||||
@@ -289,9 +307,22 @@ fn every_subject_owned_table_has_old_and_new_sql_fences_and_retention_is_scoped(
|
|||||||
assert!(c.execute("DELETE FROM staging_records WHERE subject_id=?1", [&b]).is_err());
|
assert!(c.execute("DELETE FROM staging_records WHERE subject_id=?1", [&b]).is_err());
|
||||||
assert!(c.execute("DELETE FROM subject_deletions WHERE subject_id=?1", [&a]).is_err());
|
assert!(c.execute("DELETE FROM subject_deletions WHERE subject_id=?1", [&a]).is_err());
|
||||||
assert!(c.execute("UPDATE subject_deletions SET operation_id='changed' WHERE subject_id=?1", [&a]).is_err());
|
assert!(c.execute("UPDATE subject_deletions SET operation_id='changed' WHERE subject_id=?1", [&a]).is_err());
|
||||||
Ok(())
|
Ok(c.prepare("SELECT operation_id,subject_id,request_fingerprint FROM subject_creation_receipts ORDER BY operation_id")?
|
||||||
|
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
assert_eq!(creation_receipts.len(), 2);
|
||||||
|
assert_eq!(creation_receipts[0].1, a);
|
||||||
|
assert_eq!(creation_receipts[1].1, b);
|
||||||
store.finish_subject_deletion(&a, "delete").unwrap();
|
store.finish_subject_deletion(&a, "delete").unwrap();
|
||||||
|
let retained = store.database.try_with_connection::<_, SubjektivError>(|c| {
|
||||||
|
Ok(c.prepare("SELECT operation_id,subject_id,request_fingerprint FROM subject_creation_receipts ORDER BY operation_id")?
|
||||||
|
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)))?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||||
|
}).unwrap();
|
||||||
|
assert_eq!(retained, creation_receipts);
|
||||||
|
assert!(store.subject(&a).unwrap().is_none());
|
||||||
|
assert!(store.subject(&b).unwrap().is_some());
|
||||||
assert!(store.staging_candidate(&b, "b").unwrap().is_some());
|
assert!(store.staging_candidate(&b, "b").unwrap().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,13 @@ impl Fixture {
|
|||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let store = SubjektivStore::open(&workspace, ®istration).unwrap();
|
let store = SubjektivStore::open(&workspace, ®istration).unwrap();
|
||||||
let subject = store
|
let subject = if migrations.last().unwrap().version() < 12 {
|
||||||
|
crate::journal::tests::pre_permissions_subject_fixture(&store, "companion", "")
|
||||||
|
} else {
|
||||||
|
store
|
||||||
.create_subject(SubjectRole::new("companion").unwrap())
|
.create_subject(SubjectRole::new("companion").unwrap())
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
};
|
||||||
let attr =
|
let attr =
|
||||||
SubjectSessionAttribution::new(&subject.id, "runtime", "worker", "session").unwrap();
|
SubjectSessionAttribution::new(&subject.id, "runtime", "worker", "session").unwrap();
|
||||||
store.record_session_attribution(attr.clone()).unwrap();
|
store.record_session_attribution(attr.clone()).unwrap();
|
||||||
|
|||||||
@@ -429,12 +429,16 @@ mod input_delivery {
|
|||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let store = SubjektivStore::open(&scope, ®istration).unwrap();
|
let store = SubjektivStore::open(&scope, ®istration).unwrap();
|
||||||
let subject = store
|
let subject = if baseline.last().unwrap().version() < 12 {
|
||||||
|
pre_permissions_subject_fixture(&store, "preserved", "Existing behavior")
|
||||||
|
} else {
|
||||||
|
store
|
||||||
.create_subject_with_behavior(
|
.create_subject_with_behavior(
|
||||||
SubjectRole::new("preserved").unwrap(),
|
SubjectRole::new("preserved").unwrap(),
|
||||||
"Existing behavior".into(),
|
"Existing behavior".into(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap()
|
||||||
|
};
|
||||||
drop(store);
|
drop(store);
|
||||||
drop(scope);
|
drop(scope);
|
||||||
manager.shutdown().unwrap();
|
manager.shutdown().unwrap();
|
||||||
@@ -1591,6 +1595,42 @@ use sha2::{Digest, Sha256};
|
|||||||
use std::sync::{Arc, Barrier};
|
use std::sync::{Arc, Barrier};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
|
pub(super) fn pre_permissions_subject_fixture(
|
||||||
|
store: &SubjektivStore,
|
||||||
|
role: &str,
|
||||||
|
behavior: &str,
|
||||||
|
) -> crate::SubjectRecord {
|
||||||
|
// Freeze the schema 7–11 row/record shape; today's create API also writes policy tables.
|
||||||
|
assert!((7..=11).contains(&store.database.schema_version().unwrap()));
|
||||||
|
let record = serde_json::json!({
|
||||||
|
"schema_version": 1,
|
||||||
|
"id": "legacy-subject",
|
||||||
|
"role": role,
|
||||||
|
"behavior_md": behavior,
|
||||||
|
"state": "active",
|
||||||
|
"created_at": "2026-09-28T10:00:00.000Z",
|
||||||
|
"updated_at": "2026-09-28T10:00:00.000Z"
|
||||||
|
});
|
||||||
|
store
|
||||||
|
.database
|
||||||
|
.try_transaction::<_, crate::SubjektivError>(|tx| {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO subjects(subject_id,role,behavior_md,state,record_json,created_at,updated_at)
|
||||||
|
VALUES(?1,?2,?3,'active',?4,?5,?5)",
|
||||||
|
rusqlite::params![
|
||||||
|
"legacy-subject",
|
||||||
|
role,
|
||||||
|
behavior,
|
||||||
|
record.to_string(),
|
||||||
|
"2026-09-28T10:00:00.000Z",
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
serde_json::from_value(record).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn explicit_erasure_releases_only_after_all_body_sessions_including_unadmitted_history_are_erased()
|
fn explicit_erasure_releases_only_after_all_body_sessions_including_unadmitted_history_are_erased()
|
||||||
{
|
{
|
||||||
@@ -3133,16 +3173,17 @@ impl Fixture {
|
|||||||
SubjektivStore::register(&workspace).unwrap()
|
SubjektivStore::register(&workspace).unwrap()
|
||||||
};
|
};
|
||||||
let store = SubjektivStore::open(&workspace, ®istration).unwrap();
|
let store = SubjektivStore::open(&workspace, ®istration).unwrap();
|
||||||
let subject = store
|
let subject = if upgrade {
|
||||||
.create_subject_with_behavior(
|
pre_permissions_subject_fixture(
|
||||||
SubjectRole::new("companion").unwrap(),
|
&store,
|
||||||
if upgrade {
|
"companion",
|
||||||
"Preserve existing Subject behavior".into()
|
"Preserve existing Subject behavior",
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
} else {
|
||||||
|
store
|
||||||
|
.create_subject(SubjectRole::new("companion").unwrap())
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
let attribution =
|
let attribution =
|
||||||
SubjectSessionAttribution::new(&subject.id, "runtime", "worker", "source-session")
|
SubjectSessionAttribution::new(&subject.id, "runtime", "worker", "source-session")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -5508,12 +5549,7 @@ fn journal_migration_failure_rolls_back_ddl_and_retries_without_changing_existin
|
|||||||
))
|
))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let store = SubjektivStore::open(&scope, ®istration).unwrap();
|
let store = SubjektivStore::open(&scope, ®istration).unwrap();
|
||||||
let subject = store
|
let subject = pre_permissions_subject_fixture(&store, "preserved", "Original behavior");
|
||||||
.create_subject_with_behavior(
|
|
||||||
SubjectRole::new("preserved").unwrap(),
|
|
||||||
"Original behavior".into(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
store
|
store
|
||||||
.database
|
.database
|
||||||
.try_transaction::<_, JournalError>(|tx| {
|
.try_transaction::<_, JournalError>(|tx| {
|
||||||
|
|||||||
+23
-28
@@ -9,6 +9,7 @@ pub mod journal;
|
|||||||
mod legacy_migrations;
|
mod legacy_migrations;
|
||||||
pub mod permissions;
|
pub mod permissions;
|
||||||
mod schema;
|
mod schema;
|
||||||
|
mod subject_creation;
|
||||||
use legacy_migrations::{
|
use legacy_migrations::{
|
||||||
add_candidate_decision_receipts, add_subject_behavior, add_subject_session_attribution,
|
add_candidate_decision_receipts, add_subject_behavior, add_subject_session_attribution,
|
||||||
add_surface_generation_state, add_surface_job_provenance, create_schema,
|
add_surface_generation_state, add_surface_job_provenance, create_schema,
|
||||||
@@ -54,6 +55,8 @@ pub enum SubjektivError {
|
|||||||
InvalidRecord(String),
|
InvalidRecord(String),
|
||||||
#[error("subject `{0}` was not found in this Workspace")]
|
#[error("subject `{0}` was not found in this Workspace")]
|
||||||
SubjectNotFound(String),
|
SubjectNotFound(String),
|
||||||
|
#[error("Subject creation request `{0}` was reused with different input")]
|
||||||
|
SubjectCreationConflict(String),
|
||||||
#[error("Subject deletion request `{0}` conflicts with a saved deletion")]
|
#[error("Subject deletion request `{0}` conflicts with a saved deletion")]
|
||||||
SubjectDeletionConflict(String),
|
SubjectDeletionConflict(String),
|
||||||
#[error("subject `{0}` is legacy-retired")]
|
#[error("subject `{0}` is legacy-retired")]
|
||||||
@@ -658,6 +661,11 @@ static MIGRATIONS: &[FeatureMigration] = &[
|
|||||||
deletion::migrate,
|
deletion::migrate,
|
||||||
),
|
),
|
||||||
FeatureMigration::new(15, "add retained text-only conversations", chat::migrate),
|
FeatureMigration::new(15, "add retained text-only conversations", chat::migrate),
|
||||||
|
FeatureMigration::new(
|
||||||
|
16,
|
||||||
|
"retain replayable Subject creation receipts",
|
||||||
|
subject_creation::migrate,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
pub const REGISTRATION: FeatureRegistration =
|
pub const REGISTRATION: FeatureRegistration =
|
||||||
@@ -754,34 +762,21 @@ impl SubjektivStore {
|
|||||||
role: SubjectRole,
|
role: SubjectRole,
|
||||||
behavior_md: String,
|
behavior_md: String,
|
||||||
) -> Result<SubjectRecord> {
|
) -> Result<SubjectRecord> {
|
||||||
validate_subject_behavior(&behavior_md)?;
|
self.create_subject_request(role, behavior_md, None)
|
||||||
let timestamp = now();
|
}
|
||||||
let record = SubjectRecord {
|
|
||||||
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
/// Replays the original creation without reissuing permissions or changing the Subject.
|
||||||
id: issued_id("subject"),
|
/// The Host authenticates the administrator before calling. Keep the same operation ID
|
||||||
role,
|
/// and full input after an unknown outcome; different input is a conflict.
|
||||||
behavior_md,
|
pub fn create_subject_idempotent(
|
||||||
state: SubjectState::Active,
|
&self,
|
||||||
created_at: timestamp.clone(),
|
role: SubjectRole,
|
||||||
updated_at: timestamp,
|
behavior_md: String,
|
||||||
};
|
operation_id: &str,
|
||||||
let raw = serde_json::to_string(&record)?;
|
) -> Result<SubjectRecord> {
|
||||||
self.database.try_transaction(|transaction| {
|
server_api::authorization::validate_authorization_identifier(operation_id)
|
||||||
transaction.execute(
|
.map_err(SubjektivError::InvalidRecord)?;
|
||||||
"INSERT INTO subjects (
|
self.create_subject_request(role, behavior_md, Some(operation_id))
|
||||||
subject_id, role, behavior_md, state, record_json, created_at, updated_at
|
|
||||||
) VALUES (?1, ?2, ?3, 'active', ?4, ?5, ?6)",
|
|
||||||
params![
|
|
||||||
record.id,
|
|
||||||
record.role.as_str(),
|
|
||||||
record.behavior_md,
|
|
||||||
raw,
|
|
||||||
record.created_at,
|
|
||||||
record.updated_at
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
Ok(record)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn subject(&self, subject_id: &str) -> Result<Option<SubjectRecord>> {
|
pub fn subject(&self, subject_id: &str) -> Result<Option<SubjectRecord>> {
|
||||||
|
|||||||
@@ -68,16 +68,71 @@ fn open_version(
|
|||||||
(manager, scope, store)
|
(manager, scope, store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn legacy_unset_source_fixture(store: &SubjektivStore, subject: &str) {
|
||||||
|
// Model an unset legacy source without deleting immutable permission history or receipts.
|
||||||
|
store
|
||||||
|
.database
|
||||||
|
.try_transaction::<_, SubjektivError>(|tx| {
|
||||||
|
assert_eq!(
|
||||||
|
tx.execute(
|
||||||
|
"DELETE FROM subject_permissions WHERE subject_id=?1",
|
||||||
|
[subject]
|
||||||
|
)?,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.subject_permissions(subject).unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pre_permissions_subject_fixture(store: &SubjektivStore, behavior: &str) -> SubjectRecord {
|
||||||
|
// Freeze the version-11 row/record shape rather than invoking today's initializing create API.
|
||||||
|
assert_eq!(store.database.schema_version().unwrap(), 11);
|
||||||
|
let record = serde_json::json!({
|
||||||
|
"schema_version": 1,
|
||||||
|
"id": "legacy-subject",
|
||||||
|
"role": role().as_str(),
|
||||||
|
"behavior_md": behavior,
|
||||||
|
"state": "active",
|
||||||
|
"created_at": "2026-09-28T10:00:00.000Z",
|
||||||
|
"updated_at": "2026-09-28T10:00:00.000Z"
|
||||||
|
});
|
||||||
|
store
|
||||||
|
.database
|
||||||
|
.try_transaction::<_, SubjektivError>(|tx| {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO subjects(subject_id,role,behavior_md,state,record_json,created_at,updated_at)
|
||||||
|
VALUES(?1,?2,?3,'active',?4,?5,?5)",
|
||||||
|
params![
|
||||||
|
"legacy-subject",
|
||||||
|
role().as_str(),
|
||||||
|
behavior,
|
||||||
|
record.to_string(),
|
||||||
|
"2026-09-28T10:00:00.000Z",
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
serde_json::from_value(record).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replacements_are_cas_fenced_replayable_and_never_restore_old_permissions() {
|
fn replacements_are_cas_fenced_replayable_and_never_restore_old_permissions() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let subject;
|
let subject;
|
||||||
let first;
|
let first;
|
||||||
let first_update = update("allow", None, vec![permission("file.read", ".", true)]);
|
let first_update;
|
||||||
{
|
{
|
||||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||||
subject = store.create_subject(role()).unwrap().id;
|
subject = store.create_subject(role()).unwrap().id;
|
||||||
assert!(store.subject_permissions(&subject).unwrap().is_none());
|
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||||
|
first_update = update(
|
||||||
|
"allow",
|
||||||
|
Some(&initial.change_id),
|
||||||
|
vec![permission("file.read", ".", true)],
|
||||||
|
);
|
||||||
first = store
|
first = store
|
||||||
.set_subject_permissions(&subject, &first_update)
|
.set_subject_permissions(&subject, &first_update)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -144,8 +199,12 @@ fn failed_receipt_insert_rolls_back_source_history_and_projection() {
|
|||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||||
let subject = store.create_subject(role()).unwrap().id;
|
let subject = store.create_subject(role()).unwrap().id;
|
||||||
|
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||||
let first = store
|
let first = store
|
||||||
.set_subject_permissions(&subject, &update("first", None, Vec::new()))
|
.set_subject_permissions(
|
||||||
|
&subject,
|
||||||
|
&update("first", Some(&initial.change_id), Vec::new()),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store.database.try_transaction::<_, SubjektivError>(|tx| {
|
store.database.try_transaction::<_, SubjektivError>(|tx| {
|
||||||
tx.execute_batch("CREATE TRIGGER fail_permission_receipt BEFORE INSERT ON subject_permission_receipts BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?;
|
tx.execute_batch("CREATE TRIGGER fail_permission_receipt BEFORE INSERT ON subject_permission_receipts BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?;
|
||||||
@@ -164,7 +223,7 @@ fn failed_receipt_insert_rolls_back_source_history_and_projection() {
|
|||||||
assert_eq!(store.subject_permissions(&subject).unwrap(), Some(first));
|
assert_eq!(store.subject_permissions(&subject).unwrap(), Some(first));
|
||||||
store.database.try_transaction::<_, SubjektivError>(|tx| {
|
store.database.try_transaction::<_, SubjektivError>(|tx| {
|
||||||
let counts: (u32, u32) = tx.query_row("SELECT (SELECT count(*) FROM subject_permission_changes),(SELECT count(*) FROM subject_permission_receipts)", [], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
let counts: (u32, u32) = tx.query_row("SELECT (SELECT count(*) FROM subject_permission_changes),(SELECT count(*) FROM subject_permission_receipts)", [], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||||
assert_eq!(counts, (1, 1));
|
assert_eq!(counts, (2, 1));
|
||||||
tx.execute_batch("DROP TRIGGER fail_permission_receipt")?;
|
tx.execute_batch("DROP TRIGGER fail_permission_receipt")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
@@ -181,6 +240,7 @@ fn scope_and_validation_rejections_do_not_issue_permission_sources() {
|
|||||||
let (_a_manager, _a_scope, a) = open_store(&temp.path().join("a"), "workspace-a");
|
let (_a_manager, _a_scope, a) = open_store(&temp.path().join("a"), "workspace-a");
|
||||||
let (_b_manager, _b_scope, b) = open_store(&temp.path().join("b"), "workspace-b");
|
let (_b_manager, _b_scope, b) = open_store(&temp.path().join("b"), "workspace-b");
|
||||||
let subject = a.create_subject(role()).unwrap().id;
|
let subject = a.create_subject(role()).unwrap().id;
|
||||||
|
legacy_unset_source_fixture(&a, &subject);
|
||||||
assert!(
|
assert!(
|
||||||
b.set_subject_permissions(&subject, &update("cross-subject", None, Vec::new()))
|
b.set_subject_permissions(&subject, &update("cross-subject", None, Vec::new()))
|
||||||
.is_err()
|
.is_err()
|
||||||
@@ -220,10 +280,15 @@ fn child_grants_are_immutable_bounded_and_do_not_create_subject_ownership() {
|
|||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||||
let subject = store.create_subject(role()).unwrap().id;
|
let subject = store.create_subject(role()).unwrap().id;
|
||||||
|
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||||
let source = store
|
let source = store
|
||||||
.set_subject_permissions(
|
.set_subject_permissions(
|
||||||
&subject,
|
&subject,
|
||||||
&update("allow", None, vec![permission("file.read", "src", true)]),
|
&update(
|
||||||
|
"allow",
|
||||||
|
Some(&initial.change_id),
|
||||||
|
vec![permission("file.read", "src", true)],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let child = grant(&subject, &source.change_id, "child");
|
let child = grant(&subject, &source.change_id, "child");
|
||||||
@@ -278,10 +343,15 @@ fn redelegation_requires_current_source_and_explicit_retained_parent_ceiling() {
|
|||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||||
let subject = store.create_subject(role()).unwrap().id;
|
let subject = store.create_subject(role()).unwrap().id;
|
||||||
|
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||||
let source = store
|
let source = store
|
||||||
.set_subject_permissions(
|
.set_subject_permissions(
|
||||||
&subject,
|
&subject,
|
||||||
&update("allow", None, vec![permission("file.read", ".", true)]),
|
&update(
|
||||||
|
"allow",
|
||||||
|
Some(&initial.change_id),
|
||||||
|
vec![permission("file.read", ".", true)],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut child = grant(&subject, &source.change_id, "child");
|
let mut child = grant(&subject, &source.change_id, "child");
|
||||||
@@ -325,10 +395,15 @@ fn journal_state_neither_authenticates_roots_nor_bypasses_retained_child_ceiling
|
|||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||||
let subject = store.create_subject(role()).unwrap().id;
|
let subject = store.create_subject(role()).unwrap().id;
|
||||||
|
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||||
let source = store
|
let source = store
|
||||||
.set_subject_permissions(
|
.set_subject_permissions(
|
||||||
&subject,
|
&subject,
|
||||||
&update("allow", None, vec![permission("file.read", ".", true)]),
|
&update(
|
||||||
|
"allow",
|
||||||
|
Some(&initial.change_id),
|
||||||
|
vec![permission("file.read", ".", true)],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// The Host has authenticated root ownership outside this store. No Journal record is
|
// The Host has authenticated root ownership outside this store. No Journal record is
|
||||||
@@ -369,10 +444,15 @@ fn retained_origins_reject_cycles_foreign_subjects_and_stale_parent_sources() {
|
|||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||||
let subject = store.create_subject(role()).unwrap().id;
|
let subject = store.create_subject(role()).unwrap().id;
|
||||||
|
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||||
let source = store
|
let source = store
|
||||||
.set_subject_permissions(
|
.set_subject_permissions(
|
||||||
&subject,
|
&subject,
|
||||||
&update("allow", None, vec![permission("file.read", ".", true)]),
|
&update(
|
||||||
|
"allow",
|
||||||
|
Some(&initial.change_id),
|
||||||
|
vec![permission("file.read", ".", true)],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut child = grant(&subject, &source.change_id, "child");
|
let mut child = grant(&subject, &source.change_id, "child");
|
||||||
@@ -386,8 +466,16 @@ fn retained_origins_reject_cycles_foreign_subjects_and_stale_parent_sources() {
|
|||||||
Err(SubjektivError::AuthorizationDenied(_))
|
Err(SubjektivError::AuthorizationDenied(_))
|
||||||
));
|
));
|
||||||
let other = store.create_subject(role()).unwrap().id;
|
let other = store.create_subject(role()).unwrap().id;
|
||||||
|
let other_initial = store.subject_permissions(&other).unwrap().unwrap();
|
||||||
let other_source = store
|
let other_source = store
|
||||||
.set_subject_permissions(&other, &update("other", None, source.delegation.clone()))
|
.set_subject_permissions(
|
||||||
|
&other,
|
||||||
|
&update(
|
||||||
|
"other",
|
||||||
|
Some(&other_initial.change_id),
|
||||||
|
source.delegation.clone(),
|
||||||
|
),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut foreign = grant(&other, &other_source.change_id, "foreign-child");
|
let mut foreign = grant(&other, &other_source.change_id, "foreign-child");
|
||||||
foreign.origin_worker_id = "child".into();
|
foreign.origin_worker_id = "child".into();
|
||||||
@@ -414,10 +502,15 @@ fn failed_grant_insert_leaves_no_binding_and_can_retry_same_grant() {
|
|||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||||
let subject = store.create_subject(role()).unwrap().id;
|
let subject = store.create_subject(role()).unwrap().id;
|
||||||
|
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||||
let source = store
|
let source = store
|
||||||
.set_subject_permissions(
|
.set_subject_permissions(
|
||||||
&subject,
|
&subject,
|
||||||
&update("allow", None, vec![permission("file.read", ".", true)]),
|
&update(
|
||||||
|
"allow",
|
||||||
|
Some(&initial.change_id),
|
||||||
|
vec![permission("file.read", ".", true)],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let child = grant(&subject, &source.change_id, "child");
|
let child = grant(&subject, &source.change_id, "child");
|
||||||
@@ -470,9 +563,7 @@ fn current_schema_migration_preserves_subject_and_matches_new_database() {
|
|||||||
let original;
|
let original;
|
||||||
{
|
{
|
||||||
let (_old_manager, _old_scope, old) = open_version(&root, 11);
|
let (_old_manager, _old_scope, old) = open_version(&root, 11);
|
||||||
original = old
|
original = pre_permissions_subject_fixture(&old, "Keep behavior separate");
|
||||||
.create_subject_with_behavior(role(), "Keep behavior separate".into())
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
let (_migrated_manager, _migrated_scope, migrated) = open_store(&root, "workspace-a");
|
let (_migrated_manager, _migrated_scope, migrated) = open_store(&root, "workspace-a");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -502,6 +593,7 @@ fn unset_or_execution_only_source_cannot_issue_child_grants_and_saved_grants_sur
|
|||||||
{
|
{
|
||||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||||
let subject = store.create_subject(role()).unwrap().id;
|
let subject = store.create_subject(role()).unwrap().id;
|
||||||
|
legacy_unset_source_fixture(&store, &subject);
|
||||||
assert!(
|
assert!(
|
||||||
store
|
store
|
||||||
.bind_worker_execution_grant(&grant(&subject, "unset", "child"))
|
.bind_worker_execution_grant(&grant(&subject, "unset", "child"))
|
||||||
@@ -556,14 +648,27 @@ fn subject_grant_listing_is_scoped_retained_ordered_and_fails_on_overflow() {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.is_empty()
|
.is_empty()
|
||||||
);
|
);
|
||||||
|
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||||
let source = store
|
let source = store
|
||||||
.set_subject_permissions(
|
.set_subject_permissions(
|
||||||
&subject,
|
&subject,
|
||||||
&update("allow", None, vec![permission("file.read", ".", true)]),
|
&update(
|
||||||
|
"allow",
|
||||||
|
Some(&initial.change_id),
|
||||||
|
vec![permission("file.read", ".", true)],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let other_initial = store.subject_permissions(&other).unwrap().unwrap();
|
||||||
let other_source = store
|
let other_source = store
|
||||||
.set_subject_permissions(&other, &update("other", None, source.delegation.clone()))
|
.set_subject_permissions(
|
||||||
|
&other,
|
||||||
|
&update(
|
||||||
|
"other",
|
||||||
|
Some(&other_initial.change_id),
|
||||||
|
source.delegation.clone(),
|
||||||
|
),
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let other_grant = grant(&other, &other_source.change_id, "other-child");
|
let other_grant = grant(&other, &other_source.change_id, "other-child");
|
||||||
store.bind_worker_execution_grant(&other_grant).unwrap();
|
store.bind_worker_execution_grant(&other_grant).unwrap();
|
||||||
@@ -631,7 +736,7 @@ fn failed_schema_migration_rolls_back_new_tables_and_preserves_old_data() {
|
|||||||
let original;
|
let original;
|
||||||
{
|
{
|
||||||
let (_old_manager, _old_scope, old) = open_version(temp.path(), 11);
|
let (_old_manager, _old_scope, old) = open_version(temp.path(), 11);
|
||||||
original = old.create_subject(role()).unwrap();
|
original = pre_permissions_subject_fixture(&old, "");
|
||||||
old.database
|
old.database
|
||||||
.try_transaction::<_, SubjektivError>(|tx| {
|
.try_transaction::<_, SubjektivError>(|tx| {
|
||||||
tx.execute_batch("CREATE TABLE subject_permissions (collision TEXT)")?;
|
tx.execute_batch("CREATE TABLE subject_permissions (collision TEXT)")?;
|
||||||
@@ -660,7 +765,12 @@ fn deletion_fences_source_and_child_grant_replays_and_never_falls_back_after_pur
|
|||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
|
||||||
let subject = store.create_subject(role()).unwrap().id;
|
let subject = store.create_subject(role()).unwrap().id;
|
||||||
let replacement = update("allow", None, vec![permission("file.read", ".", true)]);
|
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||||
|
let replacement = update(
|
||||||
|
"allow",
|
||||||
|
Some(&initial.change_id),
|
||||||
|
vec![permission("file.read", ".", true)],
|
||||||
|
);
|
||||||
let source = store
|
let source = store
|
||||||
.set_subject_permissions(&subject, &replacement)
|
.set_subject_permissions(&subject, &replacement)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -688,7 +798,12 @@ fn deletion_fences_source_and_child_grant_replays_and_never_falls_back_after_pur
|
|||||||
.is_none()
|
.is_none()
|
||||||
);
|
);
|
||||||
let fresh = store.create_subject(role()).unwrap().id;
|
let fresh = store.create_subject(role()).unwrap().id;
|
||||||
assert!(store.subject_permissions(&fresh).unwrap().is_none());
|
let fresh_source = store.subject_permissions(&fresh).unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fresh_source.execution,
|
||||||
|
server_api::authorization::subject_conversation_permissions("workspace-a", &fresh)
|
||||||
|
);
|
||||||
|
assert!(fresh_source.delegation.is_empty());
|
||||||
assert!(
|
assert!(
|
||||||
store
|
store
|
||||||
.record_session_attribution(
|
.record_session_attribution(
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
//! New Subjects receive their self-scoped conversation source in the creation transaction.
|
||||||
|
//! This path is never called by migration, launch, restore or permission reads. Receipts
|
||||||
|
//! retain only the issued identity and input fingerprint across erasure, preventing replay
|
||||||
|
//! from recreating an erased Subject or restoring a revoked policy.
|
||||||
|
use super::*;
|
||||||
|
use server_api::authorization::{SubjectPermissions, subject_conversation_permissions};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
|
||||||
|
tx.execute_batch(
|
||||||
|
"CREATE TABLE subject_creation_receipts (
|
||||||
|
operation_id TEXT PRIMARY KEY CHECK(length(operation_id)>0),
|
||||||
|
subject_id TEXT NOT NULL UNIQUE,
|
||||||
|
request_fingerprint TEXT NOT NULL
|
||||||
|
);",
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubjektivStore {
|
||||||
|
pub(super) fn create_subject_request(
|
||||||
|
&self,
|
||||||
|
role: SubjectRole,
|
||||||
|
behavior_md: String,
|
||||||
|
operation_id: Option<&str>,
|
||||||
|
) -> Result<SubjectRecord> {
|
||||||
|
validate_subject_behavior(&behavior_md)?;
|
||||||
|
let fingerprint: String = Sha256::digest(serde_json::to_vec(&(&role, &behavior_md))?)
|
||||||
|
.iter()
|
||||||
|
.map(|byte| format!("{byte:02x}"))
|
||||||
|
.collect();
|
||||||
|
self.database.try_transaction(|tx| {
|
||||||
|
if let Some(operation) = operation_id {
|
||||||
|
let receipt: Option<(String, String)> = tx.query_row(
|
||||||
|
"SELECT subject_id,request_fingerprint FROM subject_creation_receipts WHERE operation_id=?1",
|
||||||
|
[operation], |row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
).optional()?;
|
||||||
|
if let Some((subject, saved)) = receipt {
|
||||||
|
if saved != fingerprint {
|
||||||
|
return Err(SubjektivError::SubjectCreationConflict(operation.into()));
|
||||||
|
}
|
||||||
|
// Current identity/state, never a stale success that can recreate deleted data.
|
||||||
|
return require_active_subject(tx, &subject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let timestamp = now();
|
||||||
|
let record = SubjectRecord {
|
||||||
|
schema_version: SUBJEKTIV_SCHEMA_VERSION,
|
||||||
|
id: issued_id("subject"), role, behavior_md,
|
||||||
|
state: SubjectState::Active,
|
||||||
|
created_at: timestamp.clone(), updated_at: timestamp,
|
||||||
|
};
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO subjects(subject_id,role,behavior_md,state,record_json,created_at,updated_at)
|
||||||
|
VALUES(?1,?2,?3,'active',?4,?5,?6)",
|
||||||
|
params![record.id,record.role.as_str(),record.behavior_md,serde_json::to_string(&record)?,record.created_at,record.updated_at],
|
||||||
|
)?;
|
||||||
|
let source = SubjectPermissions {
|
||||||
|
change_id: issued_id("permissions"),
|
||||||
|
execution: subject_conversation_permissions(self.scope_id(), &record.id),
|
||||||
|
delegation: vec![],
|
||||||
|
};
|
||||||
|
tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)",
|
||||||
|
params![record.id,source.change_id,serde_json::to_string(&source)?])?;
|
||||||
|
tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2)",params![record.id,source.change_id])?;
|
||||||
|
if let Some(operation) = operation_id {
|
||||||
|
tx.execute("INSERT INTO subject_creation_receipts(operation_id,subject_id,request_fingerprint) VALUES(?1,?2,?3)",params![operation,record.id,fingerprint])?;
|
||||||
|
}
|
||||||
|
Ok(record)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
//! SQLite component coverage: normal store creation, persisted source/CAS and exact retries.
|
||||||
|
//! Authentication/Runtime connections are covered by the Backend connected chat suite.
|
||||||
|
use super::*;
|
||||||
|
use crate::tests::{open_store, role};
|
||||||
|
use server_api::authorization::{SubjectPermissionsUpdate, validate_managed_subject_permissions};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creation_persists_self_scoped_execution_and_no_delegation() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (_manager, _scope, store) = open_store(dir.path(), "workspace-a");
|
||||||
|
let subject = store.create_subject(role()).unwrap();
|
||||||
|
let source = store.subject_permissions(&subject.id).unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
source.execution,
|
||||||
|
subject_conversation_permissions("workspace-a", &subject.id)
|
||||||
|
);
|
||||||
|
assert!(source.delegation.is_empty());
|
||||||
|
validate_managed_subject_permissions(
|
||||||
|
None,
|
||||||
|
&SubjectPermissionsUpdate {
|
||||||
|
operation_id: "validate-initial".into(),
|
||||||
|
expected_change_id: None,
|
||||||
|
execution: source.execution.clone(),
|
||||||
|
delegation: vec![],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for permission in source.execution {
|
||||||
|
let mut foreign = permission.clone();
|
||||||
|
foreign.resource = server_api::authorization::PermissionResource::Subject {
|
||||||
|
workspace_id: "workspace-a".into(),
|
||||||
|
subject_id: "other".into(),
|
||||||
|
};
|
||||||
|
assert!(!permission.covers(&foreign));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creation_replay_after_reopen_never_duplicates_or_restores_revoked_source() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let id;
|
||||||
|
let revoked;
|
||||||
|
{
|
||||||
|
let (_manager, _scope, store) = open_store(dir.path(), "workspace-a");
|
||||||
|
let first = store
|
||||||
|
.create_subject_idempotent(role(), "original".into(), "create")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.create_subject_idempotent(role(), "original".into(), "create")
|
||||||
|
.unwrap(),
|
||||||
|
first
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
store.create_subject_idempotent(role(), "changed".into(), "create"),
|
||||||
|
Err(SubjektivError::SubjectCreationConflict(_))
|
||||||
|
));
|
||||||
|
id = first.id;
|
||||||
|
let source = store.subject_permissions(&id).unwrap().unwrap();
|
||||||
|
revoked = store
|
||||||
|
.set_subject_permissions(
|
||||||
|
&id,
|
||||||
|
&SubjectPermissionsUpdate {
|
||||||
|
operation_id: "revoke".into(),
|
||||||
|
expected_change_id: Some(source.change_id),
|
||||||
|
execution: vec![],
|
||||||
|
delegation: vec![],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let (_manager, _scope, store) = open_store(dir.path(), "workspace-a");
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.create_subject_idempotent(role(), "original".into(), "create")
|
||||||
|
.unwrap()
|
||||||
|
.id,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
assert_eq!(store.subject_permissions(&id).unwrap(), Some(revoked));
|
||||||
|
assert_eq!(store.list_subjects_after(100, None).unwrap().items.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn permission_write_failure_rolls_back_subject_and_creation_receipt() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (_manager, _scope, store) = open_store(dir.path(), "workspace-a");
|
||||||
|
store.database.with_connection(|c| {
|
||||||
|
Ok(c.execute_batch("CREATE TRIGGER fail_initial_policy BEFORE INSERT ON subject_permissions BEGIN SELECT RAISE(ABORT,'injected policy failure'); END;")?)
|
||||||
|
}).unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.create_subject_idempotent(role(), String::new(), "atomic")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.list_subjects_after(100, None)
|
||||||
|
.unwrap()
|
||||||
|
.items
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.database
|
||||||
|
.with_connection(|c| Ok(c.execute_batch("DROP TRIGGER fail_initial_policy;")?))
|
||||||
|
.unwrap();
|
||||||
|
let subject = store
|
||||||
|
.create_subject_idempotent(role(), String::new(), "atomic")
|
||||||
|
.unwrap();
|
||||||
|
assert!(store.subject_permissions(&subject.id).unwrap().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn creation_replay_after_erasure_does_not_recreate_subject_or_policy() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (_manager, _scope, store) = open_store(dir.path(), "workspace-a");
|
||||||
|
let created = store
|
||||||
|
.create_subject_idempotent(role(), "private".into(), "create-erased")
|
||||||
|
.unwrap();
|
||||||
|
store.begin_subject_deletion(&created.id, "erase").unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.create_subject_idempotent(role(), "private".into(), "create-erased")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
store.finish_subject_deletion(&created.id, "erase").unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.create_subject_idempotent(role(), "private".into(), "create-erased")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.list_subjects_after(100, None)
|
||||||
|
.unwrap()
|
||||||
|
.items
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn receipt_migration_preserves_existing_unset_and_revoked_policies_and_rolls_back_failure() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let manager = feature_storage::FeatureStorage::new(dir.path());
|
||||||
|
let scope = manager.scope("workspace-a").unwrap();
|
||||||
|
let old_registration = scope
|
||||||
|
.register(feature_storage::FeatureRegistration::new(
|
||||||
|
SUBJEKTIV_FEATURE_ID,
|
||||||
|
&MIGRATIONS[..15],
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let old = SubjektivStore::open(&scope, &old_registration).unwrap();
|
||||||
|
let unset = old.create_subject(role()).unwrap();
|
||||||
|
let revoked = old.create_subject(role()).unwrap();
|
||||||
|
let source = old.subject_permissions(&revoked.id).unwrap().unwrap();
|
||||||
|
let empty = old
|
||||||
|
.set_subject_permissions(
|
||||||
|
&revoked.id,
|
||||||
|
&SubjectPermissionsUpdate {
|
||||||
|
operation_id: "revoke-before-upgrade".into(),
|
||||||
|
expected_change_id: Some(source.change_id),
|
||||||
|
execution: vec![],
|
||||||
|
delegation: vec![],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
old.database
|
||||||
|
.with_connection(|c| {
|
||||||
|
c.execute(
|
||||||
|
"DELETE FROM subject_permissions WHERE subject_id=?1",
|
||||||
|
[&unset.id],
|
||||||
|
)?;
|
||||||
|
c.execute_batch("CREATE TABLE subject_creation_receipts(conflicting_column TEXT);")?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
drop(old);
|
||||||
|
drop(scope);
|
||||||
|
drop(manager);
|
||||||
|
{
|
||||||
|
let manager = feature_storage::FeatureStorage::new(dir.path());
|
||||||
|
let scope = manager.scope("workspace-a").unwrap();
|
||||||
|
let registration = SubjektivStore::register(&scope).unwrap();
|
||||||
|
assert!(SubjektivStore::open(&scope, ®istration).is_err());
|
||||||
|
}
|
||||||
|
let manager = feature_storage::FeatureStorage::new(dir.path());
|
||||||
|
let scope = manager.scope("workspace-a").unwrap();
|
||||||
|
let registration = scope
|
||||||
|
.register(feature_storage::FeatureRegistration::new(
|
||||||
|
SUBJEKTIV_FEATURE_ID,
|
||||||
|
&MIGRATIONS[..15],
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let old = SubjektivStore::open(&scope, ®istration).unwrap();
|
||||||
|
assert_eq!(old.database.schema_version().unwrap(), 15);
|
||||||
|
assert_eq!(old.subject(&unset.id).unwrap(), Some(unset.clone()));
|
||||||
|
assert_eq!(
|
||||||
|
old.subject_permissions(&revoked.id).unwrap(),
|
||||||
|
Some(empty.clone())
|
||||||
|
);
|
||||||
|
old.database
|
||||||
|
.with_connection(|c| {
|
||||||
|
c.execute_batch("DROP TABLE subject_creation_receipts;")?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
drop(old);
|
||||||
|
drop(scope);
|
||||||
|
drop(manager);
|
||||||
|
let manager = feature_storage::FeatureStorage::new(dir.path());
|
||||||
|
let scope = manager.scope("workspace-a").unwrap();
|
||||||
|
let registration = SubjektivStore::register(&scope).unwrap();
|
||||||
|
let upgraded = SubjektivStore::open(&scope, ®istration).unwrap();
|
||||||
|
assert_eq!(upgraded.subject_permissions(&unset.id).unwrap(), None);
|
||||||
|
assert_eq!(
|
||||||
|
upgraded.subject_permissions(&revoked.id).unwrap(),
|
||||||
|
Some(empty)
|
||||||
|
);
|
||||||
|
let fresh = upgraded.create_subject(role()).unwrap();
|
||||||
|
assert!(upgraded.subject_permissions(&fresh.id).unwrap().is_some());
|
||||||
|
}
|
||||||
@@ -495,6 +495,11 @@ pub fn sanitize_workspace_display_name(value: &str) -> Result<String> {
|
|||||||
|
|
||||||
fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec<WorkspaceProfileSummary> {
|
fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec<WorkspaceProfileSummary> {
|
||||||
let labels = [
|
let labels = [
|
||||||
|
(
|
||||||
|
server_api::authorization::SUBJECT_CONVERSATION_PROFILE,
|
||||||
|
"Subject conversation",
|
||||||
|
"Default Subject conversation and memory profile; requires an explicit Subject connection",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"builtin:companion",
|
"builtin:companion",
|
||||||
"Companion",
|
"Companion",
|
||||||
@@ -541,7 +546,8 @@ pub fn selector_for_builtin_candidate(
|
|||||||
id: &str,
|
id: &str,
|
||||||
) -> Option<worker_runtime::catalog::ProfileSelector> {
|
) -> Option<worker_runtime::catalog::ProfileSelector> {
|
||||||
match id {
|
match id {
|
||||||
"builtin:companion"
|
"builtin:async-chat"
|
||||||
|
| "builtin:companion"
|
||||||
| "builtin:intake"
|
| "builtin:intake"
|
||||||
| "builtin:orchestrator"
|
| "builtin:orchestrator"
|
||||||
| "builtin:coder"
|
| "builtin:coder"
|
||||||
|
|||||||
@@ -24094,7 +24094,7 @@ async fn scoped_create_subjektiv_subject(
|
|||||||
crate::subjektiv::validate_subject_behavior(&request.behavior_md)
|
crate::subjektiv::validate_subject_behavior(&request.behavior_md)
|
||||||
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
||||||
let subject = open_subjektiv_store(&api)?
|
let subject = open_subjektiv_store(&api)?
|
||||||
.create_subject_with_behavior(role, request.behavior_md)
|
.create_subject_idempotent(role, request.behavior_md, &request.operation_id)
|
||||||
.map_err(subjektiv_store_error)?;
|
.map_err(subjektiv_store_error)?;
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
@@ -25990,6 +25990,7 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error {
|
|||||||
Error::WorkspacePermissionDenied(error.to_string())
|
Error::WorkspacePermissionDenied(error.to_string())
|
||||||
}
|
}
|
||||||
crate::subjektiv::SubjektivError::SubjectPermissionsConflict(_)
|
crate::subjektiv::SubjektivError::SubjectPermissionsConflict(_)
|
||||||
|
| crate::subjektiv::SubjektivError::SubjectCreationConflict(_)
|
||||||
| crate::subjektiv::SubjektivError::SubjectDeletionConflict(_)
|
| crate::subjektiv::SubjektivError::SubjectDeletionConflict(_)
|
||||||
| crate::subjektiv::SubjektivError::PermissionOperationConflict(_)
|
| crate::subjektiv::SubjektivError::PermissionOperationConflict(_)
|
||||||
| crate::subjektiv::SubjektivError::WorkerExecutionGrantConflict(_) => {
|
| crate::subjektiv::SubjektivError::WorkerExecutionGrantConflict(_) => {
|
||||||
@@ -45715,7 +45716,10 @@ mod tests {
|
|||||||
&subject_id,
|
&subject_id,
|
||||||
&SubjectPermissionsUpdate {
|
&SubjectPermissionsUpdate {
|
||||||
operation_id: "completion-grant".into(),
|
operation_id: "completion-grant".into(),
|
||||||
expected_change_id: None,
|
expected_change_id: store
|
||||||
|
.subject_permissions(&subject_id)
|
||||||
|
.unwrap()
|
||||||
|
.map(|p| p.change_id),
|
||||||
execution: vec![ScopedPermission {
|
execution: vec![ScopedPermission {
|
||||||
operation: "merge_request.complete".into(),
|
operation: "merge_request.complete".into(),
|
||||||
resource: PermissionResource::MergeRequest {
|
resource: PermissionResource::MergeRequest {
|
||||||
@@ -47788,7 +47792,7 @@ mod tests {
|
|||||||
app.clone(),
|
app.clone(),
|
||||||
"POST",
|
"POST",
|
||||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||||
Some(serde_json::json!({ "role": "Browser author" })),
|
Some(serde_json::json!({ "operation_id":"browser-unauthorized", "role": "Browser author" })),
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -47797,6 +47801,7 @@ mod tests {
|
|||||||
"POST",
|
"POST",
|
||||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||||
Some(serde_json::json!({
|
Some(serde_json::json!({
|
||||||
|
"operation_id":"browser-create",
|
||||||
"role": " Browser author ",
|
"role": " Browser author ",
|
||||||
"behavior_md": "Prefer explicit evidence.\nAsk when uncertain."
|
"behavior_md": "Prefer explicit evidence.\nAsk when uncertain."
|
||||||
})),
|
})),
|
||||||
@@ -47883,7 +47888,7 @@ mod tests {
|
|||||||
app.clone(),
|
app.clone(),
|
||||||
"POST",
|
"POST",
|
||||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||||
Some(serde_json::json!({ "role": " " })),
|
Some(serde_json::json!({ "operation_id":"invalid-role", "role": " " })),
|
||||||
&owner_token,
|
&owner_token,
|
||||||
StatusCode::BAD_REQUEST,
|
StatusCode::BAD_REQUEST,
|
||||||
)
|
)
|
||||||
@@ -47896,7 +47901,9 @@ mod tests {
|
|||||||
app.clone(),
|
app.clone(),
|
||||||
"POST",
|
"POST",
|
||||||
"/api/w/another-workspace/subjektiv/subjects",
|
"/api/w/another-workspace/subjektiv/subjects",
|
||||||
Some(serde_json::json!({ "role": "Cross Workspace" })),
|
Some(
|
||||||
|
serde_json::json!({ "operation_id":"cross-workspace", "role": "Cross Workspace" }),
|
||||||
|
),
|
||||||
&owner_token,
|
&owner_token,
|
||||||
StatusCode::NOT_FOUND,
|
StatusCode::NOT_FOUND,
|
||||||
)
|
)
|
||||||
@@ -47981,6 +47988,7 @@ mod tests {
|
|||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
}),
|
}),
|
||||||
Json(server_api::SubjektivSubjectCreateRequest {
|
Json(server_api::SubjektivSubjectCreateRequest {
|
||||||
|
operation_id: format!("create-subject-{}", uuid::Uuid::now_v7()),
|
||||||
role: "companion".to_string(),
|
role: "companion".to_string(),
|
||||||
behavior_md: "Prefer explicit evidence.".to_string(),
|
behavior_md: "Prefer explicit evidence.".to_string(),
|
||||||
}),
|
}),
|
||||||
@@ -48044,6 +48052,7 @@ mod tests {
|
|||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
}),
|
}),
|
||||||
Json(server_api::SubjektivSubjectCreateRequest {
|
Json(server_api::SubjektivSubjectCreateRequest {
|
||||||
|
operation_id: format!("create-subject-{}", uuid::Uuid::now_v7()),
|
||||||
role: "reviewer".to_string(),
|
role: "reviewer".to_string(),
|
||||||
behavior_md: "x".repeat(server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES + 1),
|
behavior_md: "x".repeat(server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES + 1),
|
||||||
}),
|
}),
|
||||||
@@ -48540,6 +48549,18 @@ mod tests {
|
|||||||
serde_json::to_vec(&body).unwrap(),
|
serde_json::to_vec(&body).unwrap(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
let initial = store.subject_permissions(&subject.id).unwrap().unwrap();
|
||||||
|
store
|
||||||
|
.set_subject_permissions(
|
||||||
|
&subject.id,
|
||||||
|
&SubjectPermissionsUpdate {
|
||||||
|
operation_id: "bound-authorization-revoke".into(),
|
||||||
|
expected_change_id: Some(initial.change_id),
|
||||||
|
execution: vec![],
|
||||||
|
delegation: vec![],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let denied = build_router(api.clone())
|
let denied = build_router(api.clone())
|
||||||
.oneshot(check(subject_request.clone()))
|
.oneshot(check(subject_request.clone()))
|
||||||
.await
|
.await
|
||||||
@@ -48559,7 +48580,10 @@ mod tests {
|
|||||||
&subject.id,
|
&subject.id,
|
||||||
&SubjectPermissionsUpdate {
|
&SubjectPermissionsUpdate {
|
||||||
operation_id: Uuid::now_v7().to_string(),
|
operation_id: Uuid::now_v7().to_string(),
|
||||||
expected_change_id: None,
|
expected_change_id: store
|
||||||
|
.subject_permissions(&subject.id)
|
||||||
|
.unwrap()
|
||||||
|
.map(|p| p.change_id),
|
||||||
execution: vec![
|
execution: vec![
|
||||||
ScopedPermission {
|
ScopedPermission {
|
||||||
operation: "memory.resident.supply".into(),
|
operation: "memory.resident.supply".into(),
|
||||||
@@ -48688,7 +48712,11 @@ mod tests {
|
|||||||
&subject.id,
|
&subject.id,
|
||||||
&SubjectPermissionsUpdate {
|
&SubjectPermissionsUpdate {
|
||||||
operation_id: "staging-scope-fixture".into(),
|
operation_id: "staging-scope-fixture".into(),
|
||||||
expected_change_id: None,
|
expected_change_id: open_subjektiv_store(&api)
|
||||||
|
.unwrap()
|
||||||
|
.subject_permissions(&subject.id)
|
||||||
|
.unwrap()
|
||||||
|
.map(|p| p.change_id),
|
||||||
execution,
|
execution,
|
||||||
delegation: vec![],
|
delegation: vec![],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -775,7 +775,7 @@ async fn connected_chat_delegation(scenario: Scenario) {
|
|||||||
];
|
];
|
||||||
foreign = Some((other_subject, other_ticket));
|
foreign = Some((other_subject, other_ticket));
|
||||||
}
|
}
|
||||||
account_request(&h,"POST",&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),Some(json!({"operation_id":"chat-policy","expected_change_id":null,"execution":execution,"delegation":delegation}))).await;
|
account_request(&h,"POST",&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),Some(json!({"operation_id":"chat-policy","expected_change_id":store.subject_permissions(&subject).unwrap().unwrap().change_id,"execution":execution,"delegation":delegation}))).await;
|
||||||
let original_policy = store.subject_permissions(&subject).unwrap().unwrap();
|
let original_policy = store.subject_permissions(&subject).unwrap().unwrap();
|
||||||
for (name, target) in [("conversation-a", true), ("conversation-b", false)] {
|
for (name, target) in [("conversation-a", true), ("conversation-b", false)] {
|
||||||
let c = account_request(
|
let c = account_request(
|
||||||
|
|||||||
@@ -670,7 +670,10 @@ impl agen::llm_client::LlmClient for ChatModel {
|
|||||||
is_error, content, ..
|
is_error, content, ..
|
||||||
} = item
|
} = item
|
||||||
{
|
{
|
||||||
assert!(!is_error, "real connected chat Tool failed: {content:?}");
|
assert!(
|
||||||
|
!is_error,
|
||||||
|
"real connected chat Tool failed: {item:?}; detail={content:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let events = match phase {
|
let events = match phase {
|
||||||
@@ -712,11 +715,33 @@ impl agen::llm_client::LlmClient for ChatModel {
|
|||||||
request.tools.iter().any(|tool| tool.name == name),
|
request.tools.iter().any(|tool| tool.name == name),
|
||||||
"builtin:async-chat must expose {name}"
|
"builtin:async-chat must expose {name}"
|
||||||
);
|
);
|
||||||
vec![
|
let mut events = vec![
|
||||||
Event::tool_use_start(0, format!("chat-tool-{phase}"), name),
|
Event::tool_use_start(0, format!("chat-tool-{phase}"), name),
|
||||||
Event::tool_input_delta(0, input.to_string()),
|
Event::tool_input_delta(0, input.to_string()),
|
||||||
Event::tool_use_stop(0),
|
Event::tool_use_stop(0),
|
||||||
]
|
];
|
||||||
|
if phase == 3 {
|
||||||
|
for (index, name, input) in [
|
||||||
|
(1, "SubjektivMemoryQuery", json!({})),
|
||||||
|
(
|
||||||
|
2,
|
||||||
|
"SubjektivMemoryRemember",
|
||||||
|
json!({"kind":"lesson","claim":"The connected chat path supports explicit remembering.","why_useful":"Retain tested conversation behavior."}),
|
||||||
|
),
|
||||||
|
(3, "SubjektivSessionList", json!({})),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
request.tools.iter().any(|tool| tool.name == name),
|
||||||
|
"default profile must expose {name}"
|
||||||
|
);
|
||||||
|
events.extend([
|
||||||
|
Event::tool_use_start(index, format!("default-memory-{index}"), name),
|
||||||
|
Event::tool_input_delta(index, input.to_string()),
|
||||||
|
Event::tool_use_stop(index),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
events
|
||||||
}
|
}
|
||||||
4..=6 => vec![
|
4..=6 => vec![
|
||||||
Event::text_block_start(0),
|
Event::text_block_start(0),
|
||||||
@@ -743,38 +768,11 @@ struct ConnectedChat {
|
|||||||
owner: String,
|
owner: String,
|
||||||
url: String,
|
url: String,
|
||||||
lose_next_input_ack: Arc<std::sync::atomic::AtomicBool>,
|
lose_next_input_ack: Arc<std::sync::atomic::AtomicBool>,
|
||||||
|
identity: RuntimeIdentityMaterial,
|
||||||
_backend: AuthorizationCallbackServer,
|
_backend: AuthorizationCallbackServer,
|
||||||
_runtime_server: ConnectedRuntimeServer,
|
_runtime_server: ConnectedRuntimeServer,
|
||||||
_dir: tempfile::TempDir,
|
_dir: tempfile::TempDir,
|
||||||
}
|
}
|
||||||
fn publish_chat_fixture_profile(api: &WorkspaceApi) {
|
|
||||||
// A bundled Profile is not automatically a Backend-published candidate.
|
|
||||||
// Publish the actual built-in without copying or widening its capabilities.
|
|
||||||
let state = api
|
|
||||||
.config_store
|
|
||||||
.load_workspace_config(TEST_WORKSPACE_ID)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
let main = state
|
|
||||||
.snapshot
|
|
||||||
.get(&config_source::VirtualPath::parse("main.dcdl").unwrap())
|
|
||||||
.unwrap();
|
|
||||||
let content = r#"{ profile = { default_profile = "project:connected-chat"; entries = [{ selector = "project:connected-chat"; label = "Connected chat"; description = "Real built-in async chat fixture"; profile = import "$builtin/profiles/async-chat.dcdl"; }]; }; }"#;
|
|
||||||
commit_workspace_config_tree(
|
|
||||||
api,
|
|
||||||
TEST_WORKSPACE_ID,
|
|
||||||
&ConfigCommitRequest {
|
|
||||||
base_digest: state.snapshot.digest.clone(),
|
|
||||||
entrypoints: state.contract.entrypoints.clone(),
|
|
||||||
changes: vec![config_source::ConfigTreeChange::Update {
|
|
||||||
path: main.path.clone(),
|
|
||||||
expected_digest: main.content_digest.clone(),
|
|
||||||
content: content.into(),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ConnectedChat {
|
impl ConnectedChat {
|
||||||
async fn new(
|
async fn new(
|
||||||
@@ -943,7 +941,6 @@ impl ConnectedChat {
|
|||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
);
|
);
|
||||||
publish_chat_fixture_profile(&api);
|
|
||||||
seed_test_delivery_binding(&api);
|
seed_test_delivery_binding(&api);
|
||||||
let owner =
|
let owner =
|
||||||
seed_test_workspace_owner_token(api.store.as_ref(), "connected-chat-owner").await;
|
seed_test_workspace_owner_token(api.store.as_ref(), "connected-chat-owner").await;
|
||||||
@@ -957,6 +954,7 @@ impl ConnectedChat {
|
|||||||
owner,
|
owner,
|
||||||
url,
|
url,
|
||||||
lose_next_input_ack,
|
lose_next_input_ack,
|
||||||
|
identity,
|
||||||
_backend: backend,
|
_backend: backend,
|
||||||
_runtime_server: runtime_server,
|
_runtime_server: runtime_server,
|
||||||
_dir: dir,
|
_dir: dir,
|
||||||
@@ -980,6 +978,54 @@ impl ConnectedChat {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn connected_source_request(
|
||||||
|
f: &ConnectedChat,
|
||||||
|
worker: &str,
|
||||||
|
path: &str,
|
||||||
|
body: Vec<u8>,
|
||||||
|
) -> Request<Body> {
|
||||||
|
let proof = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&f.identity)
|
||||||
|
.issue(
|
||||||
|
&f.url,
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
Some(worker),
|
||||||
|
worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION,
|
||||||
|
"POST",
|
||||||
|
path,
|
||||||
|
&body,
|
||||||
|
i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(),
|
||||||
|
30,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri(path)
|
||||||
|
.header(CONTENT_TYPE, "application/json")
|
||||||
|
.header(
|
||||||
|
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
|
||||||
|
proof,
|
||||||
|
)
|
||||||
|
.body(Body::from(body))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn worker_chat_connected(
|
||||||
|
f: &ConnectedChat,
|
||||||
|
worker: &str,
|
||||||
|
operation: Value,
|
||||||
|
) -> (StatusCode, Value) {
|
||||||
|
response(
|
||||||
|
&f.api,
|
||||||
|
connected_source_request(
|
||||||
|
f,
|
||||||
|
worker,
|
||||||
|
&chat_worker_path(),
|
||||||
|
serde_json::to_vec(&json!({"operation":operation})).unwrap(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
async fn next_chat_input(
|
async fn next_chat_input(
|
||||||
inputs: &mut tokio::sync::mpsc::Receiver<(usize, String)>,
|
inputs: &mut tokio::sync::mpsc::Receiver<(usize, String)>,
|
||||||
expected: usize,
|
expected: usize,
|
||||||
@@ -1049,22 +1095,44 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let store = open_subjektiv_store(&f.api).unwrap();
|
let store = open_subjektiv_store(&f.api).unwrap();
|
||||||
let subject = store
|
let create_wire =
|
||||||
.create_subject(crate::subjektiv::SubjectRole::new("connected-chat").unwrap())
|
json!({"operation_id":"connected-subject-create","role":"connected-chat","behavior_md":""});
|
||||||
|
let subject = f
|
||||||
|
.post(
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||||
|
create_wire.clone(),
|
||||||
|
)
|
||||||
|
.await["id"]
|
||||||
|
.as_str()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.id;
|
.to_owned();
|
||||||
let operations = [
|
assert_eq!(
|
||||||
"session.record",
|
f.post(
|
||||||
"memory.resident.read",
|
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||||
"memory.resident.supply",
|
create_wire.clone()
|
||||||
"journal.startup.read",
|
)
|
||||||
"journal.startup.supply",
|
.await["id"],
|
||||||
// Restore uses the ordinary fail-closed recording hook as well.
|
subject
|
||||||
"journal.recording.request",
|
);
|
||||||
"chat.read",
|
let (status, managed) = account(
|
||||||
"chat.send",
|
&f.api,
|
||||||
];
|
&f.owner,
|
||||||
f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),json!({"operation_id":"connected-chat-policy","expected_change_id":null,"execution":operations.into_iter().map(|op|subject_permission(&subject,op)).collect::<Vec<_>>(),"delegation":[]})).await;
|
"GET",
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{managed}");
|
||||||
|
let initial = store.subject_permissions(&subject).unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
managed["permissions"],
|
||||||
|
serde_json::to_value(&initial).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
initial.execution,
|
||||||
|
server_api::authorization::subject_conversation_permissions(TEST_WORKSPACE_ID, &subject)
|
||||||
|
);
|
||||||
|
assert!(initial.delegation.is_empty());
|
||||||
let conversation = f
|
let conversation = f
|
||||||
.post(
|
.post(
|
||||||
&conversations_path(),
|
&conversations_path(),
|
||||||
@@ -1073,7 +1141,7 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id
|
|||||||
.await;
|
.await;
|
||||||
let c = conversation["id"].as_str().unwrap();
|
let c = conversation["id"].as_str().unwrap();
|
||||||
*conversation_id.lock().unwrap() = c.into();
|
*conversation_id.lock().unwrap() = c.into();
|
||||||
let created = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),json!({"runtime_id":"connected-chat","display_name":"Connected async chat","profile":"project:connected-chat","initial_submit":[],"workdir_attachments":[],"feature_connections":{"subjektiv":{"subject_id":subject}}})).await;
|
let created = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),json!({"runtime_id":"connected-chat","display_name":"Connected async chat","profile":"builtin:async-chat","initial_submit":[],"workdir_attachments":[],"feature_connections":{"subjektiv":{"subject_id":subject}}})).await;
|
||||||
let worker = created["worker_id"].as_str().unwrap();
|
let worker = created["worker_id"].as_str().unwrap();
|
||||||
let mut events = f.runtime.subscribe_worker_observation().unwrap();
|
let mut events = f.runtime.subscribe_worker_observation().unwrap();
|
||||||
let first_wire = json!({"operation_id":"connected-first","text":"CHAT_FIRST_USER_CONTENT"});
|
let first_wire = json!({"operation_id":"connected-first","text":"CHAT_FIRST_USER_CONTENT"});
|
||||||
@@ -1170,6 +1238,19 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id
|
|||||||
"three user messages and two explicit Sends, not internal output"
|
"three user messages and two explicit Sends, not internal output"
|
||||||
);
|
);
|
||||||
assert!(!public.to_string().contains("INTERNAL_ASSISTANT"));
|
assert!(!public.to_string().contains("INTERNAL_ASSISTANT"));
|
||||||
|
let staged = store.pending_staging_candidates(&subject, 20).unwrap();
|
||||||
|
assert!(
|
||||||
|
staged
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| candidate.claim
|
||||||
|
== "The connected chat path supports explicit remembering."),
|
||||||
|
"Remember must reach real authorized SQLite staging, not just return a fixture receipt: {staged:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.subject_permissions(&subject).unwrap(),
|
||||||
|
Some(initial.clone()),
|
||||||
|
"composition and memory operations never reissue policy"
|
||||||
|
);
|
||||||
let idle_wire = json!({"operation_id":"connected-idle","text":"CHAT_IDLE_USER_CONTENT"});
|
let idle_wire = json!({"operation_id":"connected-idle","text":"CHAT_IDLE_USER_CONTENT"});
|
||||||
let idle = f.post(&messages_path(c), idle_wire.clone()).await;
|
let idle = f.post(&messages_path(c), idle_wire.clone()).await;
|
||||||
assert_eq!(idle["delivery"]["state"], "accepted");
|
assert_eq!(idle["delivery"]["state"], "accepted");
|
||||||
@@ -1272,6 +1353,88 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id
|
|||||||
"explicit retry after restore preserves one public User message"
|
"explicit retry after restore preserves one public User message"
|
||||||
);
|
);
|
||||||
assert!(!public.to_string().contains("INTERNAL_ASSISTANT"));
|
assert!(!public.to_string().contains("INTERNAL_ASSISTANT"));
|
||||||
|
let other = f
|
||||||
|
.post(
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||||
|
json!({"operation_id":"foreign-default-subject","role":"foreign"}),
|
||||||
|
)
|
||||||
|
.await["id"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_owned();
|
||||||
|
for permission in [
|
||||||
|
subject_permission(&other, "chat.read"),
|
||||||
|
ScopedPermission {
|
||||||
|
operation: "filesystem.read".into(),
|
||||||
|
resource: PermissionResource::Workdir {
|
||||||
|
working_directory_id: "outside".into(),
|
||||||
|
path: ".".into(),
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let (status, body) = response(
|
||||||
|
&f.api,
|
||||||
|
connected_source_request(
|
||||||
|
&f,
|
||||||
|
worker,
|
||||||
|
&authorization_path(),
|
||||||
|
serde_json::to_vec(&permission).unwrap(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"initial self source must not cover foreign resources: {body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let revoked = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"), json!({"operation_id":"revoke-chat-defaults","expected_change_id":initial.change_id,"execution":[],"delegation":[]})).await;
|
||||||
|
assert_eq!(
|
||||||
|
f.post(
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||||
|
create_wire
|
||||||
|
)
|
||||||
|
.await["id"],
|
||||||
|
subject
|
||||||
|
);
|
||||||
|
let (status, body) = worker_chat_connected(&f, worker, read(c)).await;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"revocation cannot be repaired by profile or create replay: {body}"
|
||||||
|
);
|
||||||
|
f.post(&format!("{lifecycle_path}/stop"), json!({})).await;
|
||||||
|
let token = f
|
||||||
|
.api
|
||||||
|
.runtime
|
||||||
|
.worker(&worker_ref)
|
||||||
|
.unwrap()
|
||||||
|
.restore_observation_token
|
||||||
|
.unwrap();
|
||||||
|
let restored = f
|
||||||
|
.post(
|
||||||
|
&format!("{lifecycle_path}/restore"),
|
||||||
|
json!({"request_id":"restore-revoked-defaults","expected_observation_token":token}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_ne!(
|
||||||
|
restored["result"]["state"], "accepted",
|
||||||
|
"required startup authorization must refuse revoked source: {restored}"
|
||||||
|
);
|
||||||
|
let (status, managed) = account(
|
||||||
|
&f.api,
|
||||||
|
&f.owner,
|
||||||
|
"GET",
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
managed["permissions"], revoked["permissions"],
|
||||||
|
"restore never reissues the initial policy"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -1302,21 +1465,15 @@ impl agen::llm_client::LlmClient for NotifyObservationModel {
|
|||||||
async fn browser_notify_preserves_ack_id_limits_retry_and_console_next_draft() {
|
async fn browser_notify_preserves_ack_id_limits_retry_and_console_next_draft() {
|
||||||
let (tx, mut inputs) = tokio::sync::mpsc::channel(32);
|
let (tx, mut inputs) = tokio::sync::mpsc::channel(32);
|
||||||
let f = ConnectedChat::new(NotifyObservationModel(tx), |router| router).await;
|
let f = ConnectedChat::new(NotifyObservationModel(tx), |router| router).await;
|
||||||
let subject = open_subjektiv_store(&f.api)
|
let subject = f
|
||||||
|
.post(
|
||||||
|
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
|
||||||
|
json!({"operation_id":"notify-console-subject","role":"notify-console"}),
|
||||||
|
)
|
||||||
|
.await["id"]
|
||||||
|
.as_str()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.create_subject(crate::subjektiv::SubjectRole::new("notify-console").unwrap())
|
.to_owned();
|
||||||
.unwrap()
|
|
||||||
.id;
|
|
||||||
let operations = [
|
|
||||||
"session.record",
|
|
||||||
"memory.resident.read",
|
|
||||||
"memory.resident.supply",
|
|
||||||
"journal.startup.read",
|
|
||||||
"journal.startup.supply",
|
|
||||||
"chat.read",
|
|
||||||
"chat.send",
|
|
||||||
];
|
|
||||||
f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),json!({"operation_id":"notify-console-policy","expected_change_id":null,"execution":operations.into_iter().map(|op|subject_permission(&subject,op)).collect::<Vec<_>>(),"delegation":[]})).await;
|
|
||||||
let conversation = f
|
let conversation = f
|
||||||
.post(
|
.post(
|
||||||
&conversations_path(),
|
&conversations_path(),
|
||||||
@@ -1324,7 +1481,7 @@ async fn browser_notify_preserves_ack_id_limits_retry_and_console_next_draft() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let c = conversation["id"].as_str().unwrap();
|
let c = conversation["id"].as_str().unwrap();
|
||||||
let created = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),json!({"runtime_id":"connected-chat","display_name":"Notify Console","profile":"project:connected-chat","initial_submit":[],"workdir_attachments":[],"feature_connections":{"subjektiv":{"subject_id":subject}}})).await;
|
let created = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),json!({"runtime_id":"connected-chat","display_name":"Notify Console","profile":"builtin:async-chat","initial_submit":[],"workdir_attachments":[],"feature_connections":{"subjektiv":{"subject_id":subject}}})).await;
|
||||||
let worker = created["worker_id"].as_str().unwrap();
|
let worker = created["worker_id"].as_str().unwrap();
|
||||||
let chat = f
|
let chat = f
|
||||||
.post(
|
.post(
|
||||||
|
|||||||
@@ -145,7 +145,14 @@ impl Fixture {
|
|||||||
&subject,
|
&subject,
|
||||||
&SubjectPermissionsUpdate {
|
&SubjectPermissionsUpdate {
|
||||||
operation_id: "fixture-session-setup".into(),
|
operation_id: "fixture-session-setup".into(),
|
||||||
expected_change_id: None,
|
expected_change_id: Some(
|
||||||
|
open_subjektiv_store(&api)
|
||||||
|
.unwrap()
|
||||||
|
.subject_permissions(&subject)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.change_id,
|
||||||
|
),
|
||||||
execution: vec![subject_permission(&subject, "session.record")],
|
execution: vec![subject_permission(&subject, "session.record")],
|
||||||
delegation: vec![],
|
delegation: vec![],
|
||||||
},
|
},
|
||||||
@@ -3087,7 +3094,7 @@ async fn connected_read_only_subject_delegates_editing_profile_to_real_runtime_r
|
|||||||
// not an unset body or a registry/singleton row manufactured by the test.
|
// not an unset body or a registry/singleton row manufactured by the test.
|
||||||
let (status, body) = response(&api, human_request(&owner, "POST",
|
let (status, body) = response(&api, human_request(&owner, "POST",
|
||||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),
|
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),
|
||||||
Some(json!({"operation_id":"connected-authorization-policy", "expected_change_id":null,
|
Some(json!({"operation_id":"connected-authorization-policy", "expected_change_id":subject_store.subject_permissions(&subject).unwrap().unwrap().change_id,
|
||||||
"execution":(["session.record", "memory.resident.read", "memory.resident.supply", "journal.startup.read", "journal.startup.supply"].into_iter().map(|operation| subject_permission(&subject, operation)).collect::<Vec<_>>()),
|
"execution":(["session.record", "memory.resident.read", "memory.resident.supply", "journal.startup.read", "journal.startup.supply"].into_iter().map(|operation| subject_permission(&subject, operation)).collect::<Vec<_>>()),
|
||||||
"delegation":child_permissions.clone()})))).await;
|
"delegation":child_permissions.clone()})))).await;
|
||||||
assert_eq!(status, StatusCode::OK, "{body}");
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
@@ -3545,7 +3552,7 @@ async fn connected_two_subjects_delegate_distinct_tickets_concurrently_on_separa
|
|||||||
.human(
|
.human(
|
||||||
reqwest::Method::POST,
|
reqwest::Method::POST,
|
||||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),
|
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),
|
||||||
json!({"operation_id":format!("parallel-{label}-policy"), "expected_change_id":null,
|
json!({"operation_id":format!("parallel-{label}-policy"), "expected_change_id":subjects.subject_permissions(&subject).unwrap().unwrap().change_id,
|
||||||
"execution":execution, "delegation":permissions}),
|
"execution":execution, "delegation":permissions}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -132,7 +132,12 @@ async fn deletion_owner_scope_exact_retry_and_subject_isolation() {
|
|||||||
.create_subject(crate::subjektiv::SubjectRole::new("fresh").unwrap())
|
.create_subject(crate::subjektiv::SubjectRole::new("fresh").unwrap())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_ne!(fresh.id, subject.id);
|
assert_ne!(fresh.id, subject.id);
|
||||||
assert!(store.subject_permissions(&fresh.id).unwrap().is_none());
|
let source = store.subject_permissions(&fresh.id).unwrap().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
source.execution,
|
||||||
|
server_api::authorization::subject_conversation_permissions(TEST_WORKSPACE_ID, &fresh.id)
|
||||||
|
);
|
||||||
|
assert!(source.delegation.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store
|
store
|
||||||
.pending_staging_candidates(&fresh.id, 100)
|
.pending_staging_candidates(&fresh.id, 100)
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ fn update(screen: &Value, operation: &str, execution: Value, delegation: Value)
|
|||||||
async fn management_read_exposes_operation_use_without_duplicate_worker_target_grants() {
|
async fn management_read_exposes_operation_use_without_duplicate_worker_target_grants() {
|
||||||
let f = ManagementFixture::new().await;
|
let f = ManagementFixture::new().await;
|
||||||
let screen = f.read().await;
|
let screen = f.read().await;
|
||||||
assert!(screen["permissions"].is_null());
|
assert!(screen["permissions"]["change_id"].is_string());
|
||||||
|
assert_eq!(screen["permissions"]["delegation"], json!([]));
|
||||||
|
f.assert_current(&screen["permissions"]);
|
||||||
assert_eq!(screen["can_manage"], true);
|
assert_eq!(screen["can_manage"], true);
|
||||||
let operations = screen["operations"].as_array().expect("management catalog");
|
let operations = screen["operations"].as_array().expect("management catalog");
|
||||||
let create = operations
|
let create = operations
|
||||||
@@ -348,7 +350,7 @@ async fn read_only_account_cannot_save_or_replay_an_owner_permission_change() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
|
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
|
||||||
f.assert_current(&Value::Null);
|
f.assert_current(&initial["permissions"]);
|
||||||
let saved = f.save(input.clone()).await;
|
let saved = f.save(input.clone()).await;
|
||||||
let (status, body) = response(
|
let (status, body) = response(
|
||||||
&f.api,
|
&f.api,
|
||||||
@@ -398,8 +400,8 @@ async fn new_unknown_legacy_unavailable_and_operation_resource_mismatch_are_reje
|
|||||||
.await;
|
.await;
|
||||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{name}/{lane}: {body}");
|
assert_eq!(status, StatusCode::BAD_REQUEST, "{name}/{lane}: {body}");
|
||||||
assert!(body["message"].is_string(), "{body}");
|
assert!(body["message"].is_string(), "{body}");
|
||||||
f.assert_current(&Value::Null);
|
f.assert_current(&screen["permissions"]);
|
||||||
assert!(f.read().await["permissions"].is_null());
|
assert_eq!(f.read().await["permissions"], screen["permissions"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -417,7 +419,14 @@ async fn unsupported_saved_entries_can_only_be_retained_in_the_same_lane_or_remo
|
|||||||
&f.subject,
|
&f.subject,
|
||||||
&SubjectPermissionsUpdate {
|
&SubjectPermissionsUpdate {
|
||||||
operation_id: "seed-pre-catalog-policy".into(),
|
operation_id: "seed-pre-catalog-policy".into(),
|
||||||
expected_change_id: None,
|
expected_change_id: Some(
|
||||||
|
open_subjektiv_store(&f.api)
|
||||||
|
.unwrap()
|
||||||
|
.subject_permissions(&f.subject)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.change_id,
|
||||||
|
),
|
||||||
execution: execution.clone(),
|
execution: execution.clone(),
|
||||||
delegation: delegation.clone(),
|
delegation: delegation.clone(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,7 +77,13 @@ async fn subject_callbacks_finish_before_waiting_host_parents_release_capacity()
|
|||||||
&subject.id,
|
&subject.id,
|
||||||
&server_api::authorization::SubjectPermissionsUpdate {
|
&server_api::authorization::SubjectPermissionsUpdate {
|
||||||
operation_id: format!("subject-callback-grant-{index}"),
|
operation_id: format!("subject-callback-grant-{index}"),
|
||||||
expected_change_id: None,
|
expected_change_id: Some(
|
||||||
|
subjektiv
|
||||||
|
.subject_permissions(&subject.id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.change_id,
|
||||||
|
),
|
||||||
execution: [
|
execution: [
|
||||||
"memory.resident.read",
|
"memory.resident.read",
|
||||||
"session.record",
|
"session.record",
|
||||||
|
|||||||
@@ -1011,6 +1011,18 @@ async fn unset_subject_consolidation_denies_before_job_dispatch() {
|
|||||||
.create_subject(SubjectRole::new("unset-job-policy").unwrap())
|
.create_subject(SubjectRole::new("unset-job-policy").unwrap())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let source = attach_memory_job_source(&api, &subject.id).await;
|
let source = attach_memory_job_source(&api, &subject.id).await;
|
||||||
|
// A legacy unset policy has no source pointer; retain the initial history fixture.
|
||||||
|
api.feature_storage
|
||||||
|
.open(&api.subjektiv_registration)
|
||||||
|
.unwrap()
|
||||||
|
.with_connection(|c| {
|
||||||
|
c.execute(
|
||||||
|
"DELETE FROM subject_permissions WHERE subject_id=?1",
|
||||||
|
[&subject.id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
assert!(store.subject_permissions(&subject.id).unwrap().is_none());
|
assert!(store.subject_permissions(&subject.id).unwrap().is_none());
|
||||||
stage(&store, &subject.id, "untouched");
|
stage(&store, &subject.id, "untouched");
|
||||||
let error = start_subjektiv_staging_consolidation(
|
let error = start_subjektiv_staging_consolidation(
|
||||||
|
|||||||
@@ -432,37 +432,10 @@ async fn backend_real_runtime_journal_release_and_next_body_persisted_startup()
|
|||||||
let subject = store
|
let subject = store
|
||||||
.create_subject(crate::subjektiv::SubjectRole::new("connected-handoff").unwrap())
|
.create_subject(crate::subjektiv::SubjectRole::new("connected-handoff").unwrap())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let owner =
|
// Exercise creation-time authority through real recording and startup callbacks,
|
||||||
seed_test_workspace_owner_token(api.store.as_ref(), "connected-journal-owner").await;
|
// without a management grant or an execution/delegation policy replacement.
|
||||||
// This fixture exercises Journal continuity, not unset-policy refusal. Assign
|
let initial_policy = store.subject_permissions(&subject.id).unwrap().unwrap();
|
||||||
// the companion's required Subject operations through authenticated administration;
|
assert!(initial_policy.delegation.is_empty());
|
||||||
// no filesystem, shell or delegation permission is granted to its body.
|
|
||||||
let update = server_api::authorization::SubjectPermissionsUpdate {
|
|
||||||
operation_id: "connected-journal-activation".into(),
|
|
||||||
expected_change_id: None,
|
|
||||||
execution: connected_subject_permissions(&subject.id),
|
|
||||||
delegation: Vec::new(),
|
|
||||||
};
|
|
||||||
let response = build_router(api.clone())
|
|
||||||
.oneshot(
|
|
||||||
axum::http::Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri(format!(
|
|
||||||
"/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/permissions",
|
|
||||||
subject.id
|
|
||||||
))
|
|
||||||
.header("authorization", format!("Bearer {owner}"))
|
|
||||||
.header(CONTENT_TYPE, "application/json")
|
|
||||||
.body(Body::from(serde_json::to_vec(&update).unwrap()))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let status = response.status();
|
|
||||||
let body = to_bytes(response.into_body(), 2 * 1024 * 1024)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
|
|
||||||
let old = connected_create(&api, &subject.id, "connected-old-body").await;
|
let old = connected_create(&api, &subject.id, "connected-old-body").await;
|
||||||
let old_attr = store
|
let old_attr = store
|
||||||
.subject_session_attributions(&subject.id)
|
.subject_session_attributions(&subject.id)
|
||||||
@@ -766,6 +739,11 @@ async fn backend_real_runtime_journal_release_and_next_body_persisted_startup()
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
before
|
before
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.subject_permissions(&subject.id).unwrap(),
|
||||||
|
Some(initial_policy),
|
||||||
|
"recording and startup must not rewrite the initial policy"
|
||||||
|
);
|
||||||
for (worker, session) in [
|
for (worker, session) in [
|
||||||
(&old, old_attr.session_id.as_str()),
|
(&old, old_attr.session_id.as_str()),
|
||||||
(&next, old_attr.session_id.as_str()),
|
(&next, old_attr.session_id.as_str()),
|
||||||
@@ -1622,7 +1600,13 @@ async fn connected_restore_fixture_with_pending_head(
|
|||||||
seed_test_workspace_owner_token(api.store.as_ref(), "restore-host-auth-owner").await;
|
seed_test_workspace_owner_token(api.store.as_ref(), "restore-host-auth-owner").await;
|
||||||
let update = server_api::authorization::SubjectPermissionsUpdate {
|
let update = server_api::authorization::SubjectPermissionsUpdate {
|
||||||
operation_id: "restore-host-auth-permissions".into(),
|
operation_id: "restore-host-auth-permissions".into(),
|
||||||
expected_change_id: None,
|
expected_change_id: Some(
|
||||||
|
store
|
||||||
|
.subject_permissions(&subject.id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.change_id,
|
||||||
|
),
|
||||||
execution: connected_subject_permissions(&subject.id),
|
execution: connected_subject_permissions(&subject.id),
|
||||||
delegation: vec![],
|
delegation: vec![],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,7 +45,13 @@ impl Fixture {
|
|||||||
subject_id,
|
subject_id,
|
||||||
&server_api::authorization::SubjectPermissionsUpdate {
|
&server_api::authorization::SubjectPermissionsUpdate {
|
||||||
operation_id: format!("memory-journal-fixture-grant-{subject_id}"),
|
operation_id: format!("memory-journal-fixture-grant-{subject_id}"),
|
||||||
expected_change_id: None,
|
expected_change_id: Some(
|
||||||
|
store
|
||||||
|
.subject_permissions(subject_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.change_id,
|
||||||
|
),
|
||||||
execution: [
|
execution: [
|
||||||
"journal.memory.links.reconcile",
|
"journal.memory.links.reconcile",
|
||||||
"journal.memory.links.read",
|
"journal.memory.links.read",
|
||||||
@@ -83,6 +89,19 @@ impl Fixture {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
if granted {
|
if granted {
|
||||||
Self::grant_permissions(&store, &subject.id);
|
Self::grant_permissions(&store, &subject.id);
|
||||||
|
} else {
|
||||||
|
// Model an existing Subject with unset policy, retaining creation history.
|
||||||
|
api.feature_storage
|
||||||
|
.open(&api.subjektiv_registration)
|
||||||
|
.unwrap()
|
||||||
|
.with_connection(|connection| {
|
||||||
|
connection.execute(
|
||||||
|
"DELETE FROM subject_permissions WHERE subject_id = ?1",
|
||||||
|
[&subject.id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
let worker = Self::body_worker(&api, &subject.id);
|
let worker = Self::body_worker(&api, &subject.id);
|
||||||
let source_worker = RuntimeWorkerRef::new(RUNTIME, Uuid::new_v4().to_string());
|
let source_worker = RuntimeWorkerRef::new(RUNTIME, Uuid::new_v4().to_string());
|
||||||
@@ -802,6 +821,8 @@ async fn memory_journal_unset_or_revoked_permissions_deny_every_route_without_li
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
} else {
|
||||||
|
assert!(f.store.subject_permissions(&subject).unwrap().is_none());
|
||||||
}
|
}
|
||||||
let count_links = || {
|
let count_links = || {
|
||||||
f.api
|
f.api
|
||||||
|
|||||||
@@ -551,7 +551,7 @@ async fn public_two_subject_ticket_spawn_notify_edit_result_retry_and_fences() {
|
|||||||
]);
|
]);
|
||||||
let (status, policy) = harness.human(reqwest::Method::POST,
|
let (status, policy) = harness.human(reqwest::Method::POST,
|
||||||
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),
|
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),
|
||||||
json!({"operation_id":format!("public-{label}-policy"),"expected_change_id":null,"execution":execution,"delegation":delegation})).await;
|
json!({"operation_id":format!("public-{label}-policy"),"expected_change_id":subjects.subject_permissions(&subject).unwrap().unwrap().change_id,"execution":execution,"delegation":delegation})).await;
|
||||||
assert_eq!(status, StatusCode::OK, "{policy}");
|
assert_eq!(status, StatusCode::OK, "{policy}");
|
||||||
let change = subjects
|
let change = subjects
|
||||||
.subject_permissions(&subject)
|
.subject_permissions(&subject)
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ async fn connected_roundtrip(explicit_workdir: bool, repair: bool) {
|
|||||||
operation("worker.stop"),
|
operation("worker.stop"),
|
||||||
operation("worker.observe"),
|
operation("worker.observe"),
|
||||||
]);
|
]);
|
||||||
let (status, assigned) = response(api, human_request(&harness.owner,"POST",&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),Some(json!({"operation_id":"public-messaging-policy","expected_change_id":null,"execution":execution,"delegation":permissions.lock().unwrap().clone()})))).await;
|
let (status, assigned) = response(api, human_request(&harness.owner,"POST",&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),Some(json!({"operation_id":"public-messaging-policy","expected_change_id":store.subject_permissions(&subject).unwrap().unwrap().change_id,"execution":execution,"delegation":permissions.lock().unwrap().clone()})))).await;
|
||||||
assert_eq!(status, StatusCode::OK, "{assigned}");
|
assert_eq!(status, StatusCode::OK, "{assigned}");
|
||||||
let before = store.subject_permissions(&subject).unwrap().unwrap();
|
let before = store.subject_permissions(&subject).unwrap().unwrap();
|
||||||
if repair {
|
if repair {
|
||||||
|
|||||||
@@ -826,7 +826,13 @@ async fn create_and_restore_session_callbacks_finish_while_deletion_start_waits(
|
|||||||
&subject.id,
|
&subject.id,
|
||||||
&server_api::authorization::SubjectPermissionsUpdate {
|
&server_api::authorization::SubjectPermissionsUpdate {
|
||||||
operation_id: "session-callback-grant".into(),
|
operation_id: "session-callback-grant".into(),
|
||||||
expected_change_id: None,
|
expected_change_id: Some(
|
||||||
|
subjektiv
|
||||||
|
.subject_permissions(&subject.id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.change_id,
|
||||||
|
),
|
||||||
execution: vec![server_api::authorization::ScopedPermission {
|
execution: vec![server_api::authorization::ScopedPermission {
|
||||||
operation: "session.record".into(),
|
operation: "session.record".into(),
|
||||||
resource: server_api::authorization::PermissionResource::Subject {
|
resource: server_api::authorization::PermissionResource::Subject {
|
||||||
|
|||||||
@@ -178,7 +178,11 @@ impl Fixture {
|
|||||||
Self::request(
|
Self::request(
|
||||||
"POST",
|
"POST",
|
||||||
SUBJECTS,
|
SUBJECTS,
|
||||||
serde_json::json!({ "role": role, "behavior_md": "" }),
|
serde_json::json!({
|
||||||
|
"operation_id": format!("create-{role}"),
|
||||||
|
"role": role,
|
||||||
|
"behavior_md": ""
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1118,6 +1122,7 @@ async fn subjektiv_runtime_waits_leave_db_and_control_free_and_cancel_retains_th
|
|||||||
workspace_id: WORKSPACE.into(),
|
workspace_id: WORKSPACE.into(),
|
||||||
}),
|
}),
|
||||||
Json(server_api::SubjektivSubjectCreateRequest {
|
Json(server_api::SubjektivSubjectCreateRequest {
|
||||||
|
operation_id: format!("create-network-subject-{kind}"),
|
||||||
role: "network subject".into(),
|
role: "network subject".into(),
|
||||||
behavior_md: String::new(),
|
behavior_md: String::new(),
|
||||||
}),
|
}),
|
||||||
@@ -1136,7 +1141,14 @@ async fn subjektiv_runtime_waits_leave_db_and_control_free_and_cancel_retains_th
|
|||||||
&subject.id,
|
&subject.id,
|
||||||
&server_api::authorization::SubjectPermissionsUpdate {
|
&server_api::authorization::SubjectPermissionsUpdate {
|
||||||
operation_id: format!("session-wait-grant-{kind}"),
|
operation_id: format!("session-wait-grant-{kind}"),
|
||||||
expected_change_id: None,
|
expected_change_id: Some(
|
||||||
|
open_subjektiv_store(&fixture.api)
|
||||||
|
.unwrap()
|
||||||
|
.subject_permissions(&subject.id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.change_id,
|
||||||
|
),
|
||||||
execution: vec![server_api::authorization::ScopedPermission {
|
execution: vec![server_api::authorization::ScopedPermission {
|
||||||
operation: operation.into(),
|
operation: operation.into(),
|
||||||
resource: server_api::authorization::PermissionResource::Subject {
|
resource: server_api::authorization::PermissionResource::Subject {
|
||||||
|
|||||||
@@ -15451,11 +15451,16 @@
|
|||||||
"default": "",
|
"default": "",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"operation_id": {
|
||||||
|
"description": "Stable across exact retries; changed input with the same ID conflicts.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"role": {
|
"role": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"operation_id",
|
||||||
"role"
|
"role"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -22720,7 +22725,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224"
|
"x-yoi-source-digest": "sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
enabled = true;
|
enabled = true;
|
||||||
chat = { enabled = true; };
|
chat = { enabled = true; };
|
||||||
consolidation_tools = false;
|
consolidation_tools = false;
|
||||||
extraction = { enabled = false; };
|
extraction = { enabled = true; threshold = 50000; };
|
||||||
};
|
};
|
||||||
web = { enabled = false; };
|
web = { enabled = false; };
|
||||||
image = { enabled = false; };
|
image = { enabled = false; };
|
||||||
@@ -54,6 +54,8 @@
|
|||||||
{ tool = "SubjektivMemoryQuery"; pattern = "*"; action = "allow"; },
|
{ tool = "SubjektivMemoryQuery"; pattern = "*"; action = "allow"; },
|
||||||
{ tool = "SubjektivMemoryRead"; pattern = "*"; action = "allow"; },
|
{ tool = "SubjektivMemoryRead"; pattern = "*"; action = "allow"; },
|
||||||
{ tool = "SubjektivMemoryListChanges"; pattern = "*"; action = "allow"; },
|
{ tool = "SubjektivMemoryListChanges"; pattern = "*"; action = "allow"; },
|
||||||
|
{ tool = "SubjektivMemoryRemember"; pattern = "*"; action = "allow"; },
|
||||||
|
{ tool = "SubjektivMemoryProposeChange"; pattern = "*"; action = "allow"; },
|
||||||
{ tool = "QueryObjective"; pattern = "*"; action = "allow"; },
|
{ tool = "QueryObjective"; pattern = "*"; action = "allow"; },
|
||||||
{ tool = "ShowObjective"; pattern = "*"; action = "allow"; },
|
{ tool = "ShowObjective"; pattern = "*"; action = "allow"; },
|
||||||
{ tool = "QueryTicket"; pattern = "*"; action = "allow"; },
|
{ tool = "QueryTicket"; pattern = "*"; action = "allow"; },
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ const memoryPageCursor = "fixture-memory-page-2";
|
|||||||
const changePageCursor = "fixture-change-page-2";
|
const changePageCursor = "fixture-change-page-2";
|
||||||
const createdSubjectIds = new Set<string>();
|
const createdSubjectIds = new Set<string>();
|
||||||
const subjectCreateRequests: unknown[] = [];
|
const subjectCreateRequests: unknown[] = [];
|
||||||
|
type SubjectCreateInput = { operation_id: string; role: string; behavior_md: string };
|
||||||
|
const subjectCreateReceipts = new Map<
|
||||||
|
string,
|
||||||
|
{ request: SubjectCreateInput; subject: ReturnType<typeof subject> }
|
||||||
|
>();
|
||||||
|
const subjectCreateOutcomes: { outcome: string; subject_id?: string }[] = [];
|
||||||
let createdSubjectCount = 0;
|
let createdSubjectCount = 0;
|
||||||
|
|
||||||
const paragraphs = Array.from(
|
const paragraphs = Array.from(
|
||||||
@@ -390,6 +396,21 @@ async function staticResponse(pathname: string): Promise<Response> {
|
|||||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.pathname === "/health") return new Response("ok");
|
if (url.pathname === "/health") return new Response("ok");
|
||||||
|
// 合成HTTPの閲覧主体。実Backendの認証や権限付与の証拠にはしない。
|
||||||
|
if (url.pathname === "/api/auth/whoami") {
|
||||||
|
return json({
|
||||||
|
authentication_reference: "memory-review-fixture-authentication",
|
||||||
|
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||||
|
actor: {
|
||||||
|
user_id: "fixture-user",
|
||||||
|
account_id: "owner",
|
||||||
|
handle: "fixture",
|
||||||
|
display_name: "Synthetic Memory owner",
|
||||||
|
auth_method: "browser_session",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/hosting/access") return json({ is_admin: false });
|
||||||
if (permissionsMode) {
|
if (permissionsMode) {
|
||||||
const response = await permissionsFixture(request);
|
const response = await permissionsFixture(request);
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
@@ -397,6 +418,14 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
if (url.pathname === "/fixture/subject-create-requests") {
|
if (url.pathname === "/fixture/subject-create-requests") {
|
||||||
return json({ count: subjectCreateRequests.length, requests: subjectCreateRequests });
|
return json({ count: subjectCreateRequests.length, requests: subjectCreateRequests });
|
||||||
}
|
}
|
||||||
|
if (url.pathname === "/fixture/subject-create-evidence") {
|
||||||
|
return json({
|
||||||
|
authority: "synthetic-http-NOT-real-Backend",
|
||||||
|
requests: subjectCreateRequests,
|
||||||
|
outcomes: subjectCreateOutcomes,
|
||||||
|
created: subjects.filter((item) => createdSubjectIds.has(item.id)),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (url.pathname === "/api/workspaces") {
|
if (url.pathname === "/api/workspaces") {
|
||||||
return json([{
|
return json([{
|
||||||
workspace_id: workspaceId,
|
workspace_id: workspaceId,
|
||||||
@@ -488,22 +517,47 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
|||||||
if (url.pathname === subjectsPath) {
|
if (url.pathname === subjectsPath) {
|
||||||
if (request.method === "POST") {
|
if (request.method === "POST") {
|
||||||
const payload = await request.json().catch(() => null) as {
|
const payload = await request.json().catch(() => null) as {
|
||||||
|
operation_id?: unknown;
|
||||||
role?: unknown;
|
role?: unknown;
|
||||||
behavior_md?: unknown;
|
behavior_md?: unknown;
|
||||||
} | null;
|
} | null;
|
||||||
subjectCreateRequests.push(payload);
|
subjectCreateRequests.push(payload);
|
||||||
if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) {
|
const reject = (message: string, status = 400) => {
|
||||||
return json({ error: "Bad Request", message: "subject role must not be empty" }, 400);
|
subjectCreateOutcomes.push({ outcome: `rejected-${status}` });
|
||||||
|
return json({ error: status === 409 ? "Conflict" : "Bad Request", message }, status);
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
!payload || typeof payload.operation_id !== "string" ||
|
||||||
|
payload.operation_id.trim().length === 0
|
||||||
|
) return reject("subject create operation_id is required");
|
||||||
|
if (typeof payload.role !== "string" || payload.role.trim().length === 0) {
|
||||||
|
return reject("subject role must not be empty");
|
||||||
}
|
}
|
||||||
if (payload.role === "Permission denied") {
|
if (payload.role === "Permission denied") {
|
||||||
|
subjectCreateOutcomes.push({ outcome: "rejected-403" });
|
||||||
return json({ error: "Forbidden", message: "workspace permission denied" }, 403);
|
return json({ error: "Forbidden", message: "workspace permission denied" }, 403);
|
||||||
}
|
}
|
||||||
if (typeof payload.behavior_md !== "string") {
|
if (typeof payload.behavior_md !== "string") {
|
||||||
return json({ error: "Bad Request", message: "subject behavior must be a string" }, 400);
|
return reject("subject behavior must be a string");
|
||||||
|
}
|
||||||
|
const input: SubjectCreateInput = {
|
||||||
|
operation_id: payload.operation_id,
|
||||||
|
role: payload.role,
|
||||||
|
behavior_md: payload.behavior_md,
|
||||||
|
};
|
||||||
|
const receipt = subjectCreateReceipts.get(input.operation_id);
|
||||||
|
if (receipt) {
|
||||||
|
if (
|
||||||
|
receipt.request.role !== input.role || receipt.request.behavior_md !== input.behavior_md
|
||||||
|
) return reject("operation_id already used with different input", 409);
|
||||||
|
subjectCreateOutcomes.push({ outcome: "replay", subject_id: receipt.subject.id });
|
||||||
|
return json(receipt.subject, 201);
|
||||||
}
|
}
|
||||||
createdSubjectCount += 1;
|
createdSubjectCount += 1;
|
||||||
const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`;
|
const id = `created-subject-${String(createdSubjectCount).padStart(4, "0")}`;
|
||||||
const created = subject(id, payload.role, "active", "fingerprint-empty", payload.behavior_md);
|
const created = subject(id, input.role, "active", "fingerprint-empty", input.behavior_md);
|
||||||
|
subjectCreateReceipts.set(input.operation_id, { request: input, subject: created });
|
||||||
|
subjectCreateOutcomes.push({ outcome: "created", subject_id: id });
|
||||||
createdSubjectIds.add(id);
|
createdSubjectIds.add(id);
|
||||||
subjects.unshift(created);
|
subjects.unshift(created);
|
||||||
return json(created, 201);
|
return json(created, 201);
|
||||||
|
|||||||
@@ -482,11 +482,25 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
|
|||||||
]);
|
]);
|
||||||
const createEvidence = await (await fetch(`${baseUrl}/fixture/subject-create-requests`))
|
const createEvidence = await (await fetch(`${baseUrl}/fixture/subject-create-requests`))
|
||||||
.json();
|
.json();
|
||||||
|
const operationIds = createEvidence.requests.map((request: { operation_id: string }) =>
|
||||||
|
request.operation_id
|
||||||
|
);
|
||||||
|
for (const operationId of operationIds) {
|
||||||
|
assert(
|
||||||
|
typeof operationId === "string" &&
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||||
|
operationId,
|
||||||
|
),
|
||||||
|
"Each new creation attempt must carry a UUID operation_id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(operationIds[0] !== operationIds[1], "A resolved rejection must allow a new attempt");
|
||||||
assertEquals(createEvidence, {
|
assertEquals(createEvidence, {
|
||||||
count: 2,
|
count: 2,
|
||||||
requests: [
|
requests: [
|
||||||
{ role: "Permission denied", behavior_md: "" },
|
{ operation_id: operationIds[0], role: "Permission denied", behavior_md: "" },
|
||||||
{
|
{
|
||||||
|
operation_id: operationIds[1],
|
||||||
role: " Release steward ",
|
role: " Release steward ",
|
||||||
behavior_md: "Challenge unsupported assumptions before release.",
|
behavior_md: "Challenge unsupported assumptions before release.",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
#!/usr/bin/env -S deno run -A
|
||||||
|
/** 実Webの作成・再送を合成HTTP境界で検証する。実Backendの保存・認可は証明しない。 */
|
||||||
|
import { assert, assertEquals } from "@std/assert";
|
||||||
|
import { dirname, fromFileUrl, join, relative, resolve } from "@std/path";
|
||||||
|
import { chromium, type Page } from "playwright";
|
||||||
|
import { sha256File } from "../src/artifacts.ts";
|
||||||
|
import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts";
|
||||||
|
import { assertNoPageWideOverflow, freePort } from "./memory_document_harness.ts";
|
||||||
|
|
||||||
|
const root = resolve(dirname(fromFileUrl(import.meta.url)), "../../..");
|
||||||
|
const [buildArg, runId, modeArg] = Deno.args;
|
||||||
|
const mode = modeArg ?? (runId?.startsWith("before") ? "before" : "after");
|
||||||
|
if (
|
||||||
|
!buildArg || !runId || !/^[a-zA-Z0-9._-]+$/.test(runId) || !["before", "after"].includes(mode)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"usage: subject_creation_review.ts <actual-build-path> <unique-run-id> [before|after]",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const buildRoot = resolve(buildArg);
|
||||||
|
for (const name of ["index.html", "_app/immutable"]) {
|
||||||
|
await Deno.stat(join(buildRoot, name)).catch(() => {
|
||||||
|
throw new Error(`Actual Web build missing: ${join(buildRoot, name)}; parent must supply it.`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const output = join(root, "target/web-ux/t806", runId);
|
||||||
|
await Deno.mkdir(dirname(output), { recursive: true });
|
||||||
|
await Deno.mkdir(output); // 既存runの画像や判断を上書きしない。
|
||||||
|
|
||||||
|
async function git(args: string[]) {
|
||||||
|
const result = await new Deno.Command("git", { args, cwd: root, stdout: "piped" }).output();
|
||||||
|
return result.success ? new TextDecoder().decode(result.stdout).trim() : null;
|
||||||
|
}
|
||||||
|
async function inventory(path: string): Promise<{ path: string; sha256: string }[]> {
|
||||||
|
const files: { path: string; sha256: string }[] = [];
|
||||||
|
for await (const entry of Deno.readDir(path)) {
|
||||||
|
const child = join(path, entry.name);
|
||||||
|
if (entry.isDirectory) files.push(...await inventory(child));
|
||||||
|
else if (entry.isFile) {
|
||||||
|
files.push({ path: relative(buildRoot, child), sha256: await sha256File(child) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files.sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
}
|
||||||
|
const buildFiles = await inventory(buildRoot);
|
||||||
|
const sourceStatus = await git(["status", "--porcelain"]);
|
||||||
|
const manifest = {
|
||||||
|
evidence: "actual-Web-with-synthetic-HTTP-NOT-real-Backend",
|
||||||
|
mode,
|
||||||
|
buildRoot,
|
||||||
|
buildFiles,
|
||||||
|
source: {
|
||||||
|
commitSha: await git(["rev-parse", "HEAD"]),
|
||||||
|
dirty: sourceStatus === null ? null : sourceStatus.length > 0,
|
||||||
|
status: sourceStatus,
|
||||||
|
captureSources: await Promise.all([
|
||||||
|
"tools/web-ux/browser-tests/subject_creation_review.ts",
|
||||||
|
"tools/web-ux/browser-tests/memory_document_fixture_server.ts",
|
||||||
|
"tools/web-ux/browser-tests/memory_document_harness.ts",
|
||||||
|
].map(async (path) => ({ path, sha256: await sha256File(join(root, path)) }))),
|
||||||
|
buildProvenance:
|
||||||
|
"parent-supplied build; checkout metadata is capture-time, not a build attestation",
|
||||||
|
},
|
||||||
|
visualVerdict: "UNREVIEWED — parent must ViewImage and inspect review-context.json",
|
||||||
|
status: "running",
|
||||||
|
browser: { name: "chromium", version: "" },
|
||||||
|
expectedFaults: [
|
||||||
|
"Per width/theme: first POST reply aborted only after synthetic persistence (HTTP 201)",
|
||||||
|
...(mode === "after"
|
||||||
|
? ["Per width/theme: second POST intentionally returns HTTP 403 before fixture"]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
baselineLimit: mode === "before"
|
||||||
|
? "Capture only. Missing operation_id is added ONLY to fixture transport for synthetic persistence; original browser body is recorded. No replay or disabled-input success asserted."
|
||||||
|
: null,
|
||||||
|
captures: [] as unknown[],
|
||||||
|
scenarios: [] as unknown[],
|
||||||
|
failures: [] as string[],
|
||||||
|
};
|
||||||
|
async function saveManifest() {
|
||||||
|
await Deno.writeTextFile(join(output, "review-context.json"), JSON.stringify(manifest, null, 2));
|
||||||
|
}
|
||||||
|
type Fault = {
|
||||||
|
kind: string;
|
||||||
|
message: string;
|
||||||
|
url?: string;
|
||||||
|
method?: string;
|
||||||
|
expected: boolean;
|
||||||
|
};
|
||||||
|
function observe(page: Page, createUrl: string) {
|
||||||
|
const faults: Fault[] = [];
|
||||||
|
let documentStatus: number | null = null;
|
||||||
|
let allowAbort = false;
|
||||||
|
let allow403 = false;
|
||||||
|
page.on(
|
||||||
|
"pageerror",
|
||||||
|
(e) => faults.push({ kind: "pageerror", message: e.stack ?? e.message, expected: false }),
|
||||||
|
);
|
||||||
|
page.on("console", (e) => {
|
||||||
|
if (!["warning", "error"].includes(e.type())) return;
|
||||||
|
const url = e.location().url;
|
||||||
|
// 意図した同一endpointのネットワーク診断だけを許容し、pageerrorは常に失敗にする。
|
||||||
|
const expected = url === createUrl && (
|
||||||
|
(allowAbort && /Failed to load resource: net::ERR_FAILED/.test(e.text())) ||
|
||||||
|
(allow403 && /Failed to load resource:.*403/.test(e.text()))
|
||||||
|
);
|
||||||
|
faults.push({ kind: `console-${e.type()}`, message: e.text(), url, expected });
|
||||||
|
});
|
||||||
|
page.on("requestfailed", (r) =>
|
||||||
|
faults.push({
|
||||||
|
kind: "requestfailed",
|
||||||
|
message: r.failure()?.errorText ?? "request failed",
|
||||||
|
url: r.url(),
|
||||||
|
method: r.method(),
|
||||||
|
expected: allowAbort && r.url() === createUrl && r.method() === "POST" &&
|
||||||
|
r.failure()?.errorText === "net::ERR_FAILED",
|
||||||
|
}));
|
||||||
|
page.on("response", (r) => {
|
||||||
|
if (r.request().isNavigationRequest()) documentStatus = r.status();
|
||||||
|
if (r.status() >= 400) {
|
||||||
|
faults.push({
|
||||||
|
kind: "http",
|
||||||
|
message: `HTTP ${r.status()}`,
|
||||||
|
url: r.url(),
|
||||||
|
method: r.request().method(),
|
||||||
|
expected: allow403 && r.url() === createUrl && r.request().method() === "POST" &&
|
||||||
|
r.status() === 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
faults,
|
||||||
|
documentStatus: () => documentStatus,
|
||||||
|
expectAbort: () => {
|
||||||
|
allowAbort = true;
|
||||||
|
},
|
||||||
|
expect403: () => {
|
||||||
|
allow403 = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function controls(page: Page) {
|
||||||
|
const role = page.getByRole("textbox", { name: "Role", exact: true });
|
||||||
|
const behavior = page.getByRole("textbox", { name: /Behavior/ });
|
||||||
|
if (!await role.count()) return null;
|
||||||
|
return {
|
||||||
|
role: await role.inputValue(),
|
||||||
|
behavior: await behavior.inputValue(),
|
||||||
|
roleDisabled: await role.isDisabled(),
|
||||||
|
behaviorDisabled: await behavior.isDisabled(),
|
||||||
|
submitting: await page.locator("#subject-create-panel").getAttribute("aria-busy"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function capture(page: Page, observation: ReturnType<typeof observe>, id: string) {
|
||||||
|
const image = join(output, `${id}.png`);
|
||||||
|
const snapshot = join(output, `${id}.aria.txt`);
|
||||||
|
await page.screenshot({ path: image });
|
||||||
|
await Deno.writeTextFile(snapshot, await page.locator("body").ariaSnapshot());
|
||||||
|
manifest.captures.push({
|
||||||
|
id,
|
||||||
|
route: new URL(page.url()).pathname,
|
||||||
|
viewport: page.viewportSize(),
|
||||||
|
theme: await page.evaluate(() =>
|
||||||
|
(globalThis as any).matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
|
||||||
|
),
|
||||||
|
documentStatus: observation.documentStatus(),
|
||||||
|
visibleAlerts: await page.getByRole("alert").allTextContents(),
|
||||||
|
controls: await controls(page),
|
||||||
|
focus: await page.locator(":focus").evaluateAll((elements) =>
|
||||||
|
elements.map((e) => ({ tag: e.tagName, text: e.textContent, id: e.id }))
|
||||||
|
),
|
||||||
|
mainBounds: await page.locator("main").evaluate((e) => ({
|
||||||
|
width: e.clientWidth,
|
||||||
|
scrollTop: e.scrollTop,
|
||||||
|
})),
|
||||||
|
image,
|
||||||
|
imageSha256: await sha256File(image),
|
||||||
|
accessibilitySnapshot: snapshot,
|
||||||
|
accessibilitySha256: await sha256File(snapshot),
|
||||||
|
faults: [...observation.faults],
|
||||||
|
unexpectedFaults: observation.faults.filter((f) => !f.expected),
|
||||||
|
reviewed: false,
|
||||||
|
});
|
||||||
|
await saveManifest();
|
||||||
|
await assertNoPageWideOverflow(page);
|
||||||
|
assertEquals(observation.documentStatus(), 200, `${id}: document must render successfully`);
|
||||||
|
}
|
||||||
|
const roleText = " Release steward — evidence and uncertainty ";
|
||||||
|
const behaviorText =
|
||||||
|
"Challenge unsupported assumptions before release.\nAsk before irreversible actions; preserve the exact pending request.\nEvidence: https://example.com/review/subject-creation";
|
||||||
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
async function assertPending(page: Page) {
|
||||||
|
assertEquals(await controls(page), {
|
||||||
|
role: roleText,
|
||||||
|
behavior: behaviorText,
|
||||||
|
roleDisabled: true,
|
||||||
|
behaviorDisabled: true,
|
||||||
|
submitting: "false",
|
||||||
|
});
|
||||||
|
assert(await page.getByRole("button", { name: "Create Subject", exact: true }).isEnabled());
|
||||||
|
}
|
||||||
|
async function reopen(page: Page) {
|
||||||
|
await page.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
|
await page.locator("#subject-create-panel").waitFor({ state: "detached" });
|
||||||
|
assert(
|
||||||
|
await page.getByRole("button", { name: "New Subject", exact: true }).evaluate((e) =>
|
||||||
|
e === e.ownerDocument.activeElement
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "New Subject", exact: true }).click();
|
||||||
|
await page.locator('#subject-create-panel[aria-busy="false"]').waitFor();
|
||||||
|
}
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
manifest.browser.version = browser.version();
|
||||||
|
try {
|
||||||
|
for (const theme of ["light", "dark"] as const) {
|
||||||
|
for (const width of [1440, 768, 390]) {
|
||||||
|
const prefix = `${theme}-${width}`;
|
||||||
|
const port = await freePort();
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
const createUrl = `${base}/api/w/memory-review/subjektiv/subjects`;
|
||||||
|
const owned = await startOwnedProcesses(
|
||||||
|
[{
|
||||||
|
id: `subject-create-${prefix}`,
|
||||||
|
command: Deno.execPath(),
|
||||||
|
cwd: join(root, "tools/web-ux"),
|
||||||
|
args: [
|
||||||
|
"run",
|
||||||
|
"-A",
|
||||||
|
join(root, "tools/web-ux/browser-tests/memory_document_fixture_server.ts"),
|
||||||
|
String(port),
|
||||||
|
buildRoot,
|
||||||
|
"3",
|
||||||
|
],
|
||||||
|
readyUrl: `${base}/health`,
|
||||||
|
}],
|
||||||
|
join(output, "scenario.json"),
|
||||||
|
join(output, `logs-${prefix}`),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width, height: width === 390 ? 844 : 900 },
|
||||||
|
colorScheme: theme,
|
||||||
|
reducedMotion: "reduce",
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
page.setDefaultTimeout(15_000);
|
||||||
|
const observation = observe(page, createUrl);
|
||||||
|
const requests: unknown[] = [];
|
||||||
|
const transportOverrides: unknown[] = [];
|
||||||
|
const scenario = {
|
||||||
|
prefix,
|
||||||
|
requests,
|
||||||
|
transportOverrides,
|
||||||
|
syntheticEvidence: null as unknown,
|
||||||
|
faults: observation.faults,
|
||||||
|
};
|
||||||
|
manifest.scenarios.push(scenario);
|
||||||
|
try {
|
||||||
|
await page.goto(`${base}/w/memory-review/memory`);
|
||||||
|
await page.getByRole("heading", { name: "Subjects", level: 1, exact: true }).waitFor();
|
||||||
|
await page.getByRole("button", { name: "New Subject", exact: true }).click();
|
||||||
|
const role = page.getByRole("textbox", { name: "Role", exact: true });
|
||||||
|
const behavior = page.getByRole("textbox", { name: /Behavior/ });
|
||||||
|
await role.fill(roleText);
|
||||||
|
await behavior.fill(behaviorText);
|
||||||
|
await role.focus();
|
||||||
|
await capture(page, observation, `${prefix}-normal-form`);
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
assert(await behavior.evaluate((e) => e === e.ownerDocument.activeElement));
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
assert(
|
||||||
|
await page.getByRole("button", { name: "Create Subject", exact: true }).evaluate((e) =>
|
||||||
|
e === e.ownerDocument.activeElement
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await capture(page, observation, `${prefix}-normal-actions-keyboard`);
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
await page.route(createUrl, async (route) => {
|
||||||
|
if (route.request().method() !== "POST") return await route.continue();
|
||||||
|
attempt += 1;
|
||||||
|
const body = route.request().postDataJSON();
|
||||||
|
requests.push(body);
|
||||||
|
if (attempt === 1) {
|
||||||
|
observation.expectAbort();
|
||||||
|
// 旧buildの比較撮影だけ、fixtureへ送るコピーにoperation_idを足す。
|
||||||
|
// 実browser要求はrequestsに未変更で保存し、旧UIの契約成功とは扱わない。
|
||||||
|
const bridged = mode === "before" && !body.operation_id;
|
||||||
|
const transport = bridged ? { ...body, operation_id: crypto.randomUUID() } : body;
|
||||||
|
if (bridged) {
|
||||||
|
transportOverrides.push({
|
||||||
|
attempt,
|
||||||
|
reason: "baseline-only fixture bridge",
|
||||||
|
body: transport,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const persisted = await route.fetch({ postData: JSON.stringify(transport) });
|
||||||
|
assertEquals(
|
||||||
|
persisted.status(),
|
||||||
|
201,
|
||||||
|
"Fault must follow synthetic persistence, not validation rejection",
|
||||||
|
);
|
||||||
|
await persisted.dispose();
|
||||||
|
await route.abort("failed");
|
||||||
|
} else if (attempt === 2 && mode === "after") {
|
||||||
|
observation.expect403();
|
||||||
|
await route.fulfill({
|
||||||
|
status: 403,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
error: "Forbidden",
|
||||||
|
message: "synthetic later permission rejection",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} else await route.continue();
|
||||||
|
});
|
||||||
|
await page.getByRole("button", { name: "Create Subject", exact: true }).click();
|
||||||
|
await page.getByRole("alert").filter({ hasText: /unknown|may have been created/i })
|
||||||
|
.waitFor();
|
||||||
|
await page.locator('#subject-create-panel[aria-busy="false"]').waitFor();
|
||||||
|
await page.getByRole("alert").scrollIntoViewIfNeeded();
|
||||||
|
await capture(page, observation, `${prefix}-unknown-after-persisted-response-loss`);
|
||||||
|
await role.scrollIntoViewIfNeeded();
|
||||||
|
await capture(page, observation, `${prefix}-unknown-fields`);
|
||||||
|
if (mode === "after") {
|
||||||
|
await assertPending(page);
|
||||||
|
const first = requests[0] as { operation_id: string; role: string; behavior_md: string };
|
||||||
|
assert(uuid.test(first.operation_id), "UI must generate a UUID per creation attempt");
|
||||||
|
assertEquals(first, {
|
||||||
|
operation_id: first.operation_id,
|
||||||
|
role: roleText,
|
||||||
|
behavior_md: behaviorText,
|
||||||
|
});
|
||||||
|
await reopen(page);
|
||||||
|
await assertPending(page);
|
||||||
|
await page.getByRole("alert").scrollIntoViewIfNeeded();
|
||||||
|
await capture(page, observation, `${prefix}-cancel-reopen-pending`);
|
||||||
|
await page.getByRole("button", { name: "Create Subject", exact: true }).click();
|
||||||
|
await page.getByRole("alert").filter({ hasText: /do not have permission/ }).waitFor();
|
||||||
|
await page.locator('#subject-create-panel[aria-busy="false"]').waitFor();
|
||||||
|
assertEquals(
|
||||||
|
requests[1],
|
||||||
|
first,
|
||||||
|
"Retry after cancel/reopen must retain the complete request",
|
||||||
|
);
|
||||||
|
await assertPending(page);
|
||||||
|
await page.getByRole("alert").scrollIntoViewIfNeeded();
|
||||||
|
await capture(page, observation, `${prefix}-later-403-still-pending`);
|
||||||
|
await role.scrollIntoViewIfNeeded();
|
||||||
|
await capture(page, observation, `${prefix}-later-403-disabled-fields`);
|
||||||
|
await reopen(page);
|
||||||
|
await assertPending(page);
|
||||||
|
await page.getByRole("alert").scrollIntoViewIfNeeded();
|
||||||
|
await capture(page, observation, `${prefix}-later-403-reopened-pending`);
|
||||||
|
await page.getByRole("button", { name: "Create Subject", exact: true }).click();
|
||||||
|
await page.waitForURL("**/memory/created-subject-0001");
|
||||||
|
await page.getByRole("heading", { name: roleText.trim(), level: 1, exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assertEquals(
|
||||||
|
requests,
|
||||||
|
[first, first, first],
|
||||||
|
"All retries must use the same operation and full input",
|
||||||
|
);
|
||||||
|
const article = page.getByRole("article", { name: "User-managed Subject behavior" });
|
||||||
|
await article.waitFor();
|
||||||
|
assert((await article.textContent())?.includes("preserve the exact pending request."));
|
||||||
|
await page.getByText("No committed Memories yet.").waitFor();
|
||||||
|
await capture(page, observation, `${prefix}-recovered-actual-detail`);
|
||||||
|
}
|
||||||
|
scenario.syntheticEvidence = await (await fetch(`${base}/fixture/subject-create-evidence`))
|
||||||
|
.json();
|
||||||
|
const evidence = scenario.syntheticEvidence as {
|
||||||
|
created: { id: string; role: string; behavior_md: string }[];
|
||||||
|
requests: unknown[];
|
||||||
|
outcomes: unknown[];
|
||||||
|
};
|
||||||
|
assertEquals(evidence.created.length, 1, "Only one synthetic Subject may be created");
|
||||||
|
assertEquals(evidence.created[0].role, roleText);
|
||||||
|
assertEquals(evidence.created[0].behavior_md, behaviorText);
|
||||||
|
if (mode === "after") {
|
||||||
|
assertEquals(evidence.requests, [requests[0], requests[0]]);
|
||||||
|
assertEquals(evidence.outcomes, [
|
||||||
|
{ outcome: "created", subject_id: "created-subject-0001" },
|
||||||
|
{ outcome: "replay", subject_id: "created-subject-0001" },
|
||||||
|
]);
|
||||||
|
assertEquals(observation.faults.filter((f) => f.kind === "http" && f.expected).length, 1);
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
observation.faults.filter((f) => f.kind === "requestfailed" && f.expected).length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
observation.faults.filter((f) => !f.expected),
|
||||||
|
[],
|
||||||
|
`${prefix}: unexpected browser faults`,
|
||||||
|
);
|
||||||
|
await Deno.writeTextFile(
|
||||||
|
join(output, `${prefix}-synthetic-http-evidence.json`),
|
||||||
|
JSON.stringify(scenario, null, 2),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
manifest.failures.push(
|
||||||
|
`${prefix}: ${e instanceof Error ? e.stack ?? e.message : String(e)}`,
|
||||||
|
);
|
||||||
|
await capture(page, observation, `${prefix}-failure`).catch((captureError) =>
|
||||||
|
manifest.failures.push(`Failure capture: ${captureError}`)
|
||||||
|
);
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
scenario.syntheticEvidence = await fetch(`${base}/fixture/subject-create-evidence`)
|
||||||
|
.then((response) => response.json()).catch(() => scenario.syntheticEvidence);
|
||||||
|
await Deno.writeTextFile(
|
||||||
|
join(output, `${prefix}-synthetic-http-evidence.json`),
|
||||||
|
JSON.stringify(scenario, null, 2),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await context.close();
|
||||||
|
} finally {
|
||||||
|
await stopOwnedProcesses(owned);
|
||||||
|
await saveManifest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
await inventory(buildRoot),
|
||||||
|
buildFiles,
|
||||||
|
"Parent build changed during the capture matrix",
|
||||||
|
);
|
||||||
|
manifest.status = mode === "before"
|
||||||
|
? "baseline-capture-only-visual-unreviewed"
|
||||||
|
: "browser-assertions-passed-visual-unreviewed";
|
||||||
|
} catch (e) {
|
||||||
|
manifest.status = "failed";
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await saveManifest();
|
||||||
|
console.log(join(output, "review-context.json"));
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224)
|
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export type SubjectPermissionInterruption = { runtime_id: string, worker_id: str
|
|||||||
|
|
||||||
export type SubjektivSubjectState = "active" | "retired";
|
export type SubjektivSubjectState = "active" | "retired";
|
||||||
|
|
||||||
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
|
export type SubjektivSubjectCreateRequest = { operation_id: string, role: string, behavior_md?: string, };
|
||||||
|
|
||||||
export type SubjektivSubjectDeleteRequest = { operation_id: string, };
|
export type SubjektivSubjectDeleteRequest = { operation_id: string, };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224)
|
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224)
|
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224)
|
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224)
|
// Input contract: 0.1.0 (sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
@@ -117,6 +117,18 @@ export class SubjektivSubjectCreateError extends Error {
|
|||||||
export function validateSubjektivSubjectCreateRequest(
|
export function validateSubjektivSubjectCreateRequest(
|
||||||
request: SubjektivSubjectCreateRequest,
|
request: SubjektivSubjectCreateRequest,
|
||||||
): SubjektivSubjectCreateRequest {
|
): SubjektivSubjectCreateRequest {
|
||||||
|
if (
|
||||||
|
typeof request.operation_id !== "string" ||
|
||||||
|
request.operation_id.length === 0 ||
|
||||||
|
new TextEncoder().encode(request.operation_id).byteLength > 256 ||
|
||||||
|
request.operation_id.trim() !== request.operation_id ||
|
||||||
|
/[\p{Cc}*]/u.test(request.operation_id)
|
||||||
|
) {
|
||||||
|
throw new SubjektivSubjectCreateError(
|
||||||
|
"validation",
|
||||||
|
"Subject operation_id must be nonempty, at most 256 bytes, and contain no surrounding whitespace, control characters, or '*'.",
|
||||||
|
);
|
||||||
|
}
|
||||||
if (request.role.trim().length === 0) {
|
if (request.role.trim().length === 0) {
|
||||||
throw new SubjektivSubjectCreateError(
|
throw new SubjektivSubjectCreateError(
|
||||||
"validation",
|
"validation",
|
||||||
@@ -135,7 +147,11 @@ export function validateSubjektivSubjectCreateRequest(
|
|||||||
}
|
}
|
||||||
const behaviorMd = request.behavior_md ?? "";
|
const behaviorMd = request.behavior_md ?? "";
|
||||||
validateSubjectBehavior(behaviorMd);
|
validateSubjectBehavior(behaviorMd);
|
||||||
return { role: request.role, behavior_md: behaviorMd };
|
return {
|
||||||
|
operation_id: request.operation_id,
|
||||||
|
role: request.role,
|
||||||
|
behavior_md: behaviorMd,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateSubjectBehavior(behaviorMd: string): void {
|
function validateSubjectBehavior(behaviorMd: string): void {
|
||||||
@@ -224,6 +240,12 @@ export async function deleteSubjektivSubject(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 呼出元は意図した作成ごとにoperation_idを一度だけ採番し、結果不明なら
|
||||||
|
* 同じID・role・behavior_mdの要求全体を保持する。この関数は自動再試行しない。
|
||||||
|
* 厳密な再送は同じSubjectを返し、同じIDで入力を変えると409になる。
|
||||||
|
* 再送は権限の再付与や削除済みSubjectの復活ではない。
|
||||||
|
*/
|
||||||
export async function createSubjektivSubject(
|
export async function createSubjektivSubject(
|
||||||
fetchFn: typeof fetch,
|
fetchFn: typeof fetch,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -241,7 +263,7 @@ export async function createSubjektivSubject(
|
|||||||
} catch {
|
} catch {
|
||||||
throw new SubjektivSubjectCreateError(
|
throw new SubjektivSubjectCreateError(
|
||||||
"unknown_outcome",
|
"unknown_outcome",
|
||||||
"The request outcome is unknown. A Subject may have been created.",
|
"The request outcome is unknown. A Subject may have been created. Retry only the same operation_id and full request.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +278,7 @@ export async function createSubjektivSubject(
|
|||||||
}
|
}
|
||||||
throw new SubjektivSubjectCreateError(
|
throw new SubjektivSubjectCreateError(
|
||||||
"unknown_outcome",
|
"unknown_outcome",
|
||||||
"The request outcome is unknown. A Subject may have been created.",
|
"The request outcome is unknown. A Subject may have been created. Retry only the same operation_id and full request.",
|
||||||
response.status,
|
response.status,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -275,7 +297,7 @@ export async function createSubjektivSubject(
|
|||||||
} catch {
|
} catch {
|
||||||
throw new SubjektivSubjectCreateError(
|
throw new SubjektivSubjectCreateError(
|
||||||
"unknown_outcome",
|
"unknown_outcome",
|
||||||
"The Subject may have been created, but the response could not be confirmed.",
|
"The Subject may have been created, but the response could not be confirmed. Retry only the same operation_id and full request.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
createSubjektivSubject,
|
createSubjektivSubject,
|
||||||
SubjektivSubjectCreateError,
|
SubjektivSubjectCreateError,
|
||||||
} from '#lib/workspace/memory/api.ts';
|
} from '#lib/workspace/memory/api.ts';
|
||||||
import type { SubjektivSubjectResponse } from '#lib/generated/memory-api.ts';
|
import type { SubjektivSubjectCreateRequest, SubjektivSubjectResponse } from '#lib/generated/memory-api.ts';
|
||||||
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
|
import type { ResolvedPageData } from '#lib/workspace/navigation/deferred.ts';
|
||||||
import type { PageData as RouteData } from './$types';
|
import type { PageData as RouteData } from './$types';
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
let roleInvalid = $state(false);
|
let roleInvalid = $state(false);
|
||||||
let behaviorInvalid = $state(false);
|
let behaviorInvalid = $state(false);
|
||||||
let createOutcomeUnknown = $state(false);
|
let createOutcomeUnknown = $state(false);
|
||||||
|
let createRequest = $state<SubjektivSubjectCreateRequest | null>(null);
|
||||||
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
|
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
|
||||||
let createButton = $state<HTMLButtonElement>();
|
let createButton = $state<HTMLButtonElement>();
|
||||||
let roleInput = $state<HTMLInputElement>();
|
let roleInput = $state<HTMLInputElement>();
|
||||||
@@ -42,12 +43,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openCreateForm(): Promise<void> {
|
async function openCreateForm(): Promise<void> {
|
||||||
|
if (submitting || createExpanded) return;
|
||||||
createExpanded = true;
|
createExpanded = true;
|
||||||
|
if (!createOutcomeUnknown) {
|
||||||
createError = null;
|
createError = null;
|
||||||
roleInvalid = false;
|
roleInvalid = false;
|
||||||
behaviorInvalid = false;
|
behaviorInvalid = false;
|
||||||
createOutcomeUnknown = false;
|
|
||||||
createdSubject = null;
|
createdSubject = null;
|
||||||
|
}
|
||||||
await tick();
|
await tick();
|
||||||
roleInput?.focus();
|
roleInput?.focus();
|
||||||
}
|
}
|
||||||
@@ -55,13 +58,16 @@
|
|||||||
async function cancelCreate(): Promise<void> {
|
async function cancelCreate(): Promise<void> {
|
||||||
if (submitting) return;
|
if (submitting) return;
|
||||||
createExpanded = false;
|
createExpanded = false;
|
||||||
|
// 結果不明の要求は閉じても破棄せず、再度開いたときも厳密に再送する。
|
||||||
|
if (!createOutcomeUnknown) {
|
||||||
role = '';
|
role = '';
|
||||||
behaviorMd = '';
|
behaviorMd = '';
|
||||||
|
createRequest = null;
|
||||||
createError = null;
|
createError = null;
|
||||||
roleInvalid = false;
|
roleInvalid = false;
|
||||||
behaviorInvalid = false;
|
behaviorInvalid = false;
|
||||||
createOutcomeUnknown = false;
|
|
||||||
createdSubject = null;
|
createdSubject = null;
|
||||||
|
}
|
||||||
await tick();
|
await tick();
|
||||||
createButton?.focus();
|
createButton?.focus();
|
||||||
}
|
}
|
||||||
@@ -72,13 +78,15 @@
|
|||||||
createError = null;
|
createError = null;
|
||||||
roleInvalid = false;
|
roleInvalid = false;
|
||||||
behaviorInvalid = false;
|
behaviorInvalid = false;
|
||||||
createOutcomeUnknown = false;
|
|
||||||
try {
|
try {
|
||||||
const subject = await createSubjektivSubject(fetch, data.workspaceId, {
|
createRequest ??= {
|
||||||
|
operation_id: crypto.randomUUID(),
|
||||||
role,
|
role,
|
||||||
behavior_md: behaviorMd,
|
behavior_md: behaviorMd,
|
||||||
});
|
};
|
||||||
|
const subject = await createSubjektivSubject(fetch, data.workspaceId, createRequest);
|
||||||
createdSubject = subject;
|
createdSubject = subject;
|
||||||
|
createOutcomeUnknown = false;
|
||||||
try {
|
try {
|
||||||
await goto(subjectHref(subject.id));
|
await goto(subjectHref(subject.id));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -88,7 +96,8 @@
|
|||||||
if (error instanceof SubjektivSubjectCreateError) {
|
if (error instanceof SubjektivSubjectCreateError) {
|
||||||
roleInvalid = error.kind === 'validation' && error.message.startsWith('Subject role');
|
roleInvalid = error.kind === 'validation' && error.message.startsWith('Subject role');
|
||||||
behaviorInvalid = error.kind === 'validation' && error.message.startsWith('Subject behavior');
|
behaviorInvalid = error.kind === 'validation' && error.message.startsWith('Subject behavior');
|
||||||
createOutcomeUnknown = error.kind === 'unknown_outcome';
|
// 再送の拒否だけでは、先の結果不明な作成が未実行だったとは確認できない。
|
||||||
|
createOutcomeUnknown ||= error.kind === 'unknown_outcome';
|
||||||
createError = error.status === 401 || error.status === 403
|
createError = error.status === 401 || error.status === 403
|
||||||
? 'You do not have permission to create Subjects in this Workspace.'
|
? 'You do not have permission to create Subjects in this Workspace.'
|
||||||
: error.message;
|
: error.message;
|
||||||
@@ -102,6 +111,7 @@
|
|||||||
createError = 'The request outcome is unknown. A Subject may have been created.';
|
createError = 'The request outcome is unknown. A Subject may have been created.';
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!createOutcomeUnknown && !createdSubject) createRequest = null;
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,7 +171,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
required
|
required
|
||||||
disabled={submitting || createdSubject !== null}
|
disabled={submitting || createOutcomeUnknown || createdSubject !== null}
|
||||||
onkeydown={createFormKeydown}
|
onkeydown={createFormKeydown}
|
||||||
aria-invalid={roleInvalid ? 'true' : undefined}
|
aria-invalid={roleInvalid ? 'true' : undefined}
|
||||||
aria-describedby={createError && roleInvalid ? 'subject-role-help subject-create-error' : 'subject-role-help'}
|
aria-describedby={createError && roleInvalid ? 'subject-role-help subject-create-error' : 'subject-role-help'}
|
||||||
@@ -173,7 +183,7 @@
|
|||||||
bind:this={behaviorInput}
|
bind:this={behaviorInput}
|
||||||
bind:value={behaviorMd}
|
bind:value={behaviorMd}
|
||||||
rows="7"
|
rows="7"
|
||||||
disabled={submitting || createdSubject !== null}
|
disabled={submitting || createOutcomeUnknown || createdSubject !== null}
|
||||||
onkeydown={createFormKeydown}
|
onkeydown={createFormKeydown}
|
||||||
aria-invalid={behaviorInvalid ? 'true' : undefined}
|
aria-invalid={behaviorInvalid ? 'true' : undefined}
|
||||||
aria-describedby={createError && behaviorInvalid ? 'subject-behavior-help subject-create-error' : 'subject-behavior-help'}
|
aria-describedby={createError && behaviorInvalid ? 'subject-behavior-help subject-create-error' : 'subject-behavior-help'}
|
||||||
|
|||||||
@@ -175,9 +175,13 @@ test("creates one Subject with the exact role and navigates to its generated det
|
|||||||
];
|
];
|
||||||
expect(path).toBe("/api/w/workspace-1/subjektiv/subjects");
|
expect(path).toBe("/api/w/workspace-1/subjektiv/subjects");
|
||||||
expect(init.method).toBe("POST");
|
expect(init.method).toBe("POST");
|
||||||
expect(init.body).toBe(
|
const request = JSON.parse(init.body as string);
|
||||||
'{"role":" Review lead ","behavior_md":"Challenge unsupported assumptions."}',
|
expect(request).toEqual({
|
||||||
);
|
operation_id: expect.any(String),
|
||||||
|
role: " Review lead ",
|
||||||
|
behavior_md: "Challenge unsupported assumptions.",
|
||||||
|
});
|
||||||
|
expect(request.operation_id).not.toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps creation available for an empty list and restores focus on cancel", async () => {
|
test("keeps creation available for an empty list and restores focus on cancel", async () => {
|
||||||
|
|||||||
@@ -271,24 +271,25 @@ Deno.test("Subject list parser preserves the exact bounded response", () => {
|
|||||||
|
|
||||||
Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => {
|
Deno.test("Subject create request mirrors Backend role validation without rewriting input", () => {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }),
|
validateSubjektivSubjectCreateRequest({ operation_id: "create-attempt", role: " Release coordinator " }),
|
||||||
{ role: " Release coordinator ", behavior_md: "" },
|
{ operation_id: "create-attempt", role: " Release coordinator ", behavior_md: "" },
|
||||||
);
|
);
|
||||||
assertThrows(
|
assertThrows(
|
||||||
() => validateSubjektivSubjectCreateRequest({ role: " " }),
|
() => validateSubjektivSubjectCreateRequest({ operation_id: "create-attempt", role: " " }),
|
||||||
"must not be empty",
|
"must not be empty",
|
||||||
);
|
);
|
||||||
assertThrows(
|
assertThrows(
|
||||||
() => validateSubjektivSubjectCreateRequest({ role: "line\nbreak" }),
|
() => validateSubjektivSubjectCreateRequest({ operation_id: "create-attempt", role: "line\nbreak" }),
|
||||||
"control characters",
|
"control characters",
|
||||||
);
|
);
|
||||||
assertThrows(
|
assertThrows(
|
||||||
() => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }),
|
() => validateSubjektivSubjectCreateRequest({ operation_id: "create-attempt", role: "界".repeat(86) }),
|
||||||
"at most 256 bytes",
|
"at most 256 bytes",
|
||||||
);
|
);
|
||||||
assertThrows(
|
assertThrows(
|
||||||
() =>
|
() =>
|
||||||
validateSubjektivSubjectCreateRequest({
|
validateSubjektivSubjectCreateRequest({
|
||||||
|
operation_id: "create-attempt",
|
||||||
role: "Reviewer",
|
role: "Reviewer",
|
||||||
behavior_md: " ".repeat(3),
|
behavior_md: " ".repeat(3),
|
||||||
}),
|
}),
|
||||||
@@ -297,6 +298,7 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit
|
|||||||
assertThrows(
|
assertThrows(
|
||||||
() =>
|
() =>
|
||||||
validateSubjektivSubjectCreateRequest({
|
validateSubjektivSubjectCreateRequest({
|
||||||
|
operation_id: "create-attempt",
|
||||||
role: "Reviewer",
|
role: "Reviewer",
|
||||||
behavior_md: "x".repeat(16 * 1024 + 1),
|
behavior_md: "x".repeat(16 * 1024 + 1),
|
||||||
}),
|
}),
|
||||||
@@ -304,6 +306,35 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Subject create rejects invalid operation IDs before sending without rewriting them", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const fetchFn = (async () => {
|
||||||
|
calls += 1;
|
||||||
|
return Response.json(subject());
|
||||||
|
}) as typeof fetch;
|
||||||
|
for (const operationId of [
|
||||||
|
undefined, null, 12, "", " ", " leading", "trailing ", "create*",
|
||||||
|
"line\nbreak", "control\u007F", "x".repeat(257), "界".repeat(86),
|
||||||
|
]) {
|
||||||
|
const request = JSON.parse(JSON.stringify({
|
||||||
|
operation_id: operationId,
|
||||||
|
role: "Reviewer",
|
||||||
|
}));
|
||||||
|
await assertSubjectCreateRejects(
|
||||||
|
() => createSubjektivSubject(fetchFn, "workspace", request),
|
||||||
|
"validation",
|
||||||
|
"Subject operation_id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertEquals(calls, 0);
|
||||||
|
for (const operationId of [crypto.randomUUID(), "x".repeat(256), "界".repeat(85)]) {
|
||||||
|
assertEquals(
|
||||||
|
validateSubjektivSubjectCreateRequest({ operation_id: operationId, role: "Reviewer" }),
|
||||||
|
{ operation_id: operationId, role: "Reviewer", behavior_md: "" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Subject behavior update uses CAS and preserves exact text", async () => {
|
Deno.test("Subject behavior update uses CAS and preserves exact text", async () => {
|
||||||
const requests: Array<{ path: string; init?: RequestInit }> = [];
|
const requests: Array<{ path: string; init?: RequestInit }> = [];
|
||||||
const fetchFn = (async (path: string | URL | Request, init?: RequestInit) => {
|
const fetchFn = (async (path: string | URL | Request, init?: RequestInit) => {
|
||||||
@@ -363,6 +394,7 @@ Deno.test("Subject create client sends the exact typed body once and parses the
|
|||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
|
|
||||||
const created = await createSubjektivSubject(fetchFn, "workspace one", {
|
const created = await createSubjektivSubject(fetchFn, "workspace one", {
|
||||||
|
operation_id: "create-attempt",
|
||||||
role: " Release coordinator ",
|
role: " Release coordinator ",
|
||||||
behavior_md: "Stay concise.",
|
behavior_md: "Stay concise.",
|
||||||
});
|
});
|
||||||
@@ -376,7 +408,7 @@ Deno.test("Subject create client sends the exact typed body once and parses the
|
|||||||
assertEquals(requests[0].init?.method, "POST");
|
assertEquals(requests[0].init?.method, "POST");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
requests[0].init?.body,
|
requests[0].init?.body,
|
||||||
'{"role":" Release coordinator ","behavior_md":"Stay concise."}',
|
'{"operation_id":"create-attempt","role":" Release coordinator ","behavior_md":"Stay concise."}',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -396,6 +428,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
|
|||||||
await assertSubjectCreateRejects(
|
await assertSubjectCreateRejects(
|
||||||
() =>
|
() =>
|
||||||
createSubjektivSubject(rejectedFetch, "workspace-1", {
|
createSubjektivSubject(rejectedFetch, "workspace-1", {
|
||||||
|
operation_id: "create-attempt",
|
||||||
role: "Reviewer",
|
role: "Reviewer",
|
||||||
}),
|
}),
|
||||||
"rejected",
|
"rejected",
|
||||||
@@ -412,6 +445,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
|
|||||||
await assertSubjectCreateRejects(
|
await assertSubjectCreateRejects(
|
||||||
() =>
|
() =>
|
||||||
createSubjektivSubject(serverErrorFetch, "workspace-1", {
|
createSubjektivSubject(serverErrorFetch, "workspace-1", {
|
||||||
|
operation_id: "create-attempt",
|
||||||
role: "Reviewer",
|
role: "Reviewer",
|
||||||
}),
|
}),
|
||||||
"unknown_outcome",
|
"unknown_outcome",
|
||||||
@@ -428,6 +462,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
|
|||||||
await assertSubjectCreateRejects(
|
await assertSubjectCreateRejects(
|
||||||
() =>
|
() =>
|
||||||
createSubjektivSubject(mismatchFetch, "workspace-1", {
|
createSubjektivSubject(mismatchFetch, "workspace-1", {
|
||||||
|
operation_id: "create-attempt",
|
||||||
role: "Reviewer",
|
role: "Reviewer",
|
||||||
}),
|
}),
|
||||||
"unknown_outcome",
|
"unknown_outcome",
|
||||||
@@ -441,13 +476,65 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
|
|||||||
throw new TypeError("connection reset");
|
throw new TypeError("connection reset");
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
await assertSubjectCreateRejects(
|
await assertSubjectCreateRejects(
|
||||||
() => createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
|
() => createSubjektivSubject(unknownFetch, "workspace-1", { operation_id: "create-attempt", role: "Reviewer" }),
|
||||||
"unknown_outcome",
|
"unknown_outcome",
|
||||||
"may have been created",
|
"may have been created",
|
||||||
);
|
);
|
||||||
assertEquals(unknownCalls, 1);
|
assertEquals(unknownCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Subject create exact retry retains serialized operation ID and full input after unknown outcomes", async () => {
|
||||||
|
for (const failure of ["transport", "server", "malformed", "mismatched"] as const) {
|
||||||
|
const request = Object.freeze({
|
||||||
|
operation_id: crypto.randomUUID(),
|
||||||
|
role: " Release coordinator ",
|
||||||
|
behavior_md: "Stay concise.\nPreserve exact text.\n",
|
||||||
|
});
|
||||||
|
const bodies: unknown[] = [];
|
||||||
|
const created = { ...subject(), role: request.role, behavior_md: request.behavior_md };
|
||||||
|
const fetchFn = (async (_path: string | URL | Request, init?: RequestInit) => {
|
||||||
|
bodies.push(init?.body);
|
||||||
|
if (bodies.length === 1) {
|
||||||
|
if (failure === "transport") throw new TypeError("connection reset");
|
||||||
|
if (failure === "server") return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
||||||
|
if (failure === "malformed") return new Response("not JSON", { status: 201 });
|
||||||
|
return Response.json({ ...created, behavior_md: "Different input" }, { status: 201 });
|
||||||
|
}
|
||||||
|
return Response.json(created, { status: 201 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
await assertSubjectCreateRejects(
|
||||||
|
() => createSubjektivSubject(fetchFn, "workspace", request),
|
||||||
|
"unknown_outcome",
|
||||||
|
"Retry only the same operation_id and full request",
|
||||||
|
failure === "server" ? 500 : null,
|
||||||
|
);
|
||||||
|
assertEquals(bodies, [JSON.stringify(request)]);
|
||||||
|
assertEquals(await createSubjektivSubject(fetchFn, "workspace", request), created);
|
||||||
|
assertEquals(bodies, [JSON.stringify(request), JSON.stringify(request)]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Subject create conflict and deleted-Subject rejection never trigger a fresh operation", async () => {
|
||||||
|
for (const [status, message] of [
|
||||||
|
[409, "operation_id already used with different input"],
|
||||||
|
[404, "Subject was deleted"],
|
||||||
|
] as const) {
|
||||||
|
const request = { operation_id: crypto.randomUUID(), role: "Reviewer", behavior_md: "Changed input" };
|
||||||
|
const bodies: unknown[] = [];
|
||||||
|
const fetchFn = (async (_path: string | URL | Request, init?: RequestInit) => {
|
||||||
|
bodies.push(init?.body);
|
||||||
|
return Response.json({ message }, { status });
|
||||||
|
}) as typeof fetch;
|
||||||
|
await assertSubjectCreateRejects(
|
||||||
|
() => createSubjektivSubject(fetchFn, "workspace", request),
|
||||||
|
"rejected",
|
||||||
|
message,
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
assertEquals(bodies, [JSON.stringify(request)]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Deno.test("Resident surface parser accepts ready-empty and enforces snapshot invariants", () => {
|
Deno.test("Resident surface parser accepts ready-empty and enforces snapshot invariants", () => {
|
||||||
const readyEmpty = {
|
const readyEmpty = {
|
||||||
subject_id: "subject-1",
|
subject_id: "subject-1",
|
||||||
|
|||||||
Reference in New Issue
Block a user