Merge branch 'develop' of /home/hare/Projects/yoi into work/t-807-subject-lifecycle

This commit is contained in:
2026-10-31 13:08:48 +09:00
42 changed files with 1716 additions and 244 deletions
+3 -1
View File
@@ -1751,7 +1751,7 @@ mod tests {
assert!(feature.subjektiv.profile.enabled);
assert!(feature.subjektiv.profile.chat.enabled);
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.execution_enabled());
assert!(!feature.memory.profile.enabled);
@@ -1823,6 +1823,8 @@ mod tests {
"SubjektivMemoryQuery",
"SubjektivMemoryRead",
"SubjektivMemoryListChanges",
"SubjektivMemoryRemember",
"SubjektivMemoryProposeChange",
"QueryObjective",
"ShowObjective",
"QueryTicket",
+2
View File
@@ -55,6 +55,8 @@ use serde::{Deserialize, Serialize};
mod catalog;
pub use catalog::*;
mod subject_defaults;
pub use subject_defaults::*;
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()
}
+3 -1
View File
@@ -5686,6 +5686,8 @@ pub struct SubjektivSubjectDeleteResponse {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubjektivSubjectCreateRequest {
/// Stable across exact retries; changed input with the same ID conflicts.
pub operation_id: String,
pub role: String,
#[serde(default)]
pub behavior_md: String,
@@ -11315,7 +11317,7 @@ pub fn memory_api_typescript() -> String {
// `ts-rs` solely for this read-only projection.
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, };
+10 -1
View File
@@ -640,12 +640,21 @@ fn policy(
operation: &str,
expected: Option<&str>,
) -> 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
.set_subject_permissions(
subject,
&server_api::authorization::SubjectPermissionsUpdate {
operation_id: operation.into(),
expected_change_id: expected.map(str::to_owned),
expected_change_id: Some(expected),
execution: Vec::new(),
delegation: Vec::new(),
},
+36 -5
View File
@@ -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);
let fresh = store.create_subject(role()).unwrap().id;
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]
@@ -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() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let a = store.create_subject(role()).unwrap().id;
let b = store.create_subject(role()).unwrap().id;
let a = store
.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
.stage_candidate(candidate(&a, "a", store.workspace_id()))
.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()))
.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<_>,_>>()?;
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<_>,_>>()?;
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}");
}
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 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());
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();
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();
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());
}
@@ -35,9 +35,13 @@ impl Fixture {
))
.unwrap();
let store = SubjektivStore::open(&workspace, &registration).unwrap();
let subject = store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap();
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())
.unwrap()
};
let attr =
SubjectSessionAttribution::new(&subject.id, "runtime", "worker", "session").unwrap();
store.record_session_attribution(attr.clone()).unwrap();
+57 -21
View File
@@ -429,12 +429,16 @@ mod input_delivery {
))
.unwrap();
let store = SubjektivStore::open(&scope, &registration).unwrap();
let subject = store
.create_subject_with_behavior(
SubjectRole::new("preserved").unwrap(),
"Existing behavior".into(),
)
.unwrap();
let subject = if baseline.last().unwrap().version() < 12 {
pre_permissions_subject_fixture(&store, "preserved", "Existing behavior")
} else {
store
.create_subject_with_behavior(
SubjectRole::new("preserved").unwrap(),
"Existing behavior".into(),
)
.unwrap()
};
drop(store);
drop(scope);
manager.shutdown().unwrap();
@@ -1591,6 +1595,42 @@ use sha2::{Digest, Sha256};
use std::sync::{Arc, Barrier};
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]
fn explicit_erasure_releases_only_after_all_body_sessions_including_unadmitted_history_are_erased()
{
@@ -3133,16 +3173,17 @@ impl Fixture {
SubjektivStore::register(&workspace).unwrap()
};
let store = SubjektivStore::open(&workspace, &registration).unwrap();
let subject = store
.create_subject_with_behavior(
SubjectRole::new("companion").unwrap(),
if upgrade {
"Preserve existing Subject behavior".into()
} else {
String::new()
},
let subject = if upgrade {
pre_permissions_subject_fixture(
&store,
"companion",
"Preserve existing Subject behavior",
)
.unwrap();
} else {
store
.create_subject(SubjectRole::new("companion").unwrap())
.unwrap()
};
let attribution =
SubjectSessionAttribution::new(&subject.id, "runtime", "worker", "source-session")
.unwrap();
@@ -5508,12 +5549,7 @@ fn journal_migration_failure_rolls_back_ddl_and_retries_without_changing_existin
))
.unwrap();
let store = SubjektivStore::open(&scope, &registration).unwrap();
let subject = store
.create_subject_with_behavior(
SubjectRole::new("preserved").unwrap(),
"Original behavior".into(),
)
.unwrap();
let subject = pre_permissions_subject_fixture(&store, "preserved", "Original behavior");
store
.database
.try_transaction::<_, JournalError>(|tx| {
+23 -28
View File
@@ -9,6 +9,7 @@ pub mod journal;
mod legacy_migrations;
pub mod permissions;
mod schema;
mod subject_creation;
use legacy_migrations::{
add_candidate_decision_receipts, add_subject_behavior, add_subject_session_attribution,
add_surface_generation_state, add_surface_job_provenance, create_schema,
@@ -54,6 +55,8 @@ pub enum SubjektivError {
InvalidRecord(String),
#[error("subject `{0}` was not found in this Workspace")]
SubjectNotFound(String),
#[error("Subject creation request `{0}` was reused with different input")]
SubjectCreationConflict(String),
#[error("Subject deletion request `{0}` conflicts with a saved deletion")]
SubjectDeletionConflict(String),
#[error("subject `{0}` is legacy-retired")]
@@ -658,6 +661,11 @@ static MIGRATIONS: &[FeatureMigration] = &[
deletion::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 =
@@ -754,34 +762,21 @@ impl SubjektivStore {
role: SubjectRole,
behavior_md: String,
) -> Result<SubjectRecord> {
validate_subject_behavior(&behavior_md)?;
let timestamp = now();
let record = SubjectRecord {
schema_version: SUBJEKTIV_SCHEMA_VERSION,
id: issued_id("subject"),
role,
behavior_md,
state: SubjectState::Active,
created_at: timestamp.clone(),
updated_at: timestamp,
};
let raw = serde_json::to_string(&record)?;
self.database.try_transaction(|transaction| {
transaction.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,
raw,
record.created_at,
record.updated_at
],
)?;
Ok(record)
})
self.create_subject_request(role, behavior_md, None)
}
/// Replays the original creation without reissuing permissions or changing the Subject.
/// The Host authenticates the administrator before calling. Keep the same operation ID
/// and full input after an unknown outcome; different input is a conflict.
pub fn create_subject_idempotent(
&self,
role: SubjectRole,
behavior_md: String,
operation_id: &str,
) -> Result<SubjectRecord> {
server_api::authorization::validate_authorization_identifier(operation_id)
.map_err(SubjektivError::InvalidRecord)?;
self.create_subject_request(role, behavior_md, Some(operation_id))
}
pub fn subject(&self, subject_id: &str) -> Result<Option<SubjectRecord>> {
+133 -18
View File
@@ -68,16 +68,71 @@ fn open_version(
(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]
fn replacements_are_cas_fenced_replayable_and_never_restore_old_permissions() {
let temp = tempfile::tempdir().unwrap();
let subject;
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");
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
.set_subject_permissions(&subject, &first_update)
.unwrap();
@@ -144,8 +199,12 @@ fn failed_receipt_insert_rolls_back_source_history_and_projection() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let initial = store.subject_permissions(&subject).unwrap().unwrap();
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();
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;")?;
@@ -164,7 +223,7 @@ fn failed_receipt_insert_rolls_back_source_history_and_projection() {
assert_eq!(store.subject_permissions(&subject).unwrap(), Some(first));
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)?)))?;
assert_eq!(counts, (1, 1));
assert_eq!(counts, (2, 1));
tx.execute_batch("DROP TRIGGER fail_permission_receipt")?;
Ok(())
}).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 (_b_manager, _b_scope, b) = open_store(&temp.path().join("b"), "workspace-b");
let subject = a.create_subject(role()).unwrap().id;
legacy_unset_source_fixture(&a, &subject);
assert!(
b.set_subject_permissions(&subject, &update("cross-subject", None, Vec::new()))
.is_err()
@@ -220,10 +280,15 @@ fn child_grants_are_immutable_bounded_and_do_not_create_subject_ownership() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let initial = store.subject_permissions(&subject).unwrap().unwrap();
let source = store
.set_subject_permissions(
&subject,
&update("allow", None, vec![permission("file.read", "src", true)]),
&update(
"allow",
Some(&initial.change_id),
vec![permission("file.read", "src", true)],
),
)
.unwrap();
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 (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let initial = store.subject_permissions(&subject).unwrap().unwrap();
let source = store
.set_subject_permissions(
&subject,
&update("allow", None, vec![permission("file.read", ".", true)]),
&update(
"allow",
Some(&initial.change_id),
vec![permission("file.read", ".", true)],
),
)
.unwrap();
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 (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let initial = store.subject_permissions(&subject).unwrap().unwrap();
let source = store
.set_subject_permissions(
&subject,
&update("allow", None, vec![permission("file.read", ".", true)]),
&update(
"allow",
Some(&initial.change_id),
vec![permission("file.read", ".", true)],
),
)
.unwrap();
// 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 (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let initial = store.subject_permissions(&subject).unwrap().unwrap();
let source = store
.set_subject_permissions(
&subject,
&update("allow", None, vec![permission("file.read", ".", true)]),
&update(
"allow",
Some(&initial.change_id),
vec![permission("file.read", ".", true)],
),
)
.unwrap();
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(_))
));
let other = store.create_subject(role()).unwrap().id;
let other_initial = store.subject_permissions(&other).unwrap().unwrap();
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();
let mut foreign = grant(&other, &other_source.change_id, "foreign-child");
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 (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
let subject = store.create_subject(role()).unwrap().id;
let initial = store.subject_permissions(&subject).unwrap().unwrap();
let source = store
.set_subject_permissions(
&subject,
&update("allow", None, vec![permission("file.read", ".", true)]),
&update(
"allow",
Some(&initial.change_id),
vec![permission("file.read", ".", true)],
),
)
.unwrap();
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 (_old_manager, _old_scope, old) = open_version(&root, 11);
original = old
.create_subject_with_behavior(role(), "Keep behavior separate".into())
.unwrap();
original = pre_permissions_subject_fixture(&old, "Keep behavior separate");
}
let (_migrated_manager, _migrated_scope, migrated) = open_store(&root, "workspace-a");
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 subject = store.create_subject(role()).unwrap().id;
legacy_unset_source_fixture(&store, &subject);
assert!(
store
.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()
.is_empty()
);
let initial = store.subject_permissions(&subject).unwrap().unwrap();
let source = store
.set_subject_permissions(
&subject,
&update("allow", None, vec![permission("file.read", ".", true)]),
&update(
"allow",
Some(&initial.change_id),
vec![permission("file.read", ".", true)],
),
)
.unwrap();
let other_initial = store.subject_permissions(&other).unwrap().unwrap();
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();
let other_grant = grant(&other, &other_source.change_id, "other-child");
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 (_old_manager, _old_scope, old) = open_version(temp.path(), 11);
original = old.create_subject(role()).unwrap();
original = pre_permissions_subject_fixture(&old, "");
old.database
.try_transaction::<_, SubjektivError>(|tx| {
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 (_manager, _scope, store) = open_store(temp.path(), "workspace-a");
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
.set_subject_permissions(&subject, &replacement)
.unwrap();
@@ -688,7 +798,12 @@ fn deletion_fences_source_and_child_grant_replays_and_never_falls_back_after_pur
.is_none()
);
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!(
store
.record_session_attribution(
+75
View File
@@ -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, &registration).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, &registration).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, &registration).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> {
let labels = [
(
server_api::authorization::SUBJECT_CONVERSATION_PROFILE,
"Subject conversation",
"Default Subject conversation and memory profile; requires an explicit Subject connection",
),
(
"builtin:companion",
"Companion",
@@ -541,7 +546,8 @@ pub fn selector_for_builtin_candidate(
id: &str,
) -> Option<worker_runtime::catalog::ProfileSelector> {
match id {
"builtin:companion"
"builtin:async-chat"
| "builtin:companion"
| "builtin:intake"
| "builtin:orchestrator"
| "builtin:coder"
+35 -7
View File
@@ -24094,7 +24094,7 @@ async fn scoped_create_subjektiv_subject(
crate::subjektiv::validate_subject_behavior(&request.behavior_md)
.map_err(|error| Error::InvalidInput(error.to_string()))?;
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)?;
Ok((
StatusCode::CREATED,
@@ -25990,6 +25990,7 @@ fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error {
Error::WorkspacePermissionDenied(error.to_string())
}
crate::subjektiv::SubjektivError::SubjectPermissionsConflict(_)
| crate::subjektiv::SubjektivError::SubjectCreationConflict(_)
| crate::subjektiv::SubjektivError::SubjectDeletionConflict(_)
| crate::subjektiv::SubjektivError::PermissionOperationConflict(_)
| crate::subjektiv::SubjektivError::WorkerExecutionGrantConflict(_) => {
@@ -45715,7 +45716,10 @@ mod tests {
&subject_id,
&SubjectPermissionsUpdate {
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 {
operation: "merge_request.complete".into(),
resource: PermissionResource::MergeRequest {
@@ -47788,7 +47792,7 @@ mod tests {
app.clone(),
"POST",
&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,
)
.await;
@@ -47797,6 +47801,7 @@ mod tests {
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
Some(serde_json::json!({
"operation_id":"browser-create",
"role": " Browser author ",
"behavior_md": "Prefer explicit evidence.\nAsk when uncertain."
})),
@@ -47883,7 +47888,7 @@ mod tests {
app.clone(),
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
Some(serde_json::json!({ "role": " " })),
Some(serde_json::json!({ "operation_id":"invalid-role", "role": " " })),
&owner_token,
StatusCode::BAD_REQUEST,
)
@@ -47896,7 +47901,9 @@ mod tests {
app.clone(),
"POST",
"/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,
StatusCode::NOT_FOUND,
)
@@ -47981,6 +47988,7 @@ mod tests {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
Json(server_api::SubjektivSubjectCreateRequest {
operation_id: format!("create-subject-{}", uuid::Uuid::now_v7()),
role: "companion".to_string(),
behavior_md: "Prefer explicit evidence.".to_string(),
}),
@@ -48044,6 +48052,7 @@ mod tests {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
Json(server_api::SubjektivSubjectCreateRequest {
operation_id: format!("create-subject-{}", uuid::Uuid::now_v7()),
role: "reviewer".to_string(),
behavior_md: "x".repeat(server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES + 1),
}),
@@ -48540,6 +48549,18 @@ mod tests {
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())
.oneshot(check(subject_request.clone()))
.await
@@ -48559,7 +48580,10 @@ mod tests {
&subject.id,
&SubjectPermissionsUpdate {
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![
ScopedPermission {
operation: "memory.resident.supply".into(),
@@ -48688,7 +48712,11 @@ mod tests {
&subject.id,
&SubjectPermissionsUpdate {
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,
delegation: vec![],
},
@@ -775,7 +775,7 @@ async fn connected_chat_delegation(scenario: Scenario) {
];
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();
for (name, target) in [("conversation-a", true), ("conversation-b", false)] {
let c = account_request(
@@ -670,7 +670,10 @@ impl agen::llm_client::LlmClient for ChatModel {
is_error, content, ..
} = 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 {
@@ -712,11 +715,33 @@ impl agen::llm_client::LlmClient for ChatModel {
request.tools.iter().any(|tool| tool.name == name),
"builtin:async-chat must expose {name}"
);
vec![
let mut events = vec![
Event::tool_use_start(0, format!("chat-tool-{phase}"), name),
Event::tool_input_delta(0, input.to_string()),
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![
Event::text_block_start(0),
@@ -743,38 +768,11 @@ struct ConnectedChat {
owner: String,
url: String,
lose_next_input_ack: Arc<std::sync::atomic::AtomicBool>,
identity: RuntimeIdentityMaterial,
_backend: AuthorizationCallbackServer,
_runtime_server: ConnectedRuntimeServer,
_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 {
async fn new(
@@ -943,7 +941,6 @@ impl ConnectedChat {
)
.unwrap(),
);
publish_chat_fixture_profile(&api);
seed_test_delivery_binding(&api);
let owner =
seed_test_workspace_owner_token(api.store.as_ref(), "connected-chat-owner").await;
@@ -957,6 +954,7 @@ impl ConnectedChat {
owner,
url,
lose_next_input_ack,
identity,
_backend: backend,
_runtime_server: runtime_server,
_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(
inputs: &mut tokio::sync::mpsc::Receiver<(usize, String)>,
expected: usize,
@@ -1049,22 +1095,44 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id
})
.await;
let store = open_subjektiv_store(&f.api).unwrap();
let subject = store
.create_subject(crate::subjektiv::SubjectRole::new("connected-chat").unwrap())
let create_wire =
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()
.id;
let operations = [
"session.record",
"memory.resident.read",
"memory.resident.supply",
"journal.startup.read",
"journal.startup.supply",
// Restore uses the ordinary fail-closed recording hook as well.
"journal.recording.request",
"chat.read",
"chat.send",
];
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;
.to_owned();
assert_eq!(
f.post(
&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects"),
create_wire.clone()
)
.await["id"],
subject
);
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, "{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
.post(
&conversations_path(),
@@ -1073,7 +1141,7 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id
.await;
let c = conversation["id"].as_str().unwrap();
*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 mut events = f.runtime.subscribe_worker_observation().unwrap();
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"
);
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 = f.post(&messages_path(c), idle_wire.clone()).await;
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"
);
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)]
@@ -1302,21 +1465,15 @@ impl agen::llm_client::LlmClient for NotifyObservationModel {
async fn browser_notify_preserves_ack_id_limits_retry_and_console_next_draft() {
let (tx, mut inputs) = tokio::sync::mpsc::channel(32);
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()
.create_subject(crate::subjektiv::SubjectRole::new("notify-console").unwrap())
.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;
.to_owned();
let conversation = f
.post(
&conversations_path(),
@@ -1324,7 +1481,7 @@ async fn browser_notify_preserves_ack_id_limits_retry_and_console_next_draft() {
)
.await;
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 chat = f
.post(
@@ -145,7 +145,14 @@ impl Fixture {
&subject,
&SubjectPermissionsUpdate {
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")],
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.
let (status, body) = response(&api, human_request(&owner, "POST",
&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<_>>()),
"delegation":child_permissions.clone()})))).await;
assert_eq!(status, StatusCode::OK, "{body}");
@@ -3545,7 +3552,7 @@ async fn connected_two_subjects_delegate_distinct_tickets_concurrently_on_separa
.human(
reqwest::Method::POST,
&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}),
)
.await;
@@ -132,7 +132,12 @@ async fn deletion_owner_scope_exact_retry_and_subject_isolation() {
.create_subject(crate::subjektiv::SubjectRole::new("fresh").unwrap())
.unwrap();
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!(
store
.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() {
let f = ManagementFixture::new().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);
let operations = screen["operations"].as_array().expect("management catalog");
let create = operations
@@ -348,7 +350,7 @@ async fn read_only_account_cannot_save_or_replay_an_owner_permission_change() {
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
f.assert_current(&Value::Null);
f.assert_current(&initial["permissions"]);
let saved = f.save(input.clone()).await;
let (status, body) = response(
&f.api,
@@ -398,8 +400,8 @@ async fn new_unknown_legacy_unavailable_and_operation_resource_mismatch_are_reje
.await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{name}/{lane}: {body}");
assert!(body["message"].is_string(), "{body}");
f.assert_current(&Value::Null);
assert!(f.read().await["permissions"].is_null());
f.assert_current(&screen["permissions"]);
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,
&SubjectPermissionsUpdate {
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(),
delegation: delegation.clone(),
},
@@ -77,7 +77,13 @@ async fn subject_callbacks_finish_before_waiting_host_parents_release_capacity()
&subject.id,
&server_api::authorization::SubjectPermissionsUpdate {
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: [
"memory.resident.read",
"session.record",
@@ -1011,6 +1011,18 @@ async fn unset_subject_consolidation_denies_before_job_dispatch() {
.create_subject(SubjectRole::new("unset-job-policy").unwrap())
.unwrap();
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());
stage(&store, &subject.id, "untouched");
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
.create_subject(crate::subjektiv::SubjectRole::new("connected-handoff").unwrap())
.unwrap();
let owner =
seed_test_workspace_owner_token(api.store.as_ref(), "connected-journal-owner").await;
// This fixture exercises Journal continuity, not unset-policy refusal. Assign
// the companion's required Subject operations through authenticated administration;
// 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));
// Exercise creation-time authority through real recording and startup callbacks,
// without a management grant or an execution/delegation policy replacement.
let initial_policy = store.subject_permissions(&subject.id).unwrap().unwrap();
assert!(initial_policy.delegation.is_empty());
let old = connected_create(&api, &subject.id, "connected-old-body").await;
let old_attr = store
.subject_session_attributions(&subject.id)
@@ -766,6 +739,11 @@ async fn backend_real_runtime_journal_release_and_next_body_persisted_startup()
.unwrap(),
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 [
(&old, 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;
let update = server_api::authorization::SubjectPermissionsUpdate {
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),
delegation: vec![],
};
@@ -45,7 +45,13 @@ impl Fixture {
subject_id,
&server_api::authorization::SubjectPermissionsUpdate {
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: [
"journal.memory.links.reconcile",
"journal.memory.links.read",
@@ -83,6 +89,19 @@ impl Fixture {
.unwrap();
if granted {
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 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();
} else {
assert!(f.store.subject_permissions(&subject).unwrap().is_none());
}
let count_links = || {
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,
&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}");
let change = subjects
.subject_permissions(&subject)
@@ -385,7 +385,7 @@ async fn connected_roundtrip(explicit_workdir: bool, repair: bool) {
operation("worker.stop"),
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}");
let before = store.subject_permissions(&subject).unwrap().unwrap();
if repair {
@@ -826,7 +826,13 @@ async fn create_and_restore_session_callbacks_finish_while_deletion_start_waits(
&subject.id,
&server_api::authorization::SubjectPermissionsUpdate {
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 {
operation: "session.record".into(),
resource: server_api::authorization::PermissionResource::Subject {
@@ -178,7 +178,11 @@ impl Fixture {
Self::request(
"POST",
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(),
}),
Json(server_api::SubjektivSubjectCreateRequest {
operation_id: format!("create-network-subject-{kind}"),
role: "network subject".into(),
behavior_md: String::new(),
}),
@@ -1136,7 +1141,14 @@ async fn subjektiv_runtime_waits_leave_db_and_control_free_and_cancel_retains_th
&subject.id,
&server_api::authorization::SubjectPermissionsUpdate {
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 {
operation: operation.into(),
resource: server_api::authorization::PermissionResource::Subject {
+6 -1
View File
@@ -15451,11 +15451,16 @@
"default": "",
"type": "string"
},
"operation_id": {
"description": "Stable across exact retries; changed input with the same ID conflicts.",
"type": "string"
},
"role": {
"type": "string"
}
},
"required": [
"operation_id",
"role"
],
"type": "object"
@@ -22720,7 +22725,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:2ac605f7ea70d0c725f9993a5c59c4a8c4266cf69184902db7b3ea7fd5932224"
"x-yoi-source-digest": "sha256:85593527d939626a32b88d9b2760b531681b087d95fefa5119901fe86d60d76c"
},
"openapi": "3.1.0",
"paths": {
+3 -1
View File
@@ -14,7 +14,7 @@
enabled = true;
chat = { enabled = true; };
consolidation_tools = false;
extraction = { enabled = false; };
extraction = { enabled = true; threshold = 50000; };
};
web = { enabled = false; };
image = { enabled = false; };
@@ -54,6 +54,8 @@
{ tool = "SubjektivMemoryQuery"; pattern = "*"; action = "allow"; },
{ tool = "SubjektivMemoryRead"; pattern = "*"; action = "allow"; },
{ tool = "SubjektivMemoryListChanges"; pattern = "*"; action = "allow"; },
{ tool = "SubjektivMemoryRemember"; pattern = "*"; action = "allow"; },
{ tool = "SubjektivMemoryProposeChange"; pattern = "*"; action = "allow"; },
{ tool = "QueryObjective"; pattern = "*"; action = "allow"; },
{ tool = "ShowObjective"; 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 createdSubjectIds = new Set<string>();
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;
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) => {
const url = new URL(request.url);
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) {
const response = await permissionsFixture(request);
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") {
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") {
return json([{
workspace_id: workspaceId,
@@ -488,22 +517,47 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
if (url.pathname === subjectsPath) {
if (request.method === "POST") {
const payload = await request.json().catch(() => null) as {
operation_id?: unknown;
role?: unknown;
behavior_md?: unknown;
} | null;
subjectCreateRequests.push(payload);
if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) {
return json({ error: "Bad Request", message: "subject role must not be empty" }, 400);
const reject = (message: string, status = 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") {
subjectCreateOutcomes.push({ outcome: "rejected-403" });
return json({ error: "Forbidden", message: "workspace permission denied" }, 403);
}
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;
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);
subjects.unshift(created);
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`))
.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, {
count: 2,
requests: [
{ role: "Permission denied", behavior_md: "" },
{ operation_id: operationIds[0], role: "Permission denied", behavior_md: "" },
{
operation_id: operationIds[1],
role: " Release steward ",
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.
// 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
// Output: web/workspace/src/lib/generated/companion-api.ts
// 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 SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
export type SubjektivSubjectCreateRequest = { operation_id: string, role: string, behavior_md?: string, };
export type SubjektivSubjectDeleteRequest = { operation_id: string, };
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// 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
// Output: web/workspace/src/lib/generated/repository-api.ts
// 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.
// 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
// Output: web/workspace/src/lib/generated/runtime-api.ts
// 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.
// 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
// Output: web/workspace/src/lib/generated/ticket-api.ts
// 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.
// 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
// 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
+26 -4
View File
@@ -117,6 +117,18 @@ export class SubjektivSubjectCreateError extends Error {
export function validateSubjektivSubjectCreateRequest(
request: 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) {
throw new SubjektivSubjectCreateError(
"validation",
@@ -135,7 +147,11 @@ export function validateSubjektivSubjectCreateRequest(
}
const behaviorMd = request.behavior_md ?? "";
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 {
@@ -224,6 +240,12 @@ export async function deleteSubjektivSubject(
}
}
/**
* 呼出元は意図した作成ごとにoperation_idを一度だけ採番し、結果不明なら
* 同じID・role・behavior_mdの要求全体を保持する。この関数は自動再試行しない。
* 厳密な再送は同じSubjectを返し、同じIDで入力を変えると409になる。
* 再送は権限の再付与や削除済みSubjectの復活ではない。
*/
export async function createSubjektivSubject(
fetchFn: typeof fetch,
workspaceId: string,
@@ -241,7 +263,7 @@ export async function createSubjektivSubject(
} catch {
throw new SubjektivSubjectCreateError(
"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(
"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,
);
}
@@ -275,7 +297,7 @@ export async function createSubjektivSubject(
} catch {
throw new SubjektivSubjectCreateError(
"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,
SubjektivSubjectCreateError,
} 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 { PageData as RouteData } from './$types';
@@ -23,6 +23,7 @@
let roleInvalid = $state(false);
let behaviorInvalid = $state(false);
let createOutcomeUnknown = $state(false);
let createRequest = $state<SubjektivSubjectCreateRequest | null>(null);
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
let createButton = $state<HTMLButtonElement>();
let roleInput = $state<HTMLInputElement>();
@@ -42,12 +43,14 @@
}
async function openCreateForm(): Promise<void> {
if (submitting || createExpanded) return;
createExpanded = true;
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
createdSubject = null;
if (!createOutcomeUnknown) {
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createdSubject = null;
}
await tick();
roleInput?.focus();
}
@@ -55,13 +58,16 @@
async function cancelCreate(): Promise<void> {
if (submitting) return;
createExpanded = false;
role = '';
behaviorMd = '';
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
createdSubject = null;
// 結果不明の要求は閉じても破棄せず、再度開いたときも厳密に再送する。
if (!createOutcomeUnknown) {
role = '';
behaviorMd = '';
createRequest = null;
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createdSubject = null;
}
await tick();
createButton?.focus();
}
@@ -72,13 +78,15 @@
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
try {
const subject = await createSubjektivSubject(fetch, data.workspaceId, {
createRequest ??= {
operation_id: crypto.randomUUID(),
role,
behavior_md: behaviorMd,
});
};
const subject = await createSubjektivSubject(fetch, data.workspaceId, createRequest);
createdSubject = subject;
createOutcomeUnknown = false;
try {
await goto(subjectHref(subject.id));
} catch {
@@ -88,7 +96,8 @@
if (error instanceof SubjektivSubjectCreateError) {
roleInvalid = error.kind === 'validation' && error.message.startsWith('Subject role');
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
? 'You do not have permission to create Subjects in this Workspace.'
: error.message;
@@ -102,6 +111,7 @@
createError = 'The request outcome is unknown. A Subject may have been created.';
}
} finally {
if (!createOutcomeUnknown && !createdSubject) createRequest = null;
submitting = false;
}
}
@@ -161,7 +171,7 @@
type="text"
autocomplete="off"
required
disabled={submitting || createdSubject !== null}
disabled={submitting || createOutcomeUnknown || createdSubject !== null}
onkeydown={createFormKeydown}
aria-invalid={roleInvalid ? 'true' : undefined}
aria-describedby={createError && roleInvalid ? 'subject-role-help subject-create-error' : 'subject-role-help'}
@@ -173,7 +183,7 @@
bind:this={behaviorInput}
bind:value={behaviorMd}
rows="7"
disabled={submitting || createdSubject !== null}
disabled={submitting || createOutcomeUnknown || createdSubject !== null}
onkeydown={createFormKeydown}
aria-invalid={behaviorInvalid ? 'true' : undefined}
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(init.method).toBe("POST");
expect(init.body).toBe(
'{"role":" Review lead ","behavior_md":"Challenge unsupported assumptions."}',
);
const request = JSON.parse(init.body as string);
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 () => {
+94 -7
View File
@@ -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", () => {
assertEquals(
validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }),
{ role: " Release coordinator ", behavior_md: "" },
validateSubjektivSubjectCreateRequest({ operation_id: "create-attempt", role: " Release coordinator " }),
{ operation_id: "create-attempt", role: " Release coordinator ", behavior_md: "" },
);
assertThrows(
() => validateSubjektivSubjectCreateRequest({ role: " " }),
() => validateSubjektivSubjectCreateRequest({ operation_id: "create-attempt", role: " " }),
"must not be empty",
);
assertThrows(
() => validateSubjektivSubjectCreateRequest({ role: "line\nbreak" }),
() => validateSubjektivSubjectCreateRequest({ operation_id: "create-attempt", role: "line\nbreak" }),
"control characters",
);
assertThrows(
() => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }),
() => validateSubjektivSubjectCreateRequest({ operation_id: "create-attempt", role: "界".repeat(86) }),
"at most 256 bytes",
);
assertThrows(
() =>
validateSubjektivSubjectCreateRequest({
operation_id: "create-attempt",
role: "Reviewer",
behavior_md: " ".repeat(3),
}),
@@ -297,6 +298,7 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit
assertThrows(
() =>
validateSubjektivSubjectCreateRequest({
operation_id: "create-attempt",
role: "Reviewer",
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 () => {
const requests: Array<{ path: string; 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;
const created = await createSubjektivSubject(fetchFn, "workspace one", {
operation_id: "create-attempt",
role: " Release coordinator ",
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?.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(
() =>
createSubjektivSubject(rejectedFetch, "workspace-1", {
operation_id: "create-attempt",
role: "Reviewer",
}),
"rejected",
@@ -412,6 +445,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
await assertSubjectCreateRejects(
() =>
createSubjektivSubject(serverErrorFetch, "workspace-1", {
operation_id: "create-attempt",
role: "Reviewer",
}),
"unknown_outcome",
@@ -428,6 +462,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
await assertSubjectCreateRejects(
() =>
createSubjektivSubject(mismatchFetch, "workspace-1", {
operation_id: "create-attempt",
role: "Reviewer",
}),
"unknown_outcome",
@@ -441,13 +476,65 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
throw new TypeError("connection reset");
}) as typeof fetch;
await assertSubjectCreateRejects(
() => createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
() => createSubjektivSubject(unknownFetch, "workspace-1", { operation_id: "create-attempt", role: "Reviewer" }),
"unknown_outcome",
"may have been created",
);
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", () => {
const readyEmpty = {
subject_id: "subject-1",